1167 lines
33 KiB
JavaScript
1167 lines
33 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 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 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 ADMIN_PASSWORD = process.env.AUTH_ADMIN_PASSWORD || process.env.ADMIN_PASSWORD || '';
|
|
|
|
const MSG = {
|
|
GCClientWelcome: 4004,
|
|
GCClientHello: 4006,
|
|
JoinableCustomLobbiesRequest: 7468,
|
|
JoinableCustomLobbiesResponse: 7469,
|
|
};
|
|
|
|
const REGIONS = {
|
|
0: 'Auto',
|
|
1: 'US West',
|
|
2: 'US East',
|
|
3: 'Europe',
|
|
5: 'Singapore',
|
|
6: 'Dubai',
|
|
7: 'Australia',
|
|
8: 'Stockholm',
|
|
9: 'Austria',
|
|
10: 'Brazil',
|
|
11: 'South Africa',
|
|
14: 'Chile',
|
|
15: 'Peru',
|
|
16: 'India',
|
|
19: 'Japan',
|
|
37: 'Taiwan',
|
|
38: 'Argentina',
|
|
};
|
|
|
|
const args = process.argv.slice(2);
|
|
|
|
function hasArg(name) {
|
|
return args.includes(name);
|
|
}
|
|
|
|
function argVal(name) {
|
|
const i = args.indexOf(name);
|
|
return i >= 0 ? args[i + 1] : undefined;
|
|
}
|
|
|
|
function numberOption(name, envName, fallback) {
|
|
const raw = argVal(name) ?? process.env[envName];
|
|
if (raw === undefined) return fallback;
|
|
const parsed = Number(raw);
|
|
return Number.isFinite(parsed) ? parsed : fallback;
|
|
}
|
|
|
|
function printUsage() {
|
|
console.log(`Usage:
|
|
node index.js [--port <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_ACCOUNT / STEAM_PASSWORD Optional fallback credentials
|
|
|
|
Regions:
|
|
${Object.entries(REGIONS).map(([k, v]) => `${k}=${v}`).join(', ')}`);
|
|
}
|
|
|
|
if (hasArg('--help') || hasArg('-h')) {
|
|
printUsage();
|
|
process.exit(0);
|
|
}
|
|
|
|
const port = numberOption('--port', 'PORT', DEFAULT_PORT);
|
|
const refreshIntervalMs = numberOption('--refresh-ms', 'REFRESH_MS', DEFAULT_REFRESH_MS);
|
|
const serverRegion = numberOption('--region', 'DOTA_REGION', 0);
|
|
const customGameId = argVal('--game-id') ?? process.env.DOTA_CUSTOM_GAME_ID;
|
|
const oneShot = hasArg('--once');
|
|
const initialGameFilter = argVal('--game')?.toLowerCase();
|
|
const initialMapFilter = argVal('--map')?.toLowerCase();
|
|
|
|
const proto = protobuf.loadSync(path.join(__dirname, 'dota.proto'));
|
|
const Hello = proto.lookupType('CMsgClientHello');
|
|
const LobbiesReq = proto.lookupType('CMsgJoinableCustomLobbiesRequest');
|
|
const LobbiesResp = proto.lookupType('CMsgJoinableCustomLobbiesResponse');
|
|
|
|
function ensureParentDir(file) {
|
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
}
|
|
|
|
class JsonStore {
|
|
constructor(file) {
|
|
this.file = file;
|
|
}
|
|
|
|
load() {
|
|
try {
|
|
const raw = fs.readFileSync(this.file, 'utf8');
|
|
return JSON.parse(raw);
|
|
} catch (err) {
|
|
if (err.code !== 'ENOENT') {
|
|
console.warn(`warn: could not read ${this.file}: ${err.message}`);
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
save(data) {
|
|
ensureParentDir(this.file);
|
|
const payload = {
|
|
...data,
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
fs.writeFileSync(this.file, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 });
|
|
}
|
|
|
|
remove() {
|
|
try {
|
|
fs.unlinkSync(this.file);
|
|
} catch (err) {
|
|
if (err.code !== 'ENOENT') throw err;
|
|
}
|
|
}
|
|
}
|
|
|
|
class AuthStore extends JsonStore {
|
|
load() {
|
|
const parsed = super.load();
|
|
if (!parsed || typeof parsed.refreshToken !== 'string') return null;
|
|
return parsed;
|
|
}
|
|
}
|
|
|
|
class CredentialsStore extends JsonStore {
|
|
load() {
|
|
const parsed = super.load();
|
|
if (!parsed || typeof parsed.accountName !== 'string' || typeof parsed.password !== 'string') return null;
|
|
return parsed;
|
|
}
|
|
}
|
|
|
|
function decodeJwtPayload(token) {
|
|
const [, payload] = String(token).split('.');
|
|
if (!payload) return null;
|
|
const normalized = payload.replace(/-/g, '+').replace(/_/g, '/');
|
|
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=');
|
|
try {
|
|
return JSON.parse(Buffer.from(padded, 'base64').toString('utf8'));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function tokenExpiresSoon(token) {
|
|
const payload = decodeJwtPayload(token);
|
|
if (!payload?.exp) return false;
|
|
return payload.exp * 1000 < Date.now() + 24 * 60 * 60 * 1000;
|
|
}
|
|
|
|
function tokenExpiresAt(token) {
|
|
const payload = decodeJwtPayload(token);
|
|
if (!payload?.exp) return null;
|
|
return new Date(payload.exp * 1000).toISOString();
|
|
}
|
|
|
|
function resolveCredentials(credentialsStore) {
|
|
const stored = credentialsStore.load();
|
|
if (stored?.accountName && stored?.password) {
|
|
return {
|
|
accountName: stored.accountName,
|
|
password: stored.password,
|
|
source: 'file',
|
|
updatedAt: stored.updatedAt || null,
|
|
};
|
|
}
|
|
|
|
if (process.env.STEAM_ACCOUNT && process.env.STEAM_PASSWORD) {
|
|
return {
|
|
accountName: process.env.STEAM_ACCOUNT,
|
|
password: process.env.STEAM_PASSWORD,
|
|
source: 'env',
|
|
updatedAt: null,
|
|
};
|
|
}
|
|
|
|
return {
|
|
accountName: '',
|
|
password: '',
|
|
source: 'missing',
|
|
updatedAt: null,
|
|
};
|
|
}
|
|
|
|
function createLogOnDetails({ authStore, accountName, accountPassword, forcePassword = false }) {
|
|
const auth = authStore.load();
|
|
const tokenBelongsToAccount = !accountName || !auth?.accountName || auth.accountName === accountName;
|
|
const canRefreshWithPassword = Boolean(accountName && accountPassword);
|
|
const canUseToken = auth?.refreshToken
|
|
&& tokenBelongsToAccount
|
|
&& (!tokenExpiresSoon(auth.refreshToken) || !canRefreshWithPassword);
|
|
|
|
if (!forcePassword && canUseToken) {
|
|
const details = {
|
|
refreshToken: auth.refreshToken,
|
|
machineName: 'doka-lobby',
|
|
};
|
|
if (auth.steamID) details.steamID = auth.steamID;
|
|
|
|
return {
|
|
source: 'refresh-token',
|
|
details,
|
|
};
|
|
}
|
|
|
|
if (!accountName || !accountPassword) {
|
|
return { source: 'missing', details: null };
|
|
}
|
|
|
|
return {
|
|
source: 'password',
|
|
details: {
|
|
accountName,
|
|
password: accountPassword,
|
|
machineName: 'doka-lobby',
|
|
},
|
|
};
|
|
}
|
|
|
|
class DotaLobbyClient extends EventEmitter {
|
|
constructor({ authStore, credentialsStore }) {
|
|
super();
|
|
this.authStore = authStore;
|
|
this.credentialsStore = credentialsStore;
|
|
this.credentials = resolveCredentials(credentialsStore);
|
|
this.user = null;
|
|
this.connectionState = 'idle';
|
|
this.gcState = 'idle';
|
|
this.authSource = null;
|
|
this.loggedOn = false;
|
|
this.loggingOn = false;
|
|
this.dotaLaunched = false;
|
|
this.gcReady = false;
|
|
this.forcePasswordLogin = false;
|
|
this.helloTimer = null;
|
|
this.reconnectTimer = null;
|
|
this.readyWaiters = new Set();
|
|
this.pendingLobbyRequest = null;
|
|
this.steamGuardPrompt = null;
|
|
this.steamGuardCallback = null;
|
|
this.gcDownSince = null;
|
|
this.steamDownSince = null;
|
|
this.lastGcRelaunchAt = 0;
|
|
this.watchdogTimer = setInterval(() => this.watchdog(), WATCHDOG_INTERVAL_MS);
|
|
this.watchdogTimer.unref();
|
|
this.createSteamUser();
|
|
}
|
|
|
|
createSteamUser() {
|
|
this.user = new SteamUser({ autoRelogin: true, renewRefreshTokens: true });
|
|
this.bindEvents();
|
|
}
|
|
|
|
status() {
|
|
return {
|
|
steam: this.connectionState,
|
|
gc: this.gcState,
|
|
authSource: this.authSource,
|
|
loggedOn: this.loggedOn,
|
|
dotaLaunched: this.dotaLaunched,
|
|
gcReady: this.gcReady,
|
|
credentialsSource: this.credentials.source,
|
|
steamGuard: this.steamGuardPrompt,
|
|
};
|
|
}
|
|
|
|
setConnectionState(state) {
|
|
if (this.connectionState === state) return;
|
|
this.connectionState = state;
|
|
this.emit('change');
|
|
}
|
|
|
|
setGcState(state) {
|
|
if (this.gcState === state) return;
|
|
this.gcState = state;
|
|
this.emit('change');
|
|
}
|
|
|
|
bindEvents() {
|
|
this.user.on('loggedOn', () => {
|
|
this.loggingOn = false;
|
|
this.loggedOn = true;
|
|
this.dotaLaunched = false;
|
|
this.gcReady = false;
|
|
this.steamGuardCallback = null;
|
|
this.steamGuardPrompt = null;
|
|
this.clearReconnectTimer();
|
|
this.setConnectionState('online');
|
|
console.log('Logged into Steam, launching Dota 2...');
|
|
this.launchDota();
|
|
this.emit('change');
|
|
});
|
|
|
|
this.user.on('steamGuard', (domain, callback, lastCodeWrong) => {
|
|
this.steamGuardCallback = callback;
|
|
this.steamGuardPrompt = {
|
|
required: true,
|
|
domain: domain || null,
|
|
lastCodeWrong: Boolean(lastCodeWrong),
|
|
requestedAt: new Date().toISOString(),
|
|
};
|
|
this.setConnectionState('steam-guard');
|
|
this.emit('change');
|
|
});
|
|
|
|
this.user.on('refreshToken', (refreshToken) => {
|
|
const steamID = this.user.steamID?.getSteamID64?.();
|
|
this.authStore.save({
|
|
refreshToken,
|
|
steamID,
|
|
accountName: this.credentials.accountName,
|
|
});
|
|
this.authSource = 'refresh-token';
|
|
console.log(`Saved Steam refresh token to ${this.authStore.file}`);
|
|
this.emit('change');
|
|
});
|
|
|
|
this.user.on('disconnected', (eresult, msg) => {
|
|
this.loggedOn = false;
|
|
this.loggingOn = false;
|
|
this.dotaLaunched = false;
|
|
this.gcReady = false;
|
|
this.steamGuardCallback = null;
|
|
this.steamGuardPrompt = null;
|
|
this.clearHelloTimer();
|
|
this.rejectReadyWaiters(new Error(`Steam disconnected: ${msg || eresult || 'unknown reason'}`));
|
|
this.rejectLobbyRequest(new Error(`Steam disconnected: ${msg || eresult || 'unknown reason'}`));
|
|
this.setConnectionState('reconnecting');
|
|
this.setGcState('idle');
|
|
this.emit('change');
|
|
});
|
|
|
|
this.user.on('error', (err) => {
|
|
console.error('Steam error:', err.message);
|
|
this.loggedOn = false;
|
|
this.loggingOn = false;
|
|
this.dotaLaunched = false;
|
|
this.gcReady = false;
|
|
this.steamGuardCallback = null;
|
|
this.steamGuardPrompt = null;
|
|
this.clearHelloTimer();
|
|
this.rejectReadyWaiters(err);
|
|
this.rejectLobbyRequest(err);
|
|
this.setConnectionState('error');
|
|
this.setGcState('idle');
|
|
|
|
if (this.authSource === 'refresh-token' && this.credentials.accountName && this.credentials.password) {
|
|
console.warn('warn: refresh token login failed, falling back to password login.');
|
|
this.forcePasswordLogin = true;
|
|
try {
|
|
this.authStore.remove();
|
|
} catch (removeErr) {
|
|
console.warn(`warn: could not remove auth file: ${removeErr.message}`);
|
|
}
|
|
}
|
|
|
|
this.scheduleReconnect();
|
|
this.emit('change');
|
|
});
|
|
|
|
this.user.on('appQuit', (appid) => {
|
|
if (appid !== DOTA_APPID || !this.loggedOn) return;
|
|
this.dotaLaunched = false;
|
|
this.gcReady = false;
|
|
this.clearHelloTimer();
|
|
this.setGcState('reconnecting');
|
|
this.launchDota(true);
|
|
});
|
|
|
|
this.user.on('receivedFromGC', (appid, msgType, payload) => {
|
|
if (appid !== DOTA_APPID) return;
|
|
|
|
if (msgType === MSG.GCClientWelcome) {
|
|
this.handleGcWelcome();
|
|
return;
|
|
}
|
|
|
|
if (msgType === MSG.JoinableCustomLobbiesResponse) {
|
|
this.handleLobbyResponse(payload);
|
|
}
|
|
});
|
|
}
|
|
|
|
start() {
|
|
if (this.loggedOn || this.loggingOn || this.connectionState === 'reconnecting' || this.steamGuardCallback) return;
|
|
|
|
this.credentials = resolveCredentials(this.credentialsStore);
|
|
const logOn = createLogOnDetails({
|
|
authStore: this.authStore,
|
|
accountName: this.credentials.accountName,
|
|
accountPassword: this.credentials.password,
|
|
forcePassword: this.forcePasswordLogin,
|
|
});
|
|
|
|
if (!logOn.details) {
|
|
this.authSource = null;
|
|
this.setConnectionState('auth-required');
|
|
throw new Error('Steam credentials are missing. Open Admin auth and save account credentials.');
|
|
}
|
|
|
|
this.authSource = logOn.source;
|
|
this.loggingOn = true;
|
|
this.setConnectionState('connecting');
|
|
console.log(`Logging into Steam using ${logOn.source}...`);
|
|
this.user.logOn(logOn.details);
|
|
}
|
|
|
|
restartWithCredentials({ accountName, password, clearRefreshToken }) {
|
|
this.credentialsStore.save({ accountName, password });
|
|
this.credentials = resolveCredentials(this.credentialsStore);
|
|
this.forcePasswordLogin = true;
|
|
|
|
if (clearRefreshToken) {
|
|
this.authStore.remove();
|
|
}
|
|
|
|
this.resetSteamUser();
|
|
this.start();
|
|
}
|
|
|
|
resetSteamUser() {
|
|
this.clearReconnectTimer();
|
|
this.clearHelloTimer();
|
|
this.rejectReadyWaiters(new Error('Steam connection reset.'));
|
|
this.rejectLobbyRequest(new Error('Steam connection reset.'));
|
|
this.steamGuardCallback = null;
|
|
this.steamGuardPrompt = null;
|
|
|
|
if (this.user) {
|
|
this.user.removeAllListeners();
|
|
try {
|
|
if (this.loggedOn || this.loggingOn) this.user.logOff();
|
|
} catch (err) {
|
|
console.warn(`warn: could not log off old Steam session: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
this.loggedOn = false;
|
|
this.loggingOn = false;
|
|
this.dotaLaunched = false;
|
|
this.gcReady = false;
|
|
this.connectionState = 'idle';
|
|
this.gcState = 'idle';
|
|
this.authSource = null;
|
|
this.createSteamUser();
|
|
this.emit('change');
|
|
}
|
|
|
|
submitSteamGuardCode(code) {
|
|
if (!this.steamGuardCallback) {
|
|
throw new Error('Steam Guard code is not currently requested.');
|
|
}
|
|
|
|
const callback = this.steamGuardCallback;
|
|
this.steamGuardCallback = null;
|
|
this.steamGuardPrompt = {
|
|
required: false,
|
|
submittedAt: new Date().toISOString(),
|
|
};
|
|
this.setConnectionState('connecting');
|
|
callback(code);
|
|
this.emit('change');
|
|
}
|
|
|
|
// 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 {
|
|
this.user.sendToGC(DOTA_APPID, MSG.GCClientHello, {}, Hello.encode({ engine: 1 }).finish());
|
|
} catch (err) {
|
|
this.setGcState('error');
|
|
this.emit('warning', err);
|
|
}
|
|
};
|
|
|
|
sendHello();
|
|
this.helloTimer = setInterval(sendHello, 3000);
|
|
}
|
|
|
|
clearHelloTimer() {
|
|
if (!this.helloTimer) return;
|
|
clearInterval(this.helloTimer);
|
|
this.helloTimer = null;
|
|
}
|
|
|
|
handleGcWelcome() {
|
|
this.clearHelloTimer();
|
|
this.gcReady = true;
|
|
this.setGcState('ready');
|
|
console.log('Connected to Dota 2 game coordinator.');
|
|
|
|
for (const waiter of this.readyWaiters) {
|
|
clearTimeout(waiter.timeout);
|
|
waiter.resolve();
|
|
}
|
|
this.readyWaiters.clear();
|
|
}
|
|
|
|
rejectReadyWaiters(err) {
|
|
for (const waiter of this.readyWaiters) {
|
|
clearTimeout(waiter.timeout);
|
|
waiter.reject(err);
|
|
}
|
|
this.readyWaiters.clear();
|
|
}
|
|
|
|
waitForReady(timeoutMs = READY_TIMEOUT_MS) {
|
|
if (this.gcReady) return Promise.resolve();
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const waiter = {
|
|
resolve,
|
|
reject,
|
|
timeout: setTimeout(() => {
|
|
this.readyWaiters.delete(waiter);
|
|
this.gcReady = false;
|
|
this.setGcState('timeout');
|
|
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);
|
|
}
|
|
});
|
|
}
|
|
|
|
handleLobbyResponse(payload) {
|
|
const request = this.pendingLobbyRequest;
|
|
if (!request) return;
|
|
|
|
clearTimeout(request.timeout);
|
|
this.pendingLobbyRequest = null;
|
|
|
|
try {
|
|
const decoded = LobbiesResp.toObject(LobbiesResp.decode(payload), { longs: String });
|
|
request.resolve(decoded.lobbies || []);
|
|
} catch (err) {
|
|
request.reject(err);
|
|
}
|
|
}
|
|
|
|
rejectLobbyRequest(err) {
|
|
if (!this.pendingLobbyRequest) return;
|
|
clearTimeout(this.pendingLobbyRequest.timeout);
|
|
this.pendingLobbyRequest.reject(err);
|
|
this.pendingLobbyRequest = null;
|
|
}
|
|
}
|
|
|
|
const gameNameCache = new Map();
|
|
|
|
async function resolveGameNames(ids) {
|
|
const unique = [...new Set(ids.map((id) => String(id || '')).filter(Boolean))];
|
|
const missing = unique.filter((id) => !gameNameCache.has(id));
|
|
|
|
for (let i = 0; i < missing.length; i += 100) {
|
|
const chunk = missing.slice(i, i + 100);
|
|
const body = new URLSearchParams({ itemcount: String(chunk.length) });
|
|
chunk.forEach((id, index) => body.append(`publishedfileids[${index}]`, id));
|
|
|
|
try {
|
|
const res = await fetch('https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/', {
|
|
method: 'POST',
|
|
body,
|
|
});
|
|
const json = await res.json();
|
|
for (const detail of json.response?.publishedfiledetails || []) {
|
|
gameNameCache.set(String(detail.publishedfileid), detail.title || String(detail.publishedfileid));
|
|
}
|
|
} catch (err) {
|
|
console.error(`warn: could not resolve custom game names: ${err.message}`);
|
|
for (const id of chunk) gameNameCache.set(id, id);
|
|
}
|
|
}
|
|
|
|
return gameNameCache;
|
|
}
|
|
|
|
function normalizeLobby(lobby, names) {
|
|
const customGameIdValue = String(lobby.customGameId || '');
|
|
const memberCount = Number(lobby.memberCount || 0);
|
|
const maxPlayerCount = Number(lobby.maxPlayerCount || 0);
|
|
|
|
return {
|
|
id: String(lobby.lobbyId || ''),
|
|
customGameId: customGameIdValue,
|
|
game: names.get(customGameIdValue) || customGameIdValue,
|
|
lobby: lobby.lobbyName || '',
|
|
map: lobby.customMapName || '',
|
|
memberCount,
|
|
maxPlayerCount,
|
|
openSlots: Math.max(0, maxPlayerCount - memberCount),
|
|
players: `${memberCount}/${maxPlayerCount || '?'}`,
|
|
regionId: Number(lobby.serverRegion || 0),
|
|
region: REGIONS[lobby.serverRegion] || String(lobby.serverRegion || ''),
|
|
hasPassKey: Boolean(lobby.hasPassKey),
|
|
leader: lobby.leaderName || '',
|
|
leaderAccountId: String(lobby.leaderAccountId || ''),
|
|
};
|
|
}
|
|
|
|
function filterRows(rows, { game, map }) {
|
|
return rows
|
|
.filter((row) => !game || String(row.game).toLowerCase().includes(game))
|
|
.filter((row) => !map || String(row.map ?? '').toLowerCase().includes(map));
|
|
}
|
|
|
|
function tableRows(rows) {
|
|
return rows.map((row) => ({
|
|
game: row.game,
|
|
lobby: row.lobby,
|
|
map: row.map,
|
|
players: row.players,
|
|
region: row.region,
|
|
pass: row.hasPassKey ? 'yes' : '',
|
|
leader: row.leader,
|
|
}));
|
|
}
|
|
|
|
const state = {
|
|
status: 'starting',
|
|
lobbies: [],
|
|
totalLobbies: 0,
|
|
refreshCount: 0,
|
|
refreshInProgress: false,
|
|
lastUpdatedAt: null,
|
|
lastStartedAt: null,
|
|
lastError: null,
|
|
nextRefreshAt: null,
|
|
};
|
|
|
|
const authStore = new AuthStore(AUTH_FILE);
|
|
const credentialsStore = new CredentialsStore(CREDENTIALS_FILE);
|
|
const dota = new DotaLobbyClient({ authStore, credentialsStore });
|
|
const sseClients = new Set();
|
|
let refreshTimer = null;
|
|
let activeRefresh = null;
|
|
|
|
function publicState() {
|
|
return {
|
|
...state,
|
|
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()?.refreshToken),
|
|
},
|
|
serverFilters: {
|
|
region: serverRegion,
|
|
regionName: REGIONS[serverRegion] || String(serverRegion),
|
|
customGameId: customGameId || null,
|
|
},
|
|
};
|
|
}
|
|
|
|
function adminAuthState() {
|
|
const credentials = resolveCredentials(credentialsStore);
|
|
const auth = authStore.load();
|
|
return {
|
|
adminConfigured: Boolean(ADMIN_PASSWORD),
|
|
files: {
|
|
authFile: authStore.file,
|
|
credentialsFile: credentialsStore.file,
|
|
},
|
|
credentials: {
|
|
accountName: credentials.accountName,
|
|
password: credentials.password,
|
|
source: credentials.source,
|
|
updatedAt: credentials.updatedAt,
|
|
},
|
|
refreshToken: {
|
|
exists: Boolean(auth?.refreshToken),
|
|
accountName: auth?.accountName || null,
|
|
steamID: auth?.steamID || null,
|
|
updatedAt: auth?.updatedAt || null,
|
|
expiresAt: auth?.refreshToken ? tokenExpiresAt(auth.refreshToken) : null,
|
|
},
|
|
connection: dota.status(),
|
|
};
|
|
}
|
|
|
|
function sendJson(res, statusCode, data) {
|
|
const body = JSON.stringify(data);
|
|
res.writeHead(statusCode, {
|
|
'Content-Type': 'application/json; charset=utf-8',
|
|
'Content-Length': Buffer.byteLength(body),
|
|
'Cache-Control': 'no-store',
|
|
});
|
|
res.end(body);
|
|
}
|
|
|
|
function sendText(res, statusCode, text) {
|
|
res.writeHead(statusCode, {
|
|
'Content-Type': 'text/plain; charset=utf-8',
|
|
'Content-Length': Buffer.byteLength(text),
|
|
});
|
|
res.end(text);
|
|
}
|
|
|
|
function safeEqual(a, b) {
|
|
const left = Buffer.from(String(a || ''));
|
|
const right = Buffer.from(String(b || ''));
|
|
if (left.length !== right.length) return false;
|
|
return crypto.timingSafeEqual(left, right);
|
|
}
|
|
|
|
function requireAdmin(req, res) {
|
|
if (!ADMIN_PASSWORD) {
|
|
sendJson(res, 503, { error: 'Admin password is not configured. Set AUTH_ADMIN_PASSWORD.' });
|
|
return false;
|
|
}
|
|
|
|
const supplied = req.headers['x-admin-password'];
|
|
if (!safeEqual(supplied, ADMIN_PASSWORD)) {
|
|
sendJson(res, 401, { error: 'Invalid admin password.' });
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function readJsonBody(req, maxBytes = 64 * 1024) {
|
|
return new Promise((resolve, reject) => {
|
|
let body = '';
|
|
req.setEncoding('utf8');
|
|
req.on('data', (chunk) => {
|
|
body += chunk;
|
|
if (Buffer.byteLength(body) > maxBytes) {
|
|
reject(new Error('Request body is too large.'));
|
|
req.destroy();
|
|
}
|
|
});
|
|
req.on('end', () => {
|
|
if (!body) {
|
|
resolve({});
|
|
return;
|
|
}
|
|
try {
|
|
resolve(JSON.parse(body));
|
|
} catch {
|
|
reject(new Error('Request body must be valid JSON.'));
|
|
}
|
|
});
|
|
req.on('error', reject);
|
|
});
|
|
}
|
|
|
|
function broadcastState() {
|
|
const data = `data: ${JSON.stringify(publicState())}\n\n`;
|
|
for (const client of sseClients) {
|
|
client.write(data);
|
|
}
|
|
}
|
|
|
|
dota.on('change', broadcastState);
|
|
dota.on('warning', (err) => {
|
|
state.lastError = err.message;
|
|
broadcastState();
|
|
});
|
|
|
|
function scheduleNextRefresh(delay = refreshIntervalMs) {
|
|
if (refreshTimer) clearTimeout(refreshTimer);
|
|
state.nextRefreshAt = new Date(Date.now() + delay).toISOString();
|
|
broadcastState();
|
|
refreshTimer = setTimeout(() => {
|
|
refreshTimer = null;
|
|
refreshLobbies('timer').catch((err) => {
|
|
console.error(err.message);
|
|
});
|
|
}, delay);
|
|
}
|
|
|
|
async function refreshLobbies(reason) {
|
|
if (activeRefresh) return activeRefresh;
|
|
|
|
activeRefresh = (async () => {
|
|
state.status = 'refreshing';
|
|
state.refreshInProgress = true;
|
|
state.lastStartedAt = new Date().toISOString();
|
|
state.lastError = null;
|
|
state.nextRefreshAt = null;
|
|
broadcastState();
|
|
|
|
try {
|
|
const rawLobbies = await dota.requestLobbies({ region: serverRegion, gameId: customGameId });
|
|
const names = await resolveGameNames(rawLobbies.map((lobby) => lobby.customGameId));
|
|
const lobbies = rawLobbies.map((lobby) => normalizeLobby(lobby, names));
|
|
state.lobbies = lobbies;
|
|
state.totalLobbies = rawLobbies.length;
|
|
state.refreshCount += 1;
|
|
state.lastUpdatedAt = new Date().toISOString();
|
|
state.status = 'ready';
|
|
console.log(`Lobby refresh (${reason}) completed: ${lobbies.length} lobbies.`);
|
|
} catch (err) {
|
|
state.status = state.lobbies.length ? 'stale' : 'error';
|
|
state.lastError = err.message;
|
|
console.error(`Lobby refresh (${reason}) failed: ${err.message}`);
|
|
} finally {
|
|
state.refreshInProgress = false;
|
|
activeRefresh = null;
|
|
scheduleNextRefresh();
|
|
broadcastState();
|
|
}
|
|
|
|
return publicState();
|
|
})();
|
|
|
|
return activeRefresh;
|
|
}
|
|
|
|
function serveStatic(req, res) {
|
|
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
|
const files = {
|
|
'/': ['index.html', 'text/html; charset=utf-8'],
|
|
'/app.js': ['app.js', 'text/javascript; charset=utf-8'],
|
|
'/styles.css': ['styles.css', 'text/css; charset=utf-8'],
|
|
'/admin': ['admin.html', 'text/html; charset=utf-8'],
|
|
'/admin/': ['admin.html', 'text/html; charset=utf-8'],
|
|
'/admin.js': ['admin.js', 'text/javascript; charset=utf-8'],
|
|
};
|
|
|
|
const match = files[url.pathname];
|
|
if (!match) return false;
|
|
|
|
const [file, contentType] = match;
|
|
const filePath = path.join(__dirname, 'public', file);
|
|
fs.readFile(filePath, (err, data) => {
|
|
if (err) {
|
|
sendText(res, 500, err.message);
|
|
return;
|
|
}
|
|
|
|
res.writeHead(200, {
|
|
'Content-Type': contentType,
|
|
'Cache-Control': 'no-store',
|
|
'Content-Length': data.length,
|
|
});
|
|
res.end(data);
|
|
});
|
|
return true;
|
|
}
|
|
|
|
function createServer() {
|
|
return http.createServer(async (req, res) => {
|
|
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
|
|
|
if (req.method === 'GET' && url.pathname === '/api/state') {
|
|
sendJson(res, 200, publicState());
|
|
return;
|
|
}
|
|
|
|
if (req.method === 'POST' && url.pathname === '/api/refresh') {
|
|
try {
|
|
const snapshot = await refreshLobbies('manual');
|
|
sendJson(res, 200, snapshot);
|
|
} catch (err) {
|
|
sendJson(res, 500, { error: err.message });
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (url.pathname.startsWith('/api/admin/')) {
|
|
if (!requireAdmin(req, res)) return;
|
|
|
|
try {
|
|
if (req.method === 'GET' && url.pathname === '/api/admin/auth') {
|
|
sendJson(res, 200, adminAuthState());
|
|
return;
|
|
}
|
|
|
|
if (req.method === 'PUT' && url.pathname === '/api/admin/auth') {
|
|
const body = await readJsonBody(req);
|
|
const accountName = String(body.accountName || '').trim();
|
|
const password = String(body.password || '');
|
|
if (!accountName || !password) {
|
|
sendJson(res, 400, { error: 'Steam account and password are required.' });
|
|
return;
|
|
}
|
|
|
|
dota.restartWithCredentials({
|
|
accountName,
|
|
password,
|
|
clearRefreshToken: body.clearRefreshToken !== false,
|
|
});
|
|
|
|
refreshLobbies('auth-update').catch((err) => {
|
|
console.error(err.message);
|
|
});
|
|
sendJson(res, 200, adminAuthState());
|
|
return;
|
|
}
|
|
|
|
if (req.method === 'POST' && url.pathname === '/api/admin/steam-guard') {
|
|
const body = await readJsonBody(req);
|
|
const code = String(body.code || '').trim();
|
|
if (!code) {
|
|
sendJson(res, 400, { error: 'Steam Guard code is required.' });
|
|
return;
|
|
}
|
|
|
|
dota.submitSteamGuardCode(code);
|
|
refreshLobbies('steam-guard').catch((err) => {
|
|
console.error(err.message);
|
|
});
|
|
sendJson(res, 200, adminAuthState());
|
|
return;
|
|
}
|
|
|
|
if (req.method === 'POST' && url.pathname === '/api/admin/clear-token') {
|
|
authStore.remove();
|
|
dota.forcePasswordLogin = true;
|
|
sendJson(res, 200, adminAuthState());
|
|
return;
|
|
}
|
|
} catch (err) {
|
|
sendJson(res, 500, { error: err.message });
|
|
return;
|
|
}
|
|
|
|
sendText(res, 404, 'Not found');
|
|
return;
|
|
}
|
|
|
|
if (req.method === 'GET' && url.pathname === '/events') {
|
|
res.writeHead(200, {
|
|
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
'Cache-Control': 'no-store',
|
|
Connection: 'keep-alive',
|
|
'X-Accel-Buffering': 'no',
|
|
});
|
|
res.write(`data: ${JSON.stringify(publicState())}\n\n`);
|
|
const heartbeat = setInterval(() => res.write(': keep-alive\n\n'), 25000);
|
|
sseClients.add(res);
|
|
req.on('close', () => {
|
|
clearInterval(heartbeat);
|
|
sseClients.delete(res);
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (req.method === 'GET' && url.pathname === '/health') {
|
|
sendJson(res, 200, { ok: true, status: state.status, connection: dota.status() });
|
|
return;
|
|
}
|
|
|
|
if (req.method === 'GET' && serveStatic(req, res)) return;
|
|
|
|
sendText(res, 404, 'Not found');
|
|
});
|
|
}
|
|
|
|
async function runOnce() {
|
|
try {
|
|
const rawLobbies = await dota.requestLobbies({ region: serverRegion, gameId: customGameId });
|
|
const names = await resolveGameNames(rawLobbies.map((lobby) => lobby.customGameId));
|
|
const rows = rawLobbies.map((lobby) => normalizeLobby(lobby, names));
|
|
const matched = filterRows(rows, { game: initialGameFilter, map: initialMapFilter });
|
|
console.log(`\n${matched.length} of ${rows.length} lobbies match:`);
|
|
if (matched.length) console.table(tableRows(matched));
|
|
process.exit(0);
|
|
} catch (err) {
|
|
console.error(err.message);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
if (oneShot) {
|
|
runOnce();
|
|
} else {
|
|
const server = createServer();
|
|
server.listen(port, () => {
|
|
console.log(`Dota lobby web UI: http://localhost:${port}`);
|
|
if (!ADMIN_PASSWORD) {
|
|
console.warn('warn: AUTH_ADMIN_PASSWORD is not set; Steam auth cannot be edited from the web UI.');
|
|
}
|
|
refreshLobbies('startup').catch((err) => {
|
|
console.error(err.message);
|
|
});
|
|
});
|
|
}
|