Added lobby auto creation
Docker image / build-and-push (push) Successful in 57s

This commit is contained in:
Aleksandr Bogomiakov
2026-08-06 20:33:27 +01:00
parent 33e01a4a8f
commit c239669932
22 changed files with 3533 additions and 55 deletions
+55
View File
@@ -0,0 +1,55 @@
'use strict';
const fs = require('fs');
const os = require('os');
const path = require('path');
function parseClientVersion(text) {
const raw = String(text).match(/^ClientVersion=(\d+)\s*$/m)?.[1] || '';
if (!raw) return null;
const version = Number(raw);
return Number.isInteger(version) && version > 0 && version <= 0xffffffff ? version : null;
}
function parseVersionOverride(value) {
const raw = String(value ?? '').trim();
if (!raw) return null;
if (!/^\d+$/.test(raw)) throw new Error('DOTA_CLIENT_VERSION must contain digits only.');
const version = Number(raw);
if (!Number.isInteger(version) || version < 1 || version > 0xffffffff) {
throw new Error('DOTA_CLIENT_VERSION must be an unsigned 32-bit integer.');
}
return version;
}
function defaultSteamInfFiles() {
const home = os.homedir();
return [
process.env.DOTA_STEAM_INF,
path.join(home, '.local', 'share', 'Steam', 'steamapps', 'common', 'dota 2 beta', 'game', 'dota', 'steam.inf'),
path.join(home, '.steam', 'steam', 'steamapps', 'common', 'dota 2 beta', 'game', 'dota', 'steam.inf'),
path.join('/steam', 'steamapps', 'common', 'dota 2 beta', 'game', 'dota', 'steam.inf'),
].filter(Boolean);
}
function resolveDotaClientVersion(files = defaultSteamInfFiles()) {
const override = parseVersionOverride(process.env.DOTA_CLIENT_VERSION);
if (override) return { version: override, source: 'environment' };
for (const file of [...new Set(files)]) {
try {
const version = parseClientVersion(fs.readFileSync(file, 'utf8'));
if (version) return { version, source: file };
} catch (err) {
if (err.code !== 'ENOENT' && err.code !== 'ENOTDIR') {
console.warn(`warn: could not read Dota client version ${file}: ${err.message}`);
}
}
}
return { version: null, source: null };
}
module.exports = {
parseClientVersion,
resolveDotaClientVersion,
};
+163
View File
@@ -0,0 +1,163 @@
'use strict';
const UINT32_MAX = 0xffffffff;
const MAX_CUSTOM_PLAYERS = 64;
const DEFAULT_LOBBY_SETTINGS = Object.freeze({
lobbyName: '',
passKey: '',
serverRegion: 3,
customGameId: '',
customGameMode: '',
customMapName: '',
customGameCrc: '',
customGameTimestamp: '',
customMinPlayers: 2,
customMaxPlayers: 10,
notifyAtPlayers: 8,
leaveAtPlayers: 9,
adminNames: [],
allowSpectating: true,
});
function asInteger(value, name, min, max) {
const parsed = typeof value === 'number' ? value : Number(String(value).trim());
if (!Number.isInteger(parsed) || parsed < min || parsed > max) {
throw new Error(`${name} must be an integer between ${min} and ${max}.`);
}
return parsed;
}
function asOptionalIntegerString(value, name, max) {
const raw = String(value ?? '').trim();
if (!raw) return '';
if (!/^\d+$/.test(raw)) throw new Error(`${name} must contain digits only.`);
const parsed = BigInt(raw);
if (parsed < 0n || parsed > BigInt(max)) throw new Error(`${name} is outside its supported range.`);
return raw;
}
function asUint64String(value, name, { required = false } = {}) {
const raw = String(value ?? '').trim();
if (!raw) {
if (required) throw new Error(`${name} is required.`);
return '';
}
if (!/^\d+$/.test(raw)) throw new Error(`${name} must contain digits only.`);
const parsed = BigInt(raw);
if (parsed <= 0n || parsed > 0xffffffffffffffffn) {
throw new Error(`${name} must be a non-zero unsigned 64-bit integer.`);
}
return raw;
}
function parseAdminNames(value) {
const values = Array.isArray(value) ? value : String(value ?? '').split(',');
return [...new Set(values.map((entry) => String(entry).trim()).filter(Boolean))];
}
function normalizeIdentity(value) {
return String(value ?? '').normalize('NFKC').trim().toLocaleLowerCase('en-US');
}
function validateLobbySettings(input = {}) {
const merged = { ...DEFAULT_LOBBY_SETTINGS, ...input };
const lobbyName = String(merged.lobbyName ?? '').trim();
const customGameMode = String(merged.customGameMode ?? '').trim();
const customMapName = String(merged.customMapName ?? '').trim();
const passKey = String(merged.passKey ?? '');
if (!lobbyName) throw new Error('Lobby name is required.');
if (lobbyName.length > 100) throw new Error('Lobby name must be at most 100 characters.');
if (passKey.length > 64) throw new Error('Lobby password must be at most 64 characters.');
if (!customGameMode) {
throw new Error('Internal addon name is required for a public custom-game lobby. Create the form from a discovered lobby template.');
}
if (!customMapName) throw new Error('Custom map name is required.');
const customMinPlayers = asInteger(merged.customMinPlayers, 'Minimum players', 1, MAX_CUSTOM_PLAYERS);
const customMaxPlayers = asInteger(merged.customMaxPlayers, 'Maximum players', 2, MAX_CUSTOM_PLAYERS);
const notifyAtPlayers = asInteger(merged.notifyAtPlayers, 'Notification threshold', 1, MAX_CUSTOM_PLAYERS);
const leaveAtPlayers = asInteger(merged.leaveAtPlayers, 'Auto-leave threshold', 1, MAX_CUSTOM_PLAYERS);
if (customMinPlayers > customMaxPlayers) {
throw new Error('Minimum players cannot exceed maximum players.');
}
if (notifyAtPlayers > leaveAtPlayers) {
throw new Error('Notification threshold must not exceed the auto-leave threshold.');
}
if (leaveAtPlayers > customMaxPlayers) {
throw new Error('Auto-leave threshold cannot exceed the custom-game maximum player count.');
}
if (leaveAtPlayers === customMaxPlayers) {
throw new Error('Auto-leave threshold must be below the custom-game maximum while the bot occupies one lobby slot.');
}
return {
lobbyName,
passKey,
serverRegion: asInteger(merged.serverRegion, 'Server region', 0, UINT32_MAX),
customGameId: asUint64String(merged.customGameId, 'Custom game workshop ID', { required: true }),
customGameMode,
customMapName,
customGameCrc: asUint64String(merged.customGameCrc, 'Custom game CRC'),
customGameTimestamp: asOptionalIntegerString(merged.customGameTimestamp, 'Custom game timestamp', UINT32_MAX),
customMinPlayers,
customMaxPlayers,
notifyAtPlayers,
leaveAtPlayers,
adminNames: parseAdminNames(merged.adminNames),
allowSpectating: merged.allowSpectating !== false,
};
}
function toDotaLobbyDetails(settings) {
const details = {
gameName: settings.lobbyName,
serverRegion: settings.serverRegion,
gameMode: 15,
allowCheats: false,
fillWithBots: false,
allowSpectating: settings.allowSpectating,
passKey: settings.passKey,
lan: false,
customMapName: settings.customMapName,
customDifficulty: 0,
customGameId: settings.customGameId,
customMinPlayers: settings.customMinPlayers,
customMaxPlayers: settings.customMaxPlayers,
visibility: 0,
customGamePenalties: true,
};
details.customGameMode = settings.customGameMode;
if (settings.customGameCrc) details.customGameCrc = settings.customGameCrc;
if (settings.customGameTimestamp) details.customGameTimestamp = Number(settings.customGameTimestamp);
return details;
}
function findAdminMember(settings, members) {
const wanted = new Set(settings.adminNames.map(normalizeIdentity));
if (!wanted.size) return null;
return members.find((member) => wanted.has(normalizeIdentity(member.name))
|| wanted.has(normalizeIdentity(member.steamId))) || null;
}
function decideLobbySafety(settings, members, lobbyState) {
if (lobbyState !== 0) return { action: 'unsafe-state', admin: null };
if (members.length >= settings.leaveAtPlayers) return { action: 'player-limit', admin: null };
const admin = findAdminMember(settings, members);
if (admin) return { action: 'admin-joined', admin };
return { action: 'monitor', admin: null };
}
module.exports = {
DEFAULT_LOBBY_SETTINGS,
MAX_CUSTOM_PLAYERS,
decideLobbySafety,
findAdminMember,
normalizeIdentity,
parseAdminNames,
toDotaLobbyDetails,
validateLobbySettings,
};
+401
View File
@@ -0,0 +1,401 @@
'use strict';
const { EventEmitter } = require('events');
const MAX_TELEGRAM_ID = 4503599627370495n;
const TELEGRAM_TEXT_LIMIT = 4096;
const ACTIVE_PHASES = new Set([
'creating',
'restoring',
'monitoring',
'leaving',
'unsafe-state',
'connection-lost',
'error',
]);
const PHASE_LABELS = {
creating: 'создание',
restoring: 'восстановление после перезапуска',
monitoring: 'ожидание игроков',
leaving: 'бот выходит',
left: 'бот вышел',
'unsafe-state': 'лобби уже запускается',
'connection-lost': 'соединение потеряно',
unmanaged: 'автоматизация отключена',
error: 'ошибка',
idle: 'не активно',
};
class TelegramApiError extends Error {
constructor(method, description, errorCode = null) {
super(`Telegram ${method}: ${description || 'request failed'}`);
this.name = 'TelegramApiError';
this.method = method;
this.description = description || 'request failed';
this.errorCode = errorCode;
}
}
function validateBotToken(value) {
const token = String(value ?? '').trim();
if (!/^\d{5,}:[A-Za-z0-9_-]{20,}$/.test(token)) {
throw new Error('Telegram bot token has an invalid format. Copy it from @BotFather.');
}
return token;
}
function parseRecipientIds(value) {
const rawValues = Array.isArray(value)
? value
: String(value ?? '').split(/[\s,;]+/);
const result = [];
const seen = new Set();
for (const item of rawValues) {
const id = String(item ?? '').trim();
if (!id) continue;
if (!/^[1-9]\d*$/.test(id) || BigInt(id) > MAX_TELEGRAM_ID) {
throw new Error(`Telegram recipient ID "${id}" must be a positive numeric user ID.`);
}
if (!seen.has(id)) {
seen.add(id);
result.push(id);
}
}
if (!result.length) throw new Error('At least one Telegram recipient ID is required.');
return result;
}
function cleanLine(value, maxLength = 80) {
const text = String(value ?? '').replace(/[\r\n\t]+/g, ' ').trim();
return text.length > maxLength ? `${text.slice(0, maxLength - 1)}` : text;
}
function formatLobbyMessage(settings = {}, status = {}) {
const members = Array.isArray(status.members) ? status.members : [];
const humanCount = Number.isInteger(status.humanCount) ? status.humanCount : members.length;
const maxPlayers = Number(settings.customMaxPlayers || 0);
const phase = PHASE_LABELS[status.phase] || cleanLine(status.phase || 'неизвестно');
const title = cleanLine(settings.lobbyName || 'Dota 2 lobby', 100);
const lines = [
`🎮 ${title}`,
`Статус: ${phase}`,
`Игроки: ${humanCount}/${maxPlayers || '?'}`,
];
if (status.lobbyId) lines.push(`Lobby ID: ${cleanLine(status.lobbyId, 32)}`);
if (settings.customMapName) lines.push(`Карта: ${cleanLine(settings.customMapName, 80)}`);
if (settings.serverRegion !== undefined) {
lines.push(`Регион: ${cleanLine(settings.serverRegionName || settings.serverRegion, 80)}`);
}
if (status.practicePublished === true) lines.push('Публикация: в списке лобби');
if (status.practicePublished === false) lines.push('Публикация: не найдено в списке');
lines.push('', 'Состав:');
if (!members.length) {
lines.push('— пока нет игроков');
} else {
members.forEach((member, index) => {
const name = cleanLine(member.name, 60);
const steamId = cleanLine(member.steamId, 24);
lines.push(`${index + 1}. ${name || steamId || 'неизвестный игрок'}${name && steamId ? ` (${steamId})` : ''}`);
});
}
if (settings.notifyAtPlayers) lines.push('', `Уведомление: ${settings.notifyAtPlayers}`);
if (settings.leaveAtPlayers) lines.push(`Выход бота: ${settings.leaveAtPlayers}`);
if (status.leaveReason) lines.push(`Причина выхода: ${cleanLine(status.leaveReason, 80)}`);
if (status.lastError) lines.push('', `Ошибка: ${cleanLine(status.lastError, 240)}`);
const text = lines.join('\n');
return text.length <= TELEGRAM_TEXT_LIMIT
? text
: `${text.slice(0, TELEGRAM_TEXT_LIMIT - 2)}`;
}
class TelegramNotifier extends EventEmitter {
constructor({ store, fetchImpl = globalThis.fetch, debounceMs = 300 }) {
super();
if (!store) throw new Error('TelegramNotifier requires a persistent store.');
if (typeof fetchImpl !== 'function') throw new Error('TelegramNotifier requires fetch support.');
this.store = store;
this.fetchImpl = fetchImpl;
this.debounceMs = debounceMs;
const saved = store.load() || {};
this.token = typeof saved.token === 'string' ? saved.token : '';
this.recipientIds = Array.isArray(saved.recipientIds)
? saved.recipientIds.map(String).filter((id) => /^[1-9]\d*$/.test(id))
: [];
this.bot = saved.bot && typeof saved.bot === 'object' ? saved.bot : null;
this.session = saved.session && typeof saved.session === 'object' ? saved.session : null;
this.lastSuccessAt = saved.lastSuccessAt || null;
this.lastError = saved.lastError || null;
this.pendingSnapshot = null;
this.flushTimer = null;
this.flushPromise = Promise.resolve();
}
publicState() {
return {
configured: Boolean(this.token && this.recipientIds.length),
botUsername: this.bot?.username || null,
recipientIds: [...this.recipientIds],
recipientCount: this.recipientIds.length,
lastSuccessAt: this.lastSuccessAt,
lastError: this.lastError,
};
}
persist() {
this.store.save({
token: this.token,
recipientIds: this.recipientIds,
bot: this.bot,
session: this.session,
lastSuccessAt: this.lastSuccessAt,
lastError: this.lastError,
});
}
async request(token, method, payload = {}) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
timeout.unref?.();
try {
const response = await this.fetchImpl(`https://api.telegram.org/bot${token}/${method}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: controller.signal,
});
const body = await response.json().catch(() => null);
if (!response.ok || !body?.ok) {
throw new TelegramApiError(method, body?.description || `HTTP ${response.status}`, body?.error_code);
}
return body.result;
} catch (err) {
if (err instanceof TelegramApiError) throw err;
if (err.name === 'AbortError') throw new TelegramApiError(method, 'request timed out');
throw new TelegramApiError(method, err.message || 'network request failed');
} finally {
clearTimeout(timeout);
}
}
async configure({ token, recipientIds }) {
const nextToken = String(token ?? '').trim() ? validateBotToken(token) : this.token;
if (!nextToken) throw new Error('Telegram bot token is required.');
const nextRecipients = parseRecipientIds(recipientIds);
const bot = await this.request(nextToken, 'getMe');
const tokenChanged = nextToken !== this.token;
this.token = nextToken;
this.recipientIds = nextRecipients;
this.bot = { id: String(bot.id || ''), username: bot.username || '' };
this.lastError = null;
if (tokenChanged) this.session = null;
this.persist();
this.emit('change');
return this.publicState();
}
disable() {
if (this.flushTimer) clearTimeout(this.flushTimer);
this.flushTimer = null;
this.pendingSnapshot = null;
this.token = '';
this.recipientIds = [];
this.bot = null;
this.session = null;
this.lastError = null;
this.persist();
this.emit('change');
return this.publicState();
}
beginLobbySession() {
this.session = {
key: `pending:${Date.now()}`,
lobbyId: null,
messages: {},
};
this.persist();
this.emit('change');
}
queueLobbyUpdate(settings, status) {
this.pendingSnapshot = {
settings: { ...settings },
status: {
...status,
members: Array.isArray(status?.members)
? status.members.map((member) => ({ ...member }))
: [],
},
};
if (this.flushTimer) clearTimeout(this.flushTimer);
this.flushTimer = setTimeout(() => {
this.flushTimer = null;
const snapshot = this.pendingSnapshot;
this.pendingSnapshot = null;
if (!snapshot) return;
this.flushPromise = this.flushPromise
.then(() => this.syncLobby(snapshot.settings, snapshot.status))
.catch((err) => this.recordError(err));
}, this.debounceMs);
this.flushTimer.unref?.();
}
ensureSession(status) {
const active = Boolean(status.armed || status.lobbyId || ACTIVE_PHASES.has(status.phase));
if (!this.session && !active) return false;
if (!this.session) {
this.session = {
key: status.lobbyId ? `lobby:${status.lobbyId}` : `pending:${Date.now()}`,
lobbyId: status.lobbyId || null,
messages: {},
};
}
if (status.lobbyId && !this.session.lobbyId) {
this.session.lobbyId = String(status.lobbyId);
}
if (!this.session.messages || typeof this.session.messages !== 'object') {
this.session.messages = {};
}
return true;
}
shouldReplaceMessage(err) {
return err instanceof TelegramApiError
&& err.errorCode === 400
&& /message to edit not found|message can.t be edited/i.test(err.description);
}
async syncRecipient(recipientId, text) {
const existing = this.session.messages[recipientId];
if (existing?.text === text) return { recipientId, action: 'unchanged' };
if (existing?.messageId) {
try {
await this.request(this.token, 'editMessageText', {
chat_id: recipientId,
message_id: existing.messageId,
text,
disable_web_page_preview: true,
});
this.session.messages[recipientId] = { ...existing, text };
return { recipientId, action: 'edited' };
} catch (err) {
if (!this.shouldReplaceMessage(err)) throw err;
}
}
const message = await this.request(this.token, 'sendMessage', {
chat_id: recipientId,
text,
disable_web_page_preview: true,
});
this.session.messages[recipientId] = {
messageId: message.message_id,
text,
};
return { recipientId, action: 'sent' };
}
async syncLobby(settings, status) {
if (!this.token || !this.recipientIds.length || !this.ensureSession(status)) {
return { delivered: 0, failed: 0 };
}
const text = formatLobbyMessage(settings, status);
const results = await Promise.allSettled(
this.recipientIds.map(async (recipientId) => {
try {
return await this.syncRecipient(recipientId, text);
} catch (err) {
throw new Error(`${recipientId}: ${err.message}`);
}
}),
);
const failures = results.filter((result) => result.status === 'rejected');
if (results.some((result) => result.status === 'fulfilled')) {
this.lastSuccessAt = new Date().toISOString();
}
this.lastError = failures.length
? failures.map((result) => result.reason.message).join('; ')
: null;
this.persist();
this.emit('change');
return {
delivered: results.length - failures.length,
failed: failures.length,
};
}
async sendTest() {
if (!this.token || !this.recipientIds.length) {
throw new Error('Telegram notifications are not configured.');
}
const results = await Promise.allSettled(this.recipientIds.map(async (recipientId) => {
try {
return await this.request(this.token, 'sendMessage', {
chat_id: recipientId,
text: '✅ Уведомления Dota 2 lobby подключены.',
});
} catch (err) {
throw new Error(`${recipientId}: ${err.message}`);
}
}));
const failures = results.filter((result) => result.status === 'rejected');
if (results.length > failures.length) this.lastSuccessAt = new Date().toISOString();
this.lastError = failures.length
? failures.map((result) => result.reason.message).join('; ')
: null;
this.persist();
this.emit('change');
return {
delivered: results.length - failures.length,
failed: failures.length,
errors: failures.map((result) => result.reason.message),
};
}
async sendAlert({ title, body }) {
if (!this.token || !this.recipientIds.length) return { delivered: 0, failed: 0 };
const text = [`🔔 ${cleanLine(title, 160)}`, cleanLine(body, 500)].filter(Boolean).join('\n');
const results = await Promise.allSettled(this.recipientIds.map(async (recipientId) => {
try {
return await this.request(this.token, 'sendMessage', { chat_id: recipientId, text });
} catch (err) {
throw new Error(`${recipientId}: ${err.message}`);
}
}));
const failures = results.filter((result) => result.status === 'rejected');
if (results.length > failures.length) this.lastSuccessAt = new Date().toISOString();
this.lastError = failures.length
? failures.map((result) => result.reason.message).join('; ')
: null;
this.persist();
this.emit('change');
return {
delivered: results.length - failures.length,
failed: failures.length,
};
}
recordError(err) {
this.lastError = err.message || String(err);
this.persist();
this.emit('change');
console.error(`Telegram notification failed: ${this.lastError}`);
}
}
module.exports = {
TelegramApiError,
TelegramNotifier,
formatLobbyMessage,
parseRecipientIds,
validateBotToken,
};
+44
View File
@@ -0,0 +1,44 @@
'use strict';
const fs = require('fs');
const os = require('os');
const path = require('path');
function parseWorkshopPublishData(text) {
const sourceFolder = String(text).match(/"source_folder"\s+"([^"]+)"/)?.[1]?.trim() || '';
const publishTime = String(text).match(/"publish_time"\s+"(\d+)"/)?.[1] || '';
if (!sourceFolder) return null;
return { sourceFolder, publishTime };
}
function defaultWorkshopRoots() {
const home = os.homedir();
return [
process.env.DOTA_WORKSHOP_DIR,
path.join(home, '.local', 'share', 'Steam', 'steamapps', 'workshop', 'content', '570'),
path.join(home, '.steam', 'steam', 'steamapps', 'workshop', 'content', '570'),
].filter(Boolean);
}
function resolveLocalWorkshopMetadata(customGameId, roots = defaultWorkshopRoots()) {
const id = String(customGameId || '').trim();
if (!/^\d+$/.test(id)) return null;
for (const root of [...new Set(roots)]) {
const file = path.join(root, id, 'publish_data.txt');
try {
const metadata = parseWorkshopPublishData(fs.readFileSync(file, 'utf8'));
if (metadata) return { ...metadata, file };
} catch (err) {
if (err.code !== 'ENOENT' && err.code !== 'ENOTDIR') {
console.warn(`warn: could not read Workshop metadata ${file}: ${err.message}`);
}
}
}
return null;
}
module.exports = {
parseWorkshopPublishData,
resolveLocalWorkshopMetadata,
};