const crypto = require('crypto'); const fs = require('fs'); const http = require('http'); const path = require('path'); const { EventEmitter } = require('events'); const SteamUser = require('steam-user'); const protobuf = require('protobufjs'); const webpush = require('web-push'); const { DEFAULT_LOBBY_SETTINGS, decideLobbySafety, findAdminMember, toDotaLobbyDetails, validateLobbySettings, } = require('./lib/lobby-settings'); const { resolveDotaClientVersion } = require('./lib/dota-client-version'); const { TelegramNotifier } = require('./lib/telegram-notifier'); const { resolveLocalWorkshopMetadata } = require('./lib/workshop-metadata'); const APP_VERSION = require('./package.json').version; const DOTA_APPID = 570; const DEFAULT_PORT = 3000; const DEFAULT_REFRESH_MS = 3 * 60 * 1000; const READY_TIMEOUT_MS = 60 * 1000; const LOBBY_TIMEOUT_MS = 30 * 1000; const PRACTICE_LOBBY_TIMEOUT_MS = 10 * 1000; const OWN_LOBBY_TIMEOUT_MS = 30 * 1000; const WATCHDOG_INTERVAL_MS = 30 * 1000; const GC_RELAUNCH_AFTER_MS = 2 * 60 * 1000; const GC_RELOG_AFTER_MS = 10 * 60 * 1000; const STEAM_STUCK_AFTER_MS = 10 * 60 * 1000; const AUTH_FILE = process.env.STEAM_AUTH_FILE || path.join(__dirname, '.steam-auth.json'); const CREDENTIALS_FILE = process.env.STEAM_CREDENTIALS_FILE || path.join(path.dirname(AUTH_FILE), '.steam-credentials.json'); const LOBBY_SETTINGS_FILE = process.env.LOBBY_SETTINGS_FILE || path.join(path.dirname(AUTH_FILE), '.lobby-settings.json'); const PUSH_FILE = process.env.WEB_PUSH_FILE || path.join(path.dirname(AUTH_FILE), '.web-push.json'); const TELEGRAM_FILE = process.env.TELEGRAM_FILE || path.join(path.dirname(AUTH_FILE), '.telegram.json'); const STEAM_DATA_DIR = process.env.STEAM_DATA_DIR || path.join(path.dirname(AUTH_FILE), 'node-steam-user'); const PUSH_SUBJECT = process.env.WEB_PUSH_SUBJECT || 'mailto:admin@example.com'; const ADMIN_PASSWORD = process.env.AUTH_ADMIN_PASSWORD || process.env.ADMIN_PASSWORD || ''; const MSG = { GCClientWelcome: 4004, GCClientHello: 4006, SOCreate: 21, SOUpdate: 22, SODestroy: 23, SOCacheSubscribed: 24, SOCacheUnsubscribed: 25, SOUpdateMultiple: 26, PracticeLobbyCreate: 7038, PracticeLobbyResponse: 7039, PracticeLobbyLeave: 7040, PracticeLobbyList: 7042, PracticeLobbyListResponse: 7043, PracticeLobbySetDetails: 7046, JoinableCustomLobbiesRequest: 7468, JoinableCustomLobbiesResponse: 7469, }; const SO_TYPE_DOTA_LOBBY = 2004; const DOTA_LOBBY_STATE_UI = 0; const REGIONS = { 0: 'Auto', 1: 'US West', 2: 'US East', 3: 'Europe', 5: 'Singapore', 6: 'Dubai', 7: 'Australia', 8: 'Stockholm', 9: 'Austria', 10: 'Brazil', 11: 'South Africa', 14: 'Chile', 15: 'Peru', 16: 'India', 19: 'Japan', 37: 'Taiwan', 38: 'Argentina', }; const args = process.argv.slice(2); function hasArg(name) { return args.includes(name); } function argVal(name) { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; } function numberOption(name, envName, fallback) { const raw = argVal(name) ?? process.env[envName]; if (raw === undefined) return fallback; const parsed = Number(raw); return Number.isFinite(parsed) ? parsed : fallback; } function printUsage() { console.log(`Usage: node index.js [--port ] [--region ] [--game-id ] Options: --port Web UI port, default ${DEFAULT_PORT} --region GC server region, default 0 (${REGIONS[0]}) --game-id Workshop id of a custom game, filters server-side --once One-shot console mode, supports --game and --map filters --refresh-ms Background refresh interval, default ${DEFAULT_REFRESH_MS} Environment: AUTH_ADMIN_PASSWORD Password required for /admin auth editing STEAM_AUTH_FILE Refresh-token file, default ${AUTH_FILE} STEAM_CREDENTIALS_FILE Steam account/password file, default ${CREDENTIALS_FILE} STEAM_DATA_DIR Persistent steam-user machine-auth directory, default ${STEAM_DATA_DIR} TELEGRAM_FILE Telegram bot configuration, default ${TELEGRAM_FILE} STEAM_ACCOUNT / STEAM_PASSWORD Optional fallback credentials DOTA_CLIENT_VERSION Current ClientVersion from game/dota/steam.inf (auto-detected when available) DOTA_STEAM_INF Optional path to game/dota/steam.inf Regions: ${Object.entries(REGIONS).map(([k, v]) => `${k}=${v}`).join(', ')}`); } if (hasArg('--help') || hasArg('-h')) { printUsage(); process.exit(0); } const port = numberOption('--port', 'PORT', DEFAULT_PORT); const refreshIntervalMs = numberOption('--refresh-ms', 'REFRESH_MS', DEFAULT_REFRESH_MS); const serverRegion = numberOption('--region', 'DOTA_REGION', 0); const customGameId = argVal('--game-id') ?? process.env.DOTA_CUSTOM_GAME_ID; const oneShot = hasArg('--once'); const initialGameFilter = argVal('--game')?.toLowerCase(); const initialMapFilter = argVal('--map')?.toLowerCase(); const dotaClientVersion = resolveDotaClientVersion(); const proto = protobuf.loadSync(path.join(__dirname, 'dota.proto')); const Hello = proto.lookupType('CMsgClientHello'); const Welcome = proto.lookupType('CMsgClientWelcome'); const GenericResult = proto.lookupType('CMsgGenericResult'); const SOSingle = proto.lookupType('CMsgSOSingleObject'); const SOMultiple = proto.lookupType('CMsgSOMultipleObjects'); const SOCacheSubscribed = proto.lookupType('CMsgSOCacheSubscribed'); const SOCacheUnsubscribed = proto.lookupType('CMsgSOCacheUnsubscribed'); const DotaLobby = proto.lookupType('CSODOTALobby'); const PracticeLobbyCreate = proto.lookupType('CMsgPracticeLobbyCreate'); const PracticeLobbySetDetails = proto.lookupType('CMsgPracticeLobbySetDetails'); const PracticeLobbyLeave = proto.lookupType('CMsgPracticeLobbyLeave'); const PracticeLobbyList = proto.lookupType('CMsgPracticeLobbyList'); const PracticeLobbyListResponse = proto.lookupType('CMsgPracticeLobbyListResponse'); const LobbiesReq = proto.lookupType('CMsgJoinableCustomLobbiesRequest'); const LobbiesResp = proto.lookupType('CMsgJoinableCustomLobbiesResponse'); function ensureParentDir(file) { fs.mkdirSync(path.dirname(file), { recursive: true }); } class JsonStore { constructor(file) { this.file = file; } load() { try { const raw = fs.readFileSync(this.file, 'utf8'); return JSON.parse(raw); } catch (err) { if (err.code !== 'ENOENT') { console.warn(`warn: could not read ${this.file}: ${err.message}`); } return null; } } save(data) { ensureParentDir(this.file); const payload = { ...data, updatedAt: new Date().toISOString(), }; fs.writeFileSync(this.file, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 }); } remove() { try { fs.unlinkSync(this.file); } catch (err) { if (err.code !== 'ENOENT') throw err; } } } class AuthStore extends JsonStore { loadEntries() { const parsed = super.load() || {}; if (Array.isArray(parsed.tokens)) { return parsed.tokens.filter((entry) => ( entry && typeof entry.accountName === 'string' && typeof entry.refreshToken === 'string' )); } if (typeof parsed.refreshToken === 'string') { return [{ accountName: String(parsed.accountName || ''), refreshToken: parsed.refreshToken, steamID: parsed.steamID || null, updatedAt: parsed.updatedAt || null, }]; } return []; } load(accountName = '') { const wanted = normalizeAccountName(accountName); const entries = this.loadEntries(); if (!wanted) return entries.length === 1 ? entries[0] : null; return entries.find((entry) => normalizeAccountName(entry.accountName) === wanted) || (entries.length === 1 && !entries[0].accountName ? entries[0] : null); } save(data) { if (!data?.refreshToken || !data?.accountName) { throw new Error('Steam refresh token and account name are required.'); } const wanted = normalizeAccountName(data.accountName); const entries = this.loadEntries().filter( (entry) => normalizeAccountName(entry.accountName) !== wanted, ); entries.push({ accountName: data.accountName, refreshToken: data.refreshToken, steamID: data.steamID || null, updatedAt: new Date().toISOString(), }); super.save({ tokens: entries }); } remove(accountName = '') { const wanted = normalizeAccountName(accountName); if (!wanted) { super.remove(); return; } const entries = this.loadEntries().filter( (entry) => normalizeAccountName(entry.accountName) !== wanted, ); if (entries.length) super.save({ tokens: entries }); else super.remove(); } } class CredentialsStore extends JsonStore { loadData() { const parsed = super.load() || {}; let accounts = []; if (Array.isArray(parsed.accounts)) { accounts = parsed.accounts.filter((entry) => ( entry && typeof entry.accountName === 'string' && typeof entry.password === 'string' )); } else if (typeof parsed.accountName === 'string' && typeof parsed.password === 'string') { accounts = [{ accountName: parsed.accountName, password: parsed.password, updatedAt: parsed.updatedAt || null, }]; } const requestedActive = normalizeAccountName(parsed.activeAccountName); const active = accounts.find( (entry) => normalizeAccountName(entry.accountName) === requestedActive, ) || accounts[0] || null; return { accounts, activeAccountName: active?.accountName || '', }; } load() { const data = this.loadData(); return data.accounts.find( (entry) => normalizeAccountName(entry.accountName) === normalizeAccountName(data.activeAccountName), ) || null; } get(accountName) { const wanted = normalizeAccountName(accountName); return this.loadData().accounts.find( (entry) => normalizeAccountName(entry.accountName) === wanted, ) || null; } listPublic() { const data = this.loadData(); return { activeAccountName: data.activeAccountName, accounts: data.accounts.map((entry) => ({ accountName: entry.accountName, passwordSaved: Boolean(entry.password), updatedAt: entry.updatedAt || null, })), }; } saveAccount({ accountName, password, activate = true }) { const cleanName = String(accountName || '').trim(); if (!cleanName) throw new Error('Steam account name is required.'); const wanted = normalizeAccountName(cleanName); const data = this.loadData(); const previous = data.accounts.find( (entry) => normalizeAccountName(entry.accountName) === wanted, ); const nextPassword = String(password || '') || previous?.password || ''; if (!nextPassword) throw new Error('Steam password is required for a new saved account.'); const accounts = data.accounts.filter( (entry) => normalizeAccountName(entry.accountName) !== wanted, ); accounts.push({ accountName: cleanName, password: nextPassword, updatedAt: new Date().toISOString(), }); const activeAccountName = activate ? cleanName : (data.activeAccountName || cleanName); super.save({ activeAccountName, accounts }); return this.get(cleanName); } activate(accountName) { const data = this.loadData(); const account = data.accounts.find( (entry) => normalizeAccountName(entry.accountName) === normalizeAccountName(accountName), ); if (!account) throw new Error('Saved Steam account was not found.'); super.save({ activeAccountName: account.accountName, accounts: data.accounts }); return account; } removeAccount(accountName) { const wanted = normalizeAccountName(accountName); const data = this.loadData(); const accounts = data.accounts.filter( (entry) => normalizeAccountName(entry.accountName) !== wanted, ); if (accounts.length === data.accounts.length) throw new Error('Saved Steam account was not found.'); const activeWasRemoved = normalizeAccountName(data.activeAccountName) === wanted; const activeAccountName = activeWasRemoved ? (accounts[0]?.accountName || '') : data.activeAccountName; if (accounts.length) super.save({ activeAccountName, accounts }); else super.remove(); return { activeAccountName, accounts }; } } function normalizeAccountName(value) { return String(value || '').trim().toLocaleLowerCase('en-US'); } class LobbySettingsStore extends JsonStore { loadSettings() { const parsed = super.load(); if (!parsed?.settings) return { ...DEFAULT_LOBBY_SETTINGS }; return { ...DEFAULT_LOBBY_SETTINGS, ...parsed.settings }; } saveSettings(settings) { const previous = super.load() || {}; this.save({ ...previous, settings }); } loadRuntime() { const parsed = super.load(); return parsed?.runtime && typeof parsed.runtime === 'object' ? parsed.runtime : {}; } saveRuntime(runtime) { const previous = super.load() || {}; this.save({ ...previous, runtime }); } } class PushService { constructor(file) { this.store = new JsonStore(file); const saved = this.store.load() || {}; this.vapidKeys = saved.vapidKeys?.publicKey && saved.vapidKeys?.privateKey ? saved.vapidKeys : webpush.generateVAPIDKeys(); this.subscriptions = Array.isArray(saved.subscriptions) ? saved.subscriptions.filter((subscription) => PushService.validSubscription(subscription)) : []; this.persist(); webpush.setVapidDetails(PUSH_SUBJECT, this.vapidKeys.publicKey, this.vapidKeys.privateKey); } static validSubscription(subscription) { return Boolean( subscription && typeof subscription.endpoint === 'string' && subscription.endpoint.startsWith('https://') && typeof subscription.keys?.p256dh === 'string' && typeof subscription.keys?.auth === 'string', ); } persist() { this.store.save({ vapidKeys: this.vapidKeys, subscriptions: this.subscriptions, }); } publicState() { return { publicKey: this.vapidKeys.publicKey, subscriptions: this.subscriptions.length, }; } subscribe(subscription) { if (!PushService.validSubscription(subscription)) { throw new Error('Invalid browser push subscription.'); } this.subscriptions = this.subscriptions.filter((item) => item.endpoint !== subscription.endpoint); this.subscriptions.push(subscription); this.persist(); } unsubscribe(endpoint) { const before = this.subscriptions.length; this.subscriptions = this.subscriptions.filter((item) => item.endpoint !== endpoint); if (before !== this.subscriptions.length) this.persist(); } async send(payload) { const message = JSON.stringify({ url: '/admin', ...payload, }); const stale = new Set(); const results = await Promise.allSettled(this.subscriptions.map(async (subscription) => { try { await webpush.sendNotification(subscription, message, { TTL: 60 * 60, urgency: 'high', topic: String(payload.tag || 'doka-lobby').slice(0, 32), }); } catch (err) { if (err.statusCode === 404 || err.statusCode === 410) stale.add(subscription.endpoint); throw err; } })); if (stale.size) { this.subscriptions = this.subscriptions.filter((item) => !stale.has(item.endpoint)); this.persist(); } return { delivered: results.filter((result) => result.status === 'fulfilled').length, failed: results.filter((result) => result.status === 'rejected').length, }; } } function decodeJwtPayload(token) { const [, payload] = String(token).split('.'); if (!payload) return null; const normalized = payload.replace(/-/g, '+').replace(/_/g, '/'); const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '='); try { return JSON.parse(Buffer.from(padded, 'base64').toString('utf8')); } catch { return null; } } function tokenExpiresSoon(token) { const payload = decodeJwtPayload(token); if (!payload?.exp) return false; return payload.exp * 1000 < Date.now() + 24 * 60 * 60 * 1000; } function tokenExpiresAt(token) { const payload = decodeJwtPayload(token); if (!payload?.exp) return null; return new Date(payload.exp * 1000).toISOString(); } function resolveCredentials(credentialsStore) { const stored = credentialsStore.load(); if (stored?.accountName && stored?.password) { return { accountName: stored.accountName, password: stored.password, source: 'saved-account', updatedAt: stored.updatedAt || null, }; } if (process.env.STEAM_ACCOUNT && process.env.STEAM_PASSWORD) { return { accountName: process.env.STEAM_ACCOUNT, password: process.env.STEAM_PASSWORD, source: 'env', updatedAt: null, }; } return { accountName: '', password: '', source: 'missing', updatedAt: null, }; } function createLogOnDetails({ authStore, accountName, accountPassword, forcePassword = false }) { const auth = authStore.load(accountName); const canRefreshWithPassword = Boolean(accountName && accountPassword); const canUseToken = auth?.refreshToken && (!tokenExpiresSoon(auth.refreshToken) || !canRefreshWithPassword); if (!forcePassword && canUseToken) { const details = { refreshToken: auth.refreshToken, machineName: 'doka-lobby', }; if (auth.steamID) details.steamID = auth.steamID; return { source: 'refresh-token', details, }; } if (!accountName || !accountPassword) { return { source: 'missing', details: null }; } return { source: 'password', details: { accountName, password: accountPassword, machineName: 'doka-lobby', }, }; } class DotaLobbyClient extends EventEmitter { constructor({ authStore, credentialsStore }) { super(); this.authStore = authStore; this.credentialsStore = credentialsStore; this.credentials = resolveCredentials(credentialsStore); this.user = null; this.connectionState = 'idle'; this.gcState = 'idle'; this.authSource = null; this.loggedOn = false; this.loggingOn = false; this.dotaLaunched = false; this.gcReady = false; this.forcePasswordLogin = false; this.helloTimer = null; this.reconnectTimer = null; this.readyWaiters = new Set(); this.pendingLobbyRequest = null; this.pendingPracticeLobbyRequest = null; this.currentLobby = null; this.ownLobbyWaiters = new Set(); this.ownLobbyClearWaiters = new Set(); this.lobbyOperation = null; this.steamGuardPrompt = null; this.steamGuardCallback = null; this.gcDownSince = null; this.steamDownSince = null; this.lastGcRelaunchAt = 0; this.watchdogTimer = setInterval(() => this.watchdog(), WATCHDOG_INTERVAL_MS); this.watchdogTimer.unref(); this.createSteamUser(); } createSteamUser() { fs.mkdirSync(STEAM_DATA_DIR, { recursive: true, mode: 0o700 }); this.user = new SteamUser({ autoRelogin: true, renewRefreshTokens: true, dataDirectory: STEAM_DATA_DIR, }); this.bindEvents(); } status() { return { steam: this.connectionState, gc: this.gcState, authSource: this.authSource, loggedOn: this.loggedOn, dotaLaunched: this.dotaLaunched, gcReady: this.gcReady, credentialsSource: this.credentials.source, activeAccountName: this.credentials.accountName || null, steamGuard: this.steamGuardPrompt, ownLobbyId: this.currentLobby?.lobbyId || null, dotaClientVersion: dotaClientVersion.version, dotaClientVersionSource: dotaClientVersion.source, }; } setConnectionState(state) { if (this.connectionState === state) return; this.connectionState = state; this.emit('change'); } setGcState(state) { if (this.gcState === state) return; this.gcState = state; this.emit('change'); } bindEvents() { this.user.on('loggedOn', () => { this.loggingOn = false; this.loggedOn = true; this.dotaLaunched = false; this.gcReady = false; this.steamGuardCallback = null; this.steamGuardPrompt = null; this.clearReconnectTimer(); this.setConnectionState('online'); console.log('Logged into Steam, launching Dota 2...'); try { this.user.setPersona(SteamUser.EPersonaState.Online); } catch (err) { console.warn(`warn: could not set Steam persona online: ${err.message}`); } this.launchDota(); this.emit('change'); }); this.user.on('steamGuard', (domain, callback, lastCodeWrong) => { this.steamGuardCallback = callback; this.steamGuardPrompt = { required: true, domain: domain || null, lastCodeWrong: Boolean(lastCodeWrong), requestedAt: new Date().toISOString(), }; this.setConnectionState('steam-guard'); this.emit('change'); }); this.user.on('refreshToken', (refreshToken) => { const steamID = this.user.steamID?.getSteamID64?.(); this.authStore.save({ refreshToken, steamID, accountName: this.credentials.accountName, }); this.authSource = 'refresh-token'; console.log(`Saved Steam refresh token to ${this.authStore.file}`); this.emit('change'); }); this.user.on('disconnected', (eresult, msg) => { this.loggedOn = false; this.loggingOn = false; this.dotaLaunched = false; this.gcReady = false; this.steamGuardCallback = null; this.steamGuardPrompt = null; this.clearHelloTimer(); this.rejectReadyWaiters(new Error(`Steam disconnected: ${msg || eresult || 'unknown reason'}`)); this.rejectLobbyRequest(new Error(`Steam disconnected: ${msg || eresult || 'unknown reason'}`)); this.rejectOwnLobbyWaiters(new Error(`Steam disconnected: ${msg || eresult || 'unknown reason'}`)); this.clearCurrentLobby('steam-disconnected'); this.setConnectionState('reconnecting'); this.setGcState('idle'); this.emit('change'); }); this.user.on('error', (err) => { console.error('Steam error:', err.message); this.loggedOn = false; this.loggingOn = false; this.dotaLaunched = false; this.gcReady = false; this.steamGuardCallback = null; this.steamGuardPrompt = null; this.clearHelloTimer(); this.rejectReadyWaiters(err); this.rejectLobbyRequest(err); this.rejectOwnLobbyWaiters(err); this.clearCurrentLobby('steam-error'); this.setConnectionState('error'); this.setGcState('idle'); if (this.authSource === 'refresh-token' && this.credentials.accountName && this.credentials.password) { console.warn('warn: refresh token login failed, falling back to password login.'); this.forcePasswordLogin = true; try { this.authStore.remove(this.credentials.accountName); } catch (removeErr) { console.warn(`warn: could not remove auth file: ${removeErr.message}`); } } this.scheduleReconnect(); this.emit('change'); }); this.user.on('appQuit', (appid) => { if (appid !== DOTA_APPID || !this.loggedOn) return; this.dotaLaunched = false; this.gcReady = false; this.clearHelloTimer(); this.setGcState('reconnecting'); this.launchDota(true); }); this.user.on('receivedFromGC', (appid, msgType, payload) => { if (appid !== DOTA_APPID) return; if (msgType === MSG.GCClientWelcome) { this.handleGcWelcome(payload); return; } if (msgType === MSG.JoinableCustomLobbiesResponse) { this.handleLobbyResponse(payload); return; } if (msgType === MSG.PracticeLobbyListResponse) { this.handlePracticeLobbyResponse(payload); return; } try { if (msgType === MSG.SOCacheSubscribed) { this.handleSOCacheSubscribed(payload); } else if (msgType === MSG.SOUpdateMultiple) { this.handleSOUpdateMultiple(payload); } else if (msgType === MSG.SOCreate || msgType === MSG.SOUpdate) { this.handleSOSingle(payload); } else if (msgType === MSG.SODestroy) { this.handleSODestroy(payload); } else if (msgType === MSG.SOCacheUnsubscribed) { this.handleSOCacheUnsubscribed(payload); } } catch (err) { this.emit('warning', new Error(`Could not decode Dota lobby update: ${err.message}`)); } }); } start() { if (this.loggedOn || this.loggingOn || this.connectionState === 'reconnecting' || this.steamGuardCallback) return; this.credentials = resolveCredentials(this.credentialsStore); const logOn = createLogOnDetails({ authStore: this.authStore, accountName: this.credentials.accountName, accountPassword: this.credentials.password, forcePassword: this.forcePasswordLogin, }); if (!logOn.details) { this.authSource = null; this.setConnectionState('auth-required'); throw new Error('Steam credentials are missing. Open Admin auth and save account credentials.'); } this.authSource = logOn.source; this.loggingOn = true; this.setConnectionState('connecting'); console.log(`Logging into Steam using ${logOn.source}...`); this.user.logOn(logOn.details); } restartWithCredentials({ accountName, password, clearRefreshToken }) { const sameCurrentAccount = normalizeAccountName(accountName) === normalizeAccountName(this.credentials.accountName); this.credentialsStore.saveAccount({ accountName, password: password || (sameCurrentAccount ? this.credentials.password : ''), activate: true, }); this.credentials = resolveCredentials(this.credentialsStore); this.forcePasswordLogin = Boolean(clearRefreshToken); if (clearRefreshToken) { this.authStore.remove(this.credentials.accountName); } this.resetSteamUser(); this.start(); } switchAccount(accountName) { const previous = normalizeAccountName(this.credentials.accountName); const account = this.credentialsStore.activate(accountName); this.credentials = resolveCredentials(this.credentialsStore); this.forcePasswordLogin = false; if ( previous === normalizeAccountName(account.accountName) && (this.loggedOn || this.loggingOn) ) { this.emit('change'); return; } this.resetSteamUser(); this.start(); } resetSteamUser() { this.clearReconnectTimer(); this.clearHelloTimer(); this.rejectReadyWaiters(new Error('Steam connection reset.')); this.rejectLobbyRequest(new Error('Steam connection reset.')); this.rejectOwnLobbyWaiters(new Error('Steam connection reset.')); this.clearCurrentLobby('steam-reset'); this.steamGuardCallback = null; this.steamGuardPrompt = null; if (this.user) { this.user.removeAllListeners(); try { if (this.loggedOn || this.loggingOn) this.user.logOff(); } catch (err) { console.warn(`warn: could not log off old Steam session: ${err.message}`); } } this.loggedOn = false; this.loggingOn = false; this.dotaLaunched = false; this.gcReady = false; this.connectionState = 'idle'; this.gcState = 'idle'; this.authSource = null; this.createSteamUser(); this.emit('change'); } submitSteamGuardCode(code) { if (!this.steamGuardCallback) { throw new Error('Steam Guard code is not currently requested.'); } const callback = this.steamGuardCallback; this.steamGuardCallback = null; this.steamGuardPrompt = { required: false, submittedAt: new Date().toISOString(), }; this.setConnectionState('connecting'); callback(code); this.emit('change'); } // Periodic self-healing: the GC can silently drop our session (e.g. GC // maintenance restarts) while hello messages keep going into the void. // Escalate: relaunch Dota after GC_RELAUNCH_AFTER_MS, then tear down and // rebuild the whole Steam connection after GC_RELOG_AFTER_MS. Also rebuilds // the connection if Steam itself stays offline longer than STEAM_STUCK_AFTER_MS. watchdog() { const now = Date.now(); if (this.steamGuardCallback) { // Waiting on user input; nothing to heal automatically. this.gcDownSince = null; this.steamDownSince = null; return; } if (!this.loggedOn) { this.gcDownSince = null; if (!this.steamDownSince) { this.steamDownSince = now; return; } if (now - this.steamDownSince >= STEAM_STUCK_AFTER_MS) { this.steamDownSince = now; console.warn('watchdog: Steam connection stuck offline, rebuilding session...'); this.hardReset(); } return; } this.steamDownSince = null; if (this.gcReady) { this.gcDownSince = null; return; } if (!this.gcDownSince) { this.gcDownSince = now; return; } if (now - this.gcDownSince >= GC_RELOG_AFTER_MS) { this.gcDownSince = now; console.warn('watchdog: GC unavailable despite relaunches, rebuilding Steam session...'); this.hardReset(); return; } if (now - this.gcDownSince >= GC_RELAUNCH_AFTER_MS && now - this.lastGcRelaunchAt >= GC_RELAUNCH_AFTER_MS) { this.lastGcRelaunchAt = now; console.warn('watchdog: GC not ready, relaunching Dota 2...'); this.clearHelloTimer(); this.launchDota(true); } } hardReset() { this.gcDownSince = null; this.resetSteamUser(); try { this.start(); } catch (err) { console.error(`watchdog: could not restart Steam connection: ${err.message}`); } } scheduleReconnect() { if (this.reconnectTimer) return; this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null; try { this.start(); } catch (err) { console.error(err.message); } }, 15000); } clearReconnectTimer() { if (!this.reconnectTimer) return; clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } launchDota(force = false) { if (!this.loggedOn) return; if (this.dotaLaunched && !force) return; try { this.user.gamesPlayed([DOTA_APPID], force); this.dotaLaunched = true; this.setGcState('connecting'); this.startHelloLoop(); } catch (err) { this.setGcState('error'); this.emit('warning', err); } } startHelloLoop() { if (!this.loggedOn || this.gcReady || this.helloTimer) return; const sendHello = () => { if (!this.loggedOn || this.gcReady) return; try { const hello = { engine: 1 }; if (dotaClientVersion.version) hello.version = dotaClientVersion.version; this.user.sendToGC(DOTA_APPID, MSG.GCClientHello, {}, Hello.encode(hello).finish()); } catch (err) { this.setGcState('error'); this.emit('warning', err); } }; sendHello(); this.helloTimer = setInterval(sendHello, 3000); } clearHelloTimer() { if (!this.helloTimer) return; clearInterval(this.helloTimer); this.helloTimer = null; } handleGcWelcome(payload) { this.clearHelloTimer(); this.gcReady = true; this.setGcState('ready'); console.log('Connected to Dota 2 game coordinator.'); try { const welcome = Welcome.decode(payload); for (const cache of welcome.outofdateSubscribedCaches || []) { this.processSubscribedObjects(cache.objects || []); } } catch (err) { this.emit('warning', new Error(`Could not decode GC welcome caches: ${err.message}`)); } for (const waiter of this.readyWaiters) { clearTimeout(waiter.timeout); waiter.resolve(); } this.readyWaiters.clear(); } processSubscribedObjects(objects) { for (const object of objects) { if (Number(object.typeId) !== SO_TYPE_DOTA_LOBBY) continue; const latest = object.objectData?.at(-1); if (latest) this.updateCurrentLobby(latest); } } handleSOCacheSubscribed(payload) { const cache = SOCacheSubscribed.decode(payload); this.processSubscribedObjects(cache.objects || []); } handleSOUpdateMultiple(payload) { const update = SOMultiple.decode(payload); for (const object of [...(update.objectsAdded || []), ...(update.objectsModified || [])]) { if (Number(object.typeId) === SO_TYPE_DOTA_LOBBY && object.objectData?.length) { this.updateCurrentLobby(object.objectData); } } if ((update.objectsRemoved || []).some((object) => Number(object.typeId) === SO_TYPE_DOTA_LOBBY)) { this.clearCurrentLobby('socache-removed'); } } handleSOSingle(payload) { const object = SOSingle.decode(payload); if (Number(object.typeId) === SO_TYPE_DOTA_LOBBY && object.objectData?.length) { this.updateCurrentLobby(object.objectData); } } handleSODestroy(payload) { const object = SOSingle.decode(payload); if (Number(object.typeId) === SO_TYPE_DOTA_LOBBY) this.clearCurrentLobby('socache-destroyed'); } handleSOCacheUnsubscribed(payload) { const cache = SOCacheUnsubscribed.decode(payload); const ownerId = String(cache.ownerSoid?.id || ''); if (this.currentLobby && ownerId === this.currentLobby.lobbyId) { this.clearCurrentLobby('socache-unsubscribed'); } } updateCurrentLobby(payload) { const decoded = DotaLobby.toObject(DotaLobby.decode(payload), { longs: String }); const lobby = { lobbyId: String(decoded.lobbyId || ''), leaderId: String(decoded.leaderId || ''), state: Number(decoded.state || 0), gameMode: Number(decoded.gameMode || 0), gameName: decoded.gameName || '', serverRegion: Number(decoded.serverRegion || 0), customGameMode: decoded.customGameMode || '', customMapName: decoded.customMapName || '', customGameId: String(decoded.customGameId || ''), customMinPlayers: Number(decoded.customMinPlayers || 0), customMaxPlayers: Number(decoded.customMaxPlayers || 0), visibility: Number(decoded.visibility || 0), lobbyType: Number(decoded.lobbyType ?? -1), customGameCrc: String(decoded.customGameCrc || ''), customGameTimestamp: Number(decoded.customGameTimestamp || 0), customGameAutoCreatedLobby: Boolean(decoded.customGameAutoCreatedLobby), customGamePenalties: Boolean(decoded.customGamePenalties), members: (decoded.allMembers || []).map((member) => ({ steamId: String(member.id || ''), team: Number(member.team ?? 5), slot: Number(member.slot || 0), })).filter((member) => member.steamId && member.steamId !== '0'), }; this.currentLobby = lobby; for (const waiter of this.ownLobbyWaiters) { clearTimeout(waiter.timeout); waiter.resolve(lobby); } this.ownLobbyWaiters.clear(); this.emit('own-lobby', lobby); this.emit('change'); } clearCurrentLobby(reason) { const previous = this.currentLobby; this.currentLobby = null; if (!previous) return; for (const waiter of this.ownLobbyClearWaiters) { clearTimeout(waiter.timeout); waiter.resolve(); } this.ownLobbyClearWaiters.clear(); this.emit('own-lobby-cleared', { reason, lobby: previous }); this.emit('change'); } rejectOwnLobbyWaiters(err) { for (const waiter of [...this.ownLobbyWaiters, ...this.ownLobbyClearWaiters]) { clearTimeout(waiter.timeout); waiter.reject(err); } this.ownLobbyWaiters.clear(); this.ownLobbyClearWaiters.clear(); this.lobbyOperation = null; } waitForOwnLobby(timeoutMs = OWN_LOBBY_TIMEOUT_MS) { if (this.currentLobby) return Promise.resolve(this.currentLobby); return new Promise((resolve, reject) => { const waiter = { resolve, reject, timeout: setTimeout(() => { this.ownLobbyWaiters.delete(waiter); reject(new Error('Dota GC did not publish the created lobby in time.')); }, timeoutMs), }; this.ownLobbyWaiters.add(waiter); }); } waitForOwnLobbyClear(timeoutMs = OWN_LOBBY_TIMEOUT_MS) { if (!this.currentLobby) return Promise.resolve(); return new Promise((resolve, reject) => { const waiter = { resolve, reject, timeout: setTimeout(() => { this.ownLobbyClearWaiters.delete(waiter); reject(new Error('Dota GC did not confirm that the bot left the lobby in time.')); }, timeoutMs), }; this.ownLobbyClearWaiters.add(waiter); }); } async createPracticeLobby(settings) { await this.waitForReady(); if (this.currentLobby) throw new Error('The bot is already in a lobby. Stop it before creating another one.'); if (this.lobbyOperation) throw new Error('Another lobby operation is already in progress.'); const operation = (async () => { const details = toDotaLobbyDetails(settings); const payload = { passKey: settings.passKey, lobbyDetails: details }; if (dotaClientVersion.version) payload.clientVersion = dotaClientVersion.version; const lobbyPromise = this.waitForOwnLobby(); const responseFailure = new Promise((_, reject) => { this.user.sendToGC( DOTA_APPID, MSG.PracticeLobbyCreate, {}, PracticeLobbyCreate.encode(payload).finish(), (appid, msgType, responsePayload) => { try { if (appid !== DOTA_APPID || msgType !== MSG.PracticeLobbyResponse) { reject(new Error(`Unexpected Dota GC lobby-create response ${msgType}.`)); return; } const response = GenericResult.decode(responsePayload); if (Number(response.result) !== 1) { reject(new Error(`Dota GC rejected lobby creation with result ${response.result ?? 'unknown'}.`)); } // A successful generic response is not enough: the SOCache // lobby snapshot remains the authoritative completion signal. } catch (err) { reject(new Error(`Could not decode Dota GC lobby-create response: ${err.message}`)); } }, ); }); const lobby = await Promise.race([lobbyPromise, responseFailure]); // The official client updates the details after creation as well. Send // the complete public custom-game metadata once the lobby ID is known so // the GC does not retain a half-configured, unlisted lobby. this.user.sendToGC( DOTA_APPID, MSG.PracticeLobbySetDetails, {}, PracticeLobbySetDetails.encode({ ...details, lobbyId: lobby.lobbyId }).finish(), ); // PracticeLobbyCreate places the owner into Radiant slot 1. Keep that // state: it matches the real client and known working lobby bots. The // configured real-player threshold still makes this bot leave early. return lobby; })(); this.lobbyOperation = operation; try { return await operation; } finally { if (this.lobbyOperation === operation) this.lobbyOperation = null; } } async leavePracticeLobby() { await this.waitForReady(); if (!this.currentLobby) return; if (this.lobbyOperation) { await this.lobbyOperation; if (!this.currentLobby) return; } const operation = (async () => { this.user.sendToGC( DOTA_APPID, MSG.PracticeLobbyLeave, {}, PracticeLobbyLeave.encode({}).finish(), ); await this.waitForOwnLobbyClear(); })(); this.lobbyOperation = operation; try { await operation; } finally { if (this.lobbyOperation === operation) this.lobbyOperation = null; } } resolvePersonas(steamIds) { const ids = [...new Set(steamIds.map(String).filter(Boolean))]; if (!ids.length) return Promise.resolve({}); return new Promise((resolve, reject) => { this.user.getPersonas(ids, (err, personas) => { if (err) reject(err); else resolve(personas || {}); }); }); } rejectReadyWaiters(err) { for (const waiter of this.readyWaiters) { clearTimeout(waiter.timeout); waiter.reject(err); } this.readyWaiters.clear(); } waitForReady(timeoutMs = READY_TIMEOUT_MS) { if (this.gcReady) return Promise.resolve(); return new Promise((resolve, reject) => { const waiter = { resolve, reject, timeout: setTimeout(() => { this.readyWaiters.delete(waiter); this.gcReady = false; this.setGcState('timeout'); if (this.loggedOn) { this.clearHelloTimer(); this.launchDota(true); } reject(new Error('Dota 2 game coordinator did not become ready in time.')); }, timeoutMs), }; this.readyWaiters.add(waiter); try { this.start(); if (this.loggedOn) { this.launchDota(); this.startHelloLoop(); } } catch (err) { clearTimeout(waiter.timeout); this.readyWaiters.delete(waiter); reject(err); } }); } async requestLobbies({ region, gameId }) { await this.waitForReady(); if (this.pendingLobbyRequest) { throw new Error('A lobby request is already in progress.'); } return new Promise((resolve, reject) => { const request = { resolve, reject, timeout: setTimeout(() => { if (this.pendingLobbyRequest !== request) return; this.pendingLobbyRequest = null; this.gcReady = false; this.setGcState('timeout'); this.clearHelloTimer(); this.launchDota(true); reject(new Error('No lobby list response from GC after 30s.')); }, LOBBY_TIMEOUT_MS), }; this.pendingLobbyRequest = request; const payload = { serverRegion: region }; if (gameId) payload.customGameId = gameId; try { this.user.sendToGC(DOTA_APPID, MSG.JoinableCustomLobbiesRequest, {}, LobbiesReq.encode(payload).finish()); } catch (err) { clearTimeout(request.timeout); this.pendingLobbyRequest = null; reject(err); } }); } async requestPracticeLobbies({ region, passKey = '' }) { await this.waitForReady(); if (this.pendingPracticeLobbyRequest) { throw new Error('A practice lobby metadata request is already in progress.'); } return new Promise((resolve, reject) => { const request = { resolve, reject, timeout: setTimeout(() => { if (this.pendingPracticeLobbyRequest !== request) return; this.pendingPracticeLobbyRequest = null; reject(new Error('No practice lobby metadata response from GC after 10s.')); }, PRACTICE_LOBBY_TIMEOUT_MS), }; this.pendingPracticeLobbyRequest = request; try { const payload = { region, gameMode: 15 }; if (passKey) payload.passKey = passKey; this.user.sendToGC( DOTA_APPID, MSG.PracticeLobbyList, {}, PracticeLobbyList.encode(payload).finish(), (appid, msgType, responsePayload) => { if (this.pendingPracticeLobbyRequest !== request) return; if (appid !== DOTA_APPID || msgType !== MSG.PracticeLobbyListResponse) { clearTimeout(request.timeout); this.pendingPracticeLobbyRequest = null; reject(new Error(`Unexpected practice lobby list response ${msgType}.`)); return; } this.handlePracticeLobbyResponse(responsePayload); }, ); } catch (err) { clearTimeout(request.timeout); this.pendingPracticeLobbyRequest = null; reject(err); } }); } handleLobbyResponse(payload) { const request = this.pendingLobbyRequest; if (!request) return; clearTimeout(request.timeout); this.pendingLobbyRequest = null; try { const decoded = LobbiesResp.toObject(LobbiesResp.decode(payload), { longs: String }); request.resolve(decoded.lobbies || []); } catch (err) { request.reject(err); } } handlePracticeLobbyResponse(payload) { const request = this.pendingPracticeLobbyRequest; if (!request) return; clearTimeout(request.timeout); this.pendingPracticeLobbyRequest = null; try { const decoded = PracticeLobbyListResponse.toObject( PracticeLobbyListResponse.decode(payload), { longs: String }, ); request.resolve(decoded.lobbies || []); } catch (err) { request.reject(err); } } rejectLobbyRequest(err) { if (this.pendingLobbyRequest) { clearTimeout(this.pendingLobbyRequest.timeout); this.pendingLobbyRequest.reject(err); this.pendingLobbyRequest = null; } if (this.pendingPracticeLobbyRequest) { clearTimeout(this.pendingPracticeLobbyRequest.timeout); this.pendingPracticeLobbyRequest.reject(err); this.pendingPracticeLobbyRequest = null; } } } class LobbyAutomation extends EventEmitter { constructor({ dotaClient, settingsStore, pushService, telegramNotifier }) { super(); this.dota = dotaClient; this.settingsStore = settingsStore; this.push = pushService; this.telegram = telegramNotifier; this.settings = settingsStore.loadSettings(); const savedRuntime = settingsStore.loadRuntime(); this.armed = Boolean(savedRuntime.armed); this.expectedLobbyId = savedRuntime.lobbyId ? String(savedRuntime.lobbyId) : null; this.notifiedReady = false; this.notifiedUnsafe = false; this.memberNames = new Map(); this.evaluationVersion = 0; this.status = { phase: this.armed ? 'restoring' : 'idle', lobbyId: this.expectedLobbyId, lobbyState: null, humanCount: 0, totalMemberCount: 0, members: [], notifiedAt: null, arcadePublished: null, arcadePublicationCheckedAt: null, practicePublished: null, practicePublicationCheckedAt: null, lobbyProtocol: null, leaveReason: null, matchedAdmin: null, lastEventAt: null, lastError: null, }; dotaClient.on('own-lobby', (lobby) => this.handleLobby(lobby)); dotaClient.on('own-lobby-cleared', (event) => this.handleLobbyCleared(event)); } snapshot() { return { settings: this.settings, status: { ...this.status, armed: this.armed }, push: this.push.publicState(), telegram: this.telegram.publicState(), }; } updateStatus(patch) { this.status = { ...this.status, ...patch, lastEventAt: new Date().toISOString(), }; this.telegram.queueLobbyUpdate({ ...this.settings, serverRegionName: REGIONS[this.settings.serverRegion] || String(this.settings.serverRegion), }, { ...this.status, armed: this.armed }); this.emit('change'); } saveSettings(input) { const settings = validateLobbySettings(input); this.settingsStore.saveSettings(settings); this.settings = settings; this.emit('change'); return settings; } persistRuntime() { this.settingsStore.saveRuntime({ armed: this.armed, lobbyId: this.expectedLobbyId, }); } async start(input) { if (this.armed || this.status.phase === 'creating' || this.status.phase === 'leaving') { throw new Error('Lobby automation is already active.'); } if (this.dota.currentLobby) { throw new Error('The bot is already in a lobby. Stop that lobby before creating another one.'); } const settings = this.saveSettings(input); this.armed = true; this.expectedLobbyId = null; this.persistRuntime(); this.notifiedReady = false; this.notifiedUnsafe = false; this.memberNames.clear(); this.telegram.beginLobbySession(); this.updateStatus({ phase: 'creating', lobbyId: null, lobbyState: null, humanCount: 0, totalMemberCount: 0, members: [], notifiedAt: null, arcadePublished: null, arcadePublicationCheckedAt: null, practicePublished: null, practicePublicationCheckedAt: null, lobbyProtocol: null, leaveReason: null, matchedAdmin: null, lastError: null, }); try { const lobby = await this.dota.createPracticeLobby(settings); if (this.armed && this.status.phase === 'creating') { this.updateStatus({ phase: 'monitoring', lobbyId: lobby.lobbyId }); } return this.snapshot(); } catch (err) { this.armed = false; this.expectedLobbyId = null; this.persistRuntime(); this.updateStatus({ phase: 'error', lastError: err.message }); throw err; } } async stop(reason = 'manual') { const lobby = this.dota.currentLobby; if (!lobby) { this.armed = false; this.expectedLobbyId = null; this.persistRuntime(); this.updateStatus({ phase: 'idle', lobbyId: null, leaveReason: reason }); return this.snapshot(); } if (lobby.state !== DOTA_LOBBY_STATE_UI) { throw new Error('The lobby is already starting or running; automatic leave is disabled to avoid an abandon penalty.'); } await this.triggerLeave(reason); return this.snapshot(); } memberSnapshot(lobby) { const botSteamId = String(this.dota.user?.steamID?.getSteamID64?.() || ''); return lobby.members .filter((member) => member.steamId !== botSteamId) .map((member) => ({ ...member, name: this.memberNames.get(member.steamId) || '', })); } handleLobby(lobby) { const version = ++this.evaluationVersion; const members = this.memberSnapshot(lobby); const botSteamId = String(this.dota.user?.steamID?.getSteamID64?.() || ''); const botMember = lobby.members.find((member) => member.steamId === botSteamId); if (this.armed && this.expectedLobbyId && this.expectedLobbyId !== lobby.lobbyId) { this.armed = false; this.expectedLobbyId = null; this.persistRuntime(); this.updateStatus({ phase: 'unmanaged', lobbyId: lobby.lobbyId, lobbyState: lobby.state, humanCount: members.length, totalMemberCount: lobby.members.length, members, lastError: 'The restored lobby ID does not match the current lobby; automation was disarmed.', }); return; } if (this.armed && !this.expectedLobbyId) { this.expectedLobbyId = lobby.lobbyId; this.persistRuntime(); } const phase = this.armed && this.status.phase !== 'leaving' ? 'monitoring' : this.status.phase; this.updateStatus({ phase: this.armed ? phase : 'unmanaged', lobbyId: lobby.lobbyId, lobbyState: lobby.state, humanCount: members.length, totalMemberCount: lobby.members.length, members, lobbyProtocol: { lobbyType: lobby.lobbyType, visibility: lobby.visibility, gameMode: lobby.gameMode, serverRegion: lobby.serverRegion, customGameMode: lobby.customGameMode, customMapName: lobby.customMapName, customGameId: lobby.customGameId, customGameCrc: lobby.customGameCrc, customGameTimestamp: lobby.customGameTimestamp, autoCreated: lobby.customGameAutoCreatedLobby, penalties: lobby.customGamePenalties, botTeam: botMember?.team ?? null, botSlot: botMember?.slot ?? null, }, lastError: null, }); if (!this.armed || this.status.phase === 'leaving') return; const decision = decideLobbySafety(this.settings, members, lobby.state); if (decision.action === 'unsafe-state') { this.handleUnsafeLobbyState(lobby); return; } if (!this.notifiedReady && members.length >= this.settings.notifyAtPlayers) { this.notifiedReady = true; this.updateStatus({ notifiedAt: new Date().toISOString() }); this.sendNotification({ title: `Lobby is ready: ${members.length} players`, body: `${this.settings.lobbyName}: notification threshold ${this.settings.notifyAtPlayers} reached.`, tag: `lobby-ready-${lobby.lobbyId}`, }); } if (decision.action === 'player-limit') { this.triggerLeave('player-limit').catch((err) => console.error(err.message)); return; } if (decision.action === 'admin-joined') { this.triggerLeave('admin-joined', decision.admin).catch((err) => console.error(err.message)); return; } this.resolveAndEvaluateNames(lobby, members, version); } async resolveAndEvaluateNames(lobby, members, version) { const unresolved = members.filter((member) => !member.name); if (!unresolved.length) return; // Resolve each new member independently. A slow Steam profile must not // delay recognizing an admin whose persona response has already arrived. const results = await Promise.allSettled(unresolved.map(async (member) => { const personas = await this.dota.resolvePersonas([member.steamId]); if ( version !== this.evaluationVersion || !this.armed || this.status.phase === 'leaving' || this.dota.currentLobby?.lobbyId !== lobby.lobbyId ) return; const persona = personas[member.steamId] || {}; const name = persona.player_name || persona.persona_name || persona.name || ''; if (!name) return; this.memberNames.set(member.steamId, name); const admin = findAdminMember(this.settings, [{ ...member, name }]); if (admin) await this.triggerLeave('admin-joined', admin); })); if (version !== this.evaluationVersion || !this.armed || this.status.phase === 'leaving') return; const namedMembers = members.map((member) => ({ ...member, name: this.memberNames.get(member.steamId) || member.name || '', })); const failed = results.filter((result) => result.status === 'rejected'); this.updateStatus({ members: namedMembers, lastError: failed.length ? `Could not resolve ${failed.length} lobby member name(s); SteamID64 matching remains active.` : null, }); } handleUnsafeLobbyState(lobby) { const message = `Lobby ${lobby.lobbyId} left UI state before the bot could leave. No abandon command was sent.`; this.updateStatus({ phase: 'unsafe-state', lastError: message }); if (this.notifiedUnsafe) return; this.notifiedUnsafe = true; this.sendNotification({ title: 'Dota lobby safety warning', body: message, tag: `lobby-unsafe-${lobby.lobbyId}`, }); } async triggerLeave(reason, admin = null) { if (this.status.phase === 'leaving' || this.status.phase === 'left') return; const lobby = this.dota.currentLobby; if (!lobby) return; if (lobby.state !== DOTA_LOBBY_STATE_UI) { this.handleUnsafeLobbyState(lobby); return; } this.updateStatus({ phase: 'leaving', leaveReason: reason, matchedAdmin: admin ? { steamId: admin.steamId, name: admin.name } : null, lastError: null, }); const detail = reason === 'admin-joined' ? `Admin ${admin.name || admin.steamId} joined; the bot is leaving before launch.` : reason === 'player-limit' ? `${this.settings.leaveAtPlayers} real players joined; the bot is leaving before launch.` : 'The bot is leaving the lobby by administrator request.'; this.sendNotification({ title: 'Dota lobby bot is leaving', body: detail, tag: `lobby-leave-${lobby.lobbyId}`, }); try { await this.dota.leavePracticeLobby(); this.armed = false; this.expectedLobbyId = null; this.persistRuntime(); this.updateStatus({ phase: 'left', lobbyId: null, lobbyState: null }); } catch (err) { this.updateStatus({ phase: 'error', lastError: `Failed to leave lobby: ${err.message}` }); throw err; } } handleLobbyCleared(event) { ++this.evaluationVersion; if (this.status.phase === 'leaving' || event.reason.startsWith('socache-')) { this.armed = false; this.expectedLobbyId = null; this.persistRuntime(); this.updateStatus({ phase: 'left', lobbyId: null, lobbyState: null, members: [] }); return; } if (this.armed) { this.updateStatus({ phase: 'connection-lost', lobbyId: null, lobbyState: null }); } else { this.updateStatus({ phase: 'idle', lobbyId: null, lobbyState: null, members: [] }); } } observeArcadePublication(lobbies) { const lobbyId = this.dota.currentLobby?.lobbyId; if (!this.armed || !lobbyId) return; this.updateStatus({ arcadePublished: lobbies.some((lobby) => lobby.id === lobbyId), arcadePublicationCheckedAt: new Date().toISOString(), }); } observePracticePublication(lobbies) { const lobbyId = this.dota.currentLobby?.lobbyId; if (!this.armed || !lobbyId) return; this.updateStatus({ practicePublished: lobbies.some((lobby) => String(lobby.id || '') === lobbyId), practicePublicationCheckedAt: new Date().toISOString(), }); } sendNotification(payload) { this.push.send(payload).then((result) => { console.log(`Browser push: ${result.delivered} delivered, ${result.failed} failed.`); }).catch((err) => { console.error(`Browser push failed: ${err.message}`); }); this.telegram.sendAlert(payload).then((result) => { if (result.delivered || result.failed) { console.log(`Telegram alert: ${result.delivered} delivered, ${result.failed} failed.`); } }).catch((err) => { this.telegram.recordError(err); }); } } const gameNameCache = new Map(); async function resolveGameNames(ids) { const unique = [...new Set(ids.map((id) => String(id || '')).filter(Boolean))]; const missing = unique.filter((id) => !gameNameCache.has(id)); for (let i = 0; i < missing.length; i += 100) { const chunk = missing.slice(i, i + 100); const body = new URLSearchParams({ itemcount: String(chunk.length) }); chunk.forEach((id, index) => body.append(`publishedfileids[${index}]`, id)); try { const res = await fetch('https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/', { method: 'POST', body, }); const json = await res.json(); for (const detail of json.response?.publishedfiledetails || []) { gameNameCache.set(String(detail.publishedfileid), detail.title || String(detail.publishedfileid)); } } catch (err) { console.error(`warn: could not resolve custom game names: ${err.message}`); for (const id of chunk) gameNameCache.set(id, id); } } return gameNameCache; } function normalizeLobby(lobby, names) { const customGameIdValue = String(lobby.customGameId || ''); const memberCount = Number(lobby.memberCount || 0); const maxPlayerCount = Number(lobby.maxPlayerCount || 0); return { id: String(lobby.lobbyId || ''), customGameId: customGameIdValue, customGameMode: lobby.customGameMode || '', customGameCrc: String(lobby.customGameCrc || ''), customGameTimestamp: String(lobby.customGameTimestamp || ''), game: names.get(customGameIdValue) || customGameIdValue, lobby: lobby.lobbyName || '', map: lobby.customMapName || '', memberCount, maxPlayerCount, minPlayerCount: Number(lobby.minPlayerCount || 0), openSlots: Math.max(0, maxPlayerCount - memberCount), players: `${memberCount}/${maxPlayerCount || '?'}`, regionId: Number(lobby.serverRegion || 0), region: REGIONS[lobby.serverRegion] || String(lobby.serverRegion || ''), hasPassKey: Boolean(lobby.hasPassKey), penaltiesEnabled: Boolean(lobby.penaltiesEnabled), leader: lobby.leaderName || '', leaderAccountId: String(lobby.leaderAccountId || ''), }; } function filterRows(rows, { game, map }) { return rows .filter((row) => !game || String(row.game).toLowerCase().includes(game)) .filter((row) => !map || String(row.map ?? '').toLowerCase().includes(map)); } function tableRows(rows) { return rows.map((row) => ({ game: row.game, lobby: row.lobby, map: row.map, players: row.players, region: row.region, pass: row.hasPassKey ? 'yes' : '', leader: row.leader, })); } const state = { status: 'starting', lobbies: [], totalLobbies: 0, refreshCount: 0, refreshInProgress: false, lastUpdatedAt: null, lastStartedAt: null, lastError: null, nextRefreshAt: null, }; const authStore = new AuthStore(AUTH_FILE); const credentialsStore = new CredentialsStore(CREDENTIALS_FILE); const lobbySettingsStore = new LobbySettingsStore(LOBBY_SETTINGS_FILE); const dota = new DotaLobbyClient({ authStore, credentialsStore }); const pushService = oneShot ? null : new PushService(PUSH_FILE); const telegramNotifier = oneShot ? null : new TelegramNotifier({ store: new JsonStore(TELEGRAM_FILE), }); const automation = oneShot ? null : new LobbyAutomation({ dotaClient: dota, settingsStore: lobbySettingsStore, pushService, telegramNotifier, }); const sseClients = new Set(); let refreshTimer = null; let activeRefresh = null; function publicState() { return { ...state, version: APP_VERSION, connection: dota.status(), refreshIntervalMs, regions: REGIONS, auth: { adminConfigured: Boolean(ADMIN_PASSWORD), credentialsConfigured: Boolean(dota.credentials.accountName && dota.credentials.password), tokenConfigured: Boolean(authStore.load(dota.credentials.accountName)?.refreshToken), }, serverFilters: { region: serverRegion, regionName: REGIONS[serverRegion] || String(serverRegion), customGameId: customGameId || null, }, }; } function adminAuthState() { const credentials = resolveCredentials(credentialsStore); const auth = authStore.load(credentials.accountName); const savedAccounts = credentialsStore.listPublic(); return { adminConfigured: Boolean(ADMIN_PASSWORD), regions: REGIONS, files: { authFile: authStore.file, credentialsFile: credentialsStore.file, telegramFile: TELEGRAM_FILE, }, credentials: { accountName: credentials.accountName, passwordSaved: Boolean(credentials.password), source: credentials.source, updatedAt: credentials.updatedAt, activeAccountName: savedAccounts.activeAccountName, savedAccounts: savedAccounts.accounts, }, refreshToken: { exists: Boolean(auth?.refreshToken), accountName: auth?.accountName || null, steamID: auth?.steamID || null, updatedAt: auth?.updatedAt || null, expiresAt: auth?.refreshToken ? tokenExpiresAt(auth.refreshToken) : null, }, connection: dota.status(), lobbyAutomation: automation.snapshot(), }; } function sendJson(res, statusCode, data) { const body = JSON.stringify(data); res.writeHead(statusCode, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(body), 'Cache-Control': 'no-store', }); res.end(body); } function sendText(res, statusCode, text) { res.writeHead(statusCode, { 'Content-Type': 'text/plain; charset=utf-8', 'Content-Length': Buffer.byteLength(text), }); res.end(text); } function safeEqual(a, b) { const left = Buffer.from(String(a || '')); const right = Buffer.from(String(b || '')); if (left.length !== right.length) return false; return crypto.timingSafeEqual(left, right); } function requireAdmin(req, res) { if (!ADMIN_PASSWORD) { sendJson(res, 503, { error: 'Admin password is not configured. Set AUTH_ADMIN_PASSWORD.' }); return false; } const supplied = req.headers['x-admin-password']; if (!safeEqual(supplied, ADMIN_PASSWORD)) { sendJson(res, 401, { error: 'Invalid admin password.' }); return false; } return true; } function readJsonBody(req, maxBytes = 64 * 1024) { return new Promise((resolve, reject) => { let body = ''; req.setEncoding('utf8'); req.on('data', (chunk) => { body += chunk; if (Buffer.byteLength(body) > maxBytes) { reject(new Error('Request body is too large.')); req.destroy(); } }); req.on('end', () => { if (!body) { resolve({}); return; } try { resolve(JSON.parse(body)); } catch { reject(new Error('Request body must be valid JSON.')); } }); req.on('error', reject); }); } function broadcastState() { const data = `data: ${JSON.stringify(publicState())}\n\n`; for (const client of sseClients) { client.write(data); } } dota.on('change', broadcastState); if (automation) automation.on('change', broadcastState); if (telegramNotifier) telegramNotifier.on('change', broadcastState); dota.on('warning', (err) => { state.lastError = err.message; broadcastState(); }); function scheduleNextRefresh(delay = refreshIntervalMs) { if (refreshTimer) clearTimeout(refreshTimer); state.nextRefreshAt = new Date(Date.now() + delay).toISOString(); broadcastState(); refreshTimer = setTimeout(() => { refreshTimer = null; refreshLobbies('timer').catch((err) => { console.error(err.message); }); }, delay); } async function refreshLobbies(reason) { if (activeRefresh) return activeRefresh; activeRefresh = (async () => { state.status = 'refreshing'; state.refreshInProgress = true; state.lastStartedAt = new Date().toISOString(); state.lastError = null; state.nextRefreshAt = null; broadcastState(); try { const practiceMetadataPromise = dota.requestPracticeLobbies({ region: serverRegion }).catch((err) => { console.warn(`warn: could not enrich lobby templates: ${err.message}`); return []; }); const rawLobbies = await dota.requestLobbies({ region: serverRegion, gameId: customGameId }); const practiceMetadata = await practiceMetadataPromise; if (automation?.armed && dota.currentLobby) { let ownPracticeLobbies = practiceMetadata; const needsTargetedLookup = serverRegion !== automation.settings.serverRegion || Boolean(automation.settings.passKey); if (needsTargetedLookup) { ownPracticeLobbies = await dota.requestPracticeLobbies({ region: automation.settings.serverRegion, passKey: automation.settings.passKey, }).catch((err) => { console.warn(`warn: could not verify practice lobby publication: ${err.message}`); return []; }); } automation.observePracticePublication(ownPracticeLobbies); } const metadataByLobbyId = new Map(practiceMetadata.map((lobby) => [String(lobby.id || ''), lobby])); const enrichedLobbies = rawLobbies.map((lobby) => { const metadata = metadataByLobbyId.get(String(lobby.lobbyId || '')); const localMetadata = resolveLocalWorkshopMetadata(lobby.customGameId); return metadata || localMetadata ? { ...lobby, customGameMode: metadata?.customGameMode || localMetadata?.sourceFolder || '', penaltiesEnabled: metadata?.penaltiesEnabled, customGameTimestamp: lobby.customGameTimestamp || localMetadata?.publishTime || '', } : lobby; }); const names = await resolveGameNames(enrichedLobbies.map((lobby) => lobby.customGameId)); const lobbies = enrichedLobbies.map((lobby) => normalizeLobby(lobby, names)); state.lobbies = lobbies; if (automation) automation.observeArcadePublication(lobbies); state.totalLobbies = rawLobbies.length; state.refreshCount += 1; state.lastUpdatedAt = new Date().toISOString(); state.status = 'ready'; console.log(`Lobby refresh (${reason}) completed: ${lobbies.length} lobbies.`); } catch (err) { state.status = state.lobbies.length ? 'stale' : 'error'; state.lastError = err.message; console.error(`Lobby refresh (${reason}) failed: ${err.message}`); } finally { state.refreshInProgress = false; activeRefresh = null; scheduleNextRefresh(); broadcastState(); } return publicState(); })(); return activeRefresh; } function serveStatic(req, res) { const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`); const files = { '/': ['index.html', 'text/html; charset=utf-8'], '/app.js': ['app.js', 'text/javascript; charset=utf-8'], '/styles.css': ['styles.css', 'text/css; charset=utf-8'], '/admin': ['admin.html', 'text/html; charset=utf-8'], '/admin/': ['admin.html', 'text/html; charset=utf-8'], '/admin.js': ['admin.js', 'text/javascript; charset=utf-8'], '/sw.js': ['sw.js', 'text/javascript; charset=utf-8'], }; const match = files[url.pathname]; if (!match) return false; const [file, contentType] = match; const filePath = path.join(__dirname, 'public', file); fs.readFile(filePath, (err, data) => { if (err) { sendText(res, 500, err.message); return; } res.writeHead(200, { 'Content-Type': contentType, 'Cache-Control': 'no-store', 'Content-Length': data.length, }); res.end(data); }); return true; } function createServer() { return http.createServer(async (req, res) => { const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`); if (req.method === 'GET' && url.pathname === '/api/state') { sendJson(res, 200, publicState()); return; } if (req.method === 'POST' && url.pathname === '/api/refresh') { try { const snapshot = await refreshLobbies('manual'); sendJson(res, 200, snapshot); } catch (err) { sendJson(res, 500, { error: err.message }); } return; } if (url.pathname.startsWith('/api/admin/')) { if (!requireAdmin(req, res)) return; try { if (req.method === 'GET' && url.pathname === '/api/admin/auth') { sendJson(res, 200, adminAuthState()); return; } if (req.method === 'PUT' && url.pathname === '/api/admin/auth') { const body = await readJsonBody(req); const accountName = String(body.accountName || '').trim(); const password = String(body.password || ''); if (!accountName) { sendJson(res, 400, { error: 'Steam account name is required.' }); return; } if (automation.armed || dota.currentLobby) { throw new Error('Cannot switch Steam accounts while lobby automation is active. Leave the lobby first.'); } dota.restartWithCredentials({ accountName, password, clearRefreshToken: body.clearRefreshToken !== false, }); refreshLobbies('auth-update').catch((err) => { console.error(err.message); }); sendJson(res, 200, adminAuthState()); return; } if (req.method === 'POST' && url.pathname === '/api/admin/steam-account/activate') { const body = await readJsonBody(req); const accountName = String(body.accountName || '').trim(); if (!accountName) throw new Error('Steam account name is required.'); if (automation.armed || dota.currentLobby) { throw new Error('Cannot switch Steam accounts while lobby automation is active. Leave the lobby first.'); } dota.switchAccount(accountName); refreshLobbies('account-switch').catch((err) => console.error(err.message)); sendJson(res, 200, adminAuthState()); return; } if (req.method === 'POST' && url.pathname === '/api/admin/steam-guard') { const body = await readJsonBody(req); const code = String(body.code || '').trim(); if (!code) { sendJson(res, 400, { error: 'Steam Guard code is required.' }); return; } dota.submitSteamGuardCode(code); refreshLobbies('steam-guard').catch((err) => { console.error(err.message); }); sendJson(res, 200, adminAuthState()); return; } if (req.method === 'POST' && url.pathname === '/api/admin/clear-token') { authStore.remove(dota.credentials.accountName); dota.forcePasswordLogin = true; sendJson(res, 200, adminAuthState()); return; } if (req.method === 'PUT' && url.pathname === '/api/admin/telegram') { const body = await readJsonBody(req); const telegram = await telegramNotifier.configure({ token: body.token, recipientIds: body.recipientIds, }); telegramNotifier.queueLobbyUpdate({ ...automation.settings, serverRegionName: REGIONS[automation.settings.serverRegion] || String(automation.settings.serverRegion), }, { ...automation.status, armed: automation.armed, }); sendJson(res, 200, { ...adminAuthState(), telegram }); return; } if (req.method === 'DELETE' && url.pathname === '/api/admin/telegram') { telegramNotifier.disable(); sendJson(res, 200, adminAuthState()); return; } if (req.method === 'POST' && url.pathname === '/api/admin/test-telegram') { const result = await telegramNotifier.sendTest(); sendJson(res, 200, { ...adminAuthState(), telegramResult: result }); return; } if (req.method === 'POST' && url.pathname === '/api/admin/lobby-template') { const body = await readJsonBody(req); const lobbyId = String(body.lobbyId || '').trim(); const region = Number(body.serverRegion || 0); const customGameIdValue = String(body.customGameId || '').trim(); if (!/^\d+$/.test(lobbyId)) throw new Error('A valid source lobby ID is required.'); let metadata = state.lobbies.find((lobby) => lobby.id === lobbyId && lobby.customGameMode); const localMetadata = resolveLocalWorkshopMetadata(customGameIdValue); if (!metadata && localMetadata) { metadata = { customGameMode: localMetadata.sourceFolder, customGameTimestamp: localMetadata.publishTime, }; } if (!metadata) { const practiceLobbies = await dota.requestPracticeLobbies({ region }); const source = practiceLobbies.find((lobby) => String(lobby.id || '') === lobbyId); if (source?.customGameMode) { metadata = { customGameMode: source.customGameMode }; } } if (!metadata?.customGameMode) { throw new Error('The Dota GC did not expose the internal addon name for the selected lobby.'); } sendJson(res, 200, { templateMetadata: { customGameMode: metadata.customGameMode, customGameTimestamp: metadata.customGameTimestamp || '', }, }); return; } if (req.method === 'PUT' && url.pathname === '/api/admin/lobby-settings') { const body = await readJsonBody(req); automation.saveSettings(body); sendJson(res, 200, adminAuthState()); return; } if (req.method === 'POST' && url.pathname === '/api/admin/lobby') { const body = await readJsonBody(req); await automation.start(body); for (const delay of [1000, 12000, 25000]) { const timer = setTimeout(() => { refreshLobbies('lobby-publication-check').catch((err) => console.error(err.message)); }, delay); timer.unref(); } sendJson(res, 201, adminAuthState()); return; } if (req.method === 'DELETE' && url.pathname === '/api/admin/lobby') { await automation.stop('manual'); sendJson(res, 200, adminAuthState()); return; } if (req.method === 'PUT' && url.pathname === '/api/admin/push-subscription') { const body = await readJsonBody(req); pushService.subscribe(body.subscription); sendJson(res, 200, adminAuthState()); return; } if (req.method === 'DELETE' && url.pathname === '/api/admin/push-subscription') { const body = await readJsonBody(req); pushService.unsubscribe(String(body.endpoint || '')); sendJson(res, 200, adminAuthState()); return; } if (req.method === 'POST' && url.pathname === '/api/admin/test-notification') { const result = await pushService.send({ title: 'Dota lobby notifications enabled', body: 'This browser will be notified when the configured player threshold is reached.', tag: 'doka-lobby-test', }); sendJson(res, 200, { ...adminAuthState(), notificationResult: result }); return; } } catch (err) { const clientError = /required|must|cannot|already|invalid|disabled|telegram|recipient|not found/i.test(err.message); sendJson(res, clientError ? 400 : 500, { error: err.message }); return; } sendText(res, 404, 'Not found'); return; } if (req.method === 'GET' && url.pathname === '/events') { res.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-store', Connection: 'keep-alive', 'X-Accel-Buffering': 'no', }); res.write(`data: ${JSON.stringify(publicState())}\n\n`); const heartbeat = setInterval(() => res.write(': keep-alive\n\n'), 25000); sseClients.add(res); req.on('close', () => { clearInterval(heartbeat); sseClients.delete(res); }); return; } if (req.method === 'GET' && url.pathname === '/health') { sendJson(res, 200, { ok: true, status: state.status, connection: dota.status() }); return; } if (req.method === 'GET' && serveStatic(req, res)) return; sendText(res, 404, 'Not found'); }); } async function runOnce() { try { const rawLobbies = await dota.requestLobbies({ region: serverRegion, gameId: customGameId }); const names = await resolveGameNames(rawLobbies.map((lobby) => lobby.customGameId)); const rows = rawLobbies.map((lobby) => normalizeLobby(lobby, names)); const matched = filterRows(rows, { game: initialGameFilter, map: initialMapFilter }); console.log(`\n${matched.length} of ${rows.length} lobbies match:`); if (matched.length) console.table(tableRows(matched)); process.exit(0); } catch (err) { console.error(err.message); process.exit(1); } } if (oneShot) { runOnce(); } else { const server = createServer(); server.listen(port, () => { console.log(`Dota lobby web UI: http://localhost:${port}`); if (!ADMIN_PASSWORD) { console.warn('warn: AUTH_ADMIN_PASSWORD is not set; Steam auth cannot be edited from the web UI.'); } refreshLobbies('startup').catch((err) => { console.error(err.message); }); }); }