Add native Discord boards and availability alerts
CI / checks (push) Failing after 7m41s

This commit is contained in:
2026-10-18 16:55:49 -04:00
parent f2b2541b46
commit fd00ef5357
29 changed files with 4685 additions and 3 deletions
+7
View File
@@ -16,6 +16,13 @@ EC_SETUP_TOKEN=
# Game reporters send Authorization: Bearer <token> or X-EC-Telemetry-Token.
# Telemetry ingestion is disabled until a token of at least 32 chars is set.
EC_TELEMETRY_TOKEN=
# Optional native Discord companion. Keep these in the controller environment;
# enable the integration and choose published servers under Admin → Discord.
# The application ID and public key are in the Discord Developer Portal.
# The bot token is a secret: never put it in channel messages or screenshots.
EC_DISCORD_APPLICATION_ID=
EC_DISCORD_PUBLIC_KEY=
EC_DISCORD_BOT_TOKEN=
# Bank credentials are generated and injected automatically per native instance.
EC_BANK_MAX_BALANCE=250000
# Persistent SQLite metadata, game-file storage, and beta archives.
+3
View File
@@ -15,6 +15,9 @@ services:
ORIGIN: ${EC_PUBLIC_URL:-http://localhost:3000}
EC_SETUP_TOKEN: ${EC_SETUP_TOKEN:-}
EC_TELEMETRY_TOKEN: ${EC_TELEMETRY_TOKEN:-}
EC_DISCORD_APPLICATION_ID: ${EC_DISCORD_APPLICATION_ID:-}
EC_DISCORD_PUBLIC_KEY: ${EC_DISCORD_PUBLIC_KEY:-}
EC_DISCORD_BOT_TOKEN: ${EC_DISCORD_BOT_TOKEN:-}
EC_BANK_MAX_BALANCE: ${EC_BANK_MAX_BALANCE:-250000}
EC_AUTH_ALLOW_HTTP: ${EC_AUTH_ALLOW_HTTP:-false}
# Enable only behind a trusted proxy that sets the client-address header.
+1
View File
@@ -11,6 +11,7 @@ and launch your first server.
- [Native hosts](guides/native-hosts.md) — enrollment, game files, capacity, agent updates, and recovery.
- [Users and access](guides/authentication.md) — roles, sessions, telemetry credentials, and password recovery.
- [Fleet settings](guides/settings.md) — concurrency, retention, and cleanup.
- [Discord companion](guides/discord.md) — live server boards, private browsing, leaderboards, and availability alerts.
### Fleet operations
+156
View File
@@ -0,0 +1,156 @@
# Discord companion
Electric Cherry can publish live Black Ops I and Black Ops II server boards in
Discord, answer commands privately, and send requested map availability alerts by
DM. Configure it under **Admin → Discord**. Administrators can make changes;
Super Viewers can inspect configuration, the saved preview, and delivery health.
The integration is disabled by default and publishes no servers until selected.
## Install the application
1. Create an application in the [Discord Developer Portal](https://discord.com/developers/applications).
Copy its Application ID and Public Key, and create a bot token.
2. Set the controller environment variables below. Keep the token private; the
settings page reports whether it is configured without displaying its value.
With Docker Compose, put the values in the deployment's `.env` file and recreate
the controller to apply them. Do not commit that file.
```dotenv
EC_DISCORD_APPLICATION_ID=
EC_DISCORD_PUBLIC_KEY=
EC_DISCORD_BOT_TOKEN=
```
3. Install the application into your Discord server using the `bot` and
`applications.commands` scopes. Grant **View Channel**, **Send Messages**, and
**Read Message History** in each board channel. No Administrator permission or
privileged Gateway intents are needed. Channel overrides must also permit the
bot to read and write those channels.
4. In the Developer Portal, set **Interactions Endpoint URL** to
`https://your-controller.example/api/discord/interactions`. Use your actual
public controller origin with a trusted HTTPS certificate. Discord verifies
the endpoint before accepting it. The controller checks Discord's request
signatures; this endpoint does not use the Electric Cherry login session.
5. In Discord, enable Developer Mode, then copy the Discord server ID and the
board channel IDs. Open **Admin → Discord**, enter those IDs, and select the
game servers to publish. Either game may omit its board channel.
6. Review the information-sharing switches, enable the companion, and save.
Select **Register commands** to install the commands in the saved Discord
server. Saving configuration and registering commands are separate actions.
The deployment's reverse proxy must route the interaction endpoint to the
controller and preserve Discord's signature headers and request body. No Gateway
connection is required. Installation scopes and channel permissions follow
Discord's [OAuth2 documentation](https://docs.discord.com/developers/topics/oauth2)
and [permissions reference](https://docs.discord.com/developers/topics/permissions).
See [HTTP interactions](https://docs.discord.com/developers/interactions/receiving-and-responding)
for Discord's endpoint verification and response requirements.
Use a dedicated Discord application for this controller. The command registration
action updates that application's command set in the configured Discord server.
## Choose what is published
The published-server selection is an explicit allowlist. Newly created game
servers stay private until an administrator selects them. The selection controls
boards, private server browsing, server details, and availability watches.
Player names in server details and public connection addresses are separate,
disabled-by-default options. Connection instructions use an explicitly configured
native host public endpoint; internal host addresses and RCON credentials are not
published. Capacity, player counts, map, round, and observation state can appear
without either switch enabled.
**Publish fleet leaderboards** is a separate decision. It exposes recorded player
names and statistics from the whole fleet, including historical matches on
servers outside the published-server selection. Leave it disabled if those
statistics should remain available only to Electric Cherry users.
The settings page's preview uses the **saved settings**. Pending form edits do
not change the preview or Discord. The preview remains available while the
integration is disabled.
## Boards and browsing
Each configured game channel receives a persistent server board, split into
additional messages when needed. The controller revisits delivery about every
30 seconds and edits changed content. Stored message IDs survive controller
restarts. Periodic existence checks recreate messages that were removed.
Boards show observed occupancy, capacity and current round where available,
with separate empty, in-progress, full, starting, offline, not-responding, and
unknown states. Freshness is part of the observation. **Empty slots do not prove
that a match accepts new players**; this release does not label games joinable.
Members can use the board controls or slash commands. Browsing, filters, details,
leaderboards, and command confirmations are visible only to the member who
requested them. They do not rearrange the shared board.
| Command | Purpose |
| ------------------------------- | ------------------------------------------------------------------------------------------ |
| `/servers` | Browse published servers; filter by game, map and state, and page through results. |
| `/server name` | Inspect one published server, including optional roster names and connection instructions. |
| `/leaderboard` | Browse recorded fleet rankings by game, map, metric, squad size and page, when enabled. |
| `/watch add game map condition` | Request a one-shot DM when a map has an empty lobby or observed space. |
| `/watch list` | See your active watches and their IDs. |
| `/watch cancel id` | Cancel one of your own watches. |
| `/help` | Explain the commands and their limits. |
Commands are limited to the configured Discord server. Map options preserve the
game's map variants. Leaderboard results reflect recorded gameplay, not a new
claim that a match was won or that a player survived continuously.
## Availability alerts
A watch lasts up to 24 hours and sends one DM when its condition is satisfied.
Each member may have up to ten active watches. Requesting the same map and
condition again reuses the existing watch. Members can cancel only their own
watches.
An **empty** watch needs an observed empty lobby. A **space** watch needs a known
capacity and fewer players than that capacity. Neither condition promises that
joining is allowed. Unknown, stale, and offline observations never satisfy a
watch. The worker requires two qualifying observations at least 15 seconds apart
before queueing a notification.
Watches and pending notifications persist across controller restarts. Delivery
handles transient failures and Discord rate limits. A permanent DM failure, such
as a member blocking messages from the bot, ends that watch. Members should allow
DMs from the bot before creating a watch and can inspect active watches with
`/watch list`.
## Delivery and troubleshooting
The settings page shows the last attempt, last success, current error, active
watches, pending notifications, and links to recorded board channels. Refreshing
that page preserves pending edits. A failure keeps the last loaded information
visible.
- **Credentials need attention:** check all three environment variables and
restart the controller after changing them. A readiness indicator checks local
configuration; it does not establish that Discord has accepted the token.
- **Discord rejects the interaction endpoint:** verify HTTPS routing, the
application's Public Key, and that the reverse proxy preserves signed
requests. Discord must reach the endpoint without an interactive login.
- **Commands are missing:** save the correct Discord server ID, install the bot
with `applications.commands`, then select **Register commands**. Register again
after upgrading to a release that changes the command schema.
- **Board delivery is denied:** check the bot's permissions and channel overrides.
Authorization failures pause retries. After correcting access, select **Save
and retry** in Electric Cherry.
- **A board was deleted:** the controller's periodic check recreates it. Keep
Read Message History enabled so those checks can verify existing messages.
- **Discord rate limits or temporary outages:** delivery honors retry delays and
retries transient failures with increasing delays. Fleet operations continue
independently.
- **Old board messages remain after a channel change or disable:** the controller
attempts to remove old messages. If permissions were removed first, an
administrator may need to delete those messages in Discord.
Disabling the companion stops new publication and notifications. Cleanup may
still remove previously recorded board messages. Data already seen or copied by
members cannot be recalled by changing publication settings.
This release does not link Discord accounts to game identities, expose personal
bank or AFK accounts, mutate servers or moderation state, or announce
achievements. Those workflows require separate authorization and implementation.
+2
View File
@@ -7,6 +7,7 @@ import { invalidateFleet } from '$lib/server/pipeline/bus.js';
import { startPerformanceMonitor } from '$lib/server/performance.js';
import { withOperationSource } from '$lib/server/hosts/operation-context.js';
import { withHostRequestContext } from '$lib/server/hosts/request-context.js';
import { startDiscordWorker } from '$lib/server/discord/worker.js';
let booted = false;
let rejectionLoggerRegistered = false;
@@ -21,6 +22,7 @@ function boot(): void {
migrate();
interruptDeployments();
startWorker();
startDiscordWorker();
startPerformanceMonitor();
booted = true;
}
+107
View File
@@ -0,0 +1,107 @@
export type DiscordGame = 't5' | 't6';
export interface DiscordSettings {
enabled: boolean;
guildId: string;
channels: Record<DiscordGame, string>;
publishedServers: string[];
showPlayers: boolean;
showAddresses: boolean;
leaderboardEnabled: boolean;
}
export const defaultDiscordSettings: DiscordSettings = {
enabled: false,
guildId: '',
channels: { t5: '', t6: '' },
publishedServers: [],
showPlayers: false,
showAddresses: false,
leaderboardEnabled: false
};
export const discordSnowflake = (value: unknown): value is string =>
typeof value === 'string' && /^[1-9][0-9]{16,19}$/.test(value);
export function validateDiscordSettings(value: unknown): DiscordSettings {
if (!value || typeof value !== 'object' || Array.isArray(value))
throw new Error('Provide Discord settings.');
const input = value as Record<string, unknown>;
for (const key of ['enabled', 'showPlayers', 'showAddresses', 'leaderboardEnabled']) {
if (typeof input[key] !== 'boolean') throw new Error(`Invalid ${key} setting.`);
}
if (typeof input.guildId !== 'string' || (input.guildId && !discordSnowflake(input.guildId)))
throw new Error('Provide a valid Discord server ID.');
if (!input.channels || typeof input.channels !== 'object' || Array.isArray(input.channels))
throw new Error('Provide Discord channels for each game.');
const channels = input.channels as Record<string, unknown>;
for (const game of ['t5', 't6'] as const) {
if (
typeof channels[game] !== 'string' ||
(channels[game] && !discordSnowflake(channels[game]))
)
throw new Error('Channel IDs must be valid Discord IDs or empty.');
}
if (
!Array.isArray(input.publishedServers) ||
input.publishedServers.length > 100 ||
input.publishedServers.some(
(name) => typeof name !== 'string' || !/^[A-Za-z0-9_.-]{1,128}$/.test(name)
)
)
throw new Error('Choose up to 100 published servers.');
if (input.enabled && (!input.guildId || (!channels.t5 && !channels.t6)))
throw new Error('Choose a Discord server and at least one board channel before enabling.');
return {
enabled: input.enabled as boolean,
guildId: input.guildId,
channels: { t5: channels.t5 as string, t6: channels.t6 as string },
publishedServers: [...new Set(input.publishedServers as string[])].sort(),
showPlayers: input.showPlayers as boolean,
showAddresses: input.showAddresses as boolean,
leaderboardEnabled: input.leaderboardEnabled as boolean
};
}
export interface DiscordPublicServer {
name: string;
game: DiscordGame;
label: string;
map: string;
mapKey: string;
state: 'empty' | 'in-progress' | 'full' | 'starting' | 'offline' | 'stale' | 'unknown';
players: number | null;
capacity: number | null;
round: number | null;
observedAt: string | null;
playerNames: string[];
connection: string | null;
}
export interface DiscordMessage {
content?: string;
flags?: number;
components?: Record<string, unknown>[];
allowed_mentions: { parse: string[] };
}
export interface DiscordDeliveryStatus {
lastAttemptAt: string | null;
lastSuccessAt: string | null;
lastError: string | null;
boards: { game: DiscordGame; channelId: string; messageCount: number }[];
activeSubscriptions: number;
pendingNotifications: number;
}
export interface DiscordAdminState {
settings: DiscordSettings;
credentials: {
applicationId: string;
publicKeyConfigured: boolean;
botTokenConfigured: boolean;
};
status: DiscordDeliveryStatus;
servers: { name: string; game: DiscordGame; label: string; map: string }[];
preview: Record<DiscordGame, DiscordPublicServer[]>;
}
+1
View File
@@ -84,6 +84,7 @@ export const navigationGroups: NavigationGroup[] = [
items: [
{ label: 'Users', href: '/settings/users' },
{ label: 'Keys', href: '/server-keys', shortcut: 'k' },
{ label: 'Discord', href: '/settings/integrations/discord' },
{
label: 'Settings',
href: '/settings',
+2
View File
@@ -43,6 +43,7 @@ export function routePermission(routeId: string | null, method: string): RoutePe
if (['/api/bank/[action]', '/api/afk/[action]'].includes(routeId) && method === 'POST')
return 'machine';
if (routeId === '/api/telemetry' && method === 'POST') return 'machine';
if (routeId === '/api/discord/interactions' && method === 'POST') return 'machine';
if (routeId === '/api/moderation/runtime' && method === 'POST') return 'machine';
if (routeId === '/api/leaderboard' && reading) return 'public';
if ((routeId === '/login' || routeId === '/setup') && reading) return 'public';
@@ -63,6 +64,7 @@ export function routePermission(routeId: string | null, method: string): RoutePe
if (
within(routeId, '/api/users') ||
within(routeId, '/api/integrations') ||
within(routeId, '/settings') ||
within(routeId, '/api/settings') ||
within(routeId, '/api/server-keys') ||
+3 -1
View File
@@ -26,6 +26,7 @@ import { migrateLeaderboardReports } from './leaderboard-reports.js';
import { migratePlayerScoreTotal } from './player-score-total.js';
import { migrateBank } from './bank.js';
import { migrateAfk } from './afk.js';
import { migrateDiscord } from '../discord/schema.js';
import { migrateBankCredentials } from './bank-credentials.js';
import { migrateTemperatures } from '../hosts/temperatures.js';
import {
@@ -210,7 +211,8 @@ export const migrations: readonly Migration[] = [
name: 'moderation case lifecycle states',
mode: 'foreign-keys-off',
run: migrateModerationCaseStates
}
},
{ version: 37, name: 'Discord boards and availability subscriptions', run: migrateDiscord }
];
export const databaseVersion = migrations.length;
+27
View File
@@ -0,0 +1,27 @@
import type { DiscordAdminState } from '../../discord.js';
import { fleetIdentity } from '../../fleet-overview.js';
import { listServers } from '../fleet/servers.js';
import { discordCredentialStatus } from './config.js';
import { publicFleet } from './projection.js';
import { discordStatus, getDiscordSettings } from './store.js';
export function discordAdminState(): DiscordAdminState {
const settings = getDiscordSettings();
const published = publicFleet(settings);
return {
settings,
credentials: discordCredentialStatus(),
status: discordStatus(),
servers: listServers().flatMap((server) => {
const identity = fleetIdentity(server);
if (identity.game !== 't5' && identity.game !== 't6') return [];
return [
{ name: server.name, game: identity.game, label: identity.title, map: identity.map }
];
}),
preview: {
t5: published.filter((server) => server.game === 't5'),
t6: published.filter((server) => server.game === 't6')
}
};
}
+131
View File
@@ -0,0 +1,131 @@
import { leaderboardMetrics } from '../../leaderboard.js';
import { zombieMaps } from '../../map-aliases.js';
const games = [
{ name: 'Black Ops I', value: 't5' },
{ name: 'Black Ops II', value: 't6' }
];
export const discordMaps = (['t5', 't6'] as const).flatMap((game) =>
zombieMaps(game).map((map) => ({
name: `${game === 't5' ? 'BO1' : 'BO2'} · ${map.friendly}`,
value: [game, map.canonical, ...(game === 't6' ? [map.slug] : [])].join(':')
}))
);
const gameOption = { type: 3, name: 'game', description: 'Game', choices: games };
const mapOption = { type: 3, name: 'map', description: 'Map and ruleset', choices: discordMaps };
const pageOption = {
type: 4,
name: 'page',
description: 'Page number',
min_value: 1,
max_value: 100001
};
/** Guild commands only: no Gateway connection or privileged member/content intents. */
export const discordCommands = [
{
name: 'servers',
description: 'Browse Electric Cherry servers privately',
type: 1,
options: [
gameOption,
mapOption,
{
type: 3,
name: 'status',
description: 'Observed occupancy',
choices: [
{ name: 'All', value: 'all' },
{ name: 'Empty', value: 'empty' },
{ name: 'Space available', value: 'space' },
{ name: 'Full', value: 'full' },
{ name: 'In progress', value: 'in-progress' }
]
},
pageOption
]
},
{
name: 'server',
description: 'Show details of a published server',
type: 1,
options: [
{
type: 3,
name: 'name',
description: 'Published server name (or use a Details button)',
required: true,
max_length: 128
}
]
},
{
name: 'leaderboard',
description: 'View Electric Cherry completed-game rankings',
type: 1,
options: [
gameOption,
mapOption,
{
type: 3,
name: 'metric',
description: 'Ranking metric',
choices: leaderboardMetrics.map((metric) => ({
name: metric.label,
value: metric.key
}))
},
{
type: 4,
name: 'squad',
description: 'Number of players at match start',
min_value: 1,
max_value: 4
},
pageOption
]
},
{
name: 'watch',
description: 'Receive one private map availability notification',
type: 1,
options: [
{
type: 1,
name: 'add',
description: 'Watch a map for up to 24 hours',
options: [
{ ...gameOption, required: true },
{ ...mapOption, required: true },
{
type: 3,
name: 'condition',
description: 'Observed occupancy to watch for',
required: true,
choices: [
{ name: 'An empty lobby', value: 'empty' },
{ name: 'A lobby with space available', value: 'space' }
]
}
]
},
{ type: 1, name: 'list', description: 'List your active watches' },
{
type: 1,
name: 'cancel',
description: 'Cancel one of your watches',
options: [
{
type: 3,
name: 'id',
description: 'Watch ID shown by /watch list',
required: true,
max_length: 100
}
]
}
]
},
{ name: 'help', description: 'How to use the Electric Cherry companion', type: 1 }
];
+30
View File
@@ -0,0 +1,30 @@
import { discordSnowflake } from '../../discord.js';
/** Credentials belong to the deployment environment and never to browser settings. */
export function discordCredentials() {
return {
applicationId: (process.env.EC_DISCORD_APPLICATION_ID ?? '').trim(),
publicKey: (process.env.EC_DISCORD_PUBLIC_KEY ?? '').trim(),
botToken: (process.env.EC_DISCORD_BOT_TOKEN ?? '').trim()
};
}
export function discordCredentialStatus() {
const credentials = discordCredentials();
return {
applicationId: credentials.applicationId,
publicKeyConfigured: /^[a-fA-F0-9]{64}$/.test(credentials.publicKey),
botTokenConfigured: !!credentials.botToken
};
}
export function requireDiscordCredentials(): ReturnType<typeof discordCredentials> {
const credentials = discordCredentials();
if (
!discordSnowflake(credentials.applicationId) ||
!/^[a-fA-F0-9]{64}$/.test(credentials.publicKey) ||
!credentials.botToken
)
throw new Error('Configure the Discord application ID, public key and bot token first.');
return credentials;
}
+322
View File
@@ -0,0 +1,322 @@
import {
discordSnowflake,
type DiscordGame,
type DiscordMessage,
type DiscordPublicServer
} from '../../discord.js';
import {
leaderboardMetrics,
type LeaderboardMetric,
type LeaderboardOptions,
type LeaderboardPlayerCount
} from '../../leaderboard.js';
import { leaderboardReport } from '../reports/index.js';
import { discordCredentials } from './config.js';
import { discordMaps } from './commands.js';
import { discordServerKey, discordText, publicFleet } from './projection.js';
import {
cancelSubscription,
getDiscordSettings,
listSubscriptions,
subscribeToMap
} from './store.js';
import {
LEADERBOARD_PAGE_SIZE,
privateMessage,
renderBrowser,
renderLeaderboard,
renderServer,
renderWatchChoice,
renderWatchMaps,
type BrowserFilter,
type BrowserStatus
} from './render.js';
interface DiscordOption {
type?: number;
name: string;
value?: string | number;
options?: DiscordOption[];
}
export interface DiscordInteraction {
type: number;
application_id?: string;
guild_id?: string;
member?: { user?: { id?: string } };
data?: { name?: string; options?: DiscordOption[]; custom_id?: string; values?: string[] };
}
export interface DiscordInteractionResponse {
type: 4;
data: DiscordMessage;
}
class InputError extends Error {}
const response = (data: DiscordMessage): DiscordInteractionResponse => ({ type: 4, data });
export const privateDiscordError = (message: string) =>
response(privateMessage(discordText(message, 400)));
const statuses: BrowserStatus[] = ['all', 'empty', 'space', 'full', 'in-progress'];
function optionValues(options: DiscordOption[] | undefined): Map<string, string | number> {
if (options !== undefined && (!Array.isArray(options) || options.length > 25))
throw new InputError('Invalid command options.');
const values = new Map<string, string | number>();
for (const option of options ?? []) {
if (
!option ||
typeof option.name !== 'string' ||
values.has(option.name) ||
!['string', 'number'].includes(typeof option.value)
)
throw new InputError('Invalid command options.');
values.set(option.name, option.value!);
}
return values;
}
function stringOption(
values: Map<string, string | number>,
key: string,
limit = 128
): string | undefined {
const value = values.get(key);
if (value === undefined) return undefined;
if (typeof value !== 'string' || !value.trim() || value.length > limit)
throw new InputError(`Invalid ${key} option.`);
return value.trim();
}
function scope(values: Map<string, string | number>): { game?: DiscordGame; map?: string } {
const game = stringOption(values, 'game');
const map = stringOption(values, 'map');
if (game && game !== 't5' && game !== 't6')
throw new InputError('Choose Black Ops I or Black Ops II.');
if (map && !discordMaps.some((entry) => entry.value === map))
throw new InputError('Choose a map from the command options.');
if (game && map && !map.startsWith(`${game}:`))
throw new InputError('Choose a map from the selected game.');
return { game: (game ?? map?.slice(0, 2)) as DiscordGame | undefined, map };
}
function page(values: Map<string, string | number>): number {
const value = values.get('page') ?? 1;
if (!Number.isSafeInteger(value) || Number(value) < 1 || Number(value) > 100001)
throw new InputError('Choose a page between 1 and 100001.');
return Number(value) - 1;
}
function watchMap(
servers: DiscordPublicServer[],
key: string,
game?: DiscordGame
): DiscordPublicServer {
const found = servers.find(
(server) =>
(server.mapKey === key || discordServerKey(server.mapKey) === key) &&
(!game || server.game === game)
);
if (!found)
throw new InputError('That map is no longer published. Browse the current server list.');
if (!/^[A-Za-z0-9_.:-]{1,100}$/.test(found.mapKey))
throw new InputError('A map watch is unavailable for this reported map.');
return found;
}
async function leaderboard(values: Map<string, string | number>): Promise<DiscordMessage> {
const settings = getDiscordSettings();
if (!settings.leaderboardEnabled)
return privateMessage('Leaderboard publication is not enabled for this Discord server.');
const metric = stringOption(values, 'metric') ?? 'highestRound';
if (!leaderboardMetrics.some((entry) => entry.key === metric))
throw new InputError('Choose a ranking metric from the command options.');
const squad = values.get('squad');
if (
squad !== undefined &&
(!Number.isSafeInteger(squad) || Number(squad) < 1 || Number(squad) > 4)
)
throw new InputError('Squad size must be between 1 and 4.');
const query: LeaderboardOptions = {
...scope(values),
metric: metric as LeaderboardMetric,
players: squad as LeaderboardPlayerCount | undefined,
limit: LEADERBOARD_PAGE_SIZE,
offset: page(values) * LEADERBOARD_PAGE_SIZE
};
const report = await leaderboardReport(query);
// Publishing may have been disabled while the report worker was running.
const current = getDiscordSettings();
if (!current.enabled || current.guildId !== settings.guildId || !current.leaderboardEnabled)
return privateMessage('Leaderboard publication is no longer enabled.');
return renderLeaderboard(report, query);
}
function addWatch(
servers: DiscordPublicServer[],
key: string,
mode: string | undefined,
guildId: string,
userId: string,
game?: DiscordGame
): DiscordMessage {
if (mode !== 'empty' && mode !== 'space')
throw new InputError('Choose an empty-lobby or space-available watch.');
const server = watchMap(servers, key, game);
let watch: { id: string; expiresAt: number };
try {
watch = subscribeToMap({ guildId, userId, game: server.game, mapKey: server.mapKey, mode });
} catch (error) {
if (
error instanceof Error &&
(error.message.startsWith('You already have 10') ||
error.message.startsWith('Map watches are currently full'))
)
throw new InputError(error.message);
throw error;
}
return privateMessage(
`Watching **${discordText(server.map)}** for ${mode === 'empty' ? 'an empty lobby' : 'space available'}.\nYou will receive one DM after stable observations. Enable DMs from this server to receive it. This does not guarantee match admission.\nExpires <t:${Math.floor(watch.expiresAt / 1000)}:R> · Cancel with /watch cancel\nWatch ID: ${discordText(watch.id)}`
);
}
function parseBrowserId(id: string): BrowserFilter {
const match =
/^ec:b:(all|t5|t6):(all|empty|space|full|in-progress):(\d{1,6}):([A-Za-z0-9_-]{0,70})(?::(?:prev|next))?$/.exec(
id
);
if (!match) throw new InputError('This browser control is invalid. Run /servers again.');
const map = Buffer.from(match[4], 'base64url').toString();
if (map && !discordMaps.some((entry) => entry.value === map))
throw new InputError('This map filter is unavailable. Run /servers again.');
return {
game: match[1] === 'all' ? undefined : (match[1] as DiscordGame),
status: match[2] as BrowserStatus,
page: Number(match[3]),
map: map || undefined
};
}
/** All responses are private new messages; components never mutate the shared board. */
export async function handleDiscordInteraction(
interaction: DiscordInteraction
): Promise<DiscordInteractionResponse> {
const userId = interaction.member?.user?.id;
if (
!discordSnowflake(userId) ||
!discordSnowflake(interaction.guild_id) ||
interaction.application_id !== discordCredentials().applicationId
)
return privateDiscordError('This interaction is not available here.');
const settings = getDiscordSettings();
if (!settings.enabled || settings.guildId !== interaction.guild_id)
return privateDiscordError('Electric Cherry is not enabled for this Discord server.');
if (interaction.type !== 2 && interaction.type !== 3)
return privateDiscordError('This interaction is not supported.');
const data = interaction.data;
if (!data || typeof data !== 'object')
return privateDiscordError('This interaction is invalid.');
try {
if (interaction.type === 2 && data.name === 'leaderboard')
return response(await leaderboard(optionValues(data.options)));
if (interaction.type === 2 && data.name === 'help')
return response(
privateMessage(
'## 🍒 Electric Cherry\n/servers — browse live servers privately\n/server — inspect a published server\n/leaderboard — completed-game rankings, when enabled\n/watch add — one DM when a map has an empty lobby or space\n/watch list · /watch cancel — manage your watches\n\nWatches expire after 24 hours. Observed spaces do not guarantee that a match accepts new players.'
)
);
const servers = publicFleet(settings);
if (interaction.type === 3) {
const id = data.custom_id;
if (typeof id !== 'string' || id.length > 100)
throw new InputError('This control is invalid.');
if (id.startsWith('ec:b:')) return response(renderBrowser(servers, parseBrowserId(id)));
if (/^ec:d:[a-f0-9]{20}$/.test(id)) {
const server = servers.find(
(entry) => discordServerKey(entry.name) === id.slice(5)
);
if (!server) throw new InputError('This server is no longer published.');
return response(renderServer(server));
}
if (/^ec:maps:(t5|t6)$/.test(id))
return response(renderWatchMaps(servers, id.slice(8) as DiscordGame));
if (
id === 'ec:map' &&
Array.isArray(data.values) &&
data.values.length === 1 &&
typeof data.values[0] === 'string'
)
return response(renderWatchChoice(watchMap(servers, data.values[0])));
const watch = /^ec:w:(empty|space):([a-f0-9]{20})$/.exec(id);
if (watch)
return response(addWatch(servers, watch[2], watch[1], settings.guildId, userId));
if (/^ec:leader:(t5|t6)$/.test(id))
return response(await leaderboard(new Map([['game', id.slice(10)]])));
throw new InputError(
'This control is no longer available. Run /help for current commands.'
);
}
if (data.name === 'watch') {
if (!Array.isArray(data.options) || data.options.length !== 1)
throw new InputError('Choose watch add, list, or cancel.');
const command = data.options[0];
const values = optionValues(command.options);
if (command.name === 'add') {
const selected = scope(values);
if (!selected.game || !selected.map) throw new InputError('Choose a game and map.');
return response(
addWatch(
servers,
selected.map,
stringOption(values, 'condition'),
settings.guildId,
userId,
selected.game
)
);
}
if (command.name === 'cancel') {
const id = stringOption(values, 'id', 100);
if (!id) throw new InputError('Provide the watch ID shown by /watch list.');
return response(
privateMessage(
cancelSubscription(id, settings.guildId, userId)
? 'Watch cancelled.'
: 'No active watch with that ID belongs to you.'
)
);
}
if (command.name === 'list') {
const watches = listSubscriptions(userId, settings.guildId);
return response(
privateMessage(
`## Your map watches\n${watches.length ? watches.map((watch) => `**${discordText(servers.find((server) => server.mapKey === watch.mapKey)?.map ?? discordMaps.find((map) => map.value === watch.mapKey)?.name ?? 'Unpublished map', 60)}** · ${watch.mode === 'empty' ? 'Empty lobby' : 'Space available'} · Expires <t:${Math.floor(watch.expiresAt / 1000)}:R>\nID: ${discordText(watch.id)}`).join('\n\n') : 'No active watches. Use /watch add to create one.'}`
)
);
}
throw new InputError('Choose watch add, list, or cancel.');
}
const values = optionValues(data.options);
if (data.name === 'servers') {
const status = stringOption(values, 'status') ?? 'all';
if (!statuses.includes(status as BrowserStatus))
throw new InputError('Choose an occupancy filter from the command options.');
return response(
renderBrowser(servers, {
...scope(values),
status: status as BrowserStatus,
page: page(values)
})
);
}
if (data.name === 'server') {
const name = stringOption(values, 'name');
const server = servers.find((entry) => entry.name === name || entry.label === name);
if (!server)
throw new InputError(
'That server is not published. Use /servers and click Details.'
);
return response(renderServer(server));
}
throw new InputError('This command is not supported. Run /help for available commands.');
} catch (error) {
if (error instanceof InputError) return privateDiscordError(error.message);
throw error;
}
}
+132
View File
@@ -0,0 +1,132 @@
import { createHash } from 'node:crypto';
import type { DiscordPublicServer, DiscordSettings } from '../../discord.js';
import {
currentRound,
fleetIdentity,
freshServer,
type FleetServer
} from '../../fleet-overview.js';
import { currentCapacity } from '../../server-capacity.js';
import { parseServerName, selectedZombieMap, zombieMaps } from '../../map-aliases.js';
import { fleetSnapshot } from '../fleet/snapshot.js';
type SourceServer = FleetServer & {
network?: { publicAddress: string | null; publicPort: number | null } | null;
};
/** IDs stay short enough for Discord components, even for long registry names. */
export function discordServerKey(name: string): string {
return createHash('sha256').update(name).digest('hex').slice(0, 20);
}
/** Rendering never interprets a game nickname as Markdown, a link, or a mention. */
export function discordText(value: string, limit = 160): string {
return (
value
.replace(/[\r\n\t]/g, ' ')
// eslint-disable-next-line no-control-regex -- Strip untrusted game-name control characters.
.replace(/[\u0000-\u001f\u007f\u202a-\u202e\u2066-\u2069]/g, '')
.slice(0, limit)
.replace(/@/g, '@\u200b')
.replace(/</g, '‹')
.replace(/>/g, '›')
.replace(/([\\`*_{}[\]()~|])/g, '\\$1')
);
}
function mapIdentity(server: SourceServer, game: 't5' | 't6') {
const parsed = parseServerName(server.name);
const map = server.last_map ?? server.map ?? parsed?.mapCanonical ?? 'unknown';
const slug = server.map === map ? (server.map_slug ?? parsed?.mapSlug) : undefined;
const candidates = zombieMaps(game).filter((entry) => entry.canonical === map);
const selected =
game === 't6' && candidates.length > 1 && !slug
? undefined
: selectedZombieMap(game, map, slug);
return {
map: selected?.friendly ?? fleetIdentity(server).map ?? 'Unknown map',
mapKey: [
game,
map,
...(game === 't6' ? [selected?.slug ?? slug ?? 'unspecified'] : [])
].join(':')
};
}
function publicConnection(server: SourceServer): string | null {
const address = server.network?.publicAddress?.trim();
const port = server.network?.publicPort;
// Never fall back to agent, RCON, bind, or inferred host addresses.
if (!address || !/^[A-Za-z0-9.:[\]-]{1,253}$/.test(address)) return null;
if (!Number.isSafeInteger(port) || port! < 1 || port! > 65535) return null;
const host = address.includes(':') && !address.startsWith('[') ? `[${address}]` : address;
return `${host}:${port}`;
}
/** Explicit field projection is the boundary between private fleet data and Discord. */
export function projectFleet(
servers: readonly SourceServer[],
settings: DiscordSettings,
now = Date.now()
): DiscordPublicServer[] {
const published = new Set(settings.publishedServers);
return servers
.flatMap((server): DiscordPublicServer[] => {
if (!published.has(server.name)) return [];
const identity = fleetIdentity(server);
if (identity.game !== 't5' && identity.game !== 't6') return [];
const game = identity.game;
const fresh = freshServer(server, now);
const players =
fresh && Number.isSafeInteger(server.last_players) && server.last_players! >= 0
? server.last_players
: null;
const capacity = players !== null ? currentCapacity(server, now) : null;
const state: DiscordPublicServer['state'] =
server.health === 'starting' || server.health === 'provisioning'
? 'starting'
: server.health === 'offline'
? 'offline'
: server.health === 'stale' || (server.health === 'online' && !fresh)
? 'stale'
: players === null
? 'unknown'
: players === 0
? 'empty'
: capacity !== null && players >= capacity
? 'full'
: 'in-progress';
const observed = Date.parse(server.last_seen_at ?? '');
return [
{
name: server.name,
game,
label: identity.title,
...mapIdentity(server, game),
state,
players,
capacity,
round: players === null ? null : currentRound(server, server.telemetry, now),
observedAt:
Number.isFinite(observed) && observed <= now + 1000
? new Date(observed).toISOString()
: null,
playerNames:
settings.showPlayers && fresh && players !== null
? (server.roster ?? []).slice(0, players).map((player) => player.name)
: [],
connection: settings.showAddresses ? publicConnection(server) : null
}
];
})
.sort(
(a, b) =>
a.game.localeCompare(b.game) ||
a.label.localeCompare(b.label) ||
a.name.localeCompare(b.name)
);
}
export function publicFleet(settings: DiscordSettings, now = Date.now()): DiscordPublicServer[] {
return projectFleet(fleetSnapshot().servers, settings, now);
}
+285
View File
@@ -0,0 +1,285 @@
import type { DiscordGame, DiscordMessage, DiscordPublicServer } from '../../discord.js';
import type { LeaderboardOptions, LeaderboardResponse } from '../../leaderboard.js';
import { leaderboardMetrics } from '../../leaderboard.js';
import {
efficiencyRequirement,
formatEfficiencyValue,
isEfficiencyMetric
} from '../../leaderboard-efficiency.js';
import { MIN_RECORDED_ROUND } from '../../match-history.js';
import { gameTitle } from '../../ui/server-labels.js';
import { discordServerKey, discordText } from './projection.js';
export const BOARD_PAGE_SIZE = 8;
export const BROWSER_PAGE_SIZE = 5;
export const LEADERBOARD_PAGE_SIZE = 10;
const COMPONENTS_V2 = 1 << 15;
export const EPHEMERAL = 1 << 6;
type Component = Record<string, unknown>;
export type BrowserStatus = 'all' | 'empty' | 'space' | 'full' | 'in-progress';
export interface BrowserFilter {
game?: DiscordGame;
map?: string;
status?: BrowserStatus;
page?: number;
}
const text = (content: string): Component => ({ type: 10, content });
const button = (label: string, custom_id: string, disabled = false): Component => ({
type: 2,
style: 2,
label,
custom_id,
disabled
});
const row = (...components: Component[]): Component => ({ type: 1, components });
const container = (components: Component[]): Component => ({
type: 17,
accent_color: 0xd94868,
components
});
export function discordMessage(components: Component[], privateReply = true): DiscordMessage {
return {
flags: COMPONENTS_V2 | (privateReply ? EPHEMERAL : 0),
components,
allowed_mentions: { parse: [] }
};
}
export function privateMessage(message: string): DiscordMessage {
return discordMessage([text(message)]);
}
const states: Record<DiscordPublicServer['state'], string> = {
empty: '🟢 Empty lobby',
'in-progress': '🔵 In progress',
full: '🟠 Full',
starting: '🟡 Starting',
offline: '⚫ Offline',
stale: '🟡 Stale observation',
unknown: '⚪ Status unknown'
};
function summary(server: DiscordPublicServer): string {
const count =
server.players === null
? 'Players unknown'
: `${server.players}/${server.capacity ?? '?'} players`;
return `${count}${server.round === null ? '' : ` · Round ${server.round}`}\n${states[server.state]}`;
}
function observed(server: DiscordPublicServer): string {
return server.observedAt
? `Observed <t:${Math.floor(Date.parse(server.observedAt) / 1000)}:R>`
: 'No observation available';
}
function serverSection(server: DiscordPublicServer): Component {
const map = server.label.startsWith(server.map) ? '' : `Map: ${discordText(server.map, 50)}\n`;
return {
type: 9,
components: [
text(
`**${discordText(server.label, 90)}**\n${map}${summary(server)}\n-# ${observed(server)}`
)
],
accessory: button('Details', `ec:d:${discordServerKey(server.name)}`)
};
}
/** Each page has <= 32 total components and comfortably fits Discord's 4,000 text limit. */
export function renderBoard(
game: DiscordGame,
servers: readonly DiscordPublicServer[]
): DiscordMessage[] {
const selected = servers.filter((server) => server.game === game);
const pages = Math.max(1, Math.ceil(selected.length / BOARD_PAGE_SIZE));
return Array.from({ length: pages }, (_, page) => {
const players = selected.reduce((sum, server) => sum + (server.players ?? 0), 0);
const sections = selected
.slice(page * BOARD_PAGE_SIZE, (page + 1) * BOARD_PAGE_SIZE)
.map(serverSection);
return discordMessage(
[
container([
text(
`## 🍒 Electric Cherry · ${gameTitle(game)}\n${selected.length} servers · ${players} observed players${pages > 1 ? ` · Page ${page + 1}/${pages}` : ''}`
),
...(sections.length
? sections
: [text('No servers are published for this game yet.')]),
text(
'-# Available spaces do not establish whether an in-progress match accepts players.'
),
row(
button('Browse servers', browserId({ game })),
button('Watch a map', `ec:maps:${game}`),
button('Leaderboards', `ec:leader:${game}`)
)
])
],
false
);
});
}
export function browserId(filter: BrowserFilter): string {
return `ec:b:${filter.game ?? 'all'}:${filter.status ?? 'all'}:${filter.page ?? 0}:${Buffer.from(filter.map ?? '').toString('base64url')}`;
}
export function filterServers(
servers: readonly DiscordPublicServer[],
filter: BrowserFilter
): DiscordPublicServer[] {
return servers.filter(
(server) =>
(!filter.game || server.game === filter.game) &&
(!filter.map || server.mapKey === filter.map) &&
(!filter.status ||
filter.status === 'all' ||
(filter.status === 'space'
? server.players !== null &&
server.capacity !== null &&
server.players < server.capacity
: server.state === filter.status))
);
}
export function renderBrowser(
servers: readonly DiscordPublicServer[],
filter: BrowserFilter = {}
): DiscordMessage {
const selected = filterServers(servers, filter);
const pages = Math.max(1, Math.ceil(selected.length / BROWSER_PAGE_SIZE));
const page = Math.max(0, Math.min(filter.page ?? 0, pages - 1));
const current = { ...filter, page };
return discordMessage([
container([
text(
`## Browse servers\n${filter.game ? gameTitle(filter.game) : 'All games'} · ${selected.length} matching servers · Page ${page + 1}/${pages}`
),
...selected
.slice(page * BROWSER_PAGE_SIZE, (page + 1) * BROWSER_PAGE_SIZE)
.map(serverSection),
...(selected.length ? [] : [text('No servers match these filters.')]),
row(
button('All', browserId({ ...current, status: 'all', page: 0 })),
button('Empty', browserId({ ...current, status: 'empty', page: 0 })),
button('Space available', browserId({ ...current, status: 'space', page: 0 })),
button('Full', browserId({ ...current, status: 'full', page: 0 }))
),
row(
button(
'Previous',
`${browserId({ ...current, page: Math.max(0, page - 1) })}:prev`,
page === 0
),
button(
'Next',
`${browserId({ ...current, page: page + 1 })}:next`,
page + 1 >= pages
)
),
text(
'-# Your filters are private. Use /servers for game and map filters. Space available describes occupancy only.'
)
])
]);
}
export function renderServer(server: DiscordPublicServer): DiscordMessage {
return discordMessage([
container([
text(
`## ${discordText(server.label, 100)}\n${gameTitle(server.game)} · ${discordText(server.map, 100)}\n${summary(server)}\n${observed(server)}`
),
...(server.playerNames.length
? [
text(
`**Players**\n${server.playerNames
.slice(0, 16)
.map((name) => discordText(name, 60))
.join(' · ')}`
)
]
: []),
text(
`**Connection**\n${server.connection ? `Public endpoint: ${discordText(server.connection, 270)}\n` : ''}${server.game === 't5' ? 'Open the Black Ops I Plutonium server browser and find this lobby.' : server.connection ? 'Open the Black Ops II Plutonium console and enter connect followed by the public endpoint above.' : 'Open the Black Ops II Plutonium server browser and find this lobby.'}\nMatch admission is not reported by Electric Cherry.`
),
row(
button('Watch for empty', `ec:w:empty:${discordServerKey(server.mapKey)}`),
button('Watch for space', `ec:w:space:${discordServerKey(server.mapKey)}`),
button('Browse servers', browserId({ game: server.game }))
)
])
]);
}
export function renderWatchMaps(
servers: readonly DiscordPublicServer[],
game?: DiscordGame
): DiscordMessage {
const maps = [
...new Map(
servers
.filter((server) => !game || server.game === game)
.map((server) => [server.mapKey, server])
).values()
];
if (!maps.length) return privateMessage('No published maps are available to watch.');
return discordMessage([
text(
'## Watch a map\nChoose a map, then choose empty or space available. Watches send one DM, expire after 24 hours, and can be cancelled with /watch.'
),
row({
type: 3,
custom_id: 'ec:map',
placeholder: 'Choose a published map',
options: maps.slice(0, 25).map((server) => ({
label: `${gameTitle(server.game, true)} · ${discordText(server.map, 40)}`.slice(
0,
100
),
value: discordServerKey(server.mapKey)
}))
})
]);
}
export function renderWatchChoice(server: DiscordPublicServer): DiscordMessage {
return discordMessage([
text(
`## Watch ${discordText(server.map)}\nEmpty means zero observed players. Space means fewer players than the current observed capacity. Neither guarantees that a match accepts new players.\nOne DM · Expires in 24 hours`
),
row(
button('Empty lobby', `ec:w:empty:${discordServerKey(server.mapKey)}`),
button('Space available', `ec:w:space:${discordServerKey(server.mapKey)}`)
)
]);
}
export function renderLeaderboard(
report: LeaderboardResponse,
query: LeaderboardOptions
): DiscordMessage {
const metric = report.metric;
const label = leaderboardMetrics.find((item) => item.key === metric)!.label;
const map = report.maps.find((item) => item.key === query.map)?.name;
const items = report.entries.map((entry) => {
const value = isEfficiencyMetric(metric)
? formatEfficiencyValue(entry[metric], metric)
: entry[metric].toLocaleString('en-US');
return `**${entry.rank}.** ${discordText(entry.name, 60)} — **${value}**`;
});
return discordMessage([
container([
text(
`## ${label}\n${query.game ? gameTitle(query.game) : 'All games'}${map ? ` · ${discordText(map)}` : ''}${query.players ? ` · ${query.players}-player starts` : ''}\n${items.length ? items.join('\n') : 'No qualifying records in this scope.'}`
),
text(
`-# Completed matches with player records reaching round ${MIN_RECORDED_ROUND} or later.${isEfficiencyMetric(metric) ? ` ${efficiencyRequirement(metric)}.` : ''}\n-# Page ${Math.floor(report.offset / LEADERBOARD_PAGE_SIZE) + 1} · ${report.total} ranked players. Use /leaderboard to change filters or page.`
)
])
]);
}
+43
View File
@@ -0,0 +1,43 @@
import type Database from 'better-sqlite3';
export function migrateDiscord(handle: Database.Database): void {
handle.exec(`
CREATE TABLE IF NOT EXISTS discord_configuration (
id INTEGER PRIMARY KEY CHECK (id=1), value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS discord_health (
id INTEGER PRIMARY KEY CHECK (id=1), last_attempt_at INTEGER,
last_success_at INTEGER, last_error TEXT
);
INSERT OR IGNORE INTO discord_health(id) VALUES(1);
CREATE TABLE IF NOT EXISTS discord_boards (
id TEXT PRIMARY KEY, game TEXT NOT NULL CHECK(game IN ('t5','t6')),
guild_id TEXT NOT NULL, channel_id TEXT NOT NULL, page INTEGER NOT NULL, message_id TEXT,
content_hash TEXT, verified_at INTEGER NOT NULL DEFAULT 0, generation INTEGER NOT NULL DEFAULT 0,
UNIQUE(game,guild_id,channel_id,page)
);
CREATE TABLE IF NOT EXISTS discord_subscriptions (
id TEXT PRIMARY KEY, guild_id TEXT NOT NULL, user_id TEXT NOT NULL,
game TEXT NOT NULL CHECK(game IN ('t5','t6')), map_key TEXT NOT NULL,
mode TEXT NOT NULL CHECK(mode IN ('empty','space')),
state TEXT NOT NULL CHECK(state IN ('active','queued','delivered','failed','cancelled','expired')),
created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL,
candidate TEXT, candidate_at INTEGER, error TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS discord_subscription_active
ON discord_subscriptions(guild_id,user_id,game,map_key,mode)
WHERE state IN ('active','queued');
CREATE INDEX IF NOT EXISTS discord_subscription_expiry ON discord_subscriptions(expires_at);
CREATE TABLE IF NOT EXISTS discord_outbox (
id TEXT PRIMARY KEY, kind TEXT NOT NULL CHECK(kind IN ('board','delete','alert')),
target_id TEXT NOT NULL UNIQUE, payload TEXT NOT NULL, content_hash TEXT,
channel_id TEXT, attempts INTEGER NOT NULL DEFAULT 0,
next_at INTEGER, created_at INTEGER NOT NULL, error TEXT
);
CREATE INDEX IF NOT EXISTS discord_outbox_due ON discord_outbox(next_at);
CREATE TABLE IF NOT EXISTS discord_interactions (
id TEXT PRIMARY KEY, expires_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS discord_interactions_expiry ON discord_interactions(expires_at);
`);
}
+503
View File
@@ -0,0 +1,503 @@
import { createHash, randomUUID } from 'node:crypto';
import type Database from 'better-sqlite3';
import {
defaultDiscordSettings,
discordSnowflake,
validateDiscordSettings,
type DiscordDeliveryStatus,
type DiscordGame,
type DiscordMessage,
type DiscordSettings
} from '../../discord.js';
import { db } from '../db/index.js';
export interface DiscordSubscription {
id: string;
guildId: string;
userId: string;
game: DiscordGame;
mapKey: string;
mode: 'empty' | 'space';
state: 'active' | 'queued' | 'delivered' | 'failed' | 'cancelled' | 'expired';
expiresAt: number;
candidate: string | null;
candidateAt: number | null;
error: string | null;
}
export interface DiscordBoard {
id: string;
game: DiscordGame;
guildId: string;
channelId: string;
page: number;
messageId: string | null;
contentHash: string | null;
verifiedAt: number;
generation: number;
}
export interface DiscordJob {
id: string;
kind: 'board' | 'delete' | 'alert';
targetId: string;
payload: DiscordMessage;
contentHash: string | null;
channelId: string | null;
attempts: number;
createdAt: number;
}
export interface MapSubscriptionInput {
guildId: string;
userId: string;
game: DiscordGame;
mapKey: string;
mode: 'empty' | 'space';
}
const subscriptionColumns = `id,guild_id AS guildId,user_id AS userId,game,map_key AS mapKey,
mode,state,expires_at AS expiresAt,candidate,candidate_at AS candidateAt,error`;
const boardColumns = `id,game,guild_id AS guildId,channel_id AS channelId,page,message_id AS messageId,
content_hash AS contentHash,verified_at AS verifiedAt,generation`;
/** Synchronous, bounded transactions; delivery never holds a database transaction open. */
export class DiscordStore {
constructor(
readonly handle: Database.Database,
readonly now: () => number = Date.now
) {}
settings(): DiscordSettings {
const row = this.handle
.prepare('SELECT value FROM discord_configuration WHERE id=1')
.get() as { value: string } | undefined;
return row
? validateDiscordSettings(JSON.parse(row.value))
: structuredClone(defaultDiscordSettings);
}
saveSettings(input: unknown): DiscordSettings {
const settings = validateDiscordSettings(input);
this.handle.transaction(() => {
this.handle
.prepare(
'INSERT INTO discord_configuration(id,value) VALUES(1,?) ON CONFLICT(id) DO UPDATE SET value=excluded.value'
)
.run(JSON.stringify(settings));
this.handle
.prepare(
"UPDATE discord_subscriptions SET state='cancelled' WHERE state IN ('active','queued') AND (?=0 OR guild_id!=?)"
)
.run(Number(settings.enabled), settings.guildId);
this.handle
.prepare(
"DELETE FROM discord_outbox WHERE kind='alert' AND target_id IN (SELECT id FROM discord_subscriptions WHERE state!='queued')"
)
.run();
this.retryFailed();
})();
return settings;
}
status(): DiscordDeliveryStatus {
const row = this.handle.prepare('SELECT * FROM discord_health WHERE id=1').get() as {
last_attempt_at: number | null;
last_success_at: number | null;
last_error: string | null;
};
const iso = (value: number | null) =>
value === null ? null : new Date(value).toISOString();
return {
lastAttemptAt: iso(row.last_attempt_at),
lastSuccessAt: iso(row.last_success_at),
lastError:
row.last_error ??
(
this.handle
.prepare(
"SELECT error FROM discord_subscriptions WHERE state='failed' AND expires_at>? ORDER BY created_at DESC LIMIT 1"
)
.get(this.now()) as { error: string } | undefined
)?.error ??
null,
boards: this.handle
.prepare(
'SELECT game,channel_id AS channelId,COUNT(message_id) AS messageCount FROM discord_boards GROUP BY game,channel_id'
)
.all() as DiscordDeliveryStatus['boards'],
activeSubscriptions: this.count(
"SELECT COUNT(*) AS n FROM discord_subscriptions WHERE state IN ('active','queued') AND expires_at>?",
this.now()
),
pendingNotifications: this.count(
"SELECT COUNT(*) AS n FROM discord_outbox WHERE kind='alert' AND next_at IS NOT NULL"
)
};
}
subscribe(input: MapSubscriptionInput): { id: string; expiresAt: number } {
if (
!discordSnowflake(input.userId) ||
!discordSnowflake(input.guildId) ||
!['t5', 't6'].includes(input.game) ||
!['empty', 'space'].includes(input.mode) ||
typeof input.mapKey !== 'string' ||
!/^[a-zA-Z0-9_.:-]{1,100}$/.test(input.mapKey)
)
throw new Error('Invalid map watch.');
const settings = this.settings();
if (!settings.enabled || settings.guildId !== input.guildId)
throw new Error('Discord map watches are not enabled for this server.');
return this.handle.transaction(() => {
this.expireSubscriptions();
const existing = this.handle
.prepare(
"SELECT id,expires_at AS expiresAt FROM discord_subscriptions WHERE guild_id=? AND user_id=? AND game=? AND map_key=? AND mode=? AND state IN ('active','queued')"
)
.get(input.guildId, input.userId, input.game, input.mapKey, input.mode) as
{ id: string; expiresAt: number } | undefined;
if (existing) return existing;
if (
this.count(
"SELECT COUNT(*) AS n FROM discord_subscriptions WHERE user_id=? AND state IN ('active','queued')",
input.userId
) >= 10
)
throw new Error(
'You already have 10 active map watches. Cancel one before adding another.'
);
if (
this.count(
"SELECT COUNT(*) AS n FROM discord_subscriptions WHERE state IN ('active','queued')"
) >= 1000
)
throw new Error('Map watches are currently full. Please try later.');
const id = randomUUID();
const expiresAt = this.now() + 24 * 60 * 60_000;
this.handle
.prepare(
"INSERT INTO discord_subscriptions(id,guild_id,user_id,game,map_key,mode,state,created_at,expires_at) VALUES(?,?,?,?,?,?,'active',?,?)"
)
.run(
id,
input.guildId,
input.userId,
input.game,
input.mapKey,
input.mode,
this.now(),
expiresAt
);
return { id, expiresAt };
})();
}
subscriptions(userId?: string, guildId?: string): DiscordSubscription[] {
this.expireSubscriptions();
return this.handle
.prepare(
`SELECT ${subscriptionColumns} FROM discord_subscriptions WHERE state IN ('active','queued') ${userId ? 'AND user_id=? AND guild_id=?' : ''} ORDER BY created_at LIMIT 1000`
)
.all(...(userId ? [userId, guildId ?? ''] : [])) as DiscordSubscription[];
}
subscription(id: string): DiscordSubscription | undefined {
return this.handle
.prepare(`SELECT ${subscriptionColumns} FROM discord_subscriptions WHERE id=?`)
.get(id) as DiscordSubscription | undefined;
}
cancel(id: string, guildId: string, userId: string): boolean {
return this.handle.transaction(() => {
const updated = this.handle
.prepare(
"UPDATE discord_subscriptions SET state='cancelled' WHERE id=? AND guild_id=? AND user_id=? AND state IN ('active','queued')"
)
.run(id, guildId, userId).changes;
if (updated)
this.handle
.prepare("DELETE FROM discord_outbox WHERE kind='alert' AND target_id=?")
.run(id);
return updated > 0;
})();
}
expireSubscriptions(): void {
this.handle
.prepare(
"UPDATE discord_subscriptions SET state='expired' WHERE state IN ('active','queued') AND expires_at<=?"
)
.run(this.now());
this.handle
.prepare(
"DELETE FROM discord_outbox WHERE kind='alert' AND target_id IN (SELECT id FROM discord_subscriptions WHERE state NOT IN ('active','queued'))"
)
.run();
this.handle
.prepare(
"DELETE FROM discord_subscriptions WHERE state NOT IN ('active','queued') AND (expires_at<? OR id IN (SELECT id FROM discord_subscriptions ORDER BY created_at DESC LIMIT -1 OFFSET 10000))"
)
.run(this.now() - 7 * 86400_000);
}
candidate(id: string, candidate: string | null): void {
this.handle
.prepare(
"UPDATE discord_subscriptions SET candidate=?,candidate_at=CASE WHEN candidate IS ? THEN candidate_at ELSE ? END WHERE id=? AND state='active'"
)
.run(candidate, candidate, candidate ? this.now() : null, id);
}
queueAlert(subscription: DiscordSubscription, message: DiscordMessage): void {
this.handle.transaction(() => {
const current = this.subscription(subscription.id);
if (!current || current.state !== 'active' || current.expiresAt <= this.now()) return;
this.enqueue('alert', subscription.id, message, null);
this.handle
.prepare("UPDATE discord_subscriptions SET state='queued' WHERE id=?")
.run(subscription.id);
})();
}
boards(): DiscordBoard[] {
return this.handle
.prepare(`SELECT ${boardColumns} FROM discord_boards ORDER BY game,channel_id,page`)
.all() as DiscordBoard[];
}
board(id: string): DiscordBoard | undefined {
return this.handle
.prepare(`SELECT ${boardColumns} FROM discord_boards WHERE id=?`)
.get(id) as DiscordBoard | undefined;
}
reconcileBoards(
desired: {
game: DiscordGame;
guildId: string;
channelId: string;
messages: DiscordMessage[];
}[]
): void {
this.handle.transaction(() => {
const retained = new Set<string>();
for (const group of desired) {
for (const [page, message] of group.messages.entries()) {
let board = this.boards().find(
(item) =>
item.game === group.game &&
item.guildId === group.guildId &&
item.channelId === group.channelId &&
item.page === page
);
if (!board) {
const id = randomUUID();
this.handle
.prepare(
'INSERT INTO discord_boards(id,game,guild_id,channel_id,page) VALUES(?,?,?,?,?)'
)
.run(id, group.game, group.guildId, group.channelId, page);
board = this.board(id)!;
}
retained.add(board.id);
this.handle
.prepare("DELETE FROM discord_outbox WHERE target_id=? AND kind='delete'")
.run(board.id);
const hash = createHash('sha256').update(JSON.stringify(message)).digest('hex');
if (board.contentHash !== hash || !board.messageId)
this.enqueue('board', board.id, message, hash);
else
this.handle
.prepare(
"DELETE FROM discord_outbox WHERE target_id=? AND kind='board'"
)
.run(board.id);
}
}
for (const board of this.boards()) {
if (!retained.has(board.id)) {
if (board.messageId)
this.enqueue('delete', board.id, { allowed_mentions: { parse: [] } }, null);
else this.removeBoard(board.id);
}
}
})();
}
private enqueue(
kind: DiscordJob['kind'],
targetId: string,
payload: DiscordMessage,
hash: string | null
): void {
const existing = this.handle
.prepare('SELECT kind FROM discord_outbox WHERE target_id=?')
.get(targetId) as { kind: string } | undefined;
if (!existing && this.count('SELECT COUNT(*) AS n FROM discord_outbox') >= 500)
throw new Error('Discord delivery queue is full.');
this.handle
.prepare(
`INSERT INTO discord_outbox(id,kind,target_id,payload,content_hash,next_at,created_at) VALUES(?,?,?,?,?,?,?)
ON CONFLICT(target_id) DO UPDATE SET kind=excluded.kind,payload=excluded.payload,content_hash=excluded.content_hash,
attempts=CASE WHEN kind!=excluded.kind THEN 0 ELSE attempts END,
next_at=CASE WHEN kind!=excluded.kind THEN excluded.next_at ELSE next_at END`
)
.run(
randomUUID(),
kind,
targetId,
JSON.stringify(payload),
hash,
this.now(),
this.now()
);
}
jobs(limit = 10): DiscordJob[] {
return (
this.handle
.prepare(
`SELECT id,kind,target_id AS targetId,payload,content_hash AS contentHash,
channel_id AS channelId,attempts,created_at AS createdAt FROM discord_outbox WHERE next_at<=? ORDER BY CASE kind WHEN 'delete' THEN 0 WHEN 'alert' THEN 1 ELSE 2 END,created_at LIMIT ?`
)
.all(this.now(), limit) as (Omit<DiscordJob, 'payload'> & { payload: string })[]
).map((row) => ({ ...row, payload: JSON.parse(row.payload) }));
}
cacheDm(jobId: string, channelId: string): void {
this.handle
.prepare('UPDATE discord_outbox SET channel_id=? WHERE id=?')
.run(channelId, jobId);
}
dropJob(id: string): void {
this.handle.prepare('DELETE FROM discord_outbox WHERE id=?').run(id);
}
resumeAlert(id: string): void {
this.handle.transaction(() => {
this.handle
.prepare("DELETE FROM discord_outbox WHERE kind='alert' AND target_id=?")
.run(id);
this.handle
.prepare(
"UPDATE discord_subscriptions SET state='active',candidate=NULL,candidate_at=NULL WHERE id=? AND state='queued'"
)
.run(id);
})();
}
delivered(job: DiscordJob, messageId?: string): void {
this.handle.transaction(() => {
if (job.kind === 'board')
this.handle
.prepare(
'UPDATE discord_boards SET message_id=?,content_hash=?,verified_at=? WHERE id=?'
)
.run(messageId, job.contentHash, this.now(), job.targetId);
if (job.kind === 'delete')
this.handle.prepare('DELETE FROM discord_boards WHERE id=?').run(job.targetId);
if (job.kind === 'alert')
this.handle
.prepare(
"UPDATE discord_subscriptions SET state='delivered',error=NULL WHERE id=? AND state='queued'"
)
.run(job.targetId);
// A newer desired board can replace a payload while an HTTP call is in flight.
this.handle
.prepare('DELETE FROM discord_outbox WHERE id=? AND payload=?')
.run(job.id, JSON.stringify(job.payload));
this.handle
.prepare(
'UPDATE discord_health SET last_success_at=?,last_error=CASE WHEN EXISTS(SELECT 1 FROM discord_outbox WHERE error IS NOT NULL) THEN last_error ELSE NULL END WHERE id=1'
)
.run(this.now());
})();
}
removeBoard(id: string): void {
this.handle.prepare('DELETE FROM discord_outbox WHERE target_id=?').run(id);
this.handle.prepare('DELETE FROM discord_boards WHERE id=?').run(id);
}
missingMessage(id: string): void {
this.handle
.prepare(
'UPDATE discord_boards SET message_id=NULL,content_hash=NULL,verified_at=0,generation=generation+1 WHERE id=?'
)
.run(id);
}
verified(id: string): void {
this.handle
.prepare('UPDATE discord_boards SET verified_at=? WHERE id=?')
.run(this.now(), id);
}
attempt(): void {
this.handle
.prepare('UPDATE discord_health SET last_attempt_at=? WHERE id=1')
.run(this.now());
}
error(message: string): void {
this.handle
.prepare('UPDATE discord_health SET last_error=? WHERE id=1')
.run(message.slice(0, 300));
}
failed(job: DiscordJob, error: string, retryAt: number | null): void {
this.handle.transaction(() => {
this.handle
.prepare(
'UPDATE discord_outbox SET attempts=attempts+1,next_at=?,error=? WHERE id=?'
)
.run(retryAt, error.slice(0, 300), job.id);
if (retryAt === null && job.kind === 'alert') {
this.handle
.prepare("UPDATE discord_subscriptions SET state='failed',error=? WHERE id=?")
.run(error.slice(0, 300), job.targetId);
this.handle.prepare('DELETE FROM discord_outbox WHERE id=?').run(job.id);
}
this.error(error);
})();
}
retryFailed(): void {
this.handle
.prepare(
'UPDATE discord_outbox SET next_at=?,attempts=0,error=NULL WHERE next_at IS NULL'
)
.run(this.now());
this.handle.prepare('UPDATE discord_health SET last_error=NULL WHERE id=1').run();
}
claimInteraction(id: string): boolean {
if (!discordSnowflake(id)) throw new Error('Invalid Discord interaction ID.');
return this.handle.transaction(() => {
this.handle
.prepare('DELETE FROM discord_interactions WHERE expires_at<=?')
.run(this.now());
if (this.handle.prepare('SELECT 1 FROM discord_interactions WHERE id=?').get(id))
return false;
if (this.count('SELECT COUNT(*) AS n FROM discord_interactions') >= 10000)
throw new Error('Discord is processing too many interactions. Please try later.');
this.handle
.prepare('INSERT INTO discord_interactions(id,expires_at) VALUES(?,?)')
.run(id, this.now() + 15 * 60_000);
return true;
})();
}
private count(sql: string, ...args: (string | number)[]): number {
return (this.handle.prepare(sql).get(...args) as { n: number }).n;
}
}
export const discordStore = () => new DiscordStore(db());
export const getDiscordSettings = () => discordStore().settings();
export const saveDiscordSettings = (input: unknown) => discordStore().saveSettings(input);
export const discordStatus = () => discordStore().status();
export const subscribeToMap = (input: MapSubscriptionInput) => discordStore().subscribe(input);
export const listSubscriptions = (userId: string, guildId: string) =>
discordStore().subscriptions(userId, guildId);
export const cancelSubscription = (id: string, guildId: string, userId: string) =>
discordStore().cancel(id, guildId, userId);
export const claimDiscordInteraction = (id: string) => discordStore().claimInteraction(id);
+132
View File
@@ -0,0 +1,132 @@
/** Discord HTTP failures never include response bodies, which can contain private data. */
export class DiscordDeliveryError extends Error {
constructor(
message: string,
readonly status: number,
readonly retryAt: number | null = null,
readonly code: number | null = null
) {
super(message);
this.name = 'DiscordDeliveryError';
}
}
export interface DiscordTransport {
request<T = Record<string, unknown>>(method: string, path: string, body?: unknown): Promise<T>;
}
/** One serialized queue shares Discord's bucket/global cooldowns with every caller. */
export class DiscordRest implements DiscordTransport {
private tail: Promise<unknown> = Promise.resolve();
private globalUntil = 0;
private unauthorized = false;
private routeBuckets = new Map<string, string>();
private blockedUntil = new Map<string, number>();
constructor(
private readonly token: string,
private readonly fetcher: typeof fetch = fetch,
private readonly now: () => number = Date.now
) {}
request<T = Record<string, unknown>>(method: string, path: string, body?: unknown): Promise<T> {
const pending = this.tail.then(() => this.send<T>(method, path, body));
this.tail = pending.catch(() => undefined);
return pending;
}
private async send<T>(method: string, path: string, body?: unknown): Promise<T> {
// Callers supply resource paths, never arbitrary hosts or interaction tokens.
if (!/^\/(?:channels|users|applications)\/[A-Za-z0-9_@/-]+$/.test(path))
throw new Error('Invalid Discord API path.');
if (this.unauthorized)
throw new DiscordDeliveryError('Discord bot credentials were rejected.', 401);
const segments = path.split('/');
const major = `${segments[1]}/${segments[2]}`;
const route = `${method}:${major}/${segments
.slice(3)
.map((part) => (/^\d+$/.test(part) ? ':id' : part))
.join('/')}`;
const key = this.routeBuckets.get(route) ?? route;
const retryAt = Math.max(this.globalUntil, this.blockedUntil.get(key) ?? 0);
if (retryAt > this.now())
throw new DiscordDeliveryError(
'Discord is waiting for a rate limit to reset.',
429,
retryAt
);
let response: Response;
try {
response = await this.fetcher(`https://discord.com/api/v10${path}`, {
method,
headers: {
Authorization: `Bot ${this.token}`,
'Content-Type': 'application/json',
'User-Agent': 'ElectricCherry/1.0'
},
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(10_000),
redirect: 'error'
});
} catch {
throw new DiscordDeliveryError('Discord could not be reached.', 0, this.now() + 5_000);
}
const bucket = response.headers.get('X-RateLimit-Bucket');
const bucketKey = bucket ? `${major}:${bucket}` : key;
if (bucket) this.routeBuckets.set(route, bucketKey);
const reset = seconds(response.headers.get('X-RateLimit-Reset-After'));
if (response.headers.get('X-RateLimit-Remaining') === '0' && reset !== null)
this.blockedUntil.set(bucketKey, this.now() + reset);
let data: Record<string, unknown> = {};
if (response.status !== 204) {
try {
data = await response.json();
} catch {
if (response.ok)
throw new DiscordDeliveryError(
'Discord returned an invalid response.',
502,
this.now() + 5_000
);
}
}
if (response.status === 429) {
const delay = Math.max(
1000,
seconds(data.retry_after) ??
seconds(response.headers.get('Retry-After')) ??
reset ??
5_000
);
const until = this.now() + delay;
if (data.global === true || response.headers.get('X-RateLimit-Global') === 'true')
this.globalUntil = until;
else this.blockedUntil.set(bucketKey, until);
throw new DiscordDeliveryError('Discord rate limited this request.', 429, until);
}
if (!response.ok) {
if (response.status === 401) this.unauthorized = true;
const message =
response.status === 401
? 'Discord bot credentials were rejected.'
: response.status === 403
? 'Discord denied permission or the recipient does not accept direct messages.'
: response.status === 404
? 'Discord channel or message no longer exists.'
: `Discord request failed (HTTP ${response.status}).`;
throw new DiscordDeliveryError(
message,
response.status,
response.status >= 500 ? this.now() + 5_000 : null,
typeof data.code === 'number' ? data.code : null
);
}
return data as T;
}
}
function seconds(value: unknown): number | null {
if (value === null || value === undefined || value === '') return null;
const parsed = Number(value);
return Number.isFinite(parsed) && parsed >= 0 ? Math.ceil(parsed * 1000) : null;
}
+118
View File
@@ -0,0 +1,118 @@
import { createPublicKey, verify } from 'node:crypto';
import { error } from '@sveltejs/kit';
import { boundedBody } from '../request-body.js';
import { discordCredentials } from './config.js';
import { discordSnowflake } from '../../discord.js';
import type { DiscordMessage } from '../../discord.js';
export const DISCORD_INTERACTION_BODY_LIMIT = 64 * 1024;
/** Authenticate the exact request bytes before interpreting any interaction fields. */
export async function verifiedDiscordBody(
request: Request,
now = Date.now()
): Promise<Record<string, unknown>> {
const signature = request.headers.get('x-signature-ed25519') ?? '';
const timestamp = request.headers.get('x-signature-timestamp') ?? '';
const { publicKey } = discordCredentials();
if (
!/^[a-fA-F0-9]{128}$/.test(signature) ||
!/^[0-9]{10,12}$/.test(timestamp) ||
Math.abs(now - Number(timestamp) * 1000) > 5 * 60_000 ||
!/^[a-fA-F0-9]{64}$/.test(publicKey)
)
error(401, 'Invalid Discord signature.');
const body = await boundedBody(request, DISCORD_INTERACTION_BODY_LIMIT);
const key = createPublicKey({
key: Buffer.concat([
Buffer.from('302a300506032b6570032100', 'hex'),
Buffer.from(publicKey, 'hex')
]),
format: 'der',
type: 'spki'
});
if (
!verify(
null,
Buffer.concat([Buffer.from(timestamp), body]),
key,
Buffer.from(signature, 'hex')
)
)
error(401, 'Invalid Discord signature.');
let parsed: unknown;
try {
parsed = JSON.parse(body.toString('utf8'));
} catch {
error(400, 'Invalid Discord interaction.');
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
error(400, 'Invalid Discord interaction.');
return parsed as Record<string, unknown>;
}
export function validInteractionEnvelope(body: Record<string, unknown>): boolean {
return (
discordSnowflake(body.id) &&
body.application_id === discordCredentials().applicationId &&
discordSnowflake(body.guild_id) &&
typeof body.token === 'string' &&
body.token.length >= 10 &&
body.token.length <= 1000 &&
!!body.member &&
typeof body.member === 'object' &&
discordSnowflake((body.member as { user?: { id?: unknown } }).user?.id)
);
}
/** Interaction webhooks use their short-lived token, never the deployment bot token. */
export async function completeDiscordInteraction(
applicationId: string,
token: string,
message: DiscordMessage,
fetcher: typeof fetch = fetch,
publicationAllowed: () => boolean = () => true
): Promise<void> {
for (let attempt = 0; attempt < 3; attempt++) {
const response = await fetcher(
`https://discord.com/api/v10/webhooks/${applicationId}/${encodeURIComponent(token)}/messages/@original`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...(publicationAllowed()
? message
: {
flags: 32768 | 64,
components: [
{
type: 10,
content:
'Publication settings changed. Please run your command again.'
}
]
}),
allowed_mentions: { parse: [] }
}),
signal: AbortSignal.timeout(10_000),
redirect: 'error'
}
);
if (response.ok) return;
if (response.status === 429 && attempt < 2) {
let delay = Number(response.headers.get('retry-after') ?? NaN);
try {
const result = (await response.json()) as { retry_after?: number };
delay = Number(result.retry_after ?? delay);
} catch {
/* A valid retry header is sufficient. */
}
if (Number.isFinite(delay) && delay >= 0 && delay <= 30) {
await new Promise((resolve) => setTimeout(resolve, Math.max(250, delay * 1000)));
continue;
}
}
// Do not log a response body or URL: both can contain interaction credentials.
throw new Error(`Discord interaction delivery failed (${response.status}).`);
}
}
+415
View File
@@ -0,0 +1,415 @@
import { createHash } from 'node:crypto';
import type {
DiscordGame,
DiscordMessage,
DiscordPublicServer,
DiscordSettings
} from '../../discord.js';
import { requireDiscordCredentials } from './config.js';
import { discordText, publicFleet } from './projection.js';
import { renderBoard } from './render.js';
import { DiscordStore, discordStore, type DiscordJob, type DiscordSubscription } from './store.js';
import { DiscordDeliveryError, DiscordRest, type DiscordTransport } from './transport.js';
const TICK_MS = 30_000;
const VERIFY_MS = 15 * 60_000;
const FRESH_MS = 90_000;
let client: { token: string; rest: DiscordRest } | undefined;
export function discordRest(): DiscordRest {
const { botToken } = requireDiscordCredentials();
if (!client || client.token !== botToken)
client = { token: botToken, rest: new DiscordRest(botToken) };
return client.rest;
}
interface WorkerDependencies {
store: DiscordStore;
transport: () => DiscordTransport;
fleet: (settings: DiscordSettings, now: number) => DiscordPublicServer[];
render: (game: DiscordGame, servers: DiscordPublicServer[]) => DiscordMessage[];
now?: () => number;
}
/** A single independent reconciliation loop; it never issues game-server commands. */
export class DiscordWorker {
private running: Promise<void> | null = null;
private readonly now: () => number;
constructor(private readonly dependencies: WorkerDependencies) {
this.now = dependencies.now ?? Date.now;
}
tick(): Promise<void> {
if (this.running) return this.running;
this.running = this.run()
.catch((error: unknown) => {
// No raw fetch/config/SQL exceptions are persisted in operator-facing status.
try {
this.dependencies.store.error(
error instanceof DiscordDeliveryError
? error.message
: 'Discord delivery could not complete. Check the integration configuration.'
);
} catch {
console.error('[discord] Could not record delivery health.');
}
})
.finally(() => {
this.running = null;
});
return this.running;
}
private async run(): Promise<void> {
const { store, fleet, render } = this.dependencies;
store.expireSubscriptions();
const settings = store.settings();
const servers = settings.enabled ? fleet(settings, this.now()) : [];
store.reconcileBoards(
settings.enabled
? (['t5', 't6'] as const)
.filter((game) => settings.channels[game])
.map((game) => ({
game,
guildId: settings.guildId,
channelId: settings.channels[game],
messages: render(game, servers)
}))
: []
);
if (settings.enabled) {
for (const watch of store.subscriptions()) {
if (watch.state !== 'active') continue;
if (watch.guildId !== settings.guildId) {
store.cancel(watch.id, watch.guildId, watch.userId);
continue;
}
const match = matchingServer(watch, servers, this.now());
if (!match) {
store.candidate(watch.id, null);
continue;
}
// Persist the server identity, not each telemetry timestamp: two fresh
// observations spanning a tick stabilize brief capacity fluctuations.
if (watch.candidate?.split('|')[0] !== match.name) {
store.candidate(watch.id, `${match.name}|${match.observedAt ?? ''}`);
continue;
}
if (
watch.candidateAt !== null &&
this.now() - watch.candidateAt >= 15_000 &&
watch.candidate !== `${match.name}|${match.observedAt ?? ''}`
)
store.queueAlert(watch, alertMessage(watch, match));
}
}
const jobs = store.jobs(10);
const toVerify = store
.boards()
.find(
(board) =>
board.messageId &&
board.verifiedAt < this.now() - VERIFY_MS &&
!jobs.some((job) => job.targetId === board.id)
);
if (!jobs.length && (!settings.enabled || !toVerify)) return;
const rest = this.dependencies.transport();
const channelGuilds = new Map<string, string>();
const ensureChannel = async (channelId: string, guildId: string) => {
if (!channelGuilds.has(channelId)) {
const channel = await rest.request<{ guild_id?: string; type?: number }>(
'GET',
`/channels/${channelId}`
);
if (![0, 5].includes(channel.type ?? -1))
throw new DiscordDeliveryError(
'Choose a Discord text or announcement channel for the board.',
403
);
channelGuilds.set(channelId, channel.guild_id ?? '');
}
if (channelGuilds.get(channelId) !== guildId)
throw new DiscordDeliveryError(
'The board channel does not belong to the configured Discord server.',
403
);
};
for (const job of jobs) {
store.attempt();
try {
await this.deliver(job, rest, ensureChannel);
} catch (error) {
const failure =
error instanceof DiscordDeliveryError
? error
: new DiscordDeliveryError(
'Discord delivery could not complete.',
0,
this.now() + 5_000
);
const canRetry =
failure.retryAt !== null && (failure.status === 429 || job.attempts < 7);
const delay = Math.min(15 * 60_000, 5_000 * 2 ** Math.min(job.attempts, 8));
store.failed(
job,
failure.message,
canRetry
? Math.max(
failure.retryAt!,
this.now() + (failure.status === 429 ? 0 : delay)
)
: null
);
if (failure.status === 401 || failure.status === 429) break;
}
}
if (settings.enabled && toVerify) {
const current = store.settings();
if (
!current.enabled ||
current.guildId !== toVerify.guildId ||
current.channels[toVerify.game] !== toVerify.channelId
)
return;
store.attempt();
try {
await ensureChannel(toVerify.channelId, current.guildId);
await rest.request(
'GET',
`/channels/${toVerify.channelId}/messages/${toVerify.messageId}`
);
store.verified(toVerify.id);
} catch (error) {
if (
error instanceof DiscordDeliveryError &&
error.status === 404 &&
error.code === 10008
)
store.missingMessage(toVerify.id);
else {
store.verified(toVerify.id); // Check permissions again later, not every tick.
store.error(
error instanceof DiscordDeliveryError
? error.message
: 'Discord board verification failed.'
);
}
}
}
}
private async deliver(
job: DiscordJob,
rest: DiscordTransport,
ensureChannel: (channelId: string, guildId: string) => Promise<void>
): Promise<void> {
const { store, fleet, render } = this.dependencies;
const settings = store.settings();
if (job.kind === 'alert') {
const watch = store.subscription(job.targetId);
if (
!watch ||
watch.state !== 'queued' ||
watch.expiresAt <= this.now() ||
!settings.enabled ||
settings.guildId !== watch.guildId
) {
store.dropJob(job.id);
return;
}
const match = matchingServer(watch, fleet(settings, this.now()), this.now());
if (!match) {
store.resumeAlert(watch.id);
return;
}
let channelId = job.channelId;
if (!channelId) {
const channel = await rest.request<{ id: string }>('POST', '/users/@me/channels', {
recipient_id: watch.userId
});
channelId = channel.id;
if (!/^\d+$/.test(channelId))
throw new DiscordDeliveryError(
'Discord returned an invalid channel.',
502,
this.now() + 5_000
);
store.cacheDm(job.id, channelId);
}
// Recheck opt-in/config after the asynchronous channel lookup.
const current = store.settings();
const stillWatching = store.subscription(watch.id);
if (
!current.enabled ||
current.guildId !== watch.guildId ||
stillWatching?.state !== 'queued' ||
stillWatching.expiresAt <= this.now()
) {
store.dropJob(job.id);
return;
}
const fresh = matchingServer(watch, fleet(current, this.now()), this.now());
if (!fresh) {
store.resumeAlert(watch.id);
return;
}
await rest.request('POST', `/channels/${channelId}/messages`, {
...alertMessage(watch, fresh),
nonce: nonce(job.id),
enforce_nonce: true
});
store.delivered(job);
return;
}
const board = store.board(job.targetId);
if (!board) {
store.dropJob(job.id);
return;
}
if (job.kind === 'delete') {
if (board.messageId) {
try {
await rest.request(
'DELETE',
`/channels/${board.channelId}/messages/${board.messageId}`
);
} catch (error) {
if (!(error instanceof DiscordDeliveryError && error.status === 404))
throw error;
}
}
store.delivered(job);
return;
}
if (
!settings.enabled ||
settings.guildId !== board.guildId ||
settings.channels[board.game] !== board.channelId
) {
store.dropJob(job.id);
return;
}
await ensureChannel(board.channelId, settings.guildId);
// Rebuild after awaits so pending jobs cannot publish withdrawn fields or servers.
const current = store.settings();
if (
!current.enabled ||
current.guildId !== settings.guildId ||
current.channels[board.game] !== board.channelId
) {
store.dropJob(job.id);
return;
}
const message = render(board.game, fleet(current, this.now()))[board.page];
if (!message) {
store.dropJob(job.id);
return;
}
const updated = {
...job,
payload: message,
contentHash: createHash('sha256').update(JSON.stringify(message)).digest('hex')
};
if (board.messageId) {
try {
await rest.request(
'PATCH',
`/channels/${board.channelId}/messages/${board.messageId}`,
message
);
store.delivered(updated, board.messageId);
return;
} catch (error) {
if (!(
error instanceof DiscordDeliveryError &&
error.status === 404 &&
error.code === 10008
))
throw error;
store.missingMessage(board.id);
}
}
const result = await rest.request<{ id: string }>(
'POST',
`/channels/${board.channelId}/messages`,
{
...message,
nonce: nonce(`${board.id}:${store.board(board.id)?.generation ?? 0}`),
enforce_nonce: true
}
);
if (!/^\d+$/.test(result.id))
throw new DiscordDeliveryError(
'Discord returned an invalid message.',
502,
this.now() + 5_000
);
store.delivered(updated, result.id);
}
}
function matchingServer(
watch: DiscordSubscription,
servers: DiscordPublicServer[],
now: number
): DiscordPublicServer | undefined {
return servers.find(
(server) =>
server.game === watch.game &&
server.mapKey === watch.mapKey &&
server.observedAt !== null &&
now - Date.parse(server.observedAt) >= 0 &&
now - Date.parse(server.observedAt) <= FRESH_MS &&
['empty', 'in-progress'].includes(server.state) &&
server.players !== null &&
(watch.mode === 'empty'
? server.players === 0
: server.capacity !== null &&
server.capacity > 0 &&
server.players < server.capacity)
);
}
function alertMessage(watch: DiscordSubscription, server: DiscordPublicServer): DiscordMessage {
const safe = (value: string) => discordText(value, 100);
return {
content: `🍒 **${safe(server.map)}** ${watch.mode === 'empty' ? 'has an empty lobby' : 'has an open player slot'}: **${safe(server.label)}** (${server.players}/${server.capacity ?? '?'}).\nThis describes occupancy; joining an in-progress game may still be restricted.\nYour one-time map watch is complete. Use /servers to browse or /watch to watch again.`,
allowed_mentions: { parse: [] }
};
}
function nonce(id: string): string {
return createHash('sha256').update(id).digest('hex').slice(0, 24);
}
let worker: DiscordWorker | undefined;
let timer: ReturnType<typeof setInterval> | undefined;
export function tickDiscordWorker(): Promise<void> {
worker ??= new DiscordWorker({
store: discordStore(),
transport: discordRest,
fleet: publicFleet,
render: renderBoard
});
return worker.tick();
}
export function startDiscordWorker(): void {
if (timer) return;
void tickDiscordWorker();
timer = setInterval(() => {
void tickDiscordWorker();
}, TICK_MS);
timer.unref();
}
export function stopDiscordWorker(): void {
clearInterval(timer);
timer = undefined;
}
export function resetDiscordClient(): void {
client = undefined;
}
export function retryDiscordDelivery(): Promise<void> {
resetDiscordClient();
discordStore().retryFailed();
return tickDiscordWorker();
}
@@ -0,0 +1,81 @@
import { isHttpError, json } from '@sveltejs/kit';
import { claimDiscordInteraction, getDiscordSettings } from '$lib/server/discord/store.js';
import { privateMessage } from '$lib/server/discord/render.js';
import {
handleDiscordInteraction,
type DiscordInteraction
} from '$lib/server/discord/interactions.js';
import {
completeDiscordInteraction,
validInteractionEnvelope,
verifiedDiscordBody
} from '$lib/server/discord/webhook.js';
const seen = new Map<string, number>();
const actors = new Map<string, number>();
let pending = 0;
const privateReply = (content: string) => ({
type: 4,
data: { flags: 64, content, allowed_mentions: { parse: [] } }
});
export async function POST({ request }: { request: Request }) {
try {
const body = await verifiedDiscordBody(request);
if (body.type === 1) return json({ type: 1 });
if (![2, 3].includes(body.type as number) || !validInteractionEnvelope(body))
return json({ error: 'Invalid Discord interaction.' }, { status: 400 });
const settings = getDiscordSettings();
if (!settings.enabled || body.guild_id !== settings.guildId)
return json(privateReply('Electric Cherry is not enabled for this Discord server.'));
const now = Date.now();
for (const [key, at] of seen) if (now - at > 5 * 60_000) seen.delete(key);
for (const [key, at] of actors) if (now - at > 60_000) actors.delete(key);
const id = body.id as string;
const actor = (body.member as { user: { id: string } }).user.id;
if (seen.has(id)) return json({ type: 5, data: { flags: 64 } });
if (pending >= 32 || seen.size >= 10_000 || now - (actors.get(actor) ?? 0) < 1000)
return json(privateReply('Please wait a moment before trying again.'));
if (!claimDiscordInteraction(id)) return json({ type: 5, data: { flags: 64 } });
seen.set(id, now);
actors.set(actor, now);
pending++;
// Acknowledge immediately; report queries and Discord delivery must not consume
// the three-second response window. Durable watches are saved by the handler.
setImmediate(() => {
void (async () => {
try {
const reply = await handleDiscordInteraction(
body as unknown as DiscordInteraction
);
await completeDiscordInteraction(
body.application_id as string,
body.token as string,
reply.data,
fetch,
() => JSON.stringify(getDiscordSettings()) === JSON.stringify(settings)
);
} catch {
try {
await completeDiscordInteraction(
body.application_id as string,
body.token as string,
privateMessage(
'Electric Cherry could not complete this request. Please try again.'
)
);
} catch {
console.error('[discord] Could not deliver an interaction response.');
}
} finally {
pending--;
}
})();
});
return json({ type: 5, data: { flags: 64 } });
} catch (cause) {
if (isHttpError(cause))
return json({ error: cause.body.message }, { status: cause.status });
return json({ error: 'Could not process Discord interaction.' }, { status: 500 });
}
}
@@ -0,0 +1,36 @@
import { isHttpError, json } from '@sveltejs/kit';
import { validateDiscordSettings } from '$lib/discord.js';
import { discordAdminState } from '$lib/server/discord/admin.js';
import { requireDiscordCredentials } from '$lib/server/discord/config.js';
import { saveDiscordSettings } from '$lib/server/discord/store.js';
import { jsonBody } from '$lib/server/request-body.js';
import { listServers } from '$lib/server/fleet/servers.js';
import { resetDiscordClient } from '$lib/server/discord/worker.js';
export function GET() {
return json(discordAdminState());
}
export async function PUT({ request }: { request: Request }) {
try {
const settings = validateDiscordSettings(await jsonBody(request, 32 * 1024));
if (settings.enabled) requireDiscordCredentials();
const known = new Set(listServers().map((server) => server.name));
if (settings.publishedServers.some((name) => !known.has(name)))
return json(
{
error: 'One of the selected servers is no longer available. Refresh and try again.'
},
{ status: 400 }
);
saveDiscordSettings(settings);
resetDiscordClient();
return json(discordAdminState());
} catch (cause) {
if (isHttpError(cause)) throw cause;
return json(
{ error: cause instanceof Error ? cause.message : 'Could not save Discord settings.' },
{ status: 400 }
);
}
}
@@ -0,0 +1,43 @@
import { json } from '@sveltejs/kit';
import { discordCommands } from '$lib/server/discord/commands.js';
import { requireDiscordCredentials } from '$lib/server/discord/config.js';
import { getDiscordSettings } from '$lib/server/discord/store.js';
import { discordRest } from '$lib/server/discord/worker.js';
import { discordSnowflake } from '$lib/discord.js';
export async function POST() {
try {
const credentials = requireDiscordCredentials();
const settings = getDiscordSettings();
if (!discordSnowflake(settings.guildId))
return json({ error: 'Save a valid Discord server ID first.' }, { status: 400 });
const rest = discordRest();
for (const channelId of new Set(Object.values(settings.channels).filter(Boolean))) {
const channel = await rest.request<{ guild_id?: string; type?: number }>(
'GET',
`/channels/${channelId}`
);
if (channel.guild_id !== settings.guildId || ![0, 5].includes(channel.type ?? -1))
return json(
{
error: 'Choose text or announcement channels in the configured Discord server.'
},
{ status: 400 }
);
}
await rest.request(
'PUT',
`/applications/${credentials.applicationId}/guilds/${settings.guildId}/commands`,
discordCommands
);
return json({ ok: true, message: 'Discord commands registered for this server.' });
} catch {
// Discord error bodies and URLs can include tokens; never relay them to the UI.
return json(
{
error: 'Could not register commands. Check the bot credentials, server membership and channel permissions, then try again.'
},
{ status: 502 }
);
}
}
@@ -0,0 +1,721 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { canReadAdmin } from '$lib/auth.js';
import {
discordSnowflake,
type DiscordAdminState,
type DiscordGame,
type DiscordPublicServer,
type DiscordSettings
} from '$lib/discord.js';
import { gameTitle } from '$ui/server-labels.js';
import { countLabel } from '$ui/plural.js';
import { pollVisible } from '$ui/poll.js';
import Button from '$ui/components/primitives/Button.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import Input from '$ui/components/primitives/Input.svelte';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
import RelativeTime from '$ui/components/RelativeTime.svelte';
const games: DiscordGame[] = ['t5', 't6'];
let adminState = $state<DiscordAdminState | null>(null);
let draft = $state<DiscordSettings | null>(null);
let loading = $state(true);
let refreshing = $state(false);
let saving = $state(false);
let registering = $state(false);
let error = $state('');
let actionError = $state('');
let notice = $state('');
let origin = $state('');
let requestVersion = 0;
const administrator = $derived($page.data.user?.role === 'admin');
const canView = $derived(canReadAdmin($page.data.user?.role));
const busy = $derived(saving || registering);
const dirty = $derived(
!!adminState && !!draft && JSON.stringify(draft) !== JSON.stringify(adminState.settings)
);
const credentialsReady = $derived(
!!adminState &&
discordSnowflake(adminState.credentials.applicationId) &&
adminState.credentials.publicKeyConfigured &&
adminState.credentials.botTokenConfigured
);
const unavailableSelections = $derived(
draft?.publishedServers.filter(
(name) => !adminState?.servers.some((server) => server.name === name)
) ?? []
);
onMount(() => {
origin = window.location.origin;
if (!canView) {
loading = false;
return;
}
return pollVisible((signal) => load(signal), 30_000);
});
async function load(signal?: AbortSignal) {
if (busy) return;
const version = ++requestVersion;
refreshing = true;
try {
const response = await fetch('/api/integrations/discord', { signal });
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Could not load Discord settings.');
if (version !== requestVersion) return;
adminState = body as DiscordAdminState;
if (!draft) draft = structuredClone(body.settings);
error = '';
return true;
} catch (cause) {
if (version !== requestVersion || signal?.aborted) return;
error = cause instanceof Error ? cause.message : 'Could not load Discord settings.';
return false;
} finally {
if (version === requestVersion) {
refreshing = false;
loading = false;
}
}
}
function chooseServer(name: string, checked: boolean) {
if (!draft || !administrator) return;
draft.publishedServers = checked
? [...new Set([...draft.publishedServers, name])].sort()
: draft.publishedServers.filter((selected) => selected !== name);
}
function discard() {
if (!adminState || busy) return;
draft = $state.snapshot(adminState.settings);
notice = '';
}
async function save(event: SubmitEvent) {
event.preventDefault();
if (!draft || !administrator || busy) return;
++requestVersion;
refreshing = false;
saving = true;
error = '';
actionError = '';
notice = '';
try {
const response = await fetch('/api/integrations/discord', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(draft)
});
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Could not save Discord settings.');
const saved = body as DiscordAdminState;
adminState = saved;
draft = structuredClone(saved.settings);
notice = saved.settings.enabled
? 'Settings saved. The controller will update your Discord boards.'
: 'Settings saved. Discord is disabled.';
} catch (cause) {
actionError =
cause instanceof Error ? cause.message : 'Could not save Discord settings.';
} finally {
saving = false;
}
}
async function registerCommands() {
if (!administrator || busy || dirty) return;
registering = true;
actionError = '';
notice = '';
try {
const response = await fetch('/api/integrations/discord/register', { method: 'POST' });
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Could not register Discord commands.');
notice = 'Commands registered in your configured Discord server.';
} catch (cause) {
actionError =
cause instanceof Error ? cause.message : 'Could not register Discord commands.';
} finally {
registering = false;
}
await load();
}
function previewState(server: DiscordPublicServer) {
if (server.state === 'empty') return 'empty-lobby';
if (server.state === 'in-progress') return 'running';
return server.state;
}
</script>
<svelte:head><title>Discord · Electric Cherry</title></svelte:head>
<section class="page">
<SectionHeader
title="Discord"
subtitle="Publish live server boards, private browsing, leaderboards, and map availability alerts."
/>
{#if !canView}
<p class="ui-error" role="alert">
Administrator access is required to view this integration.
</p>
{:else}
{#if error}
<div class="feedback">
<p class="ui-error" role="alert">{error}</p>
<Button size="sm" onclick={() => load()} disabled={busy || refreshing}
>Retry loading</Button
>
</div>
{/if}
{#if notice}<p class="notice" role="status">{notice}</p>{/if}
{#if actionError}<p class="ui-error" role="alert">{actionError}</p>{/if}
{#if loading && !adminState}
<p role="status">Loading Discord settings…</p>
{:else if adminState && draft}
{#if !administrator}
<p class="muted">
You have read-only access. An administrator can change this integration.
</p>
{/if}
<div class="layout">
<form class="configuration" onsubmit={save} aria-busy={saving}>
<fieldset class="panel" disabled={!administrator || busy}>
<legend>Connection and channels</legend>
<label class="checkbox">
<input type="checkbox" bind:checked={draft.enabled} />
Enable Discord companion
</label>
<p class="help">
Enabling publishes the selected servers to your configured channels.
</p>
<Field
label="Discord server ID"
hint="Enable Developer Mode in Discord, then right-click your Discord server and copy its ID."
>
<Input
bind:value={draft.guildId}
inputmode="numeric"
pattern={'[1-9][0-9]{16,19}'}
maxlength={20}
autocomplete="off"
mono
/>
</Field>
<div class="two-columns">
{#each games as game}
<Field
label={`${gameTitle(game)} channel ID`}
hint="Leave empty to omit this game’s board."
>
<Input
bind:value={draft.channels[game]}
inputmode="numeric"
pattern={'[1-9][0-9]{16,19}'}
maxlength={20}
autocomplete="off"
mono
/>
</Field>
{/each}
</div>
</fieldset>
<fieldset class="panel" disabled={!administrator || busy}>
<legend>Published servers</legend>
<p class="help">
Only selected servers appear in Discord browsing, boards, and
availability alerts. New servers remain private until selected.
</p>
{#if adminState.servers.length === 0}<p class="muted">
No supported game servers are available.
</p>{/if}
{#each games as game}
{@const choices = adminState.servers.filter(
(server) => server.game === game
)}
{#if choices.length}
<div class="server-group">
<h3>{gameTitle(game)}</h3>
{#each choices as server (server.name)}
<label class="server-choice">
<input
type="checkbox"
checked={draft.publishedServers.includes(
server.name
)}
onchange={(event) =>
chooseServer(
server.name,
event.currentTarget.checked
)}
/>
<span
><strong>{server.label}</strong><small
>{server.name}</small
></span
>
</label>
{/each}
</div>
{/if}
{/each}
{#if unavailableSelections.length}
<div class="server-group">
<h3>No longer in the fleet</h3>
{#each unavailableSelections as name (name)}
<label class="server-choice">
<input
type="checkbox"
checked
onchange={(event) =>
chooseServer(name, event.currentTarget.checked)}
/>
<span
>{name}<small
>Remove this selection to stop publishing it.</small
></span
>
</label>
{/each}
</div>
{/if}
<p class="help">
{countLabel(draft.publishedServers.length, 'server')} selected.
</p>
</fieldset>
<fieldset class="panel" disabled={!administrator || busy}>
<legend>Information shared with members</legend>
<label class="checkbox"
><input type="checkbox" bind:checked={draft.showPlayers} />Show player
names in server details</label
>
<label class="checkbox"
><input type="checkbox" bind:checked={draft.showAddresses} />Show public
server connection addresses</label
>
<label class="checkbox"
><input
type="checkbox"
bind:checked={draft.leaderboardEnabled}
/>Publish fleet leaderboards</label
>
<p class="help">
Leaderboards publish player names and recorded statistics from the
entire fleet, including servers outside the selection above. Keep this
off if those results should stay private.
</p>
</fieldset>
{#if administrator}
<div class="save-bar">
<span class="muted"
>{dirty ? 'Unsaved changes' : 'All changes saved'}</span
>
<div class="actions">
<Button variant="ghost" onclick={discard} disabled={!dirty || busy}
>Discard</Button
>
<Button
type="submit"
variant="primary"
loading={saving}
disabled={(!dirty && !adminState.status.lastError) || busy}
>{adminState.status.lastError
? 'Save and retry'
: 'Save changes'}</Button
>
</div>
</div>
{/if}
</form>
<aside class="details">
<section class="panel" aria-labelledby="setup-heading">
<h2 id="setup-heading">Bot setup</h2>
<p class="help">
Create a Discord application and configure its credentials in the
controller environment. Restart the controller after changing
credentials.
</p>
<dl class="readiness">
<div>
<dt>Application ID</dt>
<dd>
<StatusBadge
state={discordSnowflake(
adminState.credentials.applicationId
)
? 'ready'
: 'manual'}
/>
</dd>
</div>
<div>
<dt>Public key</dt>
<dd>
<StatusBadge
state={adminState.credentials.publicKeyConfigured
? 'ready'
: 'manual'}
/>
</dd>
</div>
<div>
<dt>Bot token</dt>
<dd>
<StatusBadge
state={adminState.credentials.botTokenConfigured
? 'ready'
: 'manual'}
/>
</dd>
</div>
</dl>
{#if adminState.credentials.applicationId}<p class="application-id">
Application ID: <code>{adminState.credentials.applicationId}</code>
</p>{/if}
<Field
label="Interactions endpoint"
hint="Set this HTTPS URL in your application’s Discord Developer Portal."
>
<Input value={`${origin}/api/discord/interactions`} readonly mono />
</Field>
<p class="help">
Install the bot with the bot and applications.commands scopes. Allow
View Channel, Send Messages, and Read Message History in each board
channel.
</p>
{#if administrator}
<Button
onclick={registerCommands}
loading={registering}
disabled={busy ||
dirty ||
!credentialsReady ||
!adminState.settings.guildId}>Register commands</Button
>
<p class="help">
Registers commands in the saved Discord server. Save pending changes
first.
</p>
{/if}
</section>
<section class="panel" aria-labelledby="delivery-heading">
<div class="panel-heading">
<h2 id="delivery-heading">Delivery health</h2>
<StatusBadge
state={!adminState.settings.enabled
? 'disabled'
: adminState.status.lastError
? 'attention'
: adminState.status.lastSuccessAt
? 'ready'
: 'waiting'}
/>
</div>
{#if adminState.status.lastError}<p class="ui-error" role="alert">
{adminState.status.lastError}
</p>{/if}
<dl class="readiness">
<div>
<dt>Last attempt</dt>
<dd>
<RelativeTime
value={adminState.status.lastAttemptAt}
missing="Not recorded"
/>
</dd>
</div>
<div>
<dt>Last success</dt>
<dd>
<RelativeTime
value={adminState.status.lastSuccessAt}
missing="Not recorded"
/>
</dd>
</div>
<div>
<dt>Active watches</dt>
<dd>{adminState.status.activeSubscriptions}</dd>
</div>
<div>
<dt>Pending alerts</dt>
<dd>{adminState.status.pendingNotifications}</dd>
</div>
</dl>
{#each adminState.status.boards as board (board.game)}
<p class="board-link">
<a
href={`https://discord.com/channels/${adminState.settings.guildId}/${board.channelId}`}
target="_blank"
rel="noreferrer">{gameTitle(board.game)} board</a
><span>{countLabel(board.messageCount, 'message')}</span>
</p>
{/each}
<Button
size="sm"
onclick={() => load()}
loading={refreshing}
disabled={busy || refreshing}>Refresh status</Button
>
</section>
</aside>
</div>
<section class="panel preview" aria-labelledby="preview-heading">
<div class="panel-heading">
<h2 id="preview-heading">Published server preview</h2>
<span class="muted">Saved settings</span>
</div>
<p class="help">
This shows the information available to Discord from your saved selection. Empty
slots do not establish whether a game accepts new players.
</p>
{#if !adminState.settings.enabled}<p class="muted">
Discord is disabled. This preview is visible only in Electric Cherry.
</p>{/if}
{#if !adminState.preview.t5.length && !adminState.preview.t6.length}
<p class="muted">Select servers and save your changes to see a preview.</p>
{:else}
<div class="preview-games">
{#each games as game}
{#if adminState.preview[game].length}
<section
class="game-preview"
aria-label={`${gameTitle(game)} preview`}
>
<h3>{gameTitle(game)}</h3>
{#each adminState.preview[game] as server (server.name)}
<article class="preview-server">
<div class="panel-heading">
<strong>{server.label}</strong><StatusBadge
state={previewState(server)}
/>
</div>
<p>
{server.map} · {server.players ?? '?'} / {server.capacity ??
'?'} players{server.round === null
? ''
: ` · Round ${server.round}`}
</p>
{#if server.playerNames.length}<p class="muted">
{server.playerNames.join(', ')}
</p>{/if}
{#if server.connection}<code class="connection"
>{server.connection}</code
>{/if}
<small
>Last observed: <RelativeTime
value={server.observedAt}
missing="Not recorded"
/></small
>
</article>
{/each}
</section>
{/if}
{/each}
</div>
{/if}
</section>
{/if}
{/if}
</section>
<style>
.page,
.configuration,
.details {
display: grid;
gap: var(--space-5);
min-width: 0;
}
.layout {
display: grid;
grid-template-columns: minmax(0, 1.5fr) minmax(300px, 1fr);
gap: var(--space-5);
align-items: start;
}
.panel {
display: grid;
gap: var(--space-4);
min-width: 0;
margin: 0;
padding: var(--space-5);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
background: var(--surface);
}
legend,
h2 {
font-size: var(--text-md);
font-weight: var(--weight-semibold);
margin: 0;
}
legend {
padding: 0 var(--space-2);
}
h3 {
margin: 0;
font-size: var(--text-sm);
font-weight: var(--weight-semibold);
}
p {
margin: 0;
font-size: var(--text-sm);
line-height: 1.6;
}
.help,
.muted,
small,
dt {
color: var(--text-secondary);
}
.help,
small {
font-size: var(--text-xs);
line-height: 1.6;
}
.notice {
color: var(--adminState-online);
}
.checkbox,
.server-choice {
display: flex;
align-items: start;
gap: var(--space-3);
font-size: var(--text-sm);
line-height: 1.5;
}
input[type='checkbox'] {
width: 18px;
height: 18px;
flex: 0 0 18px;
margin: 2px 0 0;
accent-color: var(--accent-primary);
}
input[type='checkbox']:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: 3px;
}
.server-choice span {
display: grid;
min-width: 0;
}
.server-choice strong {
font-weight: var(--weight-medium);
}
.server-choice small {
overflow-wrap: anywhere;
}
.server-group {
display: grid;
gap: var(--space-3);
}
.two-columns,
.preview-games {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-4);
}
.actions,
.panel-heading,
.save-bar,
.feedback {
display: flex;
gap: var(--space-3);
align-items: center;
flex-wrap: wrap;
}
.panel-heading,
.save-bar {
justify-content: space-between;
}
.save-bar {
padding: var(--space-4);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
background: var(--surface-raised);
font-size: var(--text-sm);
}
.readiness {
display: grid;
gap: var(--space-3);
margin: 0;
font-size: var(--text-xs);
}
.readiness > div {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
dd {
margin: 0;
text-align: right;
}
.application-id {
font-size: var(--text-xs);
overflow-wrap: anywhere;
}
.board-link {
display: flex;
gap: var(--space-3);
flex-wrap: wrap;
justify-content: space-between;
font-size: var(--text-xs);
}
.board-link span {
color: var(--text-secondary);
}
.game-preview {
display: grid;
gap: var(--space-3);
align-content: start;
min-width: 0;
}
.preview-server {
display: grid;
gap: var(--space-2);
padding: var(--space-4);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
background: var(--surface-sunken);
overflow-wrap: anywhere;
}
.preview-server strong {
font-size: var(--text-sm);
}
.connection {
font-size: var(--text-xs);
white-space: pre-wrap;
}
@media (max-width: 1100px) {
.layout {
grid-template-columns: minmax(0, 1fr);
}
.details {
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: start;
}
}
@media (max-width: 640px) {
.details,
.two-columns,
.preview-games {
grid-template-columns: minmax(0, 1fr);
}
.panel {
padding: var(--space-4);
}
}
</style>
+229
View File
@@ -0,0 +1,229 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { flushSync, mount, unmount } from 'svelte';
import { writable } from 'svelte/store';
import DiscordPage from '../../src/routes/settings/integrations/discord/+page.svelte';
import type { DiscordAdminState, DiscordSettings } from '../../src/lib/discord.js';
const page = writable({ data: { user: { role: 'admin' } } });
vi.mock('$app/stores', () => ({
page: { subscribe: (run: Parameters<typeof page.subscribe>[0]) => page.subscribe(run) }
}));
let component: ReturnType<typeof mount> | undefined;
beforeEach(() => {
vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible');
});
afterEach(async () => {
if (component) await unmount(component);
component = undefined;
document.body.replaceChildren();
vi.useRealTimers();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
const serverName = 't5_zombie_theater_1';
const guildId = '12345678901234567';
const channelId = '23456789012345678';
function snapshot(): DiscordAdminState {
return {
settings: {
enabled: false,
guildId,
channels: { t5: channelId, t6: '' },
publishedServers: [],
showPlayers: false,
showAddresses: false,
leaderboardEnabled: false
},
credentials: {
applicationId: '34567890123456789',
publicKeyConfigured: true,
botTokenConfigured: true
},
status: {
lastAttemptAt: null,
lastSuccessAt: null,
lastError: null,
boards: [],
activeSubscriptions: 0,
pendingNotifications: 0
},
servers: [
{
name: serverName,
game: 't5',
label: 'Kino der Toten · Lobby 1',
map: 'Kino der Toten'
}
],
preview: { t5: [], t6: [] }
};
}
const text = (node: Element) => node.textContent?.replace(/\s+/g, ' ').trim();
const findButton = (label: string) =>
[...document.querySelectorAll('button')].find((node) => text(node) === label);
const button = (label: string) => {
const node = findButton(label);
expect(node, `Missing button: ${label}`).toBeDefined();
return node!;
};
const checkbox = (label: string) => {
const node = [...document.querySelectorAll('label')].find((node) =>
text(node)?.includes(label)
);
const input = node?.querySelector<HTMLInputElement>('input[type="checkbox"]');
expect(input, `Missing checkbox: ${label}`).toBeTruthy();
return input!;
};
const field = (label: string) => {
const node = [...document.querySelectorAll('label')].find((node) => text(node) === label)!;
const input = document.getElementById(node.htmlFor) as HTMLInputElement;
expect(input?.tagName).toBe('INPUT');
return input;
};
function enter(label: string, value: string) {
const input = field(label);
input.value = value;
input.dispatchEvent(new Event('input', { bubbles: true }));
flushSync();
}
function toggle(label: string) {
checkbox(label).click();
flushSync();
}
async function show(role = 'admin') {
page.set({ data: { user: { role } } });
component = mount(DiscordPage, { target: document.body });
flushSync();
await vi.waitFor(() => {
flushSync();
expect(document.querySelector('form')).not.toBeNull();
});
}
it('saves explicit publication choices without credentials and restores saved values on discard', async () => {
const initial = snapshot();
let submitted: DiscordSettings | undefined;
const fetch = vi.fn(async (_url: string, init?: RequestInit) => {
if (init?.method === 'PUT') {
submitted = JSON.parse(String(init.body)) as DiscordSettings;
return Response.json({ ...initial, settings: submitted });
}
return Response.json(initial);
});
vi.stubGlobal('fetch', fetch);
await show();
expect(checkbox('Enable Discord companion').checked).toBe(false);
expect(checkbox(serverName).checked).toBe(false);
expect(checkbox('Show player names').checked).toBe(false);
expect(checkbox('Show public server connection addresses').checked).toBe(false);
expect(checkbox('Publish fleet leaderboards').checked).toBe(false);
expect(document.body.textContent).toContain('including servers outside the selection above');
toggle('Enable Discord companion');
toggle(serverName);
toggle('Show player names');
toggle('Publish fleet leaderboards');
enter('Discord server ID', '45678901234567890');
expect(button('Register commands').disabled).toBe(true);
button('Save changes').click();
await vi.waitFor(() => expect(document.body.textContent).toContain('Settings saved.'));
expect(submitted).toEqual({
...initial.settings,
enabled: true,
guildId: '45678901234567890',
publishedServers: [serverName],
showPlayers: true,
leaderboardEnabled: true
});
expect(fetch.mock.calls.filter(([, init]) => init?.method === 'PUT')).toHaveLength(1);
expect(fetch.mock.calls.every(([url]) => url === '/api/integrations/discord')).toBe(true);
expect(button('Save changes').disabled).toBe(true);
toggle('Show player names');
toggle(serverName);
button('Discard').click();
flushSync();
expect(checkbox('Show player names').checked).toBe(true);
expect(checkbox(serverName).checked).toBe(true);
expect(button('Save changes').disabled).toBe(true);
});
it('keeps Super Viewer controls read-only and rejects a directly submitted mutation', async () => {
const fetch = vi.fn(async (_url: string, _init?: RequestInit) => Response.json(snapshot()));
vi.stubGlobal('fetch', fetch);
await show('super_viewer');
expect(document.body.textContent).toContain('You have read-only access.');
for (const group of document.querySelectorAll('fieldset')) expect(group.disabled).toBe(true);
expect(findButton('Save changes')).toBeUndefined();
expect(findButton('Register commands')).toBeUndefined();
document
.querySelector('form')!
.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
flushSync();
expect(fetch).toHaveBeenCalledTimes(1);
button('Refresh status').click();
await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
expect(fetch.mock.calls.every((args) => !(args[1] as RequestInit | undefined)?.method)).toBe(
true
);
});
it('preserves publication edits and the last snapshot across polling failures and recovery', async () => {
vi.useFakeTimers();
const initial = snapshot();
const updated = snapshot();
updated.status.activeSubscriptions = 3;
const fetch = vi
.fn()
.mockResolvedValueOnce(Response.json(initial))
.mockRejectedValueOnce(new Error('Discord status unavailable.'))
.mockResolvedValueOnce(Response.json(updated));
vi.stubGlobal('fetch', fetch);
await show();
toggle(serverName);
enter('Discord server ID', '45678901234567890');
await vi.advanceTimersByTimeAsync(30_000);
flushSync();
expect(document.querySelector('[role="alert"]')?.textContent).toContain(
'Discord status unavailable.'
);
expect(document.body.textContent).toContain('Kino der Toten · Lobby 1');
expect(checkbox(serverName).checked).toBe(true);
expect(field('Discord server ID').value).toBe('45678901234567890');
button('Retry loading').click();
await vi.waitFor(() => expect(document.querySelector('[role="alert"]')).toBeNull());
expect(checkbox(serverName).checked).toBe(true);
expect(field('Discord server ID').value).toBe('45678901234567890');
expect(button('Save changes').disabled).toBe(false);
expect(document.body.textContent).toContain('Unsaved changes');
});
it('registers commands only on an explicit action and retains its error through successful refreshes', async () => {
const fetch = vi.fn(async (url: string) =>
url.endsWith('/register')
? Response.json({ error: 'Command registration denied.' }, { status: 502 })
: Response.json(snapshot())
);
vi.stubGlobal('fetch', fetch);
await show();
expect(fetch.mock.calls.some(([url]) => url.endsWith('/register'))).toBe(false);
button('Register commands').click();
await vi.waitFor(() =>
expect(document.body.textContent).toContain('Command registration denied.')
);
await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(3));
expect(fetch.mock.calls[1]).toEqual(['/api/integrations/discord/register', { method: 'POST' }]);
button('Refresh status').click();
await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(4));
expect(document.querySelector('[role="alert"]')?.textContent).toContain(
'Command registration denied.'
);
});
+417
View File
@@ -0,0 +1,417 @@
import Database from 'better-sqlite3';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
defaultDiscordSettings,
type DiscordMessage,
type DiscordPublicServer
} from '../../src/lib/discord.js';
import { migrateDiscord } from '../../src/lib/server/discord/schema.js';
import { DiscordStore } from '../../src/lib/server/discord/store.js';
import {
DiscordDeliveryError,
DiscordRest,
type DiscordTransport
} from '../../src/lib/server/discord/transport.js';
import { DiscordWorker } from '../../src/lib/server/discord/worker.js';
const guildId = '12345678901234567';
const channelId = '23456789012345678';
const userId = '34567890123456789';
const messageId = '45678901234567890';
const dmId = '56789012345678901';
let handle: Database.Database;
let store: DiscordStore;
let now: number;
let server: DiscordPublicServer;
const message = (content: string): DiscordMessage => ({ content, allowed_mentions: { parse: [] } });
const watchInput = {
guildId,
userId,
game: 't5' as const,
mapKey: 't5:zombie_theater',
mode: 'empty' as const
};
beforeEach(() => {
handle = new Database(':memory:');
migrateDiscord(handle);
now = Date.parse('2026-10-18T20:00:00Z');
store = new DiscordStore(handle, () => now);
store.saveSettings({
...defaultDiscordSettings,
enabled: true,
guildId,
channels: { t5: channelId, t6: '' },
publishedServers: ['kino']
});
server = {
name: 'kino',
game: 't5',
label: 'Kino lobby',
map: 'Kino der Toten',
mapKey: watchInput.mapKey,
state: 'empty',
players: 0,
capacity: 4,
round: 1,
observedAt: new Date(now).toISOString(),
playerNames: [],
connection: null
};
});
afterEach(() => {
handle.close();
vi.restoreAllMocks();
});
function worker(rest: DiscordTransport, render = () => [message('Public board')]) {
return new DiscordWorker({
store,
transport: () => rest,
fleet: (settings) => (settings.publishedServers.includes(server.name) ? [server] : []),
render,
now: () => now
});
}
function transport(handler?: (method: string, path: string, body?: unknown) => unknown) {
const request = vi.fn(async (method: string, path: string, body?: unknown) => {
if (handler) {
const result = await handler(method, path, body);
if (result !== undefined) return result;
}
if (path === `/channels/${channelId}`) return { guild_id: guildId, type: 0 };
if (path === '/users/@me/channels') return { id: dmId };
return { id: messageId };
});
return { request, rest: { request } as DiscordTransport };
}
function advance() {
now += 30_000;
server.observedAt = new Date(now).toISOString();
}
describe('Discord delivery storage', () => {
it('retains interaction replay claims after cancellation and database reopen, then prunes expiry', () => {
const interactionId = '67890123456789012';
expect(store.claimInteraction(interactionId)).toBe(true);
const watch = store.subscribe(watchInput);
store.cancel(watch.id, guildId, userId);
const serialized = handle.serialize();
handle.close();
handle = new Database(serialized);
store = new DiscordStore(handle, () => now);
expect(store.claimInteraction(interactionId)).toBe(false);
expect(store.subscriptions(userId, guildId)).toHaveLength(0);
now += 15 * 60_000;
expect(store.claimInteraction(interactionId)).toBe(true);
expect(() => store.claimInteraction('not-a-snowflake')).toThrow(
'Invalid Discord interaction'
);
});
it('replays the migration and persists settings, board IDs, and opt-in watches', () => {
const watch = store.subscribe(watchInput);
store.reconcileBoards([{ game: 't5', guildId, channelId, messages: [message('Board')] }]);
store.delivered(store.jobs()[0], messageId);
const serialized = handle.serialize();
handle.close();
handle = new Database(serialized);
const schemaVersion = handle.pragma('schema_version', { simple: true });
migrateDiscord(handle);
expect(handle.pragma('schema_version', { simple: true })).toBe(schemaVersion);
store = new DiscordStore(handle, () => now);
expect(store.settings().guildId).toBe(guildId);
expect(store.boards()[0].messageId).toBe(messageId);
expect(store.subscriptions(userId, guildId)[0].id).toBe(watch.id);
});
it('deduplicates watches, bounds each member, and scopes cancellation to the owner and guild', () => {
const watch = store.subscribe(watchInput);
expect(store.subscribe(watchInput)).toEqual(watch);
for (let i = 1; i < 10; i++) store.subscribe({ ...watchInput, mapKey: `t5:map_${i}` });
expect(() => store.subscribe({ ...watchInput, mapKey: 't5:eleventh' })).toThrow(
'10 active'
);
expect(store.cancel(watch.id, guildId, '99999999999999999')).toBe(false);
expect(store.cancel(watch.id, '99999999999999999', userId)).toBe(false);
expect(store.cancel(watch.id, guildId, userId)).toBe(true);
expect(store.subscriptions(userId, guildId)).toHaveLength(9);
});
it('expires watches and their pending notifications, and bounds durable work atomically', () => {
const watch = store.subscribe(watchInput);
store.queueAlert(store.subscription(watch.id)!, message('Alert'));
expect(store.jobs()).toHaveLength(1);
now += 24 * 60 * 60_000;
store.expireSubscriptions();
expect(store.subscription(watch.id)?.state).toBe('expired');
expect(store.jobs()).toHaveLength(0);
expect(() =>
store.reconcileBoards([
{
game: 't5',
guildId,
channelId,
messages: Array.from({ length: 501 }, (_, i) => message(String(i)))
}
])
).toThrow('queue is full');
expect(store.boards()).toHaveLength(0);
});
});
describe('Discord board worker', () => {
it('retires the original guild board before rejecting a replacement in the wrong guild', async () => {
const { rest, request } = transport();
const instance = worker(rest);
await instance.tick();
const original = store.boards()[0];
expect(original.guildId).toBe(guildId);
store.saveSettings({ ...store.settings(), guildId: '99999999999999999' });
// The message retains its original guild until the deletion is reconciled.
expect(store.board(original.id)?.guildId).toBe(guildId);
request.mockClear();
await instance.tick();
expect(request.mock.calls.map(([method]) => method)).toEqual(['DELETE', 'GET']);
expect(request.mock.calls[0][1]).toBe(`/channels/${channelId}/messages/${messageId}`);
expect(store.board(original.id)).toBeUndefined();
expect(store.boards()).toEqual([
expect.objectContaining({ guildId: '99999999999999999', messageId: null })
]);
expect(store.status().lastError).toContain('does not belong');
});
it('creates split boards once, survives a worker restart, and skips unchanged edits', async () => {
const { rest, request } = transport();
const render = () => [message('Page 1'), message('Page 2')];
await worker(rest, render).tick();
expect(request.mock.calls.filter(([method]) => method === 'POST')).toHaveLength(2);
expect(store.boards()).toHaveLength(2);
request.mockClear();
await worker(rest, render).tick();
expect(request).not.toHaveBeenCalled();
await worker(rest, () => [message('Changed page')]).tick();
expect(request.mock.calls.some(([method]) => method === 'PATCH')).toBe(true);
expect(request.mock.calls.some(([method]) => method === 'DELETE')).toBe(true);
expect(store.boards()).toHaveLength(1);
});
it('recreates deleted messages with a new nonce while preserving other board pages', async () => {
let missing = false;
const { rest, request } = transport((method, path) => {
if (missing && method === 'PATCH' && path.includes('/messages/'))
throw new DiscordDeliveryError('Missing message.', 404, null, 10008);
});
await worker(rest).tick();
const firstNonce = (
request.mock.calls.find(([method]) => method === 'POST')![2] as { nonce: string }
).nonce;
missing = true;
request.mockClear();
await worker(rest, () => [message('Updated')]).tick();
const secondNonce = (
request.mock.calls.find(([method]) => method === 'POST')![2] as { nonce: string }
).nonce;
expect(secondNonce).not.toBe(firstNonce);
expect(store.boards()[0].messageId).toBe(messageId);
expect(store.jobs()).toHaveLength(0);
});
it('detects unchanged messages deleted externally during periodic verification', async () => {
let missing = false;
const { rest, request } = transport((method, path) => {
if (missing && method === 'GET' && path.includes('/messages/'))
throw new DiscordDeliveryError('Missing message.', 404, null, 10008);
});
const instance = worker(rest);
await instance.tick();
now += 16 * 60_000;
missing = true;
await instance.tick();
expect(store.boards()[0].messageId).toBeNull();
request.mockClear();
await instance.tick();
expect(request.mock.calls.some(([method]) => method === 'POST')).toBe(true);
});
it('refuses channels outside the allowed guild and pauses permission failures until retry', async () => {
const { rest, request } = transport((method, path) =>
method === 'GET' && path === `/channels/${channelId}`
? { guild_id: '99999999999999999', type: 0 }
: undefined
);
const instance = worker(rest);
await instance.tick();
expect(request.mock.calls.some(([method]) => method === 'POST')).toBe(false);
expect(store.status().lastError).toContain('does not belong');
request.mockClear();
await instance.tick();
expect(request).not.toHaveBeenCalled();
store.retryFailed();
await instance.tick();
expect(request).toHaveBeenCalledOnce();
});
it('checks settings again after channel lookup and removes board data when disabled', async () => {
const { rest, request } = transport();
const instance = worker(rest);
await instance.tick();
store.saveSettings({ ...store.settings(), enabled: false });
request.mockClear();
await instance.tick();
expect(request.mock.calls.map(([method]) => method)).toEqual(['DELETE']);
expect(store.boards()).toHaveLength(0);
store.saveSettings({ ...store.settings(), enabled: true });
const disabling = transport((method) => {
if (method === 'GET') store.saveSettings({ ...store.settings(), enabled: false });
});
await worker(disabling.rest).tick();
expect(disabling.request.mock.calls.some(([method]) => method === 'POST')).toBe(false);
});
it('persists rate-limit cooldowns without blocking the tick or creating overlapping runs', async () => {
let limited = true;
const { rest, request } = transport((method) => {
if (method === 'POST' && limited)
throw new DiscordDeliveryError('Rate limited.', 429, now + 120_000);
});
const instance = worker(rest);
expect(instance.tick()).toBe(instance.tick());
await instance.tick();
request.mockClear();
advance();
await instance.tick();
expect(request).not.toHaveBeenCalled();
now += 120_000;
limited = false;
await instance.tick();
expect(store.boards()[0].messageId).toBe(messageId);
});
});
describe('Discord one-time availability notifications', () => {
it('requires stable fresh observations and delivers only one private DM without mentions', async () => {
const watch = store.subscribe(watchInput);
const { rest, request } = transport();
const instance = worker(rest, () => []);
await instance.tick();
expect(request).not.toHaveBeenCalled();
advance();
await instance.tick();
expect(request.mock.calls.map(([, path]) => path)).toEqual([
'/users/@me/channels',
`/channels/${dmId}/messages`
]);
expect(request.mock.calls[1][2]).toMatchObject({
allowed_mentions: { parse: [] },
enforce_nonce: true
});
expect(store.subscription(watch.id)?.state).toBe('delivered');
request.mockClear();
advance();
await instance.tick();
expect(request).not.toHaveBeenCalled();
});
it('does not notify on stale data or unknown capacity for a space watch', async () => {
store.subscribe({ ...watchInput, mode: 'space' });
const { rest, request } = transport();
const instance = worker(rest, () => []);
server.capacity = null;
await instance.tick();
advance();
await instance.tick();
expect(request).not.toHaveBeenCalled();
server.capacity = 4;
server.state = 'unknown';
advance();
await instance.tick();
expect(request).not.toHaveBeenCalled();
server.state = 'empty';
server.observedAt = new Date(now - 100_000).toISOString();
await instance.tick();
expect(request).not.toHaveBeenCalled();
});
it('withdraws queued alerts if a server is unpublished while the DM channel opens', async () => {
const watch = store.subscribe(watchInput);
const { rest, request } = transport((method, path) => {
if (path === '/users/@me/channels')
store.saveSettings({ ...store.settings(), publishedServers: [] });
});
const instance = worker(rest, () => []);
await instance.tick();
advance();
await instance.tick();
expect(request.mock.calls).toHaveLength(1);
expect(store.subscription(watch.id)?.state).toBe('active');
expect(store.jobs()).toHaveLength(0);
});
it('stops retrying forbidden DMs and exposes the failure in delivery health', async () => {
const watch = store.subscribe(watchInput);
const { rest, request } = transport((method, path) => {
if (path.includes('/messages'))
throw new DiscordDeliveryError('Recipient does not accept direct messages.', 403);
});
const instance = worker(rest, () => []);
await instance.tick();
advance();
await instance.tick();
expect(store.subscription(watch.id)?.state).toBe('failed');
expect(store.status().lastError).toContain('direct messages');
request.mockClear();
advance();
await instance.tick();
expect(request).not.toHaveBeenCalled();
});
});
describe('Discord REST transport', () => {
it('honors global retry_after and route reset headers without network calls during cooldown', async () => {
const fetcher = vi.fn(
async () =>
new Response(JSON.stringify({ retry_after: 12.5, global: true }), { status: 429 })
);
const rest = new DiscordRest('private-token', fetcher, () => now);
await expect(
rest.request('POST', `/channels/${channelId}/messages`, {})
).rejects.toMatchObject({ status: 429, retryAt: now + 12_500 });
await expect(rest.request('GET', `/channels/${dmId}`)).rejects.toMatchObject({
status: 429
});
expect(fetcher).toHaveBeenCalledOnce();
now += 13_000;
fetcher.mockImplementation(
async () =>
new Response('{"id":"123"}', {
headers: {
'X-RateLimit-Remaining': '0',
'X-RateLimit-Reset-After': '3',
'X-RateLimit-Bucket': 'messages'
}
})
);
await rest.request('POST', `/channels/${channelId}/messages`, {});
await expect(
rest.request('POST', `/channels/${channelId}/messages`, {})
).rejects.toMatchObject({ retryAt: now + 3000 });
expect(fetcher).toHaveBeenCalledTimes(2);
});
it('never exposes response bodies or tokens and stops HTTP requests after invalid credentials', async () => {
const fetcher = vi.fn(
async () => new Response('{"message":"private response data"}', { status: 401 })
);
const rest = new DiscordRest('private-token', fetcher, () => now);
await expect(rest.request('GET', `/channels/${channelId}`)).rejects.toThrow(
'credentials were rejected'
);
await expect(rest.request('GET', `/channels/${channelId}`)).rejects.not.toThrow('private');
expect(fetcher).toHaveBeenCalledOnce();
await expect(rest.request('GET', 'https://attacker.invalid')).rejects.toThrow(
'Invalid Discord API path'
);
expect(fetcher).toHaveBeenCalledOnce();
});
});
+471
View File
@@ -0,0 +1,471 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
defaultDiscordSettings,
type DiscordMessage,
type DiscordSettings
} from '../../src/lib/discord.js';
import type { FleetServer } from '../../src/lib/fleet-overview.js';
import type { LeaderboardResponse } from '../../src/lib/leaderboard.js';
import { gameEvent } from '../helpers/telemetry.js';
vi.mock('../../src/lib/server/fleet/snapshot.js', () => ({ fleetSnapshot: vi.fn() }));
vi.mock('../../src/lib/server/reports/index.js', () => ({ leaderboardReport: vi.fn() }));
vi.mock('../../src/lib/server/discord/store.js', () => ({
getDiscordSettings: vi.fn(),
subscribeToMap: vi.fn(),
listSubscriptions: vi.fn(),
cancelSubscription: vi.fn()
}));
import { fleetSnapshot } from '../../src/lib/server/fleet/snapshot.js';
import { leaderboardReport } from '../../src/lib/server/reports/index.js';
import {
getDiscordSettings,
subscribeToMap,
listSubscriptions,
cancelSubscription
} from '../../src/lib/server/discord/store.js';
import {
discordServerKey,
discordText,
projectFleet
} from '../../src/lib/server/discord/projection.js';
import {
browserId,
filterServers,
renderBoard,
renderBrowser,
renderServer
} from '../../src/lib/server/discord/render.js';
import {
handleDiscordInteraction,
type DiscordInteraction
} from '../../src/lib/server/discord/interactions.js';
import { discordCommands, discordMaps } from '../../src/lib/server/discord/commands.js';
const now = Date.parse('2026-10-18T20:00:00Z');
const stamp = new Date(now - 10000).toISOString();
const guildId = '111111111111111111';
const userId = '222222222222222222';
const applicationId = '333333333333333333';
const name = 't5_zombie_theater_1';
let settings: DiscordSettings;
function server(overrides: Partial<FleetServer> = {}): FleetServer {
return {
name,
game: 't5',
map: 'zombie_theater',
map_slug: 'theater',
game_mode: 'zombie',
health: 'online',
last_players: 2,
capacity: 4,
capacity_observed_at: stamp,
last_map: 'zombie_theater',
last_gametype: 'zombie',
last_seen_at: stamp,
last_error: null,
held: [],
active_since: new Date(now - 60000).toISOString(),
inactive_since: null,
game_started_at: new Date(now - 60000).toISOString(),
game_ended_at: null,
roster: [
{
name: '@everyone <@123> **spoof**',
guid: 'private-guid',
score: 1000,
num: 0,
ping: 10
}
],
telemetry: { ...gameEvent('heartbeat', 28, 2), receivedAt: stamp },
...overrides
};
}
function interaction(data: DiscordInteraction['data'], type = 2): DiscordInteraction {
return {
type,
application_id: applicationId,
guild_id: guildId,
member: { user: { id: userId } },
data
};
}
function components(message: DiscordMessage): Record<string, unknown>[] {
const result: Record<string, unknown>[] = [];
const visit = (component: Record<string, unknown>) => {
result.push(component);
for (const child of (component.components ?? []) as Record<string, unknown>[]) visit(child);
if (component.accessory) visit(component.accessory as Record<string, unknown>);
};
for (const component of message.components ?? []) visit(component);
return result;
}
function messageText(message: DiscordMessage): string {
return components(message)
.map((component) => component.content ?? '')
.join('\n');
}
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
vi.setSystemTime(now);
vi.stubEnv('EC_DISCORD_APPLICATION_ID', applicationId);
settings = {
...structuredClone(defaultDiscordSettings),
enabled: true,
guildId,
channels: { t5: '444444444444444444', t6: '' },
publishedServers: [name]
};
vi.mocked(getDiscordSettings).mockImplementation(() => settings);
vi.mocked(fleetSnapshot).mockReturnValue({ servers: [server()] } as ReturnType<
typeof fleetSnapshot
>);
vi.mocked(subscribeToMap).mockReturnValue({ id: 'watch-123', expiresAt: now + 86400000 });
vi.mocked(listSubscriptions).mockReturnValue([]);
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllEnvs();
});
describe('Discord publication boundary', () => {
it('projects only opted-in servers and explicitly approved fields', () => {
const raw = {
...server(),
host: { id: 'internal-host' },
endpoint: { host: 'secret-rcon', port: 28960 },
network: { publicAddress: 'games.example.com', publicPort: 28960 },
secret: 'token-value'
};
const projected = projectFleet(
[raw as unknown as FleetServer, server({ name: 'unpublished' })],
settings,
now
);
expect(projected).toHaveLength(1);
expect(projected[0]).toMatchObject({
name,
state: 'in-progress',
players: 2,
capacity: 4,
round: 28,
mapKey: 't5:zombie_theater',
playerNames: [],
connection: null
});
for (const secret of [
'internal-host',
'secret-rcon',
'private-guid',
'token-value',
'@everyone'
])
expect(JSON.stringify(projected)).not.toContain(secret);
const optedIn = projectFleet(
[raw as unknown as FleetServer],
{ ...settings, showPlayers: true, showAddresses: true },
now
)[0];
expect(optedIn.playerNames).toHaveLength(1);
expect(optedIn.connection).toBe('games.example.com:28960');
expect(JSON.stringify(optedIn)).not.toContain('private-guid');
expect(
projectFleet([server()], { ...settings, showAddresses: true }, now)[0].connection
).toBeNull();
});
it.each([
[{ last_players: 0 }, 'empty', 0],
[{ last_players: 4 }, 'full', 4],
[{ last_players: null }, 'unknown', null],
[{ health: 'starting' }, 'starting', null],
[{ health: 'offline' }, 'offline', null],
[{ health: 'stale' }, 'stale', null],
[{ last_seen_at: new Date(now - 100000).toISOString() }, 'stale', null],
[{ last_error: 'timeout: secret diagnostics' }, 'stale', null]
] as const)('reports truthful state for %j', (change, state, players) => {
const projected = projectFleet([server(change)], settings, now)[0];
expect(projected).toMatchObject({ state, players });
if (players === null)
expect(projected).toMatchObject({ capacity: null, round: null, playerNames: [] });
});
it('never treats stale or previous-process capacity as an available space', () => {
for (const change of [
{ capacity_observed_at: new Date(now - 100000).toISOString() },
{ telemetry_after: new Date(now - 5000).toISOString() }
]) {
const projected = projectFleet([server(change)], settings, now);
expect(projected[0].capacity).toBeNull();
expect(filterServers(projected, { status: 'space' })).toEqual([]);
}
});
it('preserves T6 variants and drops registered variants after a live map change', () => {
const town = server({
name: 't6_zombie_town_1',
game: 't6',
map: 'zm_transit',
last_map: 'zm_transit',
map_slug: 'town',
telemetry: undefined
});
const setup = { ...settings, publishedServers: [town.name] };
expect(projectFleet([town], setup, now)[0]).toMatchObject({
map: 'Town Survival',
mapKey: 't6:zm_transit:town'
});
expect(projectFleet([{ ...town, last_map: 'zm_tomb' }], setup, now)[0]).toMatchObject({
map: 'Origins',
mapKey: 't6:zm_tomb:tomb'
});
});
it('neutralizes mentions, markdown links and control characters in rendered player names', () => {
const projected = projectFleet([server()], { ...settings, showPlayers: true }, now)[0];
const message = renderServer(projected);
expect(message.allowed_mentions).toEqual({ parse: [] });
expect(messageText(message)).not.toContain('@everyone');
expect(messageText(message)).not.toContain('<@123>');
expect(discordText('[link](https://example.com)\n@here')).toBe(
'\\[link\\]\\(https://example.com\\) @\u200bhere'
);
expect(JSON.stringify(message)).not.toContain('private-guid');
});
});
describe('native Discord messages', () => {
it('paginates large boards within Discord limits with unique component IDs', () => {
const fleet = Array.from({ length: 100 }, (_, i) => ({
...projectFleet([server()], settings, now)[0],
name: `server-${i}`,
label: '*'.repeat(128),
map: '_'.repeat(255),
players: Number.MAX_SAFE_INTEGER,
capacity: Number.MAX_SAFE_INTEGER,
round: Number.MAX_SAFE_INTEGER
}));
const boards = renderBoard('t5', fleet);
expect(boards).toHaveLength(13);
for (const message of [
...boards,
renderBrowser(fleet),
renderBrowser(fleet, { page: 1 })
]) {
const all = components(message);
expect(all.length).toBeLessThanOrEqual(40);
expect(messageText(message).length).toBeLessThanOrEqual(4000);
const ids = all.flatMap((component) =>
typeof component.custom_id === 'string' ? [component.custom_id] : []
);
expect(new Set(ids).size).toBe(ids.length);
expect(ids.every((id) => id.length <= 100)).toBe(true);
expect(message).not.toHaveProperty('content');
expect(message).not.toHaveProperty('embeds');
}
expect(boards.every((message) => message.flags === 32768)).toBe(true);
expect(renderBrowser(fleet).flags).toBe(32832);
});
it('defines only supported guild commands with bounded choices and required options first', () => {
expect(discordCommands.map((command) => command.name)).toEqual([
'servers',
'server',
'leaderboard',
'watch',
'help'
]);
expect(JSON.stringify(discordCommands)).not.toContain('autocomplete');
interface Schema {
name: string;
description: string;
required?: boolean;
options?: Schema[];
choices?: { name: string; value: string | number }[];
}
const inspect = (entries: Schema[]) => {
expect(entries.length).toBeLessThanOrEqual(25);
expect(new Set(entries.map((entry) => entry.name)).size).toBe(entries.length);
let optional = false;
for (const entry of entries) {
expect(entry.name).toMatch(/^[a-z_-]{1,32}$/);
expect(entry.description.length).toBeLessThanOrEqual(100);
if (entry.required) expect(optional).toBe(false);
else optional = true;
if (entry.options) inspect(entry.options);
if (entry.choices) {
expect(entry.choices.length).toBeLessThanOrEqual(25);
for (const choice of entry.choices) {
expect(choice.name.length).toBeLessThanOrEqual(100);
expect(String(choice.value).length).toBeLessThanOrEqual(100);
}
}
}
};
inspect(discordCommands);
for (const map of discordMaps) {
for (const status of ['all', 'empty', 'space', 'full', 'in-progress'] as const) {
expect(
`${browserId({ map: map.value, status, page: 100000 })}:next`.length
).toBeLessThanOrEqual(100);
}
}
expect(
discordCommands.find((command) => command.name === 'watch')?.options?.[0]
).toMatchObject({
name: 'add',
options: [
expect.objectContaining({ name: 'game', required: true }),
expect.objectContaining({ name: 'map', required: true }),
expect.objectContaining({ name: 'condition', required: true })
]
});
});
});
describe('Discord interaction isolation', () => {
it.each(['guild', 'user', 'application', 'disabled'] as const)(
'rejects unauthorized %s before reading fleet or reports',
async (failure) => {
const input = interaction({ name: 'servers' });
if (failure === 'guild') input.guild_id = '555555555555555555';
if (failure === 'user') input.member = {};
if (failure === 'application') input.application_id = '555555555555555555';
if (failure === 'disabled') settings.enabled = false;
const result = await handleDiscordInteraction(input);
expect(result.type).toBe(4);
expect(result.data.flags).toBe(32832);
expect(fleetSnapshot).not.toHaveBeenCalled();
expect(leaderboardReport).not.toHaveBeenCalled();
}
);
it('opens board details privately and refuses old unpublished IDs', async () => {
const result = await handleDiscordInteraction(
interaction({ custom_id: `ec:d:${discordServerKey(name)}` }, 3)
);
expect(result.type).toBe(4);
expect(result.data.flags).toBe(32832);
expect(messageText(result.data)).toContain('Round 28');
settings.publishedServers = [];
expect(
messageText(
(
await handleDiscordInteraction(
interaction({ custom_id: `ec:d:${discordServerKey(name)}` }, 3)
)
).data
)
).toContain('no longer published');
});
it('keeps filtered pagination private and rejects forged filters', async () => {
const result = await handleDiscordInteraction(
interaction(
{
custom_id: browserId({
game: 't5',
map: 't5:zombie_theater',
status: 'empty',
page: 900
})
},
3
)
);
expect(result.data.flags).toBe(32832);
expect(messageText(result.data)).toContain('0 matching servers');
const bad = await handleDiscordInteraction(interaction({ custom_id: 'ec:b:t7:all:0:' }, 3));
expect(messageText(bad.data)).toContain('invalid');
});
it('creates one-shot watches only for published maps and scopes cancellation to the actor', async () => {
const result = await handleDiscordInteraction(
interaction({ custom_id: `ec:w:empty:${discordServerKey('t5:zombie_theater')}` }, 3)
);
expect(subscribeToMap).toHaveBeenCalledWith({
guildId,
userId,
game: 't5',
mapKey: 't5:zombie_theater',
mode: 'empty'
});
expect(messageText(result.data)).toContain('one DM');
vi.mocked(subscribeToMap).mockClear();
settings.publishedServers = [];
await handleDiscordInteraction(
interaction({ custom_id: `ec:w:space:${discordServerKey('t5:zombie_theater')}` }, 3)
);
expect(subscribeToMap).not.toHaveBeenCalled();
await handleDiscordInteraction(
interaction({
name: 'watch',
options: [{ name: 'cancel', options: [{ name: 'id', value: 'other-watch' }] }]
})
);
expect(cancelSubscription).toHaveBeenCalledWith('other-watch', guildId, userId);
});
it('requires explicit leaderboard publication and reuses the qualified report query without leaking identities', async () => {
const input = interaction({
name: 'leaderboard',
options: [
{ name: 'game', value: 't5' },
{ name: 'map', value: 't5:zombie_theater' },
{ name: 'metric', value: 'highestRound' },
{ name: 'squad', value: 2 },
{ name: 'page', value: 3 }
]
});
await handleDiscordInteraction(input);
expect(leaderboardReport).not.toHaveBeenCalled();
settings.leaderboardEnabled = true;
vi.mocked(leaderboardReport).mockResolvedValue({
metric: 'highestRound',
offset: 20,
total: 21,
limit: 10,
maps: [{ key: 't5:zombie_theater', name: 'Kino Der Toten' }],
entries: [
{
rank: 21,
name: '@everyone',
guid: 'private-guid',
highestRound: 30,
records: { server: 'private-server' }
}
]
} as unknown as LeaderboardResponse);
const result = await handleDiscordInteraction(input);
expect(leaderboardReport).toHaveBeenCalledWith({
game: 't5',
map: 't5:zombie_theater',
metric: 'highestRound',
players: 2,
offset: 20,
limit: 10
});
expect(messageText(result.data)).toContain('Completed matches');
expect(messageText(result.data)).not.toContain('@everyone');
expect(JSON.stringify(result)).not.toContain('private-guid');
expect(JSON.stringify(result)).not.toContain('private-server');
});
it('drops an in-flight leaderboard when publication is disabled', async () => {
settings.leaderboardEnabled = true;
vi.mocked(leaderboardReport).mockImplementation(async () => {
settings.leaderboardEnabled = false;
return {} as LeaderboardResponse;
});
const result = await handleDiscordInteraction(interaction({ name: 'leaderboard' }));
expect(messageText(result.data)).toContain('no longer enabled');
});
});
+250
View File
@@ -0,0 +1,250 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { generateKeyPairSync, sign } from 'node:crypto';
import { defaultDiscordSettings, validateDiscordSettings } from '../../src/lib/discord.js';
import { routePermission, roleAllows } from '../../src/lib/server/auth/access.js';
import {
completeDiscordInteraction,
validInteractionEnvelope,
verifiedDiscordBody
} from '../../src/lib/server/discord/webhook.js';
const { privateKey, publicKey } = generateKeyPairSync('ed25519');
const applicationId = '123456789012345678';
const guildId = '223456789012345678';
const userId = '323456789012345678';
const settings = vi.hoisted(() => ({ enabled: true, guildId: '223456789012345678' }));
const handle = vi.hoisted(() => vi.fn());
vi.mock('../../src/lib/server/discord/store.js', () => ({
getDiscordSettings: () => settings,
claimDiscordInteraction: () => true
}));
vi.mock('../../src/lib/server/discord/interactions.js', () => ({
handleDiscordInteraction: handle
}));
function signedRequest(body: unknown, timestamp = String(Math.floor(Date.now() / 1000))) {
const raw = typeof body === 'string' ? body : JSON.stringify(body);
const signature = sign(null, Buffer.from(timestamp + raw), privateKey).toString('hex');
return new Request('https://ec.example/api/discord/interactions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Signature-Ed25519': signature,
'X-Signature-Timestamp': timestamp
},
body: raw
});
}
const interaction = () => ({
id: '423456789012345678',
application_id: applicationId,
guild_id: guildId,
token: 'private-interaction-token',
type: 2,
member: { user: { id: userId } },
data: { name: 'servers' }
});
beforeEach(() => {
vi.resetModules();
vi.stubEnv(
'EC_DISCORD_PUBLIC_KEY',
publicKey.export({ type: 'spki', format: 'der' }).subarray(-32).toString('hex')
);
vi.stubEnv('EC_DISCORD_APPLICATION_ID', applicationId);
settings.enabled = true;
handle.mockReset().mockResolvedValue({
type: 4,
data: { flags: 64, content: 'Private results', allowed_mentions: { parse: [] } }
});
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllEnvs();
vi.unstubAllGlobals();
});
describe('Discord signed webhook boundary', () => {
it('verifies original bytes and accepts signed PING before enabling a bot', async () => {
expect(await verifiedDiscordBody(signedRequest({ type: 1 }))).toEqual({ type: 1 });
settings.enabled = false;
const { POST } = await import('../../src/routes/api/discord/interactions/+server.js');
expect(await (await POST({ request: signedRequest({ type: 1 }) })).json()).toEqual({
type: 1
});
expect(handle).not.toHaveBeenCalled();
});
it('rejects tampering, missing signatures and timestamps outside the replay window', async () => {
const original = signedRequest({ type: 1 });
const altered = new Request(original.url, {
method: 'POST',
headers: original.headers,
body: '{"type":2}'
});
await expect(verifiedDiscordBody(altered)).rejects.toMatchObject({ status: 401 });
await expect(
verifiedDiscordBody(new Request(original.url, { method: 'POST', body: '{}' }))
).rejects.toMatchObject({ status: 401 });
for (const offset of [-360_000, 360_000])
await expect(
verifiedDiscordBody(
signedRequest({ type: 1 }, String(Math.floor((Date.now() + offset) / 1000)))
)
).rejects.toMatchObject({ status: 401 });
});
it('bounds signed request bodies and rejects malformed objects', async () => {
await expect(
verifiedDiscordBody(signedRequest('a'.repeat(65 * 1024)))
).rejects.toMatchObject({ status: 413 });
await expect(verifiedDiscordBody(signedRequest('not json'))).rejects.toMatchObject({
status: 400
});
await expect(verifiedDiscordBody(signedRequest([]))).rejects.toMatchObject({ status: 400 });
});
it('requires the configured application and a real guild member identity', () => {
expect(validInteractionEnvelope(interaction())).toBe(true);
expect(validInteractionEnvelope({ ...interaction(), application_id: guildId })).toBe(false);
expect(validInteractionEnvelope({ ...interaction(), guild_id: undefined })).toBe(false);
expect(
validInteractionEnvelope({ ...interaction(), member: {}, user: { id: userId } })
).toBe(false);
});
it('does not dispatch disabled or foreign-guild interactions', async () => {
const { POST } = await import('../../src/routes/api/discord/interactions/+server.js');
const foreign = await POST({
request: signedRequest({ ...interaction(), guild_id: userId })
});
expect((await foreign.json()).data.flags).toBe(64);
settings.enabled = false;
expect(
(await (await POST({ request: signedRequest(interaction()) })).json()).data.flags
).toBe(64);
expect(handle).not.toHaveBeenCalled();
});
it('defers privately, dispatches once per interaction and delivers without bot credentials', async () => {
const fetcher = vi.fn().mockResolvedValue(new Response('{}'));
vi.stubGlobal('fetch', fetcher);
const { POST } = await import('../../src/routes/api/discord/interactions/+server.js');
const pending = { type: 5, data: { flags: 64 } };
expect(await (await POST({ request: signedRequest(interaction()) })).json()).toEqual(
pending
);
expect(await (await POST({ request: signedRequest(interaction()) })).json()).toEqual(
pending
);
expect(handle).not.toHaveBeenCalled();
await new Promise((resolve) => setImmediate(resolve));
expect(handle).toHaveBeenCalledTimes(1);
expect(fetcher).toHaveBeenCalledOnce();
const [url, init] = fetcher.mock.calls[0];
expect(url).toContain(
`/webhooks/${applicationId}/private-interaction-token/messages/@original`
);
expect(init.headers.Authorization).toBeUndefined();
expect(JSON.parse(init.body).allowed_mentions).toEqual({ parse: [] });
});
it('prevents recipient mentions and reports sanitized delivery errors', async () => {
const fetcher = vi
.fn()
.mockResolvedValue(new Response('secret error body', { status: 403 }));
await expect(
completeDiscordInteraction(
applicationId,
'secret/token',
{ content: '@everyone', allowed_mentions: { parse: ['everyone'] } },
fetcher
)
).rejects.toThrow('Discord interaction delivery failed (403).');
const [url, init] = fetcher.mock.calls[0];
expect(url).toContain('secret%2Ftoken');
expect(JSON.parse(init.body).allowed_mentions.parse).toEqual([]);
});
it('honors short rate limits and rechecks publication before retrying', async () => {
vi.useFakeTimers();
let published = true;
const fetcher = vi
.fn()
.mockResolvedValueOnce(
new Response(JSON.stringify({ retry_after: 2 }), { status: 429 })
)
.mockResolvedValueOnce(new Response('{}'));
const delivery = completeDiscordInteraction(
applicationId,
'token',
{
content: 'Private player names',
allowed_mentions: { parse: [] }
},
fetcher,
() => published
);
await vi.advanceTimersByTimeAsync(1999);
expect(fetcher).toHaveBeenCalledTimes(1);
published = false;
await vi.advanceTimersByTimeAsync(1);
await delivery;
expect(fetcher).toHaveBeenCalledTimes(2);
const payload = fetcher.mock.calls[1][1].body;
expect(payload).not.toContain('Private player names');
expect(payload).toContain('Publication settings changed');
});
});
describe('Discord configuration and access', () => {
it('defaults to no publication and validates IDs and an explicit bounded allowlist', () => {
expect(validateDiscordSettings(defaultDiscordSettings)).toEqual(defaultDiscordSettings);
expect(defaultDiscordSettings.enabled).toBe(false);
expect(defaultDiscordSettings.showPlayers).toBe(false);
expect(defaultDiscordSettings.showAddresses).toBe(false);
expect(defaultDiscordSettings.leaderboardEnabled).toBe(false);
expect(() =>
validateDiscordSettings({ ...defaultDiscordSettings, enabled: true })
).toThrow();
expect(() =>
validateDiscordSettings({ ...defaultDiscordSettings, guildId: 'bad' })
).toThrow();
expect(() =>
validateDiscordSettings({
...defaultDiscordSettings,
publishedServers: ['../../secrets']
})
).toThrow();
expect(() =>
validateDiscordSettings({
...defaultDiscordSettings,
publishedServers: Array(101).fill('t5_map_1')
})
).toThrow();
expect(
validateDiscordSettings({
...defaultDiscordSettings,
publishedServers: ['t5_map_2', 't5_map_1', 't5_map_1']
}).publishedServers
).toEqual(['t5_map_1', 't5_map_2']);
});
it('limits browser configuration writes to administrators and exempts only exact signed POSTs', () => {
for (const route of [
'/api/integrations/discord',
'/api/integrations/discord/register',
'/settings/integrations/discord'
]) {
expect(routePermission(route, 'GET')).toBe('admin-read');
expect(roleAllows('super_viewer', routePermission(route, 'PUT'))).toBe(false);
expect(roleAllows('operator', routePermission(route, 'GET'))).toBe(false);
expect(roleAllows('admin', routePermission(route, 'PUT'))).toBe(true);
}
expect(routePermission('/api/discord/interactions', 'POST')).toBe('machine');
expect(routePermission('/api/discord/interactions', 'GET')).not.toBe('machine');
expect(routePermission('/api/discord/interactions/other', 'POST')).not.toBe('machine');
expect(routePermission('/api/discord/interactions-extra', 'POST')).not.toBe('machine');
});
});
+17 -2
View File
@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../src/lib/server/db/migrate.js', () => ({ migrate: vi.fn() }));
vi.mock('../../src/lib/server/pipeline/worker.js', () => ({ startWorker: vi.fn() }));
vi.mock('../../src/lib/server/discord/worker.js', () => ({ startDiscordWorker: vi.fn() }));
vi.mock('../../src/lib/server/provisioning/history.js', () => ({ interruptDeployments: vi.fn() }));
vi.mock('../../src/lib/server/auth/guard.js', () => ({
authenticationHandle: vi.fn(async () => new Response('OK'))
@@ -24,7 +25,13 @@ afterEach(() => {
});
describe('controller error handling', () => {
it.each(['migration', 'deployment recovery', 'worker startup', 'performance monitor'] as const)(
it.each([
'migration',
'deployment recovery',
'worker startup',
'Discord startup',
'performance monitor'
] as const)(
'retries failed %s before serving requests and boots only once after recovery',
async (phase) => {
const { handle } = await import('../../src/hooks.server.js');
@@ -32,13 +39,21 @@ describe('controller error handling', () => {
const { interruptDeployments } =
await import('../../src/lib/server/provisioning/history.js');
const { startWorker } = await import('../../src/lib/server/pipeline/worker.js');
const { startDiscordWorker } = await import('../../src/lib/server/discord/worker.js');
const { startPerformanceMonitor } = await import('../../src/lib/server/performance.js');
const { authenticationHandle } = await import('../../src/lib/server/auth/guard.js');
const steps = [migrate, interruptDeployments, startWorker, startPerformanceMonitor];
const steps = [
migrate,
interruptDeployments,
startWorker,
startDiscordWorker,
startPerformanceMonitor
];
const failedStep = [
'migration',
'deployment recovery',
'worker startup',
'Discord startup',
'performance monitor'
].indexOf(phase);
const failure = new Error('database or disk is full');