diff --git a/.dockerignore b/.dockerignore index d3ed0f7..bdec5c3 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,4 +7,8 @@ npm-debug.log .env .steam-auth.json .steam-credentials.json +.lobby-settings.json +.web-push.json +.telegram.json +node-steam-user/ *.log diff --git a/.gitignore b/.gitignore index 1968c8f..afc6e71 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,8 @@ node_modules/ .env .steam-auth.json .steam-credentials.json +.lobby-settings.json +.web-push.json +.telegram.json +node-steam-user/ *.log diff --git a/Dockerfile b/Dockerfile index a69fdf3..3939502 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,11 @@ FROM node:22-alpine ENV NODE_ENV=production \ PORT=3000 \ STEAM_AUTH_FILE=/data/.steam-auth.json \ - STEAM_CREDENTIALS_FILE=/data/.steam-credentials.json + STEAM_CREDENTIALS_FILE=/data/.steam-credentials.json \ + STEAM_DATA_DIR=/data/node-steam-user \ + LOBBY_SETTINGS_FILE=/data/.lobby-settings.json \ + WEB_PUSH_FILE=/data/.web-push.json \ + TELEGRAM_FILE=/data/.telegram.json WORKDIR /app diff --git a/README.md b/README.md index 6c6e53e..8380de9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # doka-lobby -Web UI for Dota 2 joinable custom lobbies from the game coordinator. +Web UI for Dota 2 joinable custom lobbies from the game coordinator. The admin page can also create a custom-game lobby, notify browsers and Telegram recipients as it fills, and make the Steam bot leave safely before the lobby launches. The app keeps the latest lobby snapshot in memory, refreshes it every 3 minutes, and streams updates to the browser. There is no database. Steam account credentials and the refresh token can be persisted on a PVC. @@ -18,7 +18,40 @@ Open: http://localhost:3000 ``` -Use `/admin` to enter the admin password, save Steam credentials, and submit a Steam Guard code when Steam asks for it. +Use `/admin` to enter the admin password, save and switch Steam accounts, submit a Steam Guard code when Steam asks for it, configure lobby creation, and enable notifications. + +## Steam accounts and Steam Guard + +The admin page can store multiple Steam account/password profiles. **Save and activate** updates the selected profile and reconnects Steam and the Dota GC without restarting Node. **Activate selected** switches to another saved profile in the same way. Account switching is blocked while the bot owns or monitors a lobby so it cannot disconnect unexpectedly and spoil a game. + +Passwords are stored only in the server-side credentials file and are never returned by the admin API. Each account has its own cached refresh token. `steam-user` machine authorization files are persisted in `STEAM_DATA_DIR`; together these normally avoid repeated Steam Guard prompts. Steam Guard codes themselves are not cached because they are one-time, short-lived values. + +## Lobby automation + +The **Create lobby** form requires the Workshop ID, internal addon/game-mode name, map name, region, and custom-game player limits. The internal name, CRC, and Workshop timestamp are copied automatically when you click **Create template** on an existing lobby in the lobby list; after admin authorization, the creation form is prefilled. This prevents the GC from accepting a half-configured lobby object that is not published in the custom-game list. + +Two thresholds are configured for every lobby start: + +- **Notify at real players** sends a Web Push notification once the lobby reaches that many users. +- **Bot leaves at real players** sends `CMsgPracticeLobbyLeave` immediately at that many users while the lobby is still in its UI/waiting state. + +Both values exclude the bot. The leave threshold must be lower than the custom-game maximum because Dota initially places the lobby owner in Radiant slot 1. The bot stays there while waiting, matching the official client and known working GC clients, then leaves at the configured real-player threshold so its slot becomes available before the lobby is full. + +Admin identities are exact, case-insensitive Steam persona names separated by commas. A SteamID64 can be entered instead of a name for immediate matching that is not affected by renames or duplicate persona names. When any configured admin identity appears, the bot leaves immediately. + +The application never sends an abandon command. If the lobby has already left the UI/waiting state, automatic and manual leave are blocked and a critical browser notification is sent, avoiding an abandon penalty. + +Browser push requires HTTPS outside `localhost`. Click **Enable on this browser**, allow notifications, then use **Send test**. Subscriptions and VAPID keys are stored on the same persistent volume as Steam auth. + +## Telegram notifications + +Create a bot with `@BotFather`, enter its token and numeric Telegram user IDs on `/admin`, then click **Save Telegram** and **Send test**. Every recipient must first open the bot and send it a message; Telegram does not let a bot initiate a private conversation. + +For each lobby the bot sends one tracker message to every configured recipient. It edits that message whenever the phase, publication state, player count, player names, or roster changes. Threshold, leave, and safety events are also sent as separate Telegram alerts so they generate a normal notification. + +When Dota 2 Workshop content is installed on the same machine, the app reads `publish_data.txt` to resolve the internal addon name immediately. Set `DOTA_WORKSHOP_DIR` to the `steamapps/workshop/content/570` directory when Steam uses a non-standard library path. + +The app also reads `ClientVersion` from `game/dota/steam.inf` and sends it in GC hello/lobby-create messages. On a container without Dota files, set `DOTA_CLIENT_VERSION` to that value from an up-to-date Dota installation, or mount the file and set `DOTA_STEAM_INF` to its container path. The admin page shows the detected value and source. ## Kubernetes configuration @@ -35,6 +68,18 @@ env: value: /data/.steam-auth.json - name: STEAM_CREDENTIALS_FILE value: /data/.steam-credentials.json + - name: STEAM_DATA_DIR + value: /data/node-steam-user + - name: LOBBY_SETTINGS_FILE + value: /data/.lobby-settings.json + - name: WEB_PUSH_FILE + value: /data/.web-push.json + - name: TELEGRAM_FILE + value: /data/.telegram.json + - name: WEB_PUSH_SUBJECT + value: mailto:admin@example.com + - name: DOTA_CLIENT_VERSION + value: "6888" # example only; copy the current value from game/dota/steam.inf - name: PORT value: "3000" volumeMounts: @@ -46,9 +91,13 @@ Run one replica only. A single Steam account should not be used by multiple pods ## Auth files -- `.steam-credentials.json` stores the Steam account name and password saved from `/admin`. -- `.steam-auth.json` stores the Steam refresh token emitted by `steam-user`. -- Both files are ignored by git and Docker build context. +- `.steam-credentials.json` stores saved Steam account/password profiles and the active account. +- `.steam-auth.json` stores a separate Steam refresh token for every saved account. +- `node-steam-user/` stores Steam Guard machine authorization files. +- `.lobby-settings.json` stores the last validated lobby form values. +- `.web-push.json` stores VAPID keys and browser subscriptions. +- `.telegram.json` stores the Telegram bot token, recipient IDs, and tracker message IDs. +- All credential and notification state is ignored by git and the Docker build context. If the refresh token is still valid, restarts use it. If it is missing or expired, the app falls back to the saved Steam account/password and may ask for Steam Guard again. diff --git a/dota.proto b/dota.proto index 088bd21..36a4770 100644 --- a/dota.proto +++ b/dota.proto @@ -5,7 +5,143 @@ syntax = "proto2"; message CMsgClientHello { optional uint32 version = 1; optional uint32 client_session_need = 3; - optional int32 engine = 5; // 1 = Source 2 + optional int32 engine = 7; // 1 = Source 2 +} + +message CMsgSOIDOwner { + optional uint32 type = 1; + optional uint64 id = 2; +} + +message CMsgSOSingleObject { + optional int32 type_id = 2; + optional bytes object_data = 3; + optional fixed64 version = 4; + optional CMsgSOIDOwner owner_soid = 5; +} + +message CMsgSOMultipleObjectsSingleObject { + optional int32 type_id = 1; + optional bytes object_data = 2; +} + +message CMsgSOMultipleObjects { + repeated CMsgSOMultipleObjectsSingleObject objects_modified = 2; + optional fixed64 version = 3; + repeated CMsgSOMultipleObjectsSingleObject objects_added = 4; + repeated CMsgSOMultipleObjectsSingleObject objects_removed = 5; + optional CMsgSOIDOwner owner_soid = 6; +} + +message CMsgSOCacheSubscribedType { + optional int32 type_id = 1; + repeated bytes object_data = 2; +} + +message CMsgSOCacheSubscribed { + repeated CMsgSOCacheSubscribedType objects = 2; + optional fixed64 version = 3; + optional CMsgSOIDOwner owner_soid = 4; +} + +message CMsgSOCacheUnsubscribed { + optional CMsgSOIDOwner owner_soid = 2; +} + +message CMsgClientWelcome { + optional uint32 version = 1; + repeated CMsgSOCacheSubscribed outofdate_subscribed_caches = 3; +} + +message CMsgGenericResult { + optional uint32 result = 1; +} + +message CSODOTALobbyMember { + optional fixed64 id = 1; + optional int32 team = 3; + optional uint32 slot = 7; +} + +message CSODOTALobby { + optional uint64 lobby_id = 1; + optional uint32 game_mode = 3; + optional int32 state = 4; + optional fixed64 leader_id = 11; + optional int32 lobby_type = 12; + optional string game_name = 16; + optional uint32 server_region = 21; + optional string custom_game_mode = 54; + optional string custom_map_name = 55; + optional uint64 custom_game_id = 68; + optional uint32 custom_min_players = 71; + optional uint32 custom_max_players = 72; + optional int32 visibility = 75; + optional fixed64 custom_game_crc = 76; + optional bool custom_game_auto_created_lobby = 77; + optional fixed32 custom_game_timestamp = 80; + optional bool custom_game_penalties = 107; + repeated CSODOTALobbyMember all_members = 120; +} + +message CMsgPracticeLobbySetDetails { + optional uint64 lobby_id = 1; + optional string game_name = 2; + optional uint32 server_region = 4; + optional uint32 game_mode = 5; + optional bool allow_cheats = 10; + optional bool fill_with_bots = 11; + optional bool allow_spectating = 13; + optional string pass_key = 15; + optional bool lan = 25; + optional string custom_game_mode = 26; + optional string custom_map_name = 27; + optional uint32 custom_difficulty = 28; + optional uint64 custom_game_id = 29; + optional uint32 custom_min_players = 30; + optional uint32 custom_max_players = 31; + optional int32 visibility = 33; + optional fixed64 custom_game_crc = 34; + optional fixed32 custom_game_timestamp = 37; + optional bool custom_game_penalties = 47; +} + +message CMsgPracticeLobbyCreate { + optional string search_key = 1; + optional string pass_key = 5; + optional uint32 client_version = 6; + optional CMsgPracticeLobbySetDetails lobby_details = 7; +} + +message CMsgPracticeLobbyLeave { +} + +message CMsgPracticeLobbySetTeamSlot { + optional int32 team = 1; + optional uint32 slot = 2; +} + +message CMsgPracticeLobbyList { + optional string pass_key = 2; + optional uint32 region = 3; + optional uint32 game_mode = 4; +} + +message CMsgPracticeLobbyListResponseEntry { + optional uint64 id = 1; + optional string name = 10; + optional string custom_game_mode = 11; + optional uint32 game_mode = 12; + optional uint32 players = 14; + optional string custom_map_name = 15; + optional uint32 max_player_count = 16; + optional uint32 server_region = 17; + optional uint32 min_player_count = 21; + optional bool penalties_enabled = 22; +} + +message CMsgPracticeLobbyListResponse { + repeated CMsgPracticeLobbyListResponseEntry lobbies = 2; } message CMsgJoinableCustomLobbiesRequest { @@ -24,6 +160,12 @@ message CMsgJoinableCustomLobbiesResponseEntry { optional uint32 max_player_count = 8; optional uint32 server_region = 9; optional bool has_pass_key = 11; + optional string lan_host_ping_location = 12; + optional uint32 lobby_creation_time = 13; + optional uint32 custom_game_timestamp = 14; + optional uint64 custom_game_crc = 15; + optional uint32 min_player_count = 16; + optional bool penalties_enabled = 17; } message CMsgJoinableCustomLobbiesResponse { diff --git a/index.js b/index.js index a02578a..44b790f 100644 --- a/index.js +++ b/index.js @@ -6,6 +6,18 @@ 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; @@ -14,21 +26,43 @@ 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', @@ -82,7 +116,11 @@ 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(', ')}`); @@ -100,9 +138,22 @@ 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'); @@ -146,18 +197,274 @@ class JsonStore { } class AuthStore extends JsonStore { - load() { - const parsed = super.load(); - if (!parsed || typeof parsed.refreshToken !== 'string') return null; - return parsed; + 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 || typeof parsed.accountName !== 'string' || typeof parsed.password !== 'string') return null; - return parsed; + 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, + }; } } @@ -191,7 +498,7 @@ function resolveCredentials(credentialsStore) { return { accountName: stored.accountName, password: stored.password, - source: 'file', + source: 'saved-account', updatedAt: stored.updatedAt || null, }; } @@ -214,11 +521,9 @@ function resolveCredentials(credentialsStore) { } function createLogOnDetails({ authStore, accountName, accountPassword, forcePassword = false }) { - const auth = authStore.load(); - const tokenBelongsToAccount = !accountName || !auth?.accountName || auth.accountName === accountName; + const auth = authStore.load(accountName); const canRefreshWithPassword = Boolean(accountName && accountPassword); const canUseToken = auth?.refreshToken - && tokenBelongsToAccount && (!tokenExpiresSoon(auth.refreshToken) || !canRefreshWithPassword); if (!forcePassword && canUseToken) { @@ -267,6 +572,11 @@ class DotaLobbyClient extends EventEmitter { 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; @@ -278,7 +588,12 @@ class DotaLobbyClient extends EventEmitter { } createSteamUser() { - this.user = new SteamUser({ autoRelogin: true, renewRefreshTokens: true }); + fs.mkdirSync(STEAM_DATA_DIR, { recursive: true, mode: 0o700 }); + this.user = new SteamUser({ + autoRelogin: true, + renewRefreshTokens: true, + dataDirectory: STEAM_DATA_DIR, + }); this.bindEvents(); } @@ -291,7 +606,11 @@ class DotaLobbyClient extends EventEmitter { 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, }; } @@ -318,6 +637,11 @@ class DotaLobbyClient extends EventEmitter { 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'); }); @@ -356,6 +680,8 @@ class DotaLobbyClient extends EventEmitter { 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'); @@ -372,6 +698,8 @@ class DotaLobbyClient extends EventEmitter { this.clearHelloTimer(); this.rejectReadyWaiters(err); this.rejectLobbyRequest(err); + this.rejectOwnLobbyWaiters(err); + this.clearCurrentLobby('steam-error'); this.setConnectionState('error'); this.setGcState('idle'); @@ -379,7 +707,7 @@ class DotaLobbyClient extends EventEmitter { console.warn('warn: refresh token login failed, falling back to password login.'); this.forcePasswordLogin = true; try { - this.authStore.remove(); + this.authStore.remove(this.credentials.accountName); } catch (removeErr) { console.warn(`warn: could not remove auth file: ${removeErr.message}`); } @@ -402,12 +730,34 @@ class DotaLobbyClient extends EventEmitter { if (appid !== DOTA_APPID) return; if (msgType === MSG.GCClientWelcome) { - this.handleGcWelcome(); + 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}`)); } }); } @@ -437,23 +787,47 @@ class DotaLobbyClient extends EventEmitter { } restartWithCredentials({ accountName, password, clearRefreshToken }) { - this.credentialsStore.save({ accountName, password }); + 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 = true; + this.forcePasswordLogin = Boolean(clearRefreshToken); if (clearRefreshToken) { - this.authStore.remove(); + 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; @@ -597,7 +971,9 @@ class DotaLobbyClient extends EventEmitter { const sendHello = () => { if (!this.loggedOn || this.gcReady) return; try { - this.user.sendToGC(DOTA_APPID, MSG.GCClientHello, {}, Hello.encode({ engine: 1 }).finish()); + 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); @@ -614,12 +990,21 @@ class DotaLobbyClient extends EventEmitter { this.helloTimer = null; } - handleGcWelcome() { + 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(); @@ -627,6 +1012,236 @@ class DotaLobbyClient extends EventEmitter { 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); @@ -707,6 +1322,52 @@ class DotaLobbyClient extends EventEmitter { }); } + 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; @@ -722,11 +1383,404 @@ class DotaLobbyClient extends EventEmitter { } } + 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) return; - clearTimeout(this.pendingLobbyRequest.timeout); - this.pendingLobbyRequest.reject(err); - this.pendingLobbyRequest = null; + 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); + }); } } @@ -767,16 +1821,21 @@ function normalizeLobby(lobby, names) { 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 || ''), }; @@ -814,7 +1873,18 @@ const state = { 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; @@ -829,7 +1899,7 @@ function publicState() { auth: { adminConfigured: Boolean(ADMIN_PASSWORD), credentialsConfigured: Boolean(dota.credentials.accountName && dota.credentials.password), - tokenConfigured: Boolean(authStore.load()?.refreshToken), + tokenConfigured: Boolean(authStore.load(dota.credentials.accountName)?.refreshToken), }, serverFilters: { region: serverRegion, @@ -841,18 +1911,23 @@ function publicState() { function adminAuthState() { const credentials = resolveCredentials(credentialsStore); - const auth = authStore.load(); + 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, - password: credentials.password, + passwordSaved: Boolean(credentials.password), source: credentials.source, updatedAt: credentials.updatedAt, + activeAccountName: savedAccounts.activeAccountName, + savedAccounts: savedAccounts.accounts, }, refreshToken: { exists: Boolean(auth?.refreshToken), @@ -862,6 +1937,7 @@ function adminAuthState() { expiresAt: auth?.refreshToken ? tokenExpiresAt(auth.refreshToken) : null, }, connection: dota.status(), + lobbyAutomation: automation.snapshot(), }; } @@ -939,6 +2015,8 @@ function broadcastState() { } 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(); @@ -968,10 +2046,42 @@ async function refreshLobbies(reason) { 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 names = await resolveGameNames(rawLobbies.map((lobby) => lobby.customGameId)); - const lobbies = rawLobbies.map((lobby) => normalizeLobby(lobby, names)); + 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(); @@ -1003,6 +2113,7 @@ function serveStatic(req, res) { '/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]; @@ -1058,10 +2169,13 @@ function createServer() { 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.' }); + 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, @@ -1076,6 +2190,19 @@ function createServer() { 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(); @@ -1093,13 +2220,128 @@ function createServer() { } if (req.method === 'POST' && url.pathname === '/api/admin/clear-token') { - authStore.remove(); + 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) { - sendJson(res, 500, { error: err.message }); + 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; } diff --git a/lib/dota-client-version.js b/lib/dota-client-version.js new file mode 100644 index 0000000..5d7ae49 --- /dev/null +++ b/lib/dota-client-version.js @@ -0,0 +1,55 @@ +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +function parseClientVersion(text) { + const raw = String(text).match(/^ClientVersion=(\d+)\s*$/m)?.[1] || ''; + if (!raw) return null; + const version = Number(raw); + return Number.isInteger(version) && version > 0 && version <= 0xffffffff ? version : null; +} + +function parseVersionOverride(value) { + const raw = String(value ?? '').trim(); + if (!raw) return null; + if (!/^\d+$/.test(raw)) throw new Error('DOTA_CLIENT_VERSION must contain digits only.'); + const version = Number(raw); + if (!Number.isInteger(version) || version < 1 || version > 0xffffffff) { + throw new Error('DOTA_CLIENT_VERSION must be an unsigned 32-bit integer.'); + } + return version; +} + +function defaultSteamInfFiles() { + const home = os.homedir(); + return [ + process.env.DOTA_STEAM_INF, + path.join(home, '.local', 'share', 'Steam', 'steamapps', 'common', 'dota 2 beta', 'game', 'dota', 'steam.inf'), + path.join(home, '.steam', 'steam', 'steamapps', 'common', 'dota 2 beta', 'game', 'dota', 'steam.inf'), + path.join('/steam', 'steamapps', 'common', 'dota 2 beta', 'game', 'dota', 'steam.inf'), + ].filter(Boolean); +} + +function resolveDotaClientVersion(files = defaultSteamInfFiles()) { + const override = parseVersionOverride(process.env.DOTA_CLIENT_VERSION); + if (override) return { version: override, source: 'environment' }; + + for (const file of [...new Set(files)]) { + try { + const version = parseClientVersion(fs.readFileSync(file, 'utf8')); + if (version) return { version, source: file }; + } catch (err) { + if (err.code !== 'ENOENT' && err.code !== 'ENOTDIR') { + console.warn(`warn: could not read Dota client version ${file}: ${err.message}`); + } + } + } + return { version: null, source: null }; +} + +module.exports = { + parseClientVersion, + resolveDotaClientVersion, +}; diff --git a/lib/lobby-settings.js b/lib/lobby-settings.js new file mode 100644 index 0000000..21b2520 --- /dev/null +++ b/lib/lobby-settings.js @@ -0,0 +1,163 @@ +'use strict'; + +const UINT32_MAX = 0xffffffff; +const MAX_CUSTOM_PLAYERS = 64; + +const DEFAULT_LOBBY_SETTINGS = Object.freeze({ + lobbyName: '', + passKey: '', + serverRegion: 3, + customGameId: '', + customGameMode: '', + customMapName: '', + customGameCrc: '', + customGameTimestamp: '', + customMinPlayers: 2, + customMaxPlayers: 10, + notifyAtPlayers: 8, + leaveAtPlayers: 9, + adminNames: [], + allowSpectating: true, +}); + +function asInteger(value, name, min, max) { + const parsed = typeof value === 'number' ? value : Number(String(value).trim()); + if (!Number.isInteger(parsed) || parsed < min || parsed > max) { + throw new Error(`${name} must be an integer between ${min} and ${max}.`); + } + return parsed; +} + +function asOptionalIntegerString(value, name, max) { + const raw = String(value ?? '').trim(); + if (!raw) return ''; + if (!/^\d+$/.test(raw)) throw new Error(`${name} must contain digits only.`); + const parsed = BigInt(raw); + if (parsed < 0n || parsed > BigInt(max)) throw new Error(`${name} is outside its supported range.`); + return raw; +} + +function asUint64String(value, name, { required = false } = {}) { + const raw = String(value ?? '').trim(); + if (!raw) { + if (required) throw new Error(`${name} is required.`); + return ''; + } + if (!/^\d+$/.test(raw)) throw new Error(`${name} must contain digits only.`); + const parsed = BigInt(raw); + if (parsed <= 0n || parsed > 0xffffffffffffffffn) { + throw new Error(`${name} must be a non-zero unsigned 64-bit integer.`); + } + return raw; +} + +function parseAdminNames(value) { + const values = Array.isArray(value) ? value : String(value ?? '').split(','); + return [...new Set(values.map((entry) => String(entry).trim()).filter(Boolean))]; +} + +function normalizeIdentity(value) { + return String(value ?? '').normalize('NFKC').trim().toLocaleLowerCase('en-US'); +} + +function validateLobbySettings(input = {}) { + const merged = { ...DEFAULT_LOBBY_SETTINGS, ...input }; + const lobbyName = String(merged.lobbyName ?? '').trim(); + const customGameMode = String(merged.customGameMode ?? '').trim(); + const customMapName = String(merged.customMapName ?? '').trim(); + const passKey = String(merged.passKey ?? ''); + + if (!lobbyName) throw new Error('Lobby name is required.'); + if (lobbyName.length > 100) throw new Error('Lobby name must be at most 100 characters.'); + if (passKey.length > 64) throw new Error('Lobby password must be at most 64 characters.'); + if (!customGameMode) { + throw new Error('Internal addon name is required for a public custom-game lobby. Create the form from a discovered lobby template.'); + } + if (!customMapName) throw new Error('Custom map name is required.'); + + const customMinPlayers = asInteger(merged.customMinPlayers, 'Minimum players', 1, MAX_CUSTOM_PLAYERS); + const customMaxPlayers = asInteger(merged.customMaxPlayers, 'Maximum players', 2, MAX_CUSTOM_PLAYERS); + const notifyAtPlayers = asInteger(merged.notifyAtPlayers, 'Notification threshold', 1, MAX_CUSTOM_PLAYERS); + const leaveAtPlayers = asInteger(merged.leaveAtPlayers, 'Auto-leave threshold', 1, MAX_CUSTOM_PLAYERS); + + if (customMinPlayers > customMaxPlayers) { + throw new Error('Minimum players cannot exceed maximum players.'); + } + if (notifyAtPlayers > leaveAtPlayers) { + throw new Error('Notification threshold must not exceed the auto-leave threshold.'); + } + if (leaveAtPlayers > customMaxPlayers) { + throw new Error('Auto-leave threshold cannot exceed the custom-game maximum player count.'); + } + if (leaveAtPlayers === customMaxPlayers) { + throw new Error('Auto-leave threshold must be below the custom-game maximum while the bot occupies one lobby slot.'); + } + + return { + lobbyName, + passKey, + serverRegion: asInteger(merged.serverRegion, 'Server region', 0, UINT32_MAX), + customGameId: asUint64String(merged.customGameId, 'Custom game workshop ID', { required: true }), + customGameMode, + customMapName, + customGameCrc: asUint64String(merged.customGameCrc, 'Custom game CRC'), + customGameTimestamp: asOptionalIntegerString(merged.customGameTimestamp, 'Custom game timestamp', UINT32_MAX), + customMinPlayers, + customMaxPlayers, + notifyAtPlayers, + leaveAtPlayers, + adminNames: parseAdminNames(merged.adminNames), + allowSpectating: merged.allowSpectating !== false, + }; +} + +function toDotaLobbyDetails(settings) { + const details = { + gameName: settings.lobbyName, + serverRegion: settings.serverRegion, + gameMode: 15, + allowCheats: false, + fillWithBots: false, + allowSpectating: settings.allowSpectating, + passKey: settings.passKey, + lan: false, + customMapName: settings.customMapName, + customDifficulty: 0, + customGameId: settings.customGameId, + customMinPlayers: settings.customMinPlayers, + customMaxPlayers: settings.customMaxPlayers, + visibility: 0, + customGamePenalties: true, + }; + + details.customGameMode = settings.customGameMode; + if (settings.customGameCrc) details.customGameCrc = settings.customGameCrc; + if (settings.customGameTimestamp) details.customGameTimestamp = Number(settings.customGameTimestamp); + return details; +} + +function findAdminMember(settings, members) { + const wanted = new Set(settings.adminNames.map(normalizeIdentity)); + if (!wanted.size) return null; + return members.find((member) => wanted.has(normalizeIdentity(member.name)) + || wanted.has(normalizeIdentity(member.steamId))) || null; +} + +function decideLobbySafety(settings, members, lobbyState) { + if (lobbyState !== 0) return { action: 'unsafe-state', admin: null }; + if (members.length >= settings.leaveAtPlayers) return { action: 'player-limit', admin: null }; + const admin = findAdminMember(settings, members); + if (admin) return { action: 'admin-joined', admin }; + return { action: 'monitor', admin: null }; +} + +module.exports = { + DEFAULT_LOBBY_SETTINGS, + MAX_CUSTOM_PLAYERS, + decideLobbySafety, + findAdminMember, + normalizeIdentity, + parseAdminNames, + toDotaLobbyDetails, + validateLobbySettings, +}; diff --git a/lib/telegram-notifier.js b/lib/telegram-notifier.js new file mode 100644 index 0000000..8c46aea --- /dev/null +++ b/lib/telegram-notifier.js @@ -0,0 +1,401 @@ +'use strict'; + +const { EventEmitter } = require('events'); + +const MAX_TELEGRAM_ID = 4503599627370495n; +const TELEGRAM_TEXT_LIMIT = 4096; +const ACTIVE_PHASES = new Set([ + 'creating', + 'restoring', + 'monitoring', + 'leaving', + 'unsafe-state', + 'connection-lost', + 'error', +]); + +const PHASE_LABELS = { + creating: 'создание', + restoring: 'восстановление после перезапуска', + monitoring: 'ожидание игроков', + leaving: 'бот выходит', + left: 'бот вышел', + 'unsafe-state': 'лобби уже запускается', + 'connection-lost': 'соединение потеряно', + unmanaged: 'автоматизация отключена', + error: 'ошибка', + idle: 'не активно', +}; + +class TelegramApiError extends Error { + constructor(method, description, errorCode = null) { + super(`Telegram ${method}: ${description || 'request failed'}`); + this.name = 'TelegramApiError'; + this.method = method; + this.description = description || 'request failed'; + this.errorCode = errorCode; + } +} + +function validateBotToken(value) { + const token = String(value ?? '').trim(); + if (!/^\d{5,}:[A-Za-z0-9_-]{20,}$/.test(token)) { + throw new Error('Telegram bot token has an invalid format. Copy it from @BotFather.'); + } + return token; +} + +function parseRecipientIds(value) { + const rawValues = Array.isArray(value) + ? value + : String(value ?? '').split(/[\s,;]+/); + const result = []; + const seen = new Set(); + + for (const item of rawValues) { + const id = String(item ?? '').trim(); + if (!id) continue; + if (!/^[1-9]\d*$/.test(id) || BigInt(id) > MAX_TELEGRAM_ID) { + throw new Error(`Telegram recipient ID "${id}" must be a positive numeric user ID.`); + } + if (!seen.has(id)) { + seen.add(id); + result.push(id); + } + } + + if (!result.length) throw new Error('At least one Telegram recipient ID is required.'); + return result; +} + +function cleanLine(value, maxLength = 80) { + const text = String(value ?? '').replace(/[\r\n\t]+/g, ' ').trim(); + return text.length > maxLength ? `${text.slice(0, maxLength - 1)}…` : text; +} + +function formatLobbyMessage(settings = {}, status = {}) { + const members = Array.isArray(status.members) ? status.members : []; + const humanCount = Number.isInteger(status.humanCount) ? status.humanCount : members.length; + const maxPlayers = Number(settings.customMaxPlayers || 0); + const phase = PHASE_LABELS[status.phase] || cleanLine(status.phase || 'неизвестно'); + const title = cleanLine(settings.lobbyName || 'Dota 2 lobby', 100); + const lines = [ + `🎮 ${title}`, + `Статус: ${phase}`, + `Игроки: ${humanCount}/${maxPlayers || '?'}`, + ]; + + if (status.lobbyId) lines.push(`Lobby ID: ${cleanLine(status.lobbyId, 32)}`); + if (settings.customMapName) lines.push(`Карта: ${cleanLine(settings.customMapName, 80)}`); + if (settings.serverRegion !== undefined) { + lines.push(`Регион: ${cleanLine(settings.serverRegionName || settings.serverRegion, 80)}`); + } + if (status.practicePublished === true) lines.push('Публикация: в списке лобби'); + if (status.practicePublished === false) lines.push('Публикация: не найдено в списке'); + + lines.push('', 'Состав:'); + if (!members.length) { + lines.push('— пока нет игроков'); + } else { + members.forEach((member, index) => { + const name = cleanLine(member.name, 60); + const steamId = cleanLine(member.steamId, 24); + lines.push(`${index + 1}. ${name || steamId || 'неизвестный игрок'}${name && steamId ? ` (${steamId})` : ''}`); + }); + } + + if (settings.notifyAtPlayers) lines.push('', `Уведомление: ${settings.notifyAtPlayers}`); + if (settings.leaveAtPlayers) lines.push(`Выход бота: ${settings.leaveAtPlayers}`); + if (status.leaveReason) lines.push(`Причина выхода: ${cleanLine(status.leaveReason, 80)}`); + if (status.lastError) lines.push('', `Ошибка: ${cleanLine(status.lastError, 240)}`); + + const text = lines.join('\n'); + return text.length <= TELEGRAM_TEXT_LIMIT + ? text + : `${text.slice(0, TELEGRAM_TEXT_LIMIT - 2)}…`; +} + +class TelegramNotifier extends EventEmitter { + constructor({ store, fetchImpl = globalThis.fetch, debounceMs = 300 }) { + super(); + if (!store) throw new Error('TelegramNotifier requires a persistent store.'); + if (typeof fetchImpl !== 'function') throw new Error('TelegramNotifier requires fetch support.'); + this.store = store; + this.fetchImpl = fetchImpl; + this.debounceMs = debounceMs; + const saved = store.load() || {}; + this.token = typeof saved.token === 'string' ? saved.token : ''; + this.recipientIds = Array.isArray(saved.recipientIds) + ? saved.recipientIds.map(String).filter((id) => /^[1-9]\d*$/.test(id)) + : []; + this.bot = saved.bot && typeof saved.bot === 'object' ? saved.bot : null; + this.session = saved.session && typeof saved.session === 'object' ? saved.session : null; + this.lastSuccessAt = saved.lastSuccessAt || null; + this.lastError = saved.lastError || null; + this.pendingSnapshot = null; + this.flushTimer = null; + this.flushPromise = Promise.resolve(); + } + + publicState() { + return { + configured: Boolean(this.token && this.recipientIds.length), + botUsername: this.bot?.username || null, + recipientIds: [...this.recipientIds], + recipientCount: this.recipientIds.length, + lastSuccessAt: this.lastSuccessAt, + lastError: this.lastError, + }; + } + + persist() { + this.store.save({ + token: this.token, + recipientIds: this.recipientIds, + bot: this.bot, + session: this.session, + lastSuccessAt: this.lastSuccessAt, + lastError: this.lastError, + }); + } + + async request(token, method, payload = {}) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10000); + timeout.unref?.(); + try { + const response = await this.fetchImpl(`https://api.telegram.org/bot${token}/${method}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + signal: controller.signal, + }); + const body = await response.json().catch(() => null); + if (!response.ok || !body?.ok) { + throw new TelegramApiError(method, body?.description || `HTTP ${response.status}`, body?.error_code); + } + return body.result; + } catch (err) { + if (err instanceof TelegramApiError) throw err; + if (err.name === 'AbortError') throw new TelegramApiError(method, 'request timed out'); + throw new TelegramApiError(method, err.message || 'network request failed'); + } finally { + clearTimeout(timeout); + } + } + + async configure({ token, recipientIds }) { + const nextToken = String(token ?? '').trim() ? validateBotToken(token) : this.token; + if (!nextToken) throw new Error('Telegram bot token is required.'); + const nextRecipients = parseRecipientIds(recipientIds); + const bot = await this.request(nextToken, 'getMe'); + const tokenChanged = nextToken !== this.token; + + this.token = nextToken; + this.recipientIds = nextRecipients; + this.bot = { id: String(bot.id || ''), username: bot.username || '' }; + this.lastError = null; + if (tokenChanged) this.session = null; + this.persist(); + this.emit('change'); + return this.publicState(); + } + + disable() { + if (this.flushTimer) clearTimeout(this.flushTimer); + this.flushTimer = null; + this.pendingSnapshot = null; + this.token = ''; + this.recipientIds = []; + this.bot = null; + this.session = null; + this.lastError = null; + this.persist(); + this.emit('change'); + return this.publicState(); + } + + beginLobbySession() { + this.session = { + key: `pending:${Date.now()}`, + lobbyId: null, + messages: {}, + }; + this.persist(); + this.emit('change'); + } + + queueLobbyUpdate(settings, status) { + this.pendingSnapshot = { + settings: { ...settings }, + status: { + ...status, + members: Array.isArray(status?.members) + ? status.members.map((member) => ({ ...member })) + : [], + }, + }; + if (this.flushTimer) clearTimeout(this.flushTimer); + this.flushTimer = setTimeout(() => { + this.flushTimer = null; + const snapshot = this.pendingSnapshot; + this.pendingSnapshot = null; + if (!snapshot) return; + this.flushPromise = this.flushPromise + .then(() => this.syncLobby(snapshot.settings, snapshot.status)) + .catch((err) => this.recordError(err)); + }, this.debounceMs); + this.flushTimer.unref?.(); + } + + ensureSession(status) { + const active = Boolean(status.armed || status.lobbyId || ACTIVE_PHASES.has(status.phase)); + if (!this.session && !active) return false; + if (!this.session) { + this.session = { + key: status.lobbyId ? `lobby:${status.lobbyId}` : `pending:${Date.now()}`, + lobbyId: status.lobbyId || null, + messages: {}, + }; + } + if (status.lobbyId && !this.session.lobbyId) { + this.session.lobbyId = String(status.lobbyId); + } + if (!this.session.messages || typeof this.session.messages !== 'object') { + this.session.messages = {}; + } + return true; + } + + shouldReplaceMessage(err) { + return err instanceof TelegramApiError + && err.errorCode === 400 + && /message to edit not found|message can.t be edited/i.test(err.description); + } + + async syncRecipient(recipientId, text) { + const existing = this.session.messages[recipientId]; + if (existing?.text === text) return { recipientId, action: 'unchanged' }; + + if (existing?.messageId) { + try { + await this.request(this.token, 'editMessageText', { + chat_id: recipientId, + message_id: existing.messageId, + text, + disable_web_page_preview: true, + }); + this.session.messages[recipientId] = { ...existing, text }; + return { recipientId, action: 'edited' }; + } catch (err) { + if (!this.shouldReplaceMessage(err)) throw err; + } + } + + const message = await this.request(this.token, 'sendMessage', { + chat_id: recipientId, + text, + disable_web_page_preview: true, + }); + this.session.messages[recipientId] = { + messageId: message.message_id, + text, + }; + return { recipientId, action: 'sent' }; + } + + async syncLobby(settings, status) { + if (!this.token || !this.recipientIds.length || !this.ensureSession(status)) { + return { delivered: 0, failed: 0 }; + } + const text = formatLobbyMessage(settings, status); + const results = await Promise.allSettled( + this.recipientIds.map(async (recipientId) => { + try { + return await this.syncRecipient(recipientId, text); + } catch (err) { + throw new Error(`${recipientId}: ${err.message}`); + } + }), + ); + const failures = results.filter((result) => result.status === 'rejected'); + if (results.some((result) => result.status === 'fulfilled')) { + this.lastSuccessAt = new Date().toISOString(); + } + this.lastError = failures.length + ? failures.map((result) => result.reason.message).join('; ') + : null; + this.persist(); + this.emit('change'); + return { + delivered: results.length - failures.length, + failed: failures.length, + }; + } + + async sendTest() { + if (!this.token || !this.recipientIds.length) { + throw new Error('Telegram notifications are not configured.'); + } + const results = await Promise.allSettled(this.recipientIds.map(async (recipientId) => { + try { + return await this.request(this.token, 'sendMessage', { + chat_id: recipientId, + text: '✅ Уведомления Dota 2 lobby подключены.', + }); + } catch (err) { + throw new Error(`${recipientId}: ${err.message}`); + } + })); + const failures = results.filter((result) => result.status === 'rejected'); + if (results.length > failures.length) this.lastSuccessAt = new Date().toISOString(); + this.lastError = failures.length + ? failures.map((result) => result.reason.message).join('; ') + : null; + this.persist(); + this.emit('change'); + return { + delivered: results.length - failures.length, + failed: failures.length, + errors: failures.map((result) => result.reason.message), + }; + } + + async sendAlert({ title, body }) { + if (!this.token || !this.recipientIds.length) return { delivered: 0, failed: 0 }; + const text = [`🔔 ${cleanLine(title, 160)}`, cleanLine(body, 500)].filter(Boolean).join('\n'); + const results = await Promise.allSettled(this.recipientIds.map(async (recipientId) => { + try { + return await this.request(this.token, 'sendMessage', { chat_id: recipientId, text }); + } catch (err) { + throw new Error(`${recipientId}: ${err.message}`); + } + })); + const failures = results.filter((result) => result.status === 'rejected'); + if (results.length > failures.length) this.lastSuccessAt = new Date().toISOString(); + this.lastError = failures.length + ? failures.map((result) => result.reason.message).join('; ') + : null; + this.persist(); + this.emit('change'); + return { + delivered: results.length - failures.length, + failed: failures.length, + }; + } + + recordError(err) { + this.lastError = err.message || String(err); + this.persist(); + this.emit('change'); + console.error(`Telegram notification failed: ${this.lastError}`); + } +} + +module.exports = { + TelegramApiError, + TelegramNotifier, + formatLobbyMessage, + parseRecipientIds, + validateBotToken, +}; diff --git a/lib/workshop-metadata.js b/lib/workshop-metadata.js new file mode 100644 index 0000000..af55a0b --- /dev/null +++ b/lib/workshop-metadata.js @@ -0,0 +1,44 @@ +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +function parseWorkshopPublishData(text) { + const sourceFolder = String(text).match(/"source_folder"\s+"([^"]+)"/)?.[1]?.trim() || ''; + const publishTime = String(text).match(/"publish_time"\s+"(\d+)"/)?.[1] || ''; + if (!sourceFolder) return null; + return { sourceFolder, publishTime }; +} + +function defaultWorkshopRoots() { + const home = os.homedir(); + return [ + process.env.DOTA_WORKSHOP_DIR, + path.join(home, '.local', 'share', 'Steam', 'steamapps', 'workshop', 'content', '570'), + path.join(home, '.steam', 'steam', 'steamapps', 'workshop', 'content', '570'), + ].filter(Boolean); +} + +function resolveLocalWorkshopMetadata(customGameId, roots = defaultWorkshopRoots()) { + const id = String(customGameId || '').trim(); + if (!/^\d+$/.test(id)) return null; + + for (const root of [...new Set(roots)]) { + const file = path.join(root, id, 'publish_data.txt'); + try { + const metadata = parseWorkshopPublishData(fs.readFileSync(file, 'utf8')); + if (metadata) return { ...metadata, file }; + } catch (err) { + if (err.code !== 'ENOENT' && err.code !== 'ENOTDIR') { + console.warn(`warn: could not read Workshop metadata ${file}: ${err.message}`); + } + } + } + return null; +} + +module.exports = { + parseWorkshopPublishData, + resolveLocalWorkshopMetadata, +}; diff --git a/package-lock.json b/package-lock.json index ab3e95a..cb82228 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,16 @@ { "name": "doka-lobby", - "version": "1.0.0", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "doka-lobby", - "version": "1.0.0", + "version": "1.2.0", "dependencies": { "protobufjs": "^7.4.0", - "steam-user": "^5.2.0" + "steam-user": "^5.2.0", + "web-push": "^3.6.7" }, "engines": { "node": ">=18" @@ -153,6 +154,18 @@ "node": ">= 6.0.0" } }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/binarykvparser": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binarykvparser/-/binarykvparser-2.3.0.tgz", @@ -165,6 +178,18 @@ "long": "^3.2.0" } }, + "node_modules/bn.js": { + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", + "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", + "license": "MIT" + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/bytebuffer": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/bytebuffer/-/bytebuffer-5.0.1.tgz", @@ -209,6 +234,15 @@ } } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/file-manager": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/file-manager/-/file-manager-2.0.1.tgz", @@ -230,6 +264,43 @@ "node": ">=6.0.0" } }, + "node_modules/http_ece": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", + "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/ip-address": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", @@ -239,6 +310,27 @@ "node": ">= 12" } }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/kvparser": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/kvparser/-/kvparser-1.0.2.tgz", @@ -263,6 +355,21 @@ "lzma.js": "bin/lzma.js" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -353,6 +460,12 @@ ], "license": "MIT" }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", @@ -544,6 +657,25 @@ "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", "license": "MIT" }, + "node_modules/web-push": { + "version": "3.6.7", + "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", + "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==", + "license": "MPL-2.0", + "dependencies": { + "asn1.js": "^5.3.0", + "http_ece": "1.2.0", + "https-proxy-agent": "^7.0.0", + "jws": "^4.0.0", + "minimist": "^1.2.5" + }, + "bin": { + "web-push": "src/cli.js" + }, + "engines": { + "node": ">= 16" + } + }, "node_modules/websocket-extensions": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", diff --git a/package.json b/package.json index 1b4dd8b..7034227 100644 --- a/package.json +++ b/package.json @@ -1,18 +1,20 @@ { "name": "doka-lobby", - "version": "1.1.0", + "version": "1.2.0", "private": true, "description": "List joinable Dota 2 custom game lobbies, filtered by game name / map", "main": "index.js", "scripts": { "start": "node index.js", - "once": "node index.js --once" + "once": "node index.js --once", + "test": "node --test" }, "engines": { "node": ">=18" }, "dependencies": { "protobufjs": "^7.4.0", - "steam-user": "^5.2.0" + "steam-user": "^5.2.0", + "web-push": "^3.6.7" } } diff --git a/public/admin.html b/public/admin.html index 7123d4f..940f17d 100644 --- a/public/admin.html +++ b/public/admin.html @@ -52,24 +52,167 @@ + + + + + + diff --git a/public/admin.js b/public/admin.js index 7e49e85..352f46e 100644 --- a/public/admin.js +++ b/public/admin.js @@ -5,6 +5,9 @@ const els = { steamStatus: document.querySelector('#steamStatus'), gcStatus: document.querySelector('#gcStatus'), loginPanel: document.querySelector('#loginPanel'), + notificationPanel: document.querySelector('#notificationPanel'), + telegramPanel: document.querySelector('#telegramPanel'), + lobbyPanel: document.querySelector('#lobbyPanel'), credentialsPanel: document.querySelector('#credentialsPanel'), guardPanel: document.querySelector('#guardPanel'), tokenPanel: document.querySelector('#tokenPanel'), @@ -25,10 +28,70 @@ const els = { tokenUpdated: document.querySelector('#tokenUpdated'), tokenExpires: document.querySelector('#tokenExpires'), clearTokenBtn: document.querySelector('#clearTokenBtn'), + enableNotificationsBtn: document.querySelector('#enableNotificationsBtn'), + disableNotificationsBtn: document.querySelector('#disableNotificationsBtn'), + testNotificationBtn: document.querySelector('#testNotificationBtn'), + notificationStatus: document.querySelector('#notificationStatus'), + telegramToken: document.querySelector('#telegramToken'), + telegramRecipientIds: document.querySelector('#telegramRecipientIds'), + saveTelegramBtn: document.querySelector('#saveTelegramBtn'), + testTelegramBtn: document.querySelector('#testTelegramBtn'), + disableTelegramBtn: document.querySelector('#disableTelegramBtn'), + telegramStatus: document.querySelector('#telegramStatus'), + savedSteamAccount: document.querySelector('#savedSteamAccount'), + activateAccountBtn: document.querySelector('#activateAccountBtn'), + lobbyName: document.querySelector('#lobbyName'), + lobbyRegion: document.querySelector('#lobbyRegion'), + customGameId: document.querySelector('#customGameId'), + customGameMode: document.querySelector('#customGameMode'), + customMapName: document.querySelector('#customMapName'), + lobbyPassKey: document.querySelector('#lobbyPassKey'), + customMinPlayers: document.querySelector('#customMinPlayers'), + customMaxPlayers: document.querySelector('#customMaxPlayers'), + notifyAtPlayers: document.querySelector('#notifyAtPlayers'), + leaveAtPlayers: document.querySelector('#leaveAtPlayers'), + adminNames: document.querySelector('#adminNames'), + customGameCrc: document.querySelector('#customGameCrc'), + customGameTimestamp: document.querySelector('#customGameTimestamp'), + allowSpectating: document.querySelector('#allowSpectating'), + saveLobbySettingsBtn: document.querySelector('#saveLobbySettingsBtn'), + createLobbyBtn: document.querySelector('#createLobbyBtn'), + stopLobbyBtn: document.querySelector('#stopLobbyBtn'), + automationPhase: document.querySelector('#automationPhase'), + activeLobbyId: document.querySelector('#activeLobbyId'), + activePlayerCount: document.querySelector('#activePlayerCount'), + publicationStatus: document.querySelector('#publicationStatus'), + arcadePublicationStatus: document.querySelector('#arcadePublicationStatus'), + lobbyProtocolStatus: document.querySelector('#lobbyProtocolStatus'), + dotaClientVersion: document.querySelector('#dotaClientVersion'), + leaveReason: document.querySelector('#leaveReason'), + lobbyMembers: document.querySelector('#lobbyMembers'), }; let adminPassword = sessionStorage.getItem('dokaAdminPassword') || ''; let credentialsDirty = false; +let lobbySettingsDirty = false; +let telegramDirty = false; +let regionsReady = false; +let lastAdminState = null; + +function readPendingLobbyTemplate() { + try { + const saved = sessionStorage.getItem('dokaLobbyTemplate'); + if (saved) { + const parsed = JSON.parse(saved); + if (parsed && typeof parsed === 'object') return parsed; + } + } catch { + // Fall through to the query-string compatibility path. + } + + const query = new URLSearchParams(window.location.search); + return query.get('template') === 'lobby' ? Object.fromEntries(query.entries()) : null; +} + +const pendingLobbyTemplate = readPendingLobbyTemplate(); +let lobbyTemplateApplied = false; const dateTimeFormat = new Intl.DateTimeFormat('en-GB', { day: '2-digit', @@ -81,7 +144,270 @@ function unlocked() { return Boolean(adminPassword); } +function initRegions(regions) { + if (regionsReady || !regions) return; + for (const [id, name] of Object.entries(regions).sort(([a], [b]) => Number(a) - Number(b))) { + const option = document.createElement('option'); + option.value = id; + option.textContent = `${name} (${id})`; + els.lobbyRegion.append(option); + } + regionsReady = true; +} + +function setLobbyInputs(settings) { + els.lobbyName.value = settings.lobbyName || ''; + els.lobbyRegion.value = String(settings.serverRegion ?? 3); + els.customGameId.value = settings.customGameId || ''; + els.customGameMode.value = settings.customGameMode || ''; + els.customMapName.value = settings.customMapName || ''; + els.lobbyPassKey.value = settings.passKey || ''; + els.customMinPlayers.value = String(settings.customMinPlayers ?? 2); + els.customMaxPlayers.value = String(settings.customMaxPlayers ?? 10); + els.notifyAtPlayers.value = String(settings.notifyAtPlayers ?? 8); + els.leaveAtPlayers.value = String(settings.leaveAtPlayers ?? 9); + els.adminNames.value = (settings.adminNames || []).join(', '); + els.customGameCrc.value = settings.customGameCrc || ''; + els.customGameTimestamp.value = settings.customGameTimestamp || ''; + els.allowSpectating.checked = settings.allowSpectating !== false; + lobbySettingsDirty = false; +} + +async function resolveMissingTemplateMetadata() { + if (els.customGameMode.value || !pendingLobbyTemplate?.sourceLobbyId) return; + const controller = new AbortController(); + let timedOut = false; + const timeout = setTimeout(() => { + timedOut = true; + controller.abort(); + }, 15000); + try { + showMessage('Resolving the internal addon name (local Workshop first, Dota GC fallback; up to 15 seconds)...'); + const data = await adminFetch('/api/admin/lobby-template', { + method: 'POST', + signal: controller.signal, + body: JSON.stringify({ + lobbyId: pendingLobbyTemplate.sourceLobbyId, + serverRegion: Number(pendingLobbyTemplate.serverRegion || 0), + customGameId: pendingLobbyTemplate.customGameId, + }), + }); + els.customGameMode.value = data.templateMetadata?.customGameMode || ''; + if (!els.customGameTimestamp.value && data.templateMetadata?.customGameTimestamp) { + els.customGameTimestamp.value = data.templateMetadata.customGameTimestamp; + } + if (!els.customGameMode.value) throw new Error('The source lobby has no internal addon name.'); + lobbySettingsDirty = true; + showError(''); + showMessage(`Lobby template is complete: Workshop ${els.customGameId.value}, addon ${els.customGameMode.value}, map ${els.customMapName.value}. Review the thresholds, then create it.`); + } catch (err) { + showError(`Could not complete lobby template: ${timedOut ? 'metadata lookup timed out after 15 seconds' : err.message}`); + } finally { + clearTimeout(timeout); + } +} + +function applyLobbyTemplate() { + if (!pendingLobbyTemplate || lobbyTemplateApplied) return; + + const setFromTemplate = (element, key) => { + const value = pendingLobbyTemplate[key]; + if (value !== undefined && value !== null) element.value = String(value); + }; + setFromTemplate(els.lobbyName, 'lobbyName'); + setFromTemplate(els.lobbyRegion, 'serverRegion'); + setFromTemplate(els.customGameId, 'customGameId'); + setFromTemplate(els.customGameMode, 'customGameMode'); + setFromTemplate(els.customMapName, 'customMapName'); + setFromTemplate(els.customMinPlayers, 'customMinPlayers'); + setFromTemplate(els.customMaxPlayers, 'customMaxPlayers'); + setFromTemplate(els.customGameCrc, 'customGameCrc'); + setFromTemplate(els.customGameTimestamp, 'customGameTimestamp'); + + // The source feed never exposes the lobby password. Clear any stale value. + els.lobbyPassKey.value = ''; + + const maxPlayers = Number(els.customMaxPlayers.value); + if (Number.isInteger(maxPlayers) && maxPlayers > 0) { + const highestSafeThreshold = Math.max(1, maxPlayers - 1); + const leaveAt = Math.min(Number(els.leaveAtPlayers.value) || highestSafeThreshold, highestSafeThreshold); + els.leaveAtPlayers.value = String(leaveAt); + els.notifyAtPlayers.value = String(Math.min(Number(els.notifyAtPlayers.value) || leaveAt, leaveAt)); + } + + lobbyTemplateApplied = true; + lobbySettingsDirty = true; + try { + sessionStorage.removeItem('dokaLobbyTemplate'); + } catch { + // The query-string fallback is still removed below. + } + window.history.replaceState({}, '', '/admin'); + showMessage(`Lobby template loaded: Workshop ${els.customGameId.value}, map ${els.customMapName.value}. Review the notification and leave thresholds, then create it.`); + if (!els.customGameMode.value) { + showError('The GC did not return the internal addon name for this row. Refresh the lobby list and create the template again; an incomplete template cannot be published.'); + resolveMissingTemplateMetadata(); + } +} + +function lobbyPayload() { + return { + lobbyName: els.lobbyName.value, + serverRegion: Number(els.lobbyRegion.value), + customGameId: els.customGameId.value, + customGameMode: els.customGameMode.value, + customMapName: els.customMapName.value, + passKey: els.lobbyPassKey.value, + customMinPlayers: Number(els.customMinPlayers.value), + customMaxPlayers: Number(els.customMaxPlayers.value), + notifyAtPlayers: Number(els.notifyAtPlayers.value), + leaveAtPlayers: Number(els.leaveAtPlayers.value), + adminNames: els.adminNames.value, + customGameCrc: els.customGameCrc.value, + customGameTimestamp: els.customGameTimestamp.value, + allowSpectating: els.allowSpectating.checked, + }; +} + +function renderLobbyAutomation(automation) { + const status = automation.status || {}; + const settings = automation.settings || {}; + const busy = ['creating', 'leaving'].includes(status.phase); + const hasLobby = Boolean(status.lobbyId); + + if (!lobbySettingsDirty && !busy) setLobbyInputs(settings); + if (unlocked() && regionsReady && automation.settings && !busy) applyLobbyTemplate(); + els.automationPhase.textContent = status.phase || 'idle'; + els.activeLobbyId.textContent = status.lobbyId || '-'; + els.activePlayerCount.textContent = String(status.humanCount ?? 0); + els.publicationStatus.textContent = status.practicePublished === true + ? 'listed' + : status.practicePublished === false + ? 'not listed' + : status.armed + ? 'checking' + : '-'; + els.arcadePublicationStatus.textContent = status.arcadePublished === true + ? 'listed' + : status.arcadePublished === false + ? 'not listed' + : status.armed + ? 'checking' + : '-'; + const protocol = status.lobbyProtocol; + els.lobbyProtocolStatus.textContent = protocol + ? `type=${protocol.lobbyType}, visibility=${protocol.visibility}, auto=${protocol.autoCreated ? 'yes' : 'no'}, bot team=${protocol.botTeam ?? '-'}` + : '-'; + els.leaveReason.textContent = status.leaveReason || '-'; + els.createLobbyBtn.disabled = busy || hasLobby || Boolean(status.armed); + els.stopLobbyBtn.disabled = busy || (!hasLobby && !status.armed); + els.saveLobbySettingsBtn.disabled = busy; + + const members = status.members || []; + if (members.length) { + els.lobbyMembers.textContent = members + .map((member) => member.name ? `${member.name} (${member.steamId})` : member.steamId) + .join(', '); + } else { + els.lobbyMembers.textContent = 'No real players in the active lobby.'; + } + + if (status.lastError) showError(status.lastError); +} + +function pushSupported() { + return 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window; +} + +async function currentPushSubscription() { + if (!pushSupported()) return null; + const registration = await navigator.serviceWorker.register('/sw.js'); + return registration.pushManager.getSubscription(); +} + +async function updateNotificationStatus() { + if (!pushSupported()) { + els.notificationStatus.textContent = 'not supported'; + els.enableNotificationsBtn.disabled = true; + els.disableNotificationsBtn.disabled = true; + els.testNotificationBtn.disabled = true; + return; + } + + try { + const subscription = await currentPushSubscription(); + els.notificationStatus.textContent = subscription && Notification.permission === 'granted' + ? 'enabled on this browser' + : Notification.permission; + els.disableNotificationsBtn.disabled = !subscription; + els.testNotificationBtn.disabled = !subscription; + } catch (err) { + els.notificationStatus.textContent = err.message; + } +} + +function urlBase64ToUint8Array(value) { + const padding = '='.repeat((4 - (value.length % 4)) % 4); + const base64 = (value + padding).replace(/-/g, '+').replace(/_/g, '/'); + const raw = atob(base64); + return Uint8Array.from([...raw].map((char) => char.charCodeAt(0))); +} + +function renderSavedAccounts(credentials) { + const accounts = Array.isArray(credentials.savedAccounts) ? credentials.savedAccounts : []; + const currentSelection = els.savedSteamAccount.value; + els.savedSteamAccount.replaceChildren(); + if (!accounts.length) { + const option = document.createElement('option'); + option.value = ''; + option.textContent = 'No saved accounts'; + els.savedSteamAccount.append(option); + els.savedSteamAccount.disabled = true; + els.activateAccountBtn.disabled = true; + return; + } + + for (const account of accounts) { + const option = document.createElement('option'); + option.value = account.accountName; + option.textContent = account.accountName === credentials.activeAccountName + ? `${account.accountName} (active)` + : account.accountName; + els.savedSteamAccount.append(option); + } + els.savedSteamAccount.disabled = false; + els.activateAccountBtn.disabled = false; + const preferred = accounts.some((account) => account.accountName === currentSelection) + ? currentSelection + : credentials.activeAccountName; + els.savedSteamAccount.value = preferred || accounts[0].accountName; +} + +function renderTelegram(telegram = {}) { + const bot = telegram.botUsername ? `@${telegram.botUsername}` : 'bot'; + if (telegram.configured) { + els.telegramStatus.textContent = `${bot}, ${telegram.recipientCount || 0} recipient(s)${telegram.lastError ? `; error: ${telegram.lastError}` : ''}`; + } else { + els.telegramStatus.textContent = 'not configured'; + } + els.testTelegramBtn.disabled = !telegram.configured; + els.disableTelegramBtn.disabled = !telegram.configured; + + const editing = document.activeElement === els.telegramToken + || document.activeElement === els.telegramRecipientIds + || telegramDirty; + if (!editing) { + els.telegramToken.value = ''; + els.telegramToken.placeholder = telegram.configured + ? 'configured; leave blank to keep saved token' + : 'paste token from @BotFather'; + els.telegramRecipientIds.value = (telegram.recipientIds || []).join(', '); + telegramDirty = false; + } +} + function render(data, options = {}) { + lastAdminState = data; const preserveEditedInputs = options.preserveEditedInputs !== false; const connection = data.connection || {}; const credentials = data.credentials || {}; @@ -89,6 +415,9 @@ function render(data, options = {}) { const steamGuard = connection.steamGuard || {}; els.loginPanel.hidden = unlocked(); + els.notificationPanel.hidden = !unlocked(); + els.telegramPanel.hidden = !unlocked(); + els.lobbyPanel.hidden = !unlocked(); els.credentialsPanel.hidden = !unlocked(); els.tokenPanel.hidden = !unlocked(); els.guardPanel.hidden = !unlocked() || !steamGuard.required; @@ -98,6 +427,13 @@ function render(data, options = {}) { els.tokenStatus.textContent = token.exists ? 'saved' : 'missing'; els.steamStatus.textContent = connection.steam || '-'; els.gcStatus.textContent = connection.gc || '-'; + els.dotaClientVersion.textContent = connection.dotaClientVersion + ? `${connection.dotaClientVersion}${connection.dotaClientVersionSource ? ` (${connection.dotaClientVersionSource})` : ''}` + : 'not detected; set DOTA_CLIENT_VERSION'; + initRegions(data.regions); + renderLobbyAutomation(data.lobbyAutomation || {}); + renderTelegram(data.lobbyAutomation?.telegram || {}); + renderSavedAccounts(credentials); const editingCredentials = document.activeElement === els.steamAccount || document.activeElement === els.steamPassword @@ -105,7 +441,10 @@ function render(data, options = {}) { if (!preserveEditedInputs || !editingCredentials) { els.steamAccount.value = credentials.accountName || ''; - els.steamPassword.value = credentials.password || ''; + els.steamPassword.value = ''; + els.steamPassword.placeholder = credentials.passwordSaved + ? 'saved; leave blank to keep the saved password' + : 'password required for a new account'; credentialsDirty = false; } @@ -190,6 +529,178 @@ async function clearRefreshToken() { } } +async function saveLobbySettings() { + try { + showError(''); + showMessage(''); + const data = await adminFetch('/api/admin/lobby-settings', { + method: 'PUT', + body: JSON.stringify(lobbyPayload()), + }); + lobbySettingsDirty = false; + render(data); + showMessage('Lobby settings saved.'); + } catch (err) { + showError(err.message); + } +} + +async function createLobby() { + try { + showError(''); + showMessage('Creating lobby and waiting for the Dota GC snapshot...'); + els.createLobbyBtn.disabled = true; + const data = await adminFetch('/api/admin/lobby', { + method: 'POST', + body: JSON.stringify(lobbyPayload()), + }); + lobbySettingsDirty = false; + render(data); + showMessage('Lobby created. Player monitoring and safe auto-leave are active.'); + } catch (err) { + showError(err.message); + showMessage(''); + } finally { + if (!lastAdminState?.lobbyAutomation?.status?.armed) els.createLobbyBtn.disabled = false; + } +} + +async function stopLobby() { + try { + showError(''); + showMessage('Leaving lobby...'); + els.stopLobbyBtn.disabled = true; + const data = await adminFetch('/api/admin/lobby', { + method: 'DELETE', + body: '{}', + }); + render(data); + showMessage('The bot left the lobby.'); + } catch (err) { + showError(err.message); + showMessage(''); + } +} + +async function enableNotifications() { + try { + showError(''); + if (!pushSupported()) throw new Error('Push notifications are not supported by this browser.'); + const permission = await Notification.requestPermission(); + if (permission !== 'granted') throw new Error(`Notification permission is ${permission}.`); + const registration = await navigator.serviceWorker.register('/sw.js'); + let subscription = await registration.pushManager.getSubscription(); + if (!subscription) { + const publicKey = lastAdminState?.lobbyAutomation?.push?.publicKey; + if (!publicKey) throw new Error('Web Push public key is unavailable.'); + subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(publicKey), + }); + } + const data = await adminFetch('/api/admin/push-subscription', { + method: 'PUT', + body: JSON.stringify({ subscription: subscription.toJSON() }), + }); + render(data); + await updateNotificationStatus(); + showMessage('Browser push notifications enabled.'); + } catch (err) { + showError(err.message); + } +} + +async function disableNotifications() { + try { + showError(''); + const subscription = await currentPushSubscription(); + if (subscription) { + await adminFetch('/api/admin/push-subscription', { + method: 'DELETE', + body: JSON.stringify({ endpoint: subscription.endpoint }), + }); + await subscription.unsubscribe(); + } + await updateNotificationStatus(); + showMessage('Browser push notifications disabled on this browser.'); + } catch (err) { + showError(err.message); + } +} + +async function testNotification() { + try { + showError(''); + const data = await adminFetch('/api/admin/test-notification', { method: 'POST', body: '{}' }); + const result = data.notificationResult || {}; + showMessage(`Test notification sent: ${result.delivered || 0} delivered, ${result.failed || 0} failed.`); + } catch (err) { + showError(err.message); + } +} + +async function saveTelegram() { + try { + showError(''); + showMessage('Checking the Telegram bot and saving recipients...'); + const data = await adminFetch('/api/admin/telegram', { + method: 'PUT', + body: JSON.stringify({ + token: els.telegramToken.value, + recipientIds: els.telegramRecipientIds.value, + }), + }); + telegramDirty = false; + render(data); + showMessage('Telegram notifications saved. Use Send test to verify every recipient.'); + } catch (err) { + showMessage(''); + showError(err.message); + } +} + +async function disableTelegram() { + try { + showError(''); + const data = await adminFetch('/api/admin/telegram', { method: 'DELETE', body: '{}' }); + telegramDirty = false; + render(data); + showMessage('Telegram notifications disabled and the saved bot token removed.'); + } catch (err) { + showError(err.message); + } +} + +async function testTelegram() { + try { + showError(''); + const data = await adminFetch('/api/admin/test-telegram', { method: 'POST', body: '{}' }); + const result = data.telegramResult || {}; + render(data); + const errors = result.errors?.length ? ` ${result.errors.join('; ')}` : ''; + showMessage(`Telegram test: ${result.delivered || 0} delivered, ${result.failed || 0} failed.${errors}`); + } catch (err) { + showError(err.message); + } +} + +async function activateSavedAccount() { + try { + showError(''); + showMessage('Switching Steam account and reconnecting Dota GC...'); + const data = await adminFetch('/api/admin/steam-account/activate', { + method: 'POST', + body: JSON.stringify({ accountName: els.savedSteamAccount.value }), + }); + credentialsDirty = false; + render(data); + showMessage('Steam account activated. Reconnection continues in the background.'); + } catch (err) { + showMessage(''); + showError(err.message); + } +} + els.unlockBtn.addEventListener('click', () => { adminPassword = els.adminPassword.value; sessionStorage.setItem('dokaAdminPassword', adminPassword); @@ -207,6 +718,16 @@ els.reloadBtn.addEventListener('click', () => { els.saveBtn.addEventListener('click', saveCredentials); els.submitGuardBtn.addEventListener('click', submitSteamGuard); els.clearTokenBtn.addEventListener('click', clearRefreshToken); +els.saveLobbySettingsBtn.addEventListener('click', saveLobbySettings); +els.createLobbyBtn.addEventListener('click', createLobby); +els.stopLobbyBtn.addEventListener('click', stopLobby); +els.enableNotificationsBtn.addEventListener('click', enableNotifications); +els.disableNotificationsBtn.addEventListener('click', disableNotifications); +els.testNotificationBtn.addEventListener('click', testNotification); +els.saveTelegramBtn.addEventListener('click', saveTelegram); +els.testTelegramBtn.addEventListener('click', testTelegram); +els.disableTelegramBtn.addEventListener('click', disableTelegram); +els.activateAccountBtn.addEventListener('click', activateSavedAccount); els.steamAccount.addEventListener('input', () => { credentialsDirty = true; @@ -216,6 +737,43 @@ els.steamPassword.addEventListener('input', () => { credentialsDirty = true; }); +els.savedSteamAccount.addEventListener('change', () => { + if (!els.savedSteamAccount.value) return; + els.steamAccount.value = els.savedSteamAccount.value; + els.steamPassword.value = ''; + credentialsDirty = true; +}); + +[els.telegramToken, els.telegramRecipientIds].forEach((element) => { + element.addEventListener('input', () => { + telegramDirty = true; + }); +}); + +[ + els.lobbyName, + els.lobbyRegion, + els.customGameId, + els.customGameMode, + els.customMapName, + els.lobbyPassKey, + els.customMinPlayers, + els.customMaxPlayers, + els.notifyAtPlayers, + els.leaveAtPlayers, + els.adminNames, + els.customGameCrc, + els.customGameTimestamp, + els.allowSpectating, +].forEach((element) => { + element.addEventListener('input', () => { + lobbySettingsDirty = true; + }); + element.addEventListener('change', () => { + lobbySettingsDirty = true; + }); +}); + els.steamGuardCode.addEventListener('keydown', (event) => { if (event.key === 'Enter') els.submitGuardBtn.click(); }); @@ -225,4 +783,5 @@ if (adminPassword) { } loadAuth({ preserveEditedInputs: false }); +updateNotificationStatus(); setInterval(loadAuth, 5000); diff --git a/public/app.js b/public/app.js index fb799be..304bbd6 100644 --- a/public/app.js +++ b/public/app.js @@ -154,7 +154,7 @@ function renderRows(rows, totalLoaded) { if (!rows.length) { const tr = document.createElement('tr'); const td = document.createElement('td'); - td.colSpan = 7; + td.colSpan = 8; td.className = 'empty'; td.textContent = totalLoaded ? 'No lobbies match the selected filters' : 'No data yet'; tr.append(td); @@ -192,6 +192,35 @@ function renderRows(rows, totalLoaded) { passPill.textContent = row.hasPassKey ? 'yes' : 'no'; pass.append(passPill); + const action = document.createElement('td'); + const createTemplate = document.createElement('button'); + const template = { + sourceLobbyId: String(row.id || ''), + lobbyName: row.lobby || `${row.game || 'Custom game'} ${row.map || ''}`.trim(), + serverRegion: String(row.regionId || 0), + customGameId: String(row.customGameId || ''), + customGameMode: String(row.customGameMode || ''), + customMapName: String(row.map || ''), + customMinPlayers: String(row.minPlayerCount || 1), + customMaxPlayers: String(row.maxPlayerCount || 2), + customGameCrc: String(row.customGameCrc || ''), + customGameTimestamp: String(row.customGameTimestamp || ''), + }; + createTemplate.type = 'button'; + createTemplate.className = 'template-button'; + createTemplate.textContent = 'Create template'; + createTemplate.title = 'Open the admin page and prefill lobby creation from this lobby'; + createTemplate.addEventListener('click', () => { + try { + sessionStorage.setItem('dokaLobbyTemplate', JSON.stringify(template)); + window.location.assign('/admin'); + } catch { + const fallback = new URLSearchParams({ template: 'lobby', ...template }); + window.location.assign(`/admin?${fallback}`); + } + }); + action.append(createTemplate); + tr.append( game, makeCell(row.lobby), @@ -200,6 +229,7 @@ function renderRows(rows, totalLoaded) { makeCell(row.region), pass, makeCell(row.leader), + action, ); fragment.append(tr); } diff --git a/public/index.html b/public/index.html index e7d2726..e258e47 100644 --- a/public/index.html +++ b/public/index.html @@ -94,11 +94,12 @@ Region Password Leader + - Loading... + Loading... diff --git a/public/styles.css b/public/styles.css index e1c4b7b..b4e682d 100644 --- a/public/styles.css +++ b/public/styles.css @@ -80,6 +80,12 @@ select { text-decoration: underline; } +.template-button { + height: 30px; + padding: 0 10px; + white-space: nowrap; +} + .badge { display: inline-flex; align-items: center; @@ -263,6 +269,16 @@ button.primary:hover { background: var(--accent-strong); } +button.danger { + color: #ffffff; + background: var(--danger); + border-color: var(--danger); +} + +button.danger:hover { + background: #7f2e2a; +} + .error { margin: 0 0 12px; padding: 10px 12px; @@ -300,6 +316,11 @@ button.primary:hover { line-height: 1.2; } +.panel-copy { + margin: -4px 0 14px; + color: var(--muted); +} + .admin-grid { display: grid; grid-template-columns: minmax(180px, 1fr) minmax(180px, 1fr) auto auto; @@ -311,6 +332,30 @@ button.primary:hover { min-width: 0; } +.lobby-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + align-items: end; +} + +.lobby-grid label { + min-width: 0; +} + +.lobby-grid label > span { + display: block; + margin-bottom: 5px; + color: var(--muted); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; +} + +.lobby-grid .span-2 { + grid-column: span 2; +} + .admin-grid label > span, .details dt, .hint { @@ -321,7 +366,11 @@ button.primary:hover { } .admin-grid input[type='text'], -.admin-grid input[type='password'] { +.admin-grid input[type='password'], +.admin-grid select, +.lobby-grid input[type='text'], +.lobby-grid input[type='number'], +.lobby-grid select { width: 100%; height: 36px; padding: 0 10px; @@ -332,11 +381,36 @@ button.primary:hover { outline: none; } -.admin-grid input:focus { +.admin-grid input:focus, +.admin-grid select:focus { border-color: rgba(36, 107, 90, 0.55); box-shadow: 0 0 0 3px rgba(36, 107, 90, 0.12); } +.lobby-grid input:focus, +.lobby-grid select:focus { + border-color: rgba(36, 107, 90, 0.55); + box-shadow: 0 0 0 3px rgba(36, 107, 90, 0.12); +} + +.panel-actions { + margin: 16px 0 14px; +} + +.inline-status { + align-self: center; + color: var(--muted); + font-weight: 700; +} + +.lobby-details { + margin-bottom: 10px; +} + +.member-list { + overflow-wrap: anywhere; +} + .details { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); @@ -470,7 +544,8 @@ tbody tr:last-child td { } .admin-grid, - .details { + .details, + .lobby-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } @@ -506,9 +581,14 @@ tbody tr:last-child td { } .admin-grid, - .details { + .details, + .lobby-grid { grid-template-columns: 1fr; } + + .lobby-grid .span-2 { + grid-column: auto; + } } @media (max-width: 460px) { diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..51e107d --- /dev/null +++ b/public/sw.js @@ -0,0 +1,37 @@ +'use strict'; + +self.addEventListener('push', (event) => { + let payload = { + title: 'Dota lobby update', + body: 'The monitored lobby changed.', + url: '/admin', + tag: 'doka-lobby', + }; + + try { + if (event.data) payload = { ...payload, ...event.data.json() }; + } catch { + if (event.data) payload.body = event.data.text(); + } + + event.waitUntil(self.registration.showNotification(payload.title, { + body: payload.body, + tag: payload.tag, + data: { url: payload.url || '/admin' }, + requireInteraction: true, + })); +}); + +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + const target = new URL(event.notification.data?.url || '/admin', self.location.origin).href; + event.waitUntil((async () => { + const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); + const existing = windows.find((client) => client.url.startsWith(self.location.origin)); + if (existing) { + await existing.navigate(target); + return existing.focus(); + } + return self.clients.openWindow(target); + })()); +}); diff --git a/test/dota-client-version.test.js b/test/dota-client-version.test.js new file mode 100644 index 0000000..4ea4daf --- /dev/null +++ b/test/dota-client-version.test.js @@ -0,0 +1,18 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { parseClientVersion } = require('../lib/dota-client-version'); + +test('reads ClientVersion from Dota steam.inf', () => { + assert.equal(parseClientVersion(`ClientVersion=6888 +ServerVersion=6888 +ProductName=dota2_workshop +`), 6888); +}); + +test('rejects a missing or invalid ClientVersion', () => { + assert.equal(parseClientVersion('ServerVersion=6888\n'), null); + assert.equal(parseClientVersion('ClientVersion=not-a-number\n'), null); +}); diff --git a/test/dota-proto.test.js b/test/dota-proto.test.js new file mode 100644 index 0000000..508f81b --- /dev/null +++ b/test/dota-proto.test.js @@ -0,0 +1,104 @@ +'use strict'; + +const path = require('node:path'); +const test = require('node:test'); +const assert = require('node:assert/strict'); +const protobuf = require('protobufjs'); + +const proto = protobuf.loadSync(path.join(__dirname, '..', 'dota.proto')); + +test('encodes Source 2 engine on the current CMsgClientHello field 7', () => { + const Hello = proto.lookupType('CMsgClientHello'); + assert.deepEqual([...Hello.encode({ engine: 1 }).finish()], [0x38, 0x01]); +}); + +test('round-trips a custom practice lobby creation request', () => { + const Create = proto.lookupType('CMsgPracticeLobbyCreate'); + const payload = { + passKey: 'secret', + lobbyDetails: { + gameName: 'Queue helper', + serverRegion: 3, + gameMode: 15, + lan: false, + customGameMode: 'addon', + customMapName: 'map', + customDifficulty: 0, + customGameId: '123456789012345678', + customMinPlayers: 2, + customMaxPlayers: 10, + visibility: 0, + customGamePenalties: true, + }, + }; + const decoded = Create.toObject(Create.decode(Create.encode(payload).finish()), { longs: String }); + assert.equal(decoded.passKey, 'secret'); + assert.equal(decoded.lobbyDetails.customGameId, '123456789012345678'); + assert.equal(decoded.lobbyDetails.gameMode, 15); + assert.equal(decoded.lobbyDetails.lan, false); + assert.equal(decoded.lobbyDetails.customDifficulty, 0); + assert.equal(decoded.lobbyDetails.customMaxPlayers, 10); + assert.equal(decoded.lobbyDetails.customGamePenalties, true); +}); + +test('decodes CSODOTALobby type 2004 inside an SO multiple update', () => { + const Lobby = proto.lookupType('CSODOTALobby'); + const Multiple = proto.lookupType('CMsgSOMultipleObjects'); + const lobbyBytes = Lobby.encode({ + lobbyId: '987654321012345678', + state: 0, + leaderId: '76561198000000001', + allMembers: [ + { id: '76561198000000001', team: 4, slot: 0 }, + { id: '76561198000000002', team: 0, slot: 1 }, + ], + }).finish(); + const updateBytes = Multiple.encode({ + objectsModified: [{ typeId: 2004, objectData: lobbyBytes }], + }).finish(); + const update = Multiple.decode(updateBytes); + const decoded = Lobby.toObject(Lobby.decode(update.objectsModified[0].objectData), { longs: String }); + assert.equal(update.objectsModified[0].typeId, 2004); + assert.equal(decoded.lobbyId, '987654321012345678'); + assert.equal(decoded.allMembers.length, 2); + assert.equal(decoded.allMembers[1].id, '76561198000000002'); +}); + +test('decodes reusable custom-game build metadata from a joinable lobby', () => { + const Response = proto.lookupType('CMsgJoinableCustomLobbiesResponse'); + const bytes = Response.encode({ + lobbies: [{ + lobbyId: '987654321012345678', + customGameId: '2141071809', + customMapName: 'duos', + customGameTimestamp: 1785566685, + customGameCrc: '123456789012345678', + minPlayerCount: 2, + maxPlayerCount: 12, + }], + }).finish(); + const decoded = Response.toObject(Response.decode(bytes), { longs: String }); + assert.equal(decoded.lobbies[0].customGameId, '2141071809'); + assert.equal(decoded.lobbies[0].customMapName, 'duos'); + assert.equal(decoded.lobbies[0].customGameCrc, '123456789012345678'); + assert.equal(decoded.lobbies[0].customGameTimestamp, 1785566685); +}); + +test('decodes the internal addon name used to publish a practice lobby', () => { + const Response = proto.lookupType('CMsgPracticeLobbyListResponse'); + const bytes = Response.encode({ + lobbies: [{ + id: '987654321012345678', + name: 'CHC Duos', + customGameMode: 'custom_hero_clash', + customMapName: 'duos', + maxPlayerCount: 12, + serverRegion: 9, + penaltiesEnabled: true, + }], + }).finish(); + const decoded = Response.toObject(Response.decode(bytes), { longs: String }); + assert.equal(decoded.lobbies[0].id, '987654321012345678'); + assert.equal(decoded.lobbies[0].customGameMode, 'custom_hero_clash'); + assert.equal(decoded.lobbies[0].customMapName, 'duos'); +}); diff --git a/test/lobby-settings.test.js b/test/lobby-settings.test.js new file mode 100644 index 0000000..a0f3ea6 --- /dev/null +++ b/test/lobby-settings.test.js @@ -0,0 +1,127 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + decideLobbySafety, + findAdminMember, + normalizeIdentity, + toDotaLobbyDetails, + validateLobbySettings, +} = require('../lib/lobby-settings'); +const { parseWorkshopPublishData } = require('../lib/workshop-metadata'); + +function validSettings(overrides = {}) { + return { + lobbyName: 'Long queue lobby', + serverRegion: 3, + customGameId: '123456789012345678', + customGameMode: 'my_addon', + customMapName: 'my_map', + customMinPlayers: 2, + customMaxPlayers: 10, + notifyAtPlayers: 8, + leaveAtPlayers: 9, + adminNames: 'Alice, Bob', + ...overrides, + }; +} + +test('validates per-lobby thresholds excluding the bot slot', () => { + const settings = validateLobbySettings(validSettings({ leaveAtPlayers: 9 })); + assert.equal(settings.leaveAtPlayers, 9); + assert.equal(settings.customMaxPlayers, 10); + assert.deepEqual(settings.adminNames, ['Alice', 'Bob']); +}); + +test('rejects a leave threshold above the custom-game player maximum', () => { + assert.throws( + () => validateLobbySettings(validSettings({ leaveAtPlayers: 11 })), + /cannot exceed the custom-game maximum/, + ); +}); + +test('rejects an unreachable leave threshold while the lobby owner occupies a slot', () => { + assert.throws( + () => validateLobbySettings(validSettings({ leaveAtPlayers: 10 })), + /must be below the custom-game maximum/, + ); +}); + +test('rejects notification after auto-leave', () => { + assert.throws( + () => validateLobbySettings(validSettings({ notifyAtPlayers: 9, leaveAtPlayers: 8 })), + /must not exceed/, + ); +}); + +test('normalizes unicode persona names and accepts SteamID64 as an admin identity', () => { + const settings = validateLobbySettings(validSettings({ adminNames: ['Alice', '76561198000000000'] })); + const byName = findAdminMember(settings, [{ steamId: '1', name: 'Alice' }]); + const byId = findAdminMember(settings, [{ steamId: '76561198000000000', name: 'Renamed' }]); + assert.equal(byName.name, 'Alice'); + assert.equal(byId.name, 'Renamed'); + assert.equal(normalizeIdentity(' ALICE '), 'alice'); +}); + +test('maps validated settings to current protobuf field names', () => { + const settings = validateLobbySettings(validSettings({ + customGameCrc: '42', + customGameTimestamp: '1234', + })); + assert.deepEqual(toDotaLobbyDetails(settings), { + gameName: 'Long queue lobby', + serverRegion: 3, + gameMode: 15, + allowCheats: false, + fillWithBots: false, + allowSpectating: true, + passKey: '', + lan: false, + customGameMode: 'my_addon', + customMapName: 'my_map', + customDifficulty: 0, + customGameId: '123456789012345678', + customMinPlayers: 2, + customMaxPlayers: 10, + visibility: 0, + customGameCrc: '42', + customGameTimestamp: 1234, + customGamePenalties: true, + }); +}); + +test('rejects a public custom lobby without its internal addon name', () => { + assert.throws( + () => validateLobbySettings(validSettings({ customGameMode: '' })), + /Internal addon name is required/, + ); +}); + +test('leaves at the selected real-player count before doing name lookups', () => { + const settings = validateLobbySettings(validSettings({ notifyAtPlayers: 2, leaveAtPlayers: 3 })); + const members = [ + { steamId: '1', name: '' }, + { steamId: '2', name: '' }, + { steamId: '3', name: '' }, + ]; + assert.deepEqual(decideLobbySafety(settings, members, 0), { action: 'player-limit', admin: null }); +}); + +test('never recommends an abandon-style leave after lobby launch starts', () => { + const settings = validateLobbySettings(validSettings({ notifyAtPlayers: 2, leaveAtPlayers: 3 })); + const members = Array.from({ length: 3 }, (_, i) => ({ steamId: String(i + 1), name: '' })); + assert.deepEqual(decideLobbySafety(settings, members, 1), { action: 'unsafe-state', admin: null }); +}); + +test('reads the internal addon name from Steam Workshop publish_data', () => { + assert.deepEqual(parseWorkshopPublishData(`"publish_data" +{ + "source_folder" "custom_hero_clash" + "publish_time" "1785566650" +}`), { + sourceFolder: 'custom_hero_clash', + publishTime: '1785566650', + }); +}); diff --git a/test/telegram-notifier.test.js b/test/telegram-notifier.test.js new file mode 100644 index 0000000..06842da --- /dev/null +++ b/test/telegram-notifier.test.js @@ -0,0 +1,137 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { + TelegramNotifier, + formatLobbyMessage, + parseRecipientIds, + validateBotToken, +} = require('../lib/telegram-notifier'); + +class MemoryStore { + constructor(initial = null) { + this.value = initial; + } + + load() { + return this.value; + } + + save(value) { + this.value = JSON.parse(JSON.stringify(value)); + } +} + +function fakeResponse(result) { + return { + ok: true, + status: 200, + json: async () => ({ ok: true, result }), + }; +} + +test('validates Telegram bot tokens and numeric recipient IDs without losing precision', () => { + assert.equal(validateBotToken('123456:abcdefghijklmnopqrstuvwxyz_ABC'), '123456:abcdefghijklmnopqrstuvwxyz_ABC'); + assert.deepEqual(parseRecipientIds('123456789, 4503599627370495; 123456789'), [ + '123456789', + '4503599627370495', + ]); + assert.throws(() => parseRecipientIds('alice'), /positive numeric/); + assert.throws(() => parseRecipientIds('4503599627370496'), /positive numeric/); +}); + +test('formats lobby status with real-player count and resolved member names', () => { + const text = formatLobbyMessage( + { + lobbyName: 'Duos queue', + customMapName: 'duos', + customMaxPlayers: 12, + notifyAtPlayers: 8, + leaveAtPlayers: 9, + serverRegion: 9, + }, + { + phase: 'monitoring', + lobbyId: '29963550000000000', + humanCount: 2, + practicePublished: true, + members: [ + { steamId: '76561198000000001', name: 'Alice' }, + { steamId: '76561198000000002', name: 'Bob' }, + ], + }, + ); + + assert.match(text, /Игроки: 2\/12/); + assert.match(text, /1\. Alice \(76561198000000001\)/); + assert.match(text, /2\. Bob \(76561198000000002\)/); + assert.match(text, /Публикация: в списке лобби/); +}); + +test('sends one tracker message and edits it only when lobby content changes', async () => { + const calls = []; + const fetchImpl = async (url, options) => { + const method = url.split('/').at(-1); + const payload = JSON.parse(options.body); + calls.push({ method, payload }); + if (method === 'getMe') return fakeResponse({ id: 99, username: 'lobby_bot' }); + if (method === 'sendMessage') return fakeResponse({ message_id: 42 }); + if (method === 'editMessageText') return fakeResponse({ message_id: 42 }); + throw new Error(`Unexpected method ${method}`); + }; + const store = new MemoryStore(); + const notifier = new TelegramNotifier({ store, fetchImpl, debounceMs: 1 }); + await notifier.configure({ + token: '123456:abcdefghijklmnopqrstuvwxyz_ABC', + recipientIds: ['123456789'], + }); + assert.equal(notifier.publicState().configured, true); + assert.equal(notifier.publicState().token, undefined); + + notifier.beginLobbySession(); + const settings = { lobbyName: 'Duos', customMaxPlayers: 12 }; + const first = { + phase: 'monitoring', + armed: true, + lobbyId: '123', + humanCount: 1, + members: [{ steamId: '76561198000000001', name: 'Alice' }], + }; + await notifier.syncLobby(settings, first); + await notifier.syncLobby(settings, first); + const restoredNotifier = new TelegramNotifier({ store, fetchImpl, debounceMs: 1 }); + await restoredNotifier.syncLobby(settings, { + ...first, + humanCount: 2, + members: [...first.members, { steamId: '76561198000000002', name: 'Bob' }], + }); + + assert.deepEqual(calls.map((call) => call.method), ['getMe', 'sendMessage', 'editMessageText']); + assert.equal(calls[1].payload.chat_id, '123456789'); + assert.equal(calls[2].payload.message_id, 42); + assert.match(calls[2].payload.text, /Игроки: 2\/12/); + assert.equal(store.value.token, '123456:abcdefghijklmnopqrstuvwxyz_ABC'); +}); + +test('test delivery reports recipients that have not started the bot chat', async () => { + const fetchImpl = async (url) => { + const method = url.split('/').at(-1); + if (method === 'getMe') return fakeResponse({ id: 99, username: 'lobby_bot' }); + return { + ok: false, + status: 403, + json: async () => ({ ok: false, error_code: 403, description: 'Forbidden: bot can\'t initiate conversation with a user' }), + }; + }; + const notifier = new TelegramNotifier({ store: new MemoryStore(), fetchImpl }); + await notifier.configure({ + token: '123456:abcdefghijklmnopqrstuvwxyz_ABC', + recipientIds: ['123456789'], + }); + const result = await notifier.sendTest(); + assert.equal(result.delivered, 0); + assert.equal(result.failed, 1); + assert.match(result.errors[0], /can't initiate conversation/); +});