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 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 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 ADMIN_PASSWORD = process.env.AUTH_ADMIN_PASSWORD || process.env.ADMIN_PASSWORD || ''; const MSG = { GCClientWelcome: 4004, GCClientHello: 4006, JoinableCustomLobbiesRequest: 7468, JoinableCustomLobbiesResponse: 7469, }; 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_ACCOUNT / STEAM_PASSWORD Optional fallback credentials 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 proto = protobuf.loadSync(path.join(__dirname, 'dota.proto')); const Hello = proto.lookupType('CMsgClientHello'); 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 { load() { const parsed = super.load(); if (!parsed || typeof parsed.refreshToken !== 'string') return null; return parsed; } } class CredentialsStore extends JsonStore { load() { const parsed = super.load(); if (!parsed || typeof parsed.accountName !== 'string' || typeof parsed.password !== 'string') return null; return parsed; } } 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: 'file', 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(); const tokenBelongsToAccount = !accountName || !auth?.accountName || auth.accountName === accountName; const canRefreshWithPassword = Boolean(accountName && accountPassword); const canUseToken = auth?.refreshToken && tokenBelongsToAccount && (!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.steamGuardPrompt = null; this.steamGuardCallback = null; this.createSteamUser(); } createSteamUser() { this.user = new SteamUser({ autoRelogin: true, renewRefreshTokens: true }); 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, steamGuard: this.steamGuardPrompt, }; } 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...'); 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.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.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(); } 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(); return; } if (msgType === MSG.JoinableCustomLobbiesResponse) { this.handleLobbyResponse(payload); } }); } 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 }) { this.credentialsStore.save({ accountName, password }); this.credentials = resolveCredentials(this.credentialsStore); this.forcePasswordLogin = true; if (clearRefreshToken) { this.authStore.remove(); } this.resetSteamUser(); this.start(); } resetSteamUser() { this.clearReconnectTimer(); this.clearHelloTimer(); this.rejectReadyWaiters(new Error('Steam connection reset.')); this.rejectLobbyRequest(new Error('Steam connection 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'); } 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 { this.user.sendToGC(DOTA_APPID, MSG.GCClientHello, {}, Hello.encode({ engine: 1 }).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() { this.clearHelloTimer(); this.gcReady = true; this.setGcState('ready'); console.log('Connected to Dota 2 game coordinator.'); for (const waiter of this.readyWaiters) { clearTimeout(waiter.timeout); waiter.resolve(); } this.readyWaiters.clear(); } 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'); 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); } }); } 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); } } rejectLobbyRequest(err) { if (!this.pendingLobbyRequest) return; clearTimeout(this.pendingLobbyRequest.timeout); this.pendingLobbyRequest.reject(err); this.pendingLobbyRequest = null; } } 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, game: names.get(customGameIdValue) || customGameIdValue, lobby: lobby.lobbyName || '', map: lobby.customMapName || '', memberCount, maxPlayerCount, 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), 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 dota = new DotaLobbyClient({ authStore, credentialsStore }); const sseClients = new Set(); let refreshTimer = null; let activeRefresh = null; function publicState() { return { ...state, connection: dota.status(), refreshIntervalMs, regions: REGIONS, auth: { adminConfigured: Boolean(ADMIN_PASSWORD), credentialsConfigured: Boolean(dota.credentials.accountName && dota.credentials.password), tokenConfigured: Boolean(authStore.load()?.refreshToken), }, serverFilters: { region: serverRegion, regionName: REGIONS[serverRegion] || String(serverRegion), customGameId: customGameId || null, }, }; } function adminAuthState() { const credentials = resolveCredentials(credentialsStore); const auth = authStore.load(); return { adminConfigured: Boolean(ADMIN_PASSWORD), files: { authFile: authStore.file, credentialsFile: credentialsStore.file, }, credentials: { accountName: credentials.accountName, password: credentials.password, source: credentials.source, updatedAt: credentials.updatedAt, }, 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(), }; } 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); 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 rawLobbies = await dota.requestLobbies({ region: serverRegion, gameId: customGameId }); const names = await resolveGameNames(rawLobbies.map((lobby) => lobby.customGameId)); const lobbies = rawLobbies.map((lobby) => normalizeLobby(lobby, names)); state.lobbies = 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'], }; 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 || !password) { sendJson(res, 400, { error: 'Steam account and password are required.' }); return; } 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-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.forcePasswordLogin = true; sendJson(res, 200, adminAuthState()); return; } } catch (err) { sendJson(res, 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); }); }); }