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
+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);
})());
});