Added lobby auto creation
Docker image / build-and-push (push) Successful in 57s

This commit is contained in:
Aleksandr Bogomiakov
2026-08-06 20:33:27 +01:00
parent 33e01a4a8f
commit c239669932
22 changed files with 3533 additions and 55 deletions
+4
View File
@@ -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
+4
View File
@@ -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
+5 -1
View File
@@ -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
+54 -5
View File
@@ -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.
+143 -1
View File
@@ -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 {
+1273 -31
View File
File diff suppressed because it is too large Load Diff
+55
View File
@@ -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,
};
+163
View File
@@ -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,
};
+401
View File
@@ -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,
};
+44
View File
@@ -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,
};
+135 -3
View File
@@ -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",
+5 -3
View File
@@ -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"
}
}
+147 -4
View File
@@ -52,24 +52,167 @@
<p id="adminError" class="error" hidden></p>
<p id="adminMessage" class="notice" hidden></p>
<section id="notificationPanel" class="auth-panel" hidden>
<h2>Browser notifications</h2>
<p class="panel-copy">Push notifications work in the background. Production deployments must use HTTPS.</p>
<div class="actions left">
<button id="enableNotificationsBtn" class="primary" type="button">Enable on this browser</button>
<button id="disableNotificationsBtn" type="button">Disable on this browser</button>
<button id="testNotificationBtn" type="button">Send test</button>
<span id="notificationStatus" class="inline-status">checking</span>
</div>
</section>
<section id="telegramPanel" class="auth-panel" hidden>
<h2>Telegram notifications</h2>
<p class="panel-copy">Create a bot with @BotFather. Each recipient must open the bot and send it a message before the bot can write to that numeric Telegram user ID. The token is stored only on the server and is never returned to this page.</p>
<div class="admin-grid">
<label>
<span>bot token</span>
<input id="telegramToken" type="password" autocomplete="off" placeholder="paste token or leave blank to keep saved token">
</label>
<label>
<span>Telegram user IDs, comma-separated</span>
<input id="telegramRecipientIds" type="text" inputmode="numeric" placeholder="123456789, 987654321">
</label>
</div>
<div class="actions left panel-actions">
<button id="saveTelegramBtn" class="primary" type="button">Save Telegram</button>
<button id="testTelegramBtn" type="button">Send test</button>
<button id="disableTelegramBtn" class="danger" type="button">Disable Telegram</button>
<span id="telegramStatus" class="inline-status">not configured</span>
</div>
</section>
<section id="lobbyPanel" class="auth-panel" hidden>
<h2>Lobby automation</h2>
<p class="panel-copy">Player thresholds count real users only; the Steam bot itself is excluded. To avoid protocol-specific IDs, click <strong>Create template</strong> on a lobby in the lobby list and this form will be prefilled after admin authorization.</p>
<div class="lobby-grid">
<label>
<span>lobby name</span>
<input id="lobbyName" type="text" maxlength="100" placeholder="Long queue lobby">
</label>
<label>
<span>region</span>
<select id="lobbyRegion"></select>
</label>
<label>
<span>workshop ID</span>
<input id="customGameId" type="text" inputmode="numeric" placeholder="1234567890">
</label>
<label>
<span>internal addon name</span>
<input id="customGameMode" type="text" placeholder="copied by Create template">
</label>
<label>
<span>map name</span>
<input id="customMapName" type="text" placeholder="map_name">
</label>
<label>
<span>lobby password</span>
<input id="lobbyPassKey" type="text" autocomplete="off">
</label>
<label>
<span>custom min players</span>
<input id="customMinPlayers" type="number" min="1" max="64">
</label>
<label>
<span>custom max players</span>
<input id="customMaxPlayers" type="number" min="2" max="64">
</label>
<label>
<span>notify at real players</span>
<input id="notifyAtPlayers" type="number" min="1" max="64">
</label>
<label>
<span>bot leaves at real players</span>
<input id="leaveAtPlayers" type="number" min="1" max="64">
</label>
<label class="span-2">
<span>admin persona names or SteamID64, comma-separated</span>
<input id="adminNames" type="text" placeholder="Alice, Bob, 7656119...">
</label>
<label>
<span>custom game CRC (optional)</span>
<input id="customGameCrc" type="text" inputmode="numeric">
</label>
<label>
<span>workshop timestamp (optional)</span>
<input id="customGameTimestamp" type="text" inputmode="numeric">
</label>
<label class="check">
<input id="allowSpectating" type="checkbox" checked>
<span>allow spectating</span>
</label>
</div>
<div class="actions left panel-actions">
<button id="saveLobbySettingsBtn" type="button">Save settings</button>
<button id="createLobbyBtn" class="primary" type="button">Create lobby</button>
<button id="stopLobbyBtn" class="danger" type="button">Leave lobby</button>
</div>
<dl class="details lobby-details">
<div>
<dt>automation</dt>
<dd id="automationPhase">-</dd>
</div>
<div>
<dt>lobby ID</dt>
<dd id="activeLobbyId">-</dd>
</div>
<div>
<dt>real players</dt>
<dd id="activePlayerCount">0</dd>
</div>
<div>
<dt>lobby browser / password</dt>
<dd id="publicationStatus">-</dd>
</div>
<div>
<dt>arcade Play queue</dt>
<dd id="arcadePublicationStatus">-</dd>
</div>
<div>
<dt>GC lobby flags</dt>
<dd id="lobbyProtocolStatus">-</dd>
</div>
<div>
<dt>Dota client version</dt>
<dd id="dotaClientVersion">-</dd>
</div>
<div>
<dt>leave reason</dt>
<dd id="leaveReason">-</dd>
</div>
</dl>
<div id="lobbyMembers" class="member-list muted">No active lobby members.</div>
</section>
<section id="credentialsPanel" class="auth-panel" hidden>
<h2>Steam credentials</h2>
<p class="panel-copy">Passwords, per-account refresh tokens, and Steam Guard machine authorization are stored on the server. Switching accounts reconnects Steam and Dota without restarting this process; leave an active lobby first.</p>
<div class="admin-grid">
<label>
<span>saved accounts</span>
<select id="savedSteamAccount"></select>
</label>
<div class="actions">
<button id="activateAccountBtn" type="button">Activate selected</button>
</div>
<label>
<span>account</span>
<input id="steamAccount" type="text" autocomplete="username">
</label>
<label>
<span>password</span>
<input id="steamPassword" type="text" autocomplete="current-password">
<input id="steamPassword" type="password" autocomplete="current-password" placeholder="leave blank to keep the saved password">
</label>
<label class="check">
<input id="clearToken" type="checkbox" checked>
<span>reset saved refresh token</span>
<input id="clearToken" type="checkbox">
<span>forget cached refresh token for this account</span>
</label>
<div class="actions">
<button id="reloadBtn" type="button">Reload</button>
<button id="saveBtn" class="primary" type="button">Save and reconnect</button>
<button id="saveBtn" class="primary" type="button">Save and activate</button>
</div>
</div>
</section>
+560 -1
View File
@@ -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);
+31 -1
View File
@@ -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);
}
+2 -1
View File
@@ -94,11 +94,12 @@
<th>Region</th>
<th>Password</th>
<th>Leader</th>
<th></th>
</tr>
</thead>
<tbody id="lobbyRows">
<tr>
<td colspan="7" class="empty">Loading...</td>
<td colspan="8" class="empty">Loading...</td>
</tr>
</tbody>
</table>
+84 -4
View File
@@ -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) {
+37
View File
@@ -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);
})());
});
+18
View File
@@ -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);
});
+104
View File
@@ -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');
});
+127
View File
@@ -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: ['lice', '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',
});
});
+137
View File
@@ -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/);
});