From fd00ef53572be5cf79fdec98221b43ba0c414efc Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Sun, 18 Oct 2026 16:55:49 -0400 Subject: [PATCH] Add native Discord boards and availability alerts --- .env.example | 7 + docker-compose.yml | 3 + docs/README.md | 1 + docs/guides/discord.md | 156 ++++ src/hooks.server.ts | 2 + src/lib/discord.ts | 107 +++ src/lib/navigation.ts | 1 + src/lib/server/auth/access.ts | 2 + src/lib/server/db/migrations.ts | 4 +- src/lib/server/discord/admin.ts | 27 + src/lib/server/discord/commands.ts | 131 ++++ src/lib/server/discord/config.ts | 30 + src/lib/server/discord/interactions.ts | 322 ++++++++ src/lib/server/discord/projection.ts | 132 ++++ src/lib/server/discord/render.ts | 285 +++++++ src/lib/server/discord/schema.ts | 43 ++ src/lib/server/discord/store.ts | 503 ++++++++++++ src/lib/server/discord/transport.ts | 132 ++++ src/lib/server/discord/webhook.ts | 118 +++ src/lib/server/discord/worker.ts | 415 ++++++++++ .../api/discord/interactions/+server.ts | 81 ++ .../api/integrations/discord/+server.ts | 36 + .../integrations/discord/register/+server.ts | 43 ++ .../integrations/discord/+page.svelte | 721 ++++++++++++++++++ tests/client/discord-settings.test.ts | 229 ++++++ tests/unit/discord-delivery.test.ts | 417 ++++++++++ tests/unit/discord-interactions.test.ts | 471 ++++++++++++ tests/unit/discord-webhook.test.ts | 250 ++++++ tests/unit/hooks-server.test.ts | 19 +- 29 files changed, 4685 insertions(+), 3 deletions(-) create mode 100644 docs/guides/discord.md create mode 100644 src/lib/discord.ts create mode 100644 src/lib/server/discord/admin.ts create mode 100644 src/lib/server/discord/commands.ts create mode 100644 src/lib/server/discord/config.ts create mode 100644 src/lib/server/discord/interactions.ts create mode 100644 src/lib/server/discord/projection.ts create mode 100644 src/lib/server/discord/render.ts create mode 100644 src/lib/server/discord/schema.ts create mode 100644 src/lib/server/discord/store.ts create mode 100644 src/lib/server/discord/transport.ts create mode 100644 src/lib/server/discord/webhook.ts create mode 100644 src/lib/server/discord/worker.ts create mode 100644 src/routes/api/discord/interactions/+server.ts create mode 100644 src/routes/api/integrations/discord/+server.ts create mode 100644 src/routes/api/integrations/discord/register/+server.ts create mode 100644 src/routes/settings/integrations/discord/+page.svelte create mode 100644 tests/client/discord-settings.test.ts create mode 100644 tests/unit/discord-delivery.test.ts create mode 100644 tests/unit/discord-interactions.test.ts create mode 100644 tests/unit/discord-webhook.test.ts diff --git a/.env.example b/.env.example index 46de84d..0fdb43e 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,13 @@ EC_SETUP_TOKEN= # Game reporters send Authorization: Bearer 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. diff --git a/docker-compose.yml b/docker-compose.yml index 279a868..297a2e4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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. diff --git a/docs/README.md b/docs/README.md index 66fa063..6f9add7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 diff --git a/docs/guides/discord.md b/docs/guides/discord.md new file mode 100644 index 0000000..d61918d --- /dev/null +++ b/docs/guides/discord.md @@ -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. diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 6fa6eb7..fd514d4 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -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; } diff --git a/src/lib/discord.ts b/src/lib/discord.ts new file mode 100644 index 0000000..cebc025 --- /dev/null +++ b/src/lib/discord.ts @@ -0,0 +1,107 @@ +export type DiscordGame = 't5' | 't6'; + +export interface DiscordSettings { + enabled: boolean; + guildId: string; + channels: Record; + 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; + 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; + 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[]; + 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; +} diff --git a/src/lib/navigation.ts b/src/lib/navigation.ts index 0f08cfc..73ec7c8 100644 --- a/src/lib/navigation.ts +++ b/src/lib/navigation.ts @@ -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', diff --git a/src/lib/server/auth/access.ts b/src/lib/server/auth/access.ts index 3efb655..d207f99 100644 --- a/src/lib/server/auth/access.ts +++ b/src/lib/server/auth/access.ts @@ -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') || diff --git a/src/lib/server/db/migrations.ts b/src/lib/server/db/migrations.ts index 6485cc2..eb71cec 100644 --- a/src/lib/server/db/migrations.ts +++ b/src/lib/server/db/migrations.ts @@ -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; diff --git a/src/lib/server/discord/admin.ts b/src/lib/server/discord/admin.ts new file mode 100644 index 0000000..5aa408f --- /dev/null +++ b/src/lib/server/discord/admin.ts @@ -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') + } + }; +} diff --git a/src/lib/server/discord/commands.ts b/src/lib/server/discord/commands.ts new file mode 100644 index 0000000..0660258 --- /dev/null +++ b/src/lib/server/discord/commands.ts @@ -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 } +]; diff --git a/src/lib/server/discord/config.ts b/src/lib/server/discord/config.ts new file mode 100644 index 0000000..5b7db64 --- /dev/null +++ b/src/lib/server/discord/config.ts @@ -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 { + 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; +} diff --git a/src/lib/server/discord/interactions.ts b/src/lib/server/discord/interactions.ts new file mode 100644 index 0000000..8cdb6ad --- /dev/null +++ b/src/lib/server/discord/interactions.ts @@ -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 { + if (options !== undefined && (!Array.isArray(options) || options.length > 25)) + throw new InputError('Invalid command options.'); + const values = new Map(); + 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, + 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): { 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): 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): Promise { + 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 · 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 { + 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 \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; + } +} diff --git a/src/lib/server/discord/projection.ts b/src/lib/server/discord/projection.ts new file mode 100644 index 0000000..bc91529 --- /dev/null +++ b/src/lib/server/discord/projection.ts @@ -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, '\\$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); +} diff --git a/src/lib/server/discord/render.ts b/src/lib/server/discord/render.ts new file mode 100644 index 0000000..f5c5364 --- /dev/null +++ b/src/lib/server/discord/render.ts @@ -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; +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 = { + 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 ` + : '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.` + ) + ]) + ]); +} diff --git a/src/lib/server/discord/schema.ts b/src/lib/server/discord/schema.ts new file mode 100644 index 0000000..4e85d21 --- /dev/null +++ b/src/lib/server/discord/schema.ts @@ -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); + `); +} diff --git a/src/lib/server/discord/store.ts b/src/lib/server/discord/store.ts new file mode 100644 index 0000000..d905b87 --- /dev/null +++ b/src/lib/server/discord/store.ts @@ -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 { + 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(); + 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 & { 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); diff --git a/src/lib/server/discord/transport.ts b/src/lib/server/discord/transport.ts new file mode 100644 index 0000000..53564a1 --- /dev/null +++ b/src/lib/server/discord/transport.ts @@ -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>(method: string, path: string, body?: unknown): Promise; +} + +/** One serialized queue shares Discord's bucket/global cooldowns with every caller. */ +export class DiscordRest implements DiscordTransport { + private tail: Promise = Promise.resolve(); + private globalUntil = 0; + private unauthorized = false; + private routeBuckets = new Map(); + private blockedUntil = new Map(); + + constructor( + private readonly token: string, + private readonly fetcher: typeof fetch = fetch, + private readonly now: () => number = Date.now + ) {} + + request>(method: string, path: string, body?: unknown): Promise { + const pending = this.tail.then(() => this.send(method, path, body)); + this.tail = pending.catch(() => undefined); + return pending; + } + + private async send(method: string, path: string, body?: unknown): Promise { + // 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 = {}; + 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; +} diff --git a/src/lib/server/discord/webhook.ts b/src/lib/server/discord/webhook.ts new file mode 100644 index 0000000..85a27dc --- /dev/null +++ b/src/lib/server/discord/webhook.ts @@ -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> { + 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; +} + +export function validInteractionEnvelope(body: Record): 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 { + 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}).`); + } +} diff --git a/src/lib/server/discord/worker.ts b/src/lib/server/discord/worker.ts new file mode 100644 index 0000000..092d96a --- /dev/null +++ b/src/lib/server/discord/worker.ts @@ -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 | null = null; + private readonly now: () => number; + constructor(private readonly dependencies: WorkerDependencies) { + this.now = dependencies.now ?? Date.now; + } + + tick(): Promise { + 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 { + 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(); + 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 + ): Promise { + 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 | undefined; +export function tickDiscordWorker(): Promise { + 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 { + resetDiscordClient(); + discordStore().retryFailed(); + return tickDiscordWorker(); +} diff --git a/src/routes/api/discord/interactions/+server.ts b/src/routes/api/discord/interactions/+server.ts new file mode 100644 index 0000000..edcc06e --- /dev/null +++ b/src/routes/api/discord/interactions/+server.ts @@ -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(); +const actors = new Map(); +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 }); + } +} diff --git a/src/routes/api/integrations/discord/+server.ts b/src/routes/api/integrations/discord/+server.ts new file mode 100644 index 0000000..1d143c0 --- /dev/null +++ b/src/routes/api/integrations/discord/+server.ts @@ -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 } + ); + } +} diff --git a/src/routes/api/integrations/discord/register/+server.ts b/src/routes/api/integrations/discord/register/+server.ts new file mode 100644 index 0000000..0d9f8ba --- /dev/null +++ b/src/routes/api/integrations/discord/register/+server.ts @@ -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 } + ); + } +} diff --git a/src/routes/settings/integrations/discord/+page.svelte b/src/routes/settings/integrations/discord/+page.svelte new file mode 100644 index 0000000..5807fa2 --- /dev/null +++ b/src/routes/settings/integrations/discord/+page.svelte @@ -0,0 +1,721 @@ + + +Discord · Electric Cherry + +
+ + {#if !canView} + + {:else} + {#if error} + + {/if} + {#if notice}

{notice}

{/if} + {#if actionError}{/if} + {#if loading && !adminState} +

Loading Discord settings…

+ {:else if adminState && draft} + {#if !administrator} +

+ You have read-only access. An administrator can change this integration. +

+ {/if} +
+
+
+ Connection and channels + +

+ Enabling publishes the selected servers to your configured channels. +

+ + + +
+ {#each games as game} + + + + {/each} +
+
+ +
+ Published servers +

+ Only selected servers appear in Discord browsing, boards, and + availability alerts. New servers remain private until selected. +

+ {#if adminState.servers.length === 0}

+ No supported game servers are available. +

{/if} + {#each games as game} + {@const choices = adminState.servers.filter( + (server) => server.game === game + )} + {#if choices.length} +
+

{gameTitle(game)}

+ {#each choices as server (server.name)} + + {/each} +
+ {/if} + {/each} + {#if unavailableSelections.length} +
+

No longer in the fleet

+ {#each unavailableSelections as name (name)} + + {/each} +
+ {/if} +

+ {countLabel(draft.publishedServers.length, 'server')} selected. +

+
+ +
+ Information shared with members + + + +

+ 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. +

+
+ + {#if administrator} +
+ {dirty ? 'Unsaved changes' : 'All changes saved'} +
+ + +
+
+ {/if} +
+ + +
+ +
+
+

Published server preview

+ Saved settings +
+

+ This shows the information available to Discord from your saved selection. Empty + slots do not establish whether a game accepts new players. +

+ {#if !adminState.settings.enabled}

+ Discord is disabled. This preview is visible only in Electric Cherry. +

{/if} + {#if !adminState.preview.t5.length && !adminState.preview.t6.length} +

Select servers and save your changes to see a preview.

+ {:else} +
+ {#each games as game} + {#if adminState.preview[game].length} +
+

{gameTitle(game)}

+ {#each adminState.preview[game] as server (server.name)} +
+
+ {server.label} +
+

+ {server.map} · {server.players ?? '?'} / {server.capacity ?? + '?'} players{server.round === null + ? '' + : ` · Round ${server.round}`} +

+ {#if server.playerNames.length}

+ {server.playerNames.join(', ')} +

{/if} + {#if server.connection}{server.connection}{/if} + Last observed: +
+ {/each} +
+ {/if} + {/each} +
+ {/if} +
+ {/if} + {/if} +
+ + diff --git a/tests/client/discord-settings.test.ts b/tests/client/discord-settings.test.ts new file mode 100644 index 0000000..12dce81 --- /dev/null +++ b/tests/client/discord-settings.test.ts @@ -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[0]) => page.subscribe(run) } +})); + +let component: ReturnType | 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('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.' + ); +}); diff --git a/tests/unit/discord-delivery.test.ts b/tests/unit/discord-delivery.test.ts new file mode 100644 index 0000000..d6e97fc --- /dev/null +++ b/tests/unit/discord-delivery.test.ts @@ -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(); + }); +}); diff --git a/tests/unit/discord-interactions.test.ts b/tests/unit/discord-interactions.test.ts new file mode 100644 index 0000000..a4529b2 --- /dev/null +++ b/tests/unit/discord-interactions.test.ts @@ -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 { + 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[] { + const result: Record[] = []; + const visit = (component: Record) => { + result.push(component); + for (const child of (component.components ?? []) as Record[]) visit(child); + if (component.accessory) visit(component.accessory as Record); + }; + 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'); + }); +}); diff --git a/tests/unit/discord-webhook.test.ts b/tests/unit/discord-webhook.test.ts new file mode 100644 index 0000000..72aa14f --- /dev/null +++ b/tests/unit/discord-webhook.test.ts @@ -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'); + }); +}); diff --git a/tests/unit/hooks-server.test.ts b/tests/unit/hooks-server.test.ts index 0dd607f..dcca2f2 100644 --- a/tests/unit/hooks-server.test.ts +++ b/tests/unit/hooks-server.test.ts @@ -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');