Files
Aleksandr Bogomiakov c239669932
Docker image / build-and-push (push) Successful in 57s
Added lobby auto creation
2026-08-06 20:33:27 +01:00

2409 lines
78 KiB
JavaScript

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 <n>] [--region <n>] [--game-id <workshop 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);
});
});
}