2409 lines
78 KiB
JavaScript
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);
|
|
});
|
|
});
|
|
}
|