This commit is contained in:
@@ -16,6 +16,13 @@ EC_SETUP_TOKEN=
|
||||
# Game reporters send Authorization: Bearer <token> or X-EC-Telemetry-Token.
|
||||
# Telemetry ingestion is disabled until a token of at least 32 chars is set.
|
||||
EC_TELEMETRY_TOKEN=
|
||||
# Optional native Discord companion. Keep these in the controller environment;
|
||||
# enable the integration and choose published servers under Admin → Discord.
|
||||
# The application ID and public key are in the Discord Developer Portal.
|
||||
# The bot token is a secret: never put it in channel messages or screenshots.
|
||||
EC_DISCORD_APPLICATION_ID=
|
||||
EC_DISCORD_PUBLIC_KEY=
|
||||
EC_DISCORD_BOT_TOKEN=
|
||||
# Bank credentials are generated and injected automatically per native instance.
|
||||
EC_BANK_MAX_BALANCE=250000
|
||||
# Persistent SQLite metadata, game-file storage, and beta archives.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
export type DiscordGame = 't5' | 't6';
|
||||
|
||||
export interface DiscordSettings {
|
||||
enabled: boolean;
|
||||
guildId: string;
|
||||
channels: Record<DiscordGame, string>;
|
||||
publishedServers: string[];
|
||||
showPlayers: boolean;
|
||||
showAddresses: boolean;
|
||||
leaderboardEnabled: boolean;
|
||||
}
|
||||
|
||||
export const defaultDiscordSettings: DiscordSettings = {
|
||||
enabled: false,
|
||||
guildId: '',
|
||||
channels: { t5: '', t6: '' },
|
||||
publishedServers: [],
|
||||
showPlayers: false,
|
||||
showAddresses: false,
|
||||
leaderboardEnabled: false
|
||||
};
|
||||
|
||||
export const discordSnowflake = (value: unknown): value is string =>
|
||||
typeof value === 'string' && /^[1-9][0-9]{16,19}$/.test(value);
|
||||
|
||||
export function validateDiscordSettings(value: unknown): DiscordSettings {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value))
|
||||
throw new Error('Provide Discord settings.');
|
||||
const input = value as Record<string, unknown>;
|
||||
for (const key of ['enabled', 'showPlayers', 'showAddresses', 'leaderboardEnabled']) {
|
||||
if (typeof input[key] !== 'boolean') throw new Error(`Invalid ${key} setting.`);
|
||||
}
|
||||
if (typeof input.guildId !== 'string' || (input.guildId && !discordSnowflake(input.guildId)))
|
||||
throw new Error('Provide a valid Discord server ID.');
|
||||
if (!input.channels || typeof input.channels !== 'object' || Array.isArray(input.channels))
|
||||
throw new Error('Provide Discord channels for each game.');
|
||||
const channels = input.channels as Record<string, unknown>;
|
||||
for (const game of ['t5', 't6'] as const) {
|
||||
if (
|
||||
typeof channels[game] !== 'string' ||
|
||||
(channels[game] && !discordSnowflake(channels[game]))
|
||||
)
|
||||
throw new Error('Channel IDs must be valid Discord IDs or empty.');
|
||||
}
|
||||
if (
|
||||
!Array.isArray(input.publishedServers) ||
|
||||
input.publishedServers.length > 100 ||
|
||||
input.publishedServers.some(
|
||||
(name) => typeof name !== 'string' || !/^[A-Za-z0-9_.-]{1,128}$/.test(name)
|
||||
)
|
||||
)
|
||||
throw new Error('Choose up to 100 published servers.');
|
||||
if (input.enabled && (!input.guildId || (!channels.t5 && !channels.t6)))
|
||||
throw new Error('Choose a Discord server and at least one board channel before enabling.');
|
||||
return {
|
||||
enabled: input.enabled as boolean,
|
||||
guildId: input.guildId,
|
||||
channels: { t5: channels.t5 as string, t6: channels.t6 as string },
|
||||
publishedServers: [...new Set(input.publishedServers as string[])].sort(),
|
||||
showPlayers: input.showPlayers as boolean,
|
||||
showAddresses: input.showAddresses as boolean,
|
||||
leaderboardEnabled: input.leaderboardEnabled as boolean
|
||||
};
|
||||
}
|
||||
|
||||
export interface DiscordPublicServer {
|
||||
name: string;
|
||||
game: DiscordGame;
|
||||
label: string;
|
||||
map: string;
|
||||
mapKey: string;
|
||||
state: 'empty' | 'in-progress' | 'full' | 'starting' | 'offline' | 'stale' | 'unknown';
|
||||
players: number | null;
|
||||
capacity: number | null;
|
||||
round: number | null;
|
||||
observedAt: string | null;
|
||||
playerNames: string[];
|
||||
connection: string | null;
|
||||
}
|
||||
|
||||
export interface DiscordMessage {
|
||||
content?: string;
|
||||
flags?: number;
|
||||
components?: Record<string, unknown>[];
|
||||
allowed_mentions: { parse: string[] };
|
||||
}
|
||||
|
||||
export interface DiscordDeliveryStatus {
|
||||
lastAttemptAt: string | null;
|
||||
lastSuccessAt: string | null;
|
||||
lastError: string | null;
|
||||
boards: { game: DiscordGame; channelId: string; messageCount: number }[];
|
||||
activeSubscriptions: number;
|
||||
pendingNotifications: number;
|
||||
}
|
||||
|
||||
export interface DiscordAdminState {
|
||||
settings: DiscordSettings;
|
||||
credentials: {
|
||||
applicationId: string;
|
||||
publicKeyConfigured: boolean;
|
||||
botTokenConfigured: boolean;
|
||||
};
|
||||
status: DiscordDeliveryStatus;
|
||||
servers: { name: string; game: DiscordGame; label: string; map: string }[];
|
||||
preview: Record<DiscordGame, DiscordPublicServer[]>;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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') ||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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 }
|
||||
];
|
||||
@@ -0,0 +1,30 @@
|
||||
import { discordSnowflake } from '../../discord.js';
|
||||
|
||||
/** Credentials belong to the deployment environment and never to browser settings. */
|
||||
export function discordCredentials() {
|
||||
return {
|
||||
applicationId: (process.env.EC_DISCORD_APPLICATION_ID ?? '').trim(),
|
||||
publicKey: (process.env.EC_DISCORD_PUBLIC_KEY ?? '').trim(),
|
||||
botToken: (process.env.EC_DISCORD_BOT_TOKEN ?? '').trim()
|
||||
};
|
||||
}
|
||||
|
||||
export function discordCredentialStatus() {
|
||||
const credentials = discordCredentials();
|
||||
return {
|
||||
applicationId: credentials.applicationId,
|
||||
publicKeyConfigured: /^[a-fA-F0-9]{64}$/.test(credentials.publicKey),
|
||||
botTokenConfigured: !!credentials.botToken
|
||||
};
|
||||
}
|
||||
|
||||
export function requireDiscordCredentials(): ReturnType<typeof discordCredentials> {
|
||||
const credentials = discordCredentials();
|
||||
if (
|
||||
!discordSnowflake(credentials.applicationId) ||
|
||||
!/^[a-fA-F0-9]{64}$/.test(credentials.publicKey) ||
|
||||
!credentials.botToken
|
||||
)
|
||||
throw new Error('Configure the Discord application ID, public key and bot token first.');
|
||||
return credentials;
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
import {
|
||||
discordSnowflake,
|
||||
type DiscordGame,
|
||||
type DiscordMessage,
|
||||
type DiscordPublicServer
|
||||
} from '../../discord.js';
|
||||
import {
|
||||
leaderboardMetrics,
|
||||
type LeaderboardMetric,
|
||||
type LeaderboardOptions,
|
||||
type LeaderboardPlayerCount
|
||||
} from '../../leaderboard.js';
|
||||
import { leaderboardReport } from '../reports/index.js';
|
||||
import { discordCredentials } from './config.js';
|
||||
import { discordMaps } from './commands.js';
|
||||
import { discordServerKey, discordText, publicFleet } from './projection.js';
|
||||
import {
|
||||
cancelSubscription,
|
||||
getDiscordSettings,
|
||||
listSubscriptions,
|
||||
subscribeToMap
|
||||
} from './store.js';
|
||||
import {
|
||||
LEADERBOARD_PAGE_SIZE,
|
||||
privateMessage,
|
||||
renderBrowser,
|
||||
renderLeaderboard,
|
||||
renderServer,
|
||||
renderWatchChoice,
|
||||
renderWatchMaps,
|
||||
type BrowserFilter,
|
||||
type BrowserStatus
|
||||
} from './render.js';
|
||||
|
||||
interface DiscordOption {
|
||||
type?: number;
|
||||
name: string;
|
||||
value?: string | number;
|
||||
options?: DiscordOption[];
|
||||
}
|
||||
export interface DiscordInteraction {
|
||||
type: number;
|
||||
application_id?: string;
|
||||
guild_id?: string;
|
||||
member?: { user?: { id?: string } };
|
||||
data?: { name?: string; options?: DiscordOption[]; custom_id?: string; values?: string[] };
|
||||
}
|
||||
export interface DiscordInteractionResponse {
|
||||
type: 4;
|
||||
data: DiscordMessage;
|
||||
}
|
||||
class InputError extends Error {}
|
||||
const response = (data: DiscordMessage): DiscordInteractionResponse => ({ type: 4, data });
|
||||
export const privateDiscordError = (message: string) =>
|
||||
response(privateMessage(discordText(message, 400)));
|
||||
const statuses: BrowserStatus[] = ['all', 'empty', 'space', 'full', 'in-progress'];
|
||||
|
||||
function optionValues(options: DiscordOption[] | undefined): Map<string, string | number> {
|
||||
if (options !== undefined && (!Array.isArray(options) || options.length > 25))
|
||||
throw new InputError('Invalid command options.');
|
||||
const values = new Map<string, string | number>();
|
||||
for (const option of options ?? []) {
|
||||
if (
|
||||
!option ||
|
||||
typeof option.name !== 'string' ||
|
||||
values.has(option.name) ||
|
||||
!['string', 'number'].includes(typeof option.value)
|
||||
)
|
||||
throw new InputError('Invalid command options.');
|
||||
values.set(option.name, option.value!);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function stringOption(
|
||||
values: Map<string, string | number>,
|
||||
key: string,
|
||||
limit = 128
|
||||
): string | undefined {
|
||||
const value = values.get(key);
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value !== 'string' || !value.trim() || value.length > limit)
|
||||
throw new InputError(`Invalid ${key} option.`);
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function scope(values: Map<string, string | number>): { game?: DiscordGame; map?: string } {
|
||||
const game = stringOption(values, 'game');
|
||||
const map = stringOption(values, 'map');
|
||||
if (game && game !== 't5' && game !== 't6')
|
||||
throw new InputError('Choose Black Ops I or Black Ops II.');
|
||||
if (map && !discordMaps.some((entry) => entry.value === map))
|
||||
throw new InputError('Choose a map from the command options.');
|
||||
if (game && map && !map.startsWith(`${game}:`))
|
||||
throw new InputError('Choose a map from the selected game.');
|
||||
return { game: (game ?? map?.slice(0, 2)) as DiscordGame | undefined, map };
|
||||
}
|
||||
|
||||
function page(values: Map<string, string | number>): number {
|
||||
const value = values.get('page') ?? 1;
|
||||
if (!Number.isSafeInteger(value) || Number(value) < 1 || Number(value) > 100001)
|
||||
throw new InputError('Choose a page between 1 and 100001.');
|
||||
return Number(value) - 1;
|
||||
}
|
||||
|
||||
function watchMap(
|
||||
servers: DiscordPublicServer[],
|
||||
key: string,
|
||||
game?: DiscordGame
|
||||
): DiscordPublicServer {
|
||||
const found = servers.find(
|
||||
(server) =>
|
||||
(server.mapKey === key || discordServerKey(server.mapKey) === key) &&
|
||||
(!game || server.game === game)
|
||||
);
|
||||
if (!found)
|
||||
throw new InputError('That map is no longer published. Browse the current server list.');
|
||||
if (!/^[A-Za-z0-9_.:-]{1,100}$/.test(found.mapKey))
|
||||
throw new InputError('A map watch is unavailable for this reported map.');
|
||||
return found;
|
||||
}
|
||||
|
||||
async function leaderboard(values: Map<string, string | number>): Promise<DiscordMessage> {
|
||||
const settings = getDiscordSettings();
|
||||
if (!settings.leaderboardEnabled)
|
||||
return privateMessage('Leaderboard publication is not enabled for this Discord server.');
|
||||
const metric = stringOption(values, 'metric') ?? 'highestRound';
|
||||
if (!leaderboardMetrics.some((entry) => entry.key === metric))
|
||||
throw new InputError('Choose a ranking metric from the command options.');
|
||||
const squad = values.get('squad');
|
||||
if (
|
||||
squad !== undefined &&
|
||||
(!Number.isSafeInteger(squad) || Number(squad) < 1 || Number(squad) > 4)
|
||||
)
|
||||
throw new InputError('Squad size must be between 1 and 4.');
|
||||
const query: LeaderboardOptions = {
|
||||
...scope(values),
|
||||
metric: metric as LeaderboardMetric,
|
||||
players: squad as LeaderboardPlayerCount | undefined,
|
||||
limit: LEADERBOARD_PAGE_SIZE,
|
||||
offset: page(values) * LEADERBOARD_PAGE_SIZE
|
||||
};
|
||||
const report = await leaderboardReport(query);
|
||||
// Publishing may have been disabled while the report worker was running.
|
||||
const current = getDiscordSettings();
|
||||
if (!current.enabled || current.guildId !== settings.guildId || !current.leaderboardEnabled)
|
||||
return privateMessage('Leaderboard publication is no longer enabled.');
|
||||
return renderLeaderboard(report, query);
|
||||
}
|
||||
|
||||
function addWatch(
|
||||
servers: DiscordPublicServer[],
|
||||
key: string,
|
||||
mode: string | undefined,
|
||||
guildId: string,
|
||||
userId: string,
|
||||
game?: DiscordGame
|
||||
): DiscordMessage {
|
||||
if (mode !== 'empty' && mode !== 'space')
|
||||
throw new InputError('Choose an empty-lobby or space-available watch.');
|
||||
const server = watchMap(servers, key, game);
|
||||
let watch: { id: string; expiresAt: number };
|
||||
try {
|
||||
watch = subscribeToMap({ guildId, userId, game: server.game, mapKey: server.mapKey, mode });
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
(error.message.startsWith('You already have 10') ||
|
||||
error.message.startsWith('Map watches are currently full'))
|
||||
)
|
||||
throw new InputError(error.message);
|
||||
throw error;
|
||||
}
|
||||
return privateMessage(
|
||||
`Watching **${discordText(server.map)}** for ${mode === 'empty' ? 'an empty lobby' : 'space available'}.\nYou will receive one DM after stable observations. Enable DMs from this server to receive it. This does not guarantee match admission.\nExpires <t:${Math.floor(watch.expiresAt / 1000)}:R> · Cancel with /watch cancel\nWatch ID: ${discordText(watch.id)}`
|
||||
);
|
||||
}
|
||||
|
||||
function parseBrowserId(id: string): BrowserFilter {
|
||||
const match =
|
||||
/^ec:b:(all|t5|t6):(all|empty|space|full|in-progress):(\d{1,6}):([A-Za-z0-9_-]{0,70})(?::(?:prev|next))?$/.exec(
|
||||
id
|
||||
);
|
||||
if (!match) throw new InputError('This browser control is invalid. Run /servers again.');
|
||||
const map = Buffer.from(match[4], 'base64url').toString();
|
||||
if (map && !discordMaps.some((entry) => entry.value === map))
|
||||
throw new InputError('This map filter is unavailable. Run /servers again.');
|
||||
return {
|
||||
game: match[1] === 'all' ? undefined : (match[1] as DiscordGame),
|
||||
status: match[2] as BrowserStatus,
|
||||
page: Number(match[3]),
|
||||
map: map || undefined
|
||||
};
|
||||
}
|
||||
|
||||
/** All responses are private new messages; components never mutate the shared board. */
|
||||
export async function handleDiscordInteraction(
|
||||
interaction: DiscordInteraction
|
||||
): Promise<DiscordInteractionResponse> {
|
||||
const userId = interaction.member?.user?.id;
|
||||
if (
|
||||
!discordSnowflake(userId) ||
|
||||
!discordSnowflake(interaction.guild_id) ||
|
||||
interaction.application_id !== discordCredentials().applicationId
|
||||
)
|
||||
return privateDiscordError('This interaction is not available here.');
|
||||
const settings = getDiscordSettings();
|
||||
if (!settings.enabled || settings.guildId !== interaction.guild_id)
|
||||
return privateDiscordError('Electric Cherry is not enabled for this Discord server.');
|
||||
if (interaction.type !== 2 && interaction.type !== 3)
|
||||
return privateDiscordError('This interaction is not supported.');
|
||||
const data = interaction.data;
|
||||
if (!data || typeof data !== 'object')
|
||||
return privateDiscordError('This interaction is invalid.');
|
||||
try {
|
||||
if (interaction.type === 2 && data.name === 'leaderboard')
|
||||
return response(await leaderboard(optionValues(data.options)));
|
||||
if (interaction.type === 2 && data.name === 'help')
|
||||
return response(
|
||||
privateMessage(
|
||||
'## 🍒 Electric Cherry\n/servers — browse live servers privately\n/server — inspect a published server\n/leaderboard — completed-game rankings, when enabled\n/watch add — one DM when a map has an empty lobby or space\n/watch list · /watch cancel — manage your watches\n\nWatches expire after 24 hours. Observed spaces do not guarantee that a match accepts new players.'
|
||||
)
|
||||
);
|
||||
const servers = publicFleet(settings);
|
||||
if (interaction.type === 3) {
|
||||
const id = data.custom_id;
|
||||
if (typeof id !== 'string' || id.length > 100)
|
||||
throw new InputError('This control is invalid.');
|
||||
if (id.startsWith('ec:b:')) return response(renderBrowser(servers, parseBrowserId(id)));
|
||||
if (/^ec:d:[a-f0-9]{20}$/.test(id)) {
|
||||
const server = servers.find(
|
||||
(entry) => discordServerKey(entry.name) === id.slice(5)
|
||||
);
|
||||
if (!server) throw new InputError('This server is no longer published.');
|
||||
return response(renderServer(server));
|
||||
}
|
||||
if (/^ec:maps:(t5|t6)$/.test(id))
|
||||
return response(renderWatchMaps(servers, id.slice(8) as DiscordGame));
|
||||
if (
|
||||
id === 'ec:map' &&
|
||||
Array.isArray(data.values) &&
|
||||
data.values.length === 1 &&
|
||||
typeof data.values[0] === 'string'
|
||||
)
|
||||
return response(renderWatchChoice(watchMap(servers, data.values[0])));
|
||||
const watch = /^ec:w:(empty|space):([a-f0-9]{20})$/.exec(id);
|
||||
if (watch)
|
||||
return response(addWatch(servers, watch[2], watch[1], settings.guildId, userId));
|
||||
if (/^ec:leader:(t5|t6)$/.test(id))
|
||||
return response(await leaderboard(new Map([['game', id.slice(10)]])));
|
||||
throw new InputError(
|
||||
'This control is no longer available. Run /help for current commands.'
|
||||
);
|
||||
}
|
||||
if (data.name === 'watch') {
|
||||
if (!Array.isArray(data.options) || data.options.length !== 1)
|
||||
throw new InputError('Choose watch add, list, or cancel.');
|
||||
const command = data.options[0];
|
||||
const values = optionValues(command.options);
|
||||
if (command.name === 'add') {
|
||||
const selected = scope(values);
|
||||
if (!selected.game || !selected.map) throw new InputError('Choose a game and map.');
|
||||
return response(
|
||||
addWatch(
|
||||
servers,
|
||||
selected.map,
|
||||
stringOption(values, 'condition'),
|
||||
settings.guildId,
|
||||
userId,
|
||||
selected.game
|
||||
)
|
||||
);
|
||||
}
|
||||
if (command.name === 'cancel') {
|
||||
const id = stringOption(values, 'id', 100);
|
||||
if (!id) throw new InputError('Provide the watch ID shown by /watch list.');
|
||||
return response(
|
||||
privateMessage(
|
||||
cancelSubscription(id, settings.guildId, userId)
|
||||
? 'Watch cancelled.'
|
||||
: 'No active watch with that ID belongs to you.'
|
||||
)
|
||||
);
|
||||
}
|
||||
if (command.name === 'list') {
|
||||
const watches = listSubscriptions(userId, settings.guildId);
|
||||
return response(
|
||||
privateMessage(
|
||||
`## Your map watches\n${watches.length ? watches.map((watch) => `**${discordText(servers.find((server) => server.mapKey === watch.mapKey)?.map ?? discordMaps.find((map) => map.value === watch.mapKey)?.name ?? 'Unpublished map', 60)}** · ${watch.mode === 'empty' ? 'Empty lobby' : 'Space available'} · Expires <t:${Math.floor(watch.expiresAt / 1000)}:R>\nID: ${discordText(watch.id)}`).join('\n\n') : 'No active watches. Use /watch add to create one.'}`
|
||||
)
|
||||
);
|
||||
}
|
||||
throw new InputError('Choose watch add, list, or cancel.');
|
||||
}
|
||||
const values = optionValues(data.options);
|
||||
if (data.name === 'servers') {
|
||||
const status = stringOption(values, 'status') ?? 'all';
|
||||
if (!statuses.includes(status as BrowserStatus))
|
||||
throw new InputError('Choose an occupancy filter from the command options.');
|
||||
return response(
|
||||
renderBrowser(servers, {
|
||||
...scope(values),
|
||||
status: status as BrowserStatus,
|
||||
page: page(values)
|
||||
})
|
||||
);
|
||||
}
|
||||
if (data.name === 'server') {
|
||||
const name = stringOption(values, 'name');
|
||||
const server = servers.find((entry) => entry.name === name || entry.label === name);
|
||||
if (!server)
|
||||
throw new InputError(
|
||||
'That server is not published. Use /servers and click Details.'
|
||||
);
|
||||
return response(renderServer(server));
|
||||
}
|
||||
throw new InputError('This command is not supported. Run /help for available commands.');
|
||||
} catch (error) {
|
||||
if (error instanceof InputError) return privateDiscordError(error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { DiscordPublicServer, DiscordSettings } from '../../discord.js';
|
||||
import {
|
||||
currentRound,
|
||||
fleetIdentity,
|
||||
freshServer,
|
||||
type FleetServer
|
||||
} from '../../fleet-overview.js';
|
||||
import { currentCapacity } from '../../server-capacity.js';
|
||||
import { parseServerName, selectedZombieMap, zombieMaps } from '../../map-aliases.js';
|
||||
import { fleetSnapshot } from '../fleet/snapshot.js';
|
||||
|
||||
type SourceServer = FleetServer & {
|
||||
network?: { publicAddress: string | null; publicPort: number | null } | null;
|
||||
};
|
||||
|
||||
/** IDs stay short enough for Discord components, even for long registry names. */
|
||||
export function discordServerKey(name: string): string {
|
||||
return createHash('sha256').update(name).digest('hex').slice(0, 20);
|
||||
}
|
||||
|
||||
/** Rendering never interprets a game nickname as Markdown, a link, or a mention. */
|
||||
export function discordText(value: string, limit = 160): string {
|
||||
return (
|
||||
value
|
||||
.replace(/[\r\n\t]/g, ' ')
|
||||
// eslint-disable-next-line no-control-regex -- Strip untrusted game-name control characters.
|
||||
.replace(/[\u0000-\u001f\u007f\u202a-\u202e\u2066-\u2069]/g, '')
|
||||
.slice(0, limit)
|
||||
.replace(/@/g, '@\u200b')
|
||||
.replace(/</g, '‹')
|
||||
.replace(/>/g, '›')
|
||||
.replace(/([\\`*_{}[\]()~|])/g, '\\$1')
|
||||
);
|
||||
}
|
||||
|
||||
function mapIdentity(server: SourceServer, game: 't5' | 't6') {
|
||||
const parsed = parseServerName(server.name);
|
||||
const map = server.last_map ?? server.map ?? parsed?.mapCanonical ?? 'unknown';
|
||||
const slug = server.map === map ? (server.map_slug ?? parsed?.mapSlug) : undefined;
|
||||
const candidates = zombieMaps(game).filter((entry) => entry.canonical === map);
|
||||
const selected =
|
||||
game === 't6' && candidates.length > 1 && !slug
|
||||
? undefined
|
||||
: selectedZombieMap(game, map, slug);
|
||||
return {
|
||||
map: selected?.friendly ?? fleetIdentity(server).map ?? 'Unknown map',
|
||||
mapKey: [
|
||||
game,
|
||||
map,
|
||||
...(game === 't6' ? [selected?.slug ?? slug ?? 'unspecified'] : [])
|
||||
].join(':')
|
||||
};
|
||||
}
|
||||
|
||||
function publicConnection(server: SourceServer): string | null {
|
||||
const address = server.network?.publicAddress?.trim();
|
||||
const port = server.network?.publicPort;
|
||||
// Never fall back to agent, RCON, bind, or inferred host addresses.
|
||||
if (!address || !/^[A-Za-z0-9.:[\]-]{1,253}$/.test(address)) return null;
|
||||
if (!Number.isSafeInteger(port) || port! < 1 || port! > 65535) return null;
|
||||
const host = address.includes(':') && !address.startsWith('[') ? `[${address}]` : address;
|
||||
return `${host}:${port}`;
|
||||
}
|
||||
|
||||
/** Explicit field projection is the boundary between private fleet data and Discord. */
|
||||
export function projectFleet(
|
||||
servers: readonly SourceServer[],
|
||||
settings: DiscordSettings,
|
||||
now = Date.now()
|
||||
): DiscordPublicServer[] {
|
||||
const published = new Set(settings.publishedServers);
|
||||
return servers
|
||||
.flatMap((server): DiscordPublicServer[] => {
|
||||
if (!published.has(server.name)) return [];
|
||||
const identity = fleetIdentity(server);
|
||||
if (identity.game !== 't5' && identity.game !== 't6') return [];
|
||||
const game = identity.game;
|
||||
const fresh = freshServer(server, now);
|
||||
const players =
|
||||
fresh && Number.isSafeInteger(server.last_players) && server.last_players! >= 0
|
||||
? server.last_players
|
||||
: null;
|
||||
const capacity = players !== null ? currentCapacity(server, now) : null;
|
||||
const state: DiscordPublicServer['state'] =
|
||||
server.health === 'starting' || server.health === 'provisioning'
|
||||
? 'starting'
|
||||
: server.health === 'offline'
|
||||
? 'offline'
|
||||
: server.health === 'stale' || (server.health === 'online' && !fresh)
|
||||
? 'stale'
|
||||
: players === null
|
||||
? 'unknown'
|
||||
: players === 0
|
||||
? 'empty'
|
||||
: capacity !== null && players >= capacity
|
||||
? 'full'
|
||||
: 'in-progress';
|
||||
const observed = Date.parse(server.last_seen_at ?? '');
|
||||
return [
|
||||
{
|
||||
name: server.name,
|
||||
game,
|
||||
label: identity.title,
|
||||
...mapIdentity(server, game),
|
||||
state,
|
||||
players,
|
||||
capacity,
|
||||
round: players === null ? null : currentRound(server, server.telemetry, now),
|
||||
observedAt:
|
||||
Number.isFinite(observed) && observed <= now + 1000
|
||||
? new Date(observed).toISOString()
|
||||
: null,
|
||||
playerNames:
|
||||
settings.showPlayers && fresh && players !== null
|
||||
? (server.roster ?? []).slice(0, players).map((player) => player.name)
|
||||
: [],
|
||||
connection: settings.showAddresses ? publicConnection(server) : null
|
||||
}
|
||||
];
|
||||
})
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.game.localeCompare(b.game) ||
|
||||
a.label.localeCompare(b.label) ||
|
||||
a.name.localeCompare(b.name)
|
||||
);
|
||||
}
|
||||
|
||||
export function publicFleet(settings: DiscordSettings, now = Date.now()): DiscordPublicServer[] {
|
||||
return projectFleet(fleetSnapshot().servers, settings, now);
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import type { DiscordGame, DiscordMessage, DiscordPublicServer } from '../../discord.js';
|
||||
import type { LeaderboardOptions, LeaderboardResponse } from '../../leaderboard.js';
|
||||
import { leaderboardMetrics } from '../../leaderboard.js';
|
||||
import {
|
||||
efficiencyRequirement,
|
||||
formatEfficiencyValue,
|
||||
isEfficiencyMetric
|
||||
} from '../../leaderboard-efficiency.js';
|
||||
import { MIN_RECORDED_ROUND } from '../../match-history.js';
|
||||
import { gameTitle } from '../../ui/server-labels.js';
|
||||
import { discordServerKey, discordText } from './projection.js';
|
||||
|
||||
export const BOARD_PAGE_SIZE = 8;
|
||||
export const BROWSER_PAGE_SIZE = 5;
|
||||
export const LEADERBOARD_PAGE_SIZE = 10;
|
||||
const COMPONENTS_V2 = 1 << 15;
|
||||
export const EPHEMERAL = 1 << 6;
|
||||
type Component = Record<string, unknown>;
|
||||
export type BrowserStatus = 'all' | 'empty' | 'space' | 'full' | 'in-progress';
|
||||
export interface BrowserFilter {
|
||||
game?: DiscordGame;
|
||||
map?: string;
|
||||
status?: BrowserStatus;
|
||||
page?: number;
|
||||
}
|
||||
|
||||
const text = (content: string): Component => ({ type: 10, content });
|
||||
const button = (label: string, custom_id: string, disabled = false): Component => ({
|
||||
type: 2,
|
||||
style: 2,
|
||||
label,
|
||||
custom_id,
|
||||
disabled
|
||||
});
|
||||
const row = (...components: Component[]): Component => ({ type: 1, components });
|
||||
const container = (components: Component[]): Component => ({
|
||||
type: 17,
|
||||
accent_color: 0xd94868,
|
||||
components
|
||||
});
|
||||
|
||||
export function discordMessage(components: Component[], privateReply = true): DiscordMessage {
|
||||
return {
|
||||
flags: COMPONENTS_V2 | (privateReply ? EPHEMERAL : 0),
|
||||
components,
|
||||
allowed_mentions: { parse: [] }
|
||||
};
|
||||
}
|
||||
|
||||
export function privateMessage(message: string): DiscordMessage {
|
||||
return discordMessage([text(message)]);
|
||||
}
|
||||
|
||||
const states: Record<DiscordPublicServer['state'], string> = {
|
||||
empty: '🟢 Empty lobby',
|
||||
'in-progress': '🔵 In progress',
|
||||
full: '🟠 Full',
|
||||
starting: '🟡 Starting',
|
||||
offline: '⚫ Offline',
|
||||
stale: '🟡 Stale observation',
|
||||
unknown: '⚪ Status unknown'
|
||||
};
|
||||
|
||||
function summary(server: DiscordPublicServer): string {
|
||||
const count =
|
||||
server.players === null
|
||||
? 'Players unknown'
|
||||
: `${server.players}/${server.capacity ?? '?'} players`;
|
||||
return `${count}${server.round === null ? '' : ` · Round ${server.round}`}\n${states[server.state]}`;
|
||||
}
|
||||
|
||||
function observed(server: DiscordPublicServer): string {
|
||||
return server.observedAt
|
||||
? `Observed <t:${Math.floor(Date.parse(server.observedAt) / 1000)}:R>`
|
||||
: 'No observation available';
|
||||
}
|
||||
|
||||
function serverSection(server: DiscordPublicServer): Component {
|
||||
const map = server.label.startsWith(server.map) ? '' : `Map: ${discordText(server.map, 50)}\n`;
|
||||
return {
|
||||
type: 9,
|
||||
components: [
|
||||
text(
|
||||
`**${discordText(server.label, 90)}**\n${map}${summary(server)}\n-# ${observed(server)}`
|
||||
)
|
||||
],
|
||||
accessory: button('Details', `ec:d:${discordServerKey(server.name)}`)
|
||||
};
|
||||
}
|
||||
|
||||
/** Each page has <= 32 total components and comfortably fits Discord's 4,000 text limit. */
|
||||
export function renderBoard(
|
||||
game: DiscordGame,
|
||||
servers: readonly DiscordPublicServer[]
|
||||
): DiscordMessage[] {
|
||||
const selected = servers.filter((server) => server.game === game);
|
||||
const pages = Math.max(1, Math.ceil(selected.length / BOARD_PAGE_SIZE));
|
||||
return Array.from({ length: pages }, (_, page) => {
|
||||
const players = selected.reduce((sum, server) => sum + (server.players ?? 0), 0);
|
||||
const sections = selected
|
||||
.slice(page * BOARD_PAGE_SIZE, (page + 1) * BOARD_PAGE_SIZE)
|
||||
.map(serverSection);
|
||||
return discordMessage(
|
||||
[
|
||||
container([
|
||||
text(
|
||||
`## 🍒 Electric Cherry · ${gameTitle(game)}\n${selected.length} servers · ${players} observed players${pages > 1 ? ` · Page ${page + 1}/${pages}` : ''}`
|
||||
),
|
||||
...(sections.length
|
||||
? sections
|
||||
: [text('No servers are published for this game yet.')]),
|
||||
text(
|
||||
'-# Available spaces do not establish whether an in-progress match accepts players.'
|
||||
),
|
||||
row(
|
||||
button('Browse servers', browserId({ game })),
|
||||
button('Watch a map', `ec:maps:${game}`),
|
||||
button('Leaderboards', `ec:leader:${game}`)
|
||||
)
|
||||
])
|
||||
],
|
||||
false
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function browserId(filter: BrowserFilter): string {
|
||||
return `ec:b:${filter.game ?? 'all'}:${filter.status ?? 'all'}:${filter.page ?? 0}:${Buffer.from(filter.map ?? '').toString('base64url')}`;
|
||||
}
|
||||
|
||||
export function filterServers(
|
||||
servers: readonly DiscordPublicServer[],
|
||||
filter: BrowserFilter
|
||||
): DiscordPublicServer[] {
|
||||
return servers.filter(
|
||||
(server) =>
|
||||
(!filter.game || server.game === filter.game) &&
|
||||
(!filter.map || server.mapKey === filter.map) &&
|
||||
(!filter.status ||
|
||||
filter.status === 'all' ||
|
||||
(filter.status === 'space'
|
||||
? server.players !== null &&
|
||||
server.capacity !== null &&
|
||||
server.players < server.capacity
|
||||
: server.state === filter.status))
|
||||
);
|
||||
}
|
||||
|
||||
export function renderBrowser(
|
||||
servers: readonly DiscordPublicServer[],
|
||||
filter: BrowserFilter = {}
|
||||
): DiscordMessage {
|
||||
const selected = filterServers(servers, filter);
|
||||
const pages = Math.max(1, Math.ceil(selected.length / BROWSER_PAGE_SIZE));
|
||||
const page = Math.max(0, Math.min(filter.page ?? 0, pages - 1));
|
||||
const current = { ...filter, page };
|
||||
return discordMessage([
|
||||
container([
|
||||
text(
|
||||
`## Browse servers\n${filter.game ? gameTitle(filter.game) : 'All games'} · ${selected.length} matching servers · Page ${page + 1}/${pages}`
|
||||
),
|
||||
...selected
|
||||
.slice(page * BROWSER_PAGE_SIZE, (page + 1) * BROWSER_PAGE_SIZE)
|
||||
.map(serverSection),
|
||||
...(selected.length ? [] : [text('No servers match these filters.')]),
|
||||
row(
|
||||
button('All', browserId({ ...current, status: 'all', page: 0 })),
|
||||
button('Empty', browserId({ ...current, status: 'empty', page: 0 })),
|
||||
button('Space available', browserId({ ...current, status: 'space', page: 0 })),
|
||||
button('Full', browserId({ ...current, status: 'full', page: 0 }))
|
||||
),
|
||||
row(
|
||||
button(
|
||||
'Previous',
|
||||
`${browserId({ ...current, page: Math.max(0, page - 1) })}:prev`,
|
||||
page === 0
|
||||
),
|
||||
button(
|
||||
'Next',
|
||||
`${browserId({ ...current, page: page + 1 })}:next`,
|
||||
page + 1 >= pages
|
||||
)
|
||||
),
|
||||
text(
|
||||
'-# Your filters are private. Use /servers for game and map filters. Space available describes occupancy only.'
|
||||
)
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
export function renderServer(server: DiscordPublicServer): DiscordMessage {
|
||||
return discordMessage([
|
||||
container([
|
||||
text(
|
||||
`## ${discordText(server.label, 100)}\n${gameTitle(server.game)} · ${discordText(server.map, 100)}\n${summary(server)}\n${observed(server)}`
|
||||
),
|
||||
...(server.playerNames.length
|
||||
? [
|
||||
text(
|
||||
`**Players**\n${server.playerNames
|
||||
.slice(0, 16)
|
||||
.map((name) => discordText(name, 60))
|
||||
.join(' · ')}`
|
||||
)
|
||||
]
|
||||
: []),
|
||||
text(
|
||||
`**Connection**\n${server.connection ? `Public endpoint: ${discordText(server.connection, 270)}\n` : ''}${server.game === 't5' ? 'Open the Black Ops I Plutonium server browser and find this lobby.' : server.connection ? 'Open the Black Ops II Plutonium console and enter connect followed by the public endpoint above.' : 'Open the Black Ops II Plutonium server browser and find this lobby.'}\nMatch admission is not reported by Electric Cherry.`
|
||||
),
|
||||
row(
|
||||
button('Watch for empty', `ec:w:empty:${discordServerKey(server.mapKey)}`),
|
||||
button('Watch for space', `ec:w:space:${discordServerKey(server.mapKey)}`),
|
||||
button('Browse servers', browserId({ game: server.game }))
|
||||
)
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
export function renderWatchMaps(
|
||||
servers: readonly DiscordPublicServer[],
|
||||
game?: DiscordGame
|
||||
): DiscordMessage {
|
||||
const maps = [
|
||||
...new Map(
|
||||
servers
|
||||
.filter((server) => !game || server.game === game)
|
||||
.map((server) => [server.mapKey, server])
|
||||
).values()
|
||||
];
|
||||
if (!maps.length) return privateMessage('No published maps are available to watch.');
|
||||
return discordMessage([
|
||||
text(
|
||||
'## Watch a map\nChoose a map, then choose empty or space available. Watches send one DM, expire after 24 hours, and can be cancelled with /watch.'
|
||||
),
|
||||
row({
|
||||
type: 3,
|
||||
custom_id: 'ec:map',
|
||||
placeholder: 'Choose a published map',
|
||||
options: maps.slice(0, 25).map((server) => ({
|
||||
label: `${gameTitle(server.game, true)} · ${discordText(server.map, 40)}`.slice(
|
||||
0,
|
||||
100
|
||||
),
|
||||
value: discordServerKey(server.mapKey)
|
||||
}))
|
||||
})
|
||||
]);
|
||||
}
|
||||
|
||||
export function renderWatchChoice(server: DiscordPublicServer): DiscordMessage {
|
||||
return discordMessage([
|
||||
text(
|
||||
`## Watch ${discordText(server.map)}\nEmpty means zero observed players. Space means fewer players than the current observed capacity. Neither guarantees that a match accepts new players.\nOne DM · Expires in 24 hours`
|
||||
),
|
||||
row(
|
||||
button('Empty lobby', `ec:w:empty:${discordServerKey(server.mapKey)}`),
|
||||
button('Space available', `ec:w:space:${discordServerKey(server.mapKey)}`)
|
||||
)
|
||||
]);
|
||||
}
|
||||
|
||||
export function renderLeaderboard(
|
||||
report: LeaderboardResponse,
|
||||
query: LeaderboardOptions
|
||||
): DiscordMessage {
|
||||
const metric = report.metric;
|
||||
const label = leaderboardMetrics.find((item) => item.key === metric)!.label;
|
||||
const map = report.maps.find((item) => item.key === query.map)?.name;
|
||||
const items = report.entries.map((entry) => {
|
||||
const value = isEfficiencyMetric(metric)
|
||||
? formatEfficiencyValue(entry[metric], metric)
|
||||
: entry[metric].toLocaleString('en-US');
|
||||
return `**${entry.rank}.** ${discordText(entry.name, 60)} — **${value}**`;
|
||||
});
|
||||
return discordMessage([
|
||||
container([
|
||||
text(
|
||||
`## ${label}\n${query.game ? gameTitle(query.game) : 'All games'}${map ? ` · ${discordText(map)}` : ''}${query.players ? ` · ${query.players}-player starts` : ''}\n${items.length ? items.join('\n') : 'No qualifying records in this scope.'}`
|
||||
),
|
||||
text(
|
||||
`-# Completed matches with player records reaching round ${MIN_RECORDED_ROUND} or later.${isEfficiencyMetric(metric) ? ` ${efficiencyRequirement(metric)}.` : ''}\n-# Page ${Math.floor(report.offset / LEADERBOARD_PAGE_SIZE) + 1} · ${report.total} ranked players. Use /leaderboard to change filters or page.`
|
||||
)
|
||||
])
|
||||
]);
|
||||
}
|
||||
@@ -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);
|
||||
`);
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import type Database from 'better-sqlite3';
|
||||
import {
|
||||
defaultDiscordSettings,
|
||||
discordSnowflake,
|
||||
validateDiscordSettings,
|
||||
type DiscordDeliveryStatus,
|
||||
type DiscordGame,
|
||||
type DiscordMessage,
|
||||
type DiscordSettings
|
||||
} from '../../discord.js';
|
||||
import { db } from '../db/index.js';
|
||||
|
||||
export interface DiscordSubscription {
|
||||
id: string;
|
||||
guildId: string;
|
||||
userId: string;
|
||||
game: DiscordGame;
|
||||
mapKey: string;
|
||||
mode: 'empty' | 'space';
|
||||
state: 'active' | 'queued' | 'delivered' | 'failed' | 'cancelled' | 'expired';
|
||||
expiresAt: number;
|
||||
candidate: string | null;
|
||||
candidateAt: number | null;
|
||||
error: string | null;
|
||||
}
|
||||
export interface DiscordBoard {
|
||||
id: string;
|
||||
game: DiscordGame;
|
||||
guildId: string;
|
||||
channelId: string;
|
||||
page: number;
|
||||
messageId: string | null;
|
||||
contentHash: string | null;
|
||||
verifiedAt: number;
|
||||
generation: number;
|
||||
}
|
||||
export interface DiscordJob {
|
||||
id: string;
|
||||
kind: 'board' | 'delete' | 'alert';
|
||||
targetId: string;
|
||||
payload: DiscordMessage;
|
||||
contentHash: string | null;
|
||||
channelId: string | null;
|
||||
attempts: number;
|
||||
createdAt: number;
|
||||
}
|
||||
export interface MapSubscriptionInput {
|
||||
guildId: string;
|
||||
userId: string;
|
||||
game: DiscordGame;
|
||||
mapKey: string;
|
||||
mode: 'empty' | 'space';
|
||||
}
|
||||
const subscriptionColumns = `id,guild_id AS guildId,user_id AS userId,game,map_key AS mapKey,
|
||||
mode,state,expires_at AS expiresAt,candidate,candidate_at AS candidateAt,error`;
|
||||
const boardColumns = `id,game,guild_id AS guildId,channel_id AS channelId,page,message_id AS messageId,
|
||||
content_hash AS contentHash,verified_at AS verifiedAt,generation`;
|
||||
|
||||
/** Synchronous, bounded transactions; delivery never holds a database transaction open. */
|
||||
export class DiscordStore {
|
||||
constructor(
|
||||
readonly handle: Database.Database,
|
||||
readonly now: () => number = Date.now
|
||||
) {}
|
||||
|
||||
settings(): DiscordSettings {
|
||||
const row = this.handle
|
||||
.prepare('SELECT value FROM discord_configuration WHERE id=1')
|
||||
.get() as { value: string } | undefined;
|
||||
return row
|
||||
? validateDiscordSettings(JSON.parse(row.value))
|
||||
: structuredClone(defaultDiscordSettings);
|
||||
}
|
||||
|
||||
saveSettings(input: unknown): DiscordSettings {
|
||||
const settings = validateDiscordSettings(input);
|
||||
this.handle.transaction(() => {
|
||||
this.handle
|
||||
.prepare(
|
||||
'INSERT INTO discord_configuration(id,value) VALUES(1,?) ON CONFLICT(id) DO UPDATE SET value=excluded.value'
|
||||
)
|
||||
.run(JSON.stringify(settings));
|
||||
this.handle
|
||||
.prepare(
|
||||
"UPDATE discord_subscriptions SET state='cancelled' WHERE state IN ('active','queued') AND (?=0 OR guild_id!=?)"
|
||||
)
|
||||
.run(Number(settings.enabled), settings.guildId);
|
||||
this.handle
|
||||
.prepare(
|
||||
"DELETE FROM discord_outbox WHERE kind='alert' AND target_id IN (SELECT id FROM discord_subscriptions WHERE state!='queued')"
|
||||
)
|
||||
.run();
|
||||
this.retryFailed();
|
||||
})();
|
||||
return settings;
|
||||
}
|
||||
|
||||
status(): DiscordDeliveryStatus {
|
||||
const row = this.handle.prepare('SELECT * FROM discord_health WHERE id=1').get() as {
|
||||
last_attempt_at: number | null;
|
||||
last_success_at: number | null;
|
||||
last_error: string | null;
|
||||
};
|
||||
const iso = (value: number | null) =>
|
||||
value === null ? null : new Date(value).toISOString();
|
||||
return {
|
||||
lastAttemptAt: iso(row.last_attempt_at),
|
||||
lastSuccessAt: iso(row.last_success_at),
|
||||
lastError:
|
||||
row.last_error ??
|
||||
(
|
||||
this.handle
|
||||
.prepare(
|
||||
"SELECT error FROM discord_subscriptions WHERE state='failed' AND expires_at>? ORDER BY created_at DESC LIMIT 1"
|
||||
)
|
||||
.get(this.now()) as { error: string } | undefined
|
||||
)?.error ??
|
||||
null,
|
||||
boards: this.handle
|
||||
.prepare(
|
||||
'SELECT game,channel_id AS channelId,COUNT(message_id) AS messageCount FROM discord_boards GROUP BY game,channel_id'
|
||||
)
|
||||
.all() as DiscordDeliveryStatus['boards'],
|
||||
activeSubscriptions: this.count(
|
||||
"SELECT COUNT(*) AS n FROM discord_subscriptions WHERE state IN ('active','queued') AND expires_at>?",
|
||||
this.now()
|
||||
),
|
||||
pendingNotifications: this.count(
|
||||
"SELECT COUNT(*) AS n FROM discord_outbox WHERE kind='alert' AND next_at IS NOT NULL"
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
subscribe(input: MapSubscriptionInput): { id: string; expiresAt: number } {
|
||||
if (
|
||||
!discordSnowflake(input.userId) ||
|
||||
!discordSnowflake(input.guildId) ||
|
||||
!['t5', 't6'].includes(input.game) ||
|
||||
!['empty', 'space'].includes(input.mode) ||
|
||||
typeof input.mapKey !== 'string' ||
|
||||
!/^[a-zA-Z0-9_.:-]{1,100}$/.test(input.mapKey)
|
||||
)
|
||||
throw new Error('Invalid map watch.');
|
||||
const settings = this.settings();
|
||||
if (!settings.enabled || settings.guildId !== input.guildId)
|
||||
throw new Error('Discord map watches are not enabled for this server.');
|
||||
return this.handle.transaction(() => {
|
||||
this.expireSubscriptions();
|
||||
const existing = this.handle
|
||||
.prepare(
|
||||
"SELECT id,expires_at AS expiresAt FROM discord_subscriptions WHERE guild_id=? AND user_id=? AND game=? AND map_key=? AND mode=? AND state IN ('active','queued')"
|
||||
)
|
||||
.get(input.guildId, input.userId, input.game, input.mapKey, input.mode) as
|
||||
{ id: string; expiresAt: number } | undefined;
|
||||
if (existing) return existing;
|
||||
if (
|
||||
this.count(
|
||||
"SELECT COUNT(*) AS n FROM discord_subscriptions WHERE user_id=? AND state IN ('active','queued')",
|
||||
input.userId
|
||||
) >= 10
|
||||
)
|
||||
throw new Error(
|
||||
'You already have 10 active map watches. Cancel one before adding another.'
|
||||
);
|
||||
if (
|
||||
this.count(
|
||||
"SELECT COUNT(*) AS n FROM discord_subscriptions WHERE state IN ('active','queued')"
|
||||
) >= 1000
|
||||
)
|
||||
throw new Error('Map watches are currently full. Please try later.');
|
||||
const id = randomUUID();
|
||||
const expiresAt = this.now() + 24 * 60 * 60_000;
|
||||
this.handle
|
||||
.prepare(
|
||||
"INSERT INTO discord_subscriptions(id,guild_id,user_id,game,map_key,mode,state,created_at,expires_at) VALUES(?,?,?,?,?,?,'active',?,?)"
|
||||
)
|
||||
.run(
|
||||
id,
|
||||
input.guildId,
|
||||
input.userId,
|
||||
input.game,
|
||||
input.mapKey,
|
||||
input.mode,
|
||||
this.now(),
|
||||
expiresAt
|
||||
);
|
||||
return { id, expiresAt };
|
||||
})();
|
||||
}
|
||||
|
||||
subscriptions(userId?: string, guildId?: string): DiscordSubscription[] {
|
||||
this.expireSubscriptions();
|
||||
return this.handle
|
||||
.prepare(
|
||||
`SELECT ${subscriptionColumns} FROM discord_subscriptions WHERE state IN ('active','queued') ${userId ? 'AND user_id=? AND guild_id=?' : ''} ORDER BY created_at LIMIT 1000`
|
||||
)
|
||||
.all(...(userId ? [userId, guildId ?? ''] : [])) as DiscordSubscription[];
|
||||
}
|
||||
|
||||
subscription(id: string): DiscordSubscription | undefined {
|
||||
return this.handle
|
||||
.prepare(`SELECT ${subscriptionColumns} FROM discord_subscriptions WHERE id=?`)
|
||||
.get(id) as DiscordSubscription | undefined;
|
||||
}
|
||||
|
||||
cancel(id: string, guildId: string, userId: string): boolean {
|
||||
return this.handle.transaction(() => {
|
||||
const updated = this.handle
|
||||
.prepare(
|
||||
"UPDATE discord_subscriptions SET state='cancelled' WHERE id=? AND guild_id=? AND user_id=? AND state IN ('active','queued')"
|
||||
)
|
||||
.run(id, guildId, userId).changes;
|
||||
if (updated)
|
||||
this.handle
|
||||
.prepare("DELETE FROM discord_outbox WHERE kind='alert' AND target_id=?")
|
||||
.run(id);
|
||||
return updated > 0;
|
||||
})();
|
||||
}
|
||||
|
||||
expireSubscriptions(): void {
|
||||
this.handle
|
||||
.prepare(
|
||||
"UPDATE discord_subscriptions SET state='expired' WHERE state IN ('active','queued') AND expires_at<=?"
|
||||
)
|
||||
.run(this.now());
|
||||
this.handle
|
||||
.prepare(
|
||||
"DELETE FROM discord_outbox WHERE kind='alert' AND target_id IN (SELECT id FROM discord_subscriptions WHERE state NOT IN ('active','queued'))"
|
||||
)
|
||||
.run();
|
||||
this.handle
|
||||
.prepare(
|
||||
"DELETE FROM discord_subscriptions WHERE state NOT IN ('active','queued') AND (expires_at<? OR id IN (SELECT id FROM discord_subscriptions ORDER BY created_at DESC LIMIT -1 OFFSET 10000))"
|
||||
)
|
||||
.run(this.now() - 7 * 86400_000);
|
||||
}
|
||||
|
||||
candidate(id: string, candidate: string | null): void {
|
||||
this.handle
|
||||
.prepare(
|
||||
"UPDATE discord_subscriptions SET candidate=?,candidate_at=CASE WHEN candidate IS ? THEN candidate_at ELSE ? END WHERE id=? AND state='active'"
|
||||
)
|
||||
.run(candidate, candidate, candidate ? this.now() : null, id);
|
||||
}
|
||||
|
||||
queueAlert(subscription: DiscordSubscription, message: DiscordMessage): void {
|
||||
this.handle.transaction(() => {
|
||||
const current = this.subscription(subscription.id);
|
||||
if (!current || current.state !== 'active' || current.expiresAt <= this.now()) return;
|
||||
this.enqueue('alert', subscription.id, message, null);
|
||||
this.handle
|
||||
.prepare("UPDATE discord_subscriptions SET state='queued' WHERE id=?")
|
||||
.run(subscription.id);
|
||||
})();
|
||||
}
|
||||
|
||||
boards(): DiscordBoard[] {
|
||||
return this.handle
|
||||
.prepare(`SELECT ${boardColumns} FROM discord_boards ORDER BY game,channel_id,page`)
|
||||
.all() as DiscordBoard[];
|
||||
}
|
||||
|
||||
board(id: string): DiscordBoard | undefined {
|
||||
return this.handle
|
||||
.prepare(`SELECT ${boardColumns} FROM discord_boards WHERE id=?`)
|
||||
.get(id) as DiscordBoard | undefined;
|
||||
}
|
||||
|
||||
reconcileBoards(
|
||||
desired: {
|
||||
game: DiscordGame;
|
||||
guildId: string;
|
||||
channelId: string;
|
||||
messages: DiscordMessage[];
|
||||
}[]
|
||||
): void {
|
||||
this.handle.transaction(() => {
|
||||
const retained = new Set<string>();
|
||||
for (const group of desired) {
|
||||
for (const [page, message] of group.messages.entries()) {
|
||||
let board = this.boards().find(
|
||||
(item) =>
|
||||
item.game === group.game &&
|
||||
item.guildId === group.guildId &&
|
||||
item.channelId === group.channelId &&
|
||||
item.page === page
|
||||
);
|
||||
if (!board) {
|
||||
const id = randomUUID();
|
||||
this.handle
|
||||
.prepare(
|
||||
'INSERT INTO discord_boards(id,game,guild_id,channel_id,page) VALUES(?,?,?,?,?)'
|
||||
)
|
||||
.run(id, group.game, group.guildId, group.channelId, page);
|
||||
board = this.board(id)!;
|
||||
}
|
||||
retained.add(board.id);
|
||||
this.handle
|
||||
.prepare("DELETE FROM discord_outbox WHERE target_id=? AND kind='delete'")
|
||||
.run(board.id);
|
||||
const hash = createHash('sha256').update(JSON.stringify(message)).digest('hex');
|
||||
if (board.contentHash !== hash || !board.messageId)
|
||||
this.enqueue('board', board.id, message, hash);
|
||||
else
|
||||
this.handle
|
||||
.prepare(
|
||||
"DELETE FROM discord_outbox WHERE target_id=? AND kind='board'"
|
||||
)
|
||||
.run(board.id);
|
||||
}
|
||||
}
|
||||
for (const board of this.boards()) {
|
||||
if (!retained.has(board.id)) {
|
||||
if (board.messageId)
|
||||
this.enqueue('delete', board.id, { allowed_mentions: { parse: [] } }, null);
|
||||
else this.removeBoard(board.id);
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
private enqueue(
|
||||
kind: DiscordJob['kind'],
|
||||
targetId: string,
|
||||
payload: DiscordMessage,
|
||||
hash: string | null
|
||||
): void {
|
||||
const existing = this.handle
|
||||
.prepare('SELECT kind FROM discord_outbox WHERE target_id=?')
|
||||
.get(targetId) as { kind: string } | undefined;
|
||||
if (!existing && this.count('SELECT COUNT(*) AS n FROM discord_outbox') >= 500)
|
||||
throw new Error('Discord delivery queue is full.');
|
||||
this.handle
|
||||
.prepare(
|
||||
`INSERT INTO discord_outbox(id,kind,target_id,payload,content_hash,next_at,created_at) VALUES(?,?,?,?,?,?,?)
|
||||
ON CONFLICT(target_id) DO UPDATE SET kind=excluded.kind,payload=excluded.payload,content_hash=excluded.content_hash,
|
||||
attempts=CASE WHEN kind!=excluded.kind THEN 0 ELSE attempts END,
|
||||
next_at=CASE WHEN kind!=excluded.kind THEN excluded.next_at ELSE next_at END`
|
||||
)
|
||||
.run(
|
||||
randomUUID(),
|
||||
kind,
|
||||
targetId,
|
||||
JSON.stringify(payload),
|
||||
hash,
|
||||
this.now(),
|
||||
this.now()
|
||||
);
|
||||
}
|
||||
|
||||
jobs(limit = 10): DiscordJob[] {
|
||||
return (
|
||||
this.handle
|
||||
.prepare(
|
||||
`SELECT id,kind,target_id AS targetId,payload,content_hash AS contentHash,
|
||||
channel_id AS channelId,attempts,created_at AS createdAt FROM discord_outbox WHERE next_at<=? ORDER BY CASE kind WHEN 'delete' THEN 0 WHEN 'alert' THEN 1 ELSE 2 END,created_at LIMIT ?`
|
||||
)
|
||||
.all(this.now(), limit) as (Omit<DiscordJob, 'payload'> & { payload: string })[]
|
||||
).map((row) => ({ ...row, payload: JSON.parse(row.payload) }));
|
||||
}
|
||||
|
||||
cacheDm(jobId: string, channelId: string): void {
|
||||
this.handle
|
||||
.prepare('UPDATE discord_outbox SET channel_id=? WHERE id=?')
|
||||
.run(channelId, jobId);
|
||||
}
|
||||
|
||||
dropJob(id: string): void {
|
||||
this.handle.prepare('DELETE FROM discord_outbox WHERE id=?').run(id);
|
||||
}
|
||||
|
||||
resumeAlert(id: string): void {
|
||||
this.handle.transaction(() => {
|
||||
this.handle
|
||||
.prepare("DELETE FROM discord_outbox WHERE kind='alert' AND target_id=?")
|
||||
.run(id);
|
||||
this.handle
|
||||
.prepare(
|
||||
"UPDATE discord_subscriptions SET state='active',candidate=NULL,candidate_at=NULL WHERE id=? AND state='queued'"
|
||||
)
|
||||
.run(id);
|
||||
})();
|
||||
}
|
||||
|
||||
delivered(job: DiscordJob, messageId?: string): void {
|
||||
this.handle.transaction(() => {
|
||||
if (job.kind === 'board')
|
||||
this.handle
|
||||
.prepare(
|
||||
'UPDATE discord_boards SET message_id=?,content_hash=?,verified_at=? WHERE id=?'
|
||||
)
|
||||
.run(messageId, job.contentHash, this.now(), job.targetId);
|
||||
if (job.kind === 'delete')
|
||||
this.handle.prepare('DELETE FROM discord_boards WHERE id=?').run(job.targetId);
|
||||
if (job.kind === 'alert')
|
||||
this.handle
|
||||
.prepare(
|
||||
"UPDATE discord_subscriptions SET state='delivered',error=NULL WHERE id=? AND state='queued'"
|
||||
)
|
||||
.run(job.targetId);
|
||||
// A newer desired board can replace a payload while an HTTP call is in flight.
|
||||
this.handle
|
||||
.prepare('DELETE FROM discord_outbox WHERE id=? AND payload=?')
|
||||
.run(job.id, JSON.stringify(job.payload));
|
||||
this.handle
|
||||
.prepare(
|
||||
'UPDATE discord_health SET last_success_at=?,last_error=CASE WHEN EXISTS(SELECT 1 FROM discord_outbox WHERE error IS NOT NULL) THEN last_error ELSE NULL END WHERE id=1'
|
||||
)
|
||||
.run(this.now());
|
||||
})();
|
||||
}
|
||||
|
||||
removeBoard(id: string): void {
|
||||
this.handle.prepare('DELETE FROM discord_outbox WHERE target_id=?').run(id);
|
||||
this.handle.prepare('DELETE FROM discord_boards WHERE id=?').run(id);
|
||||
}
|
||||
|
||||
missingMessage(id: string): void {
|
||||
this.handle
|
||||
.prepare(
|
||||
'UPDATE discord_boards SET message_id=NULL,content_hash=NULL,verified_at=0,generation=generation+1 WHERE id=?'
|
||||
)
|
||||
.run(id);
|
||||
}
|
||||
|
||||
verified(id: string): void {
|
||||
this.handle
|
||||
.prepare('UPDATE discord_boards SET verified_at=? WHERE id=?')
|
||||
.run(this.now(), id);
|
||||
}
|
||||
|
||||
attempt(): void {
|
||||
this.handle
|
||||
.prepare('UPDATE discord_health SET last_attempt_at=? WHERE id=1')
|
||||
.run(this.now());
|
||||
}
|
||||
|
||||
error(message: string): void {
|
||||
this.handle
|
||||
.prepare('UPDATE discord_health SET last_error=? WHERE id=1')
|
||||
.run(message.slice(0, 300));
|
||||
}
|
||||
|
||||
failed(job: DiscordJob, error: string, retryAt: number | null): void {
|
||||
this.handle.transaction(() => {
|
||||
this.handle
|
||||
.prepare(
|
||||
'UPDATE discord_outbox SET attempts=attempts+1,next_at=?,error=? WHERE id=?'
|
||||
)
|
||||
.run(retryAt, error.slice(0, 300), job.id);
|
||||
if (retryAt === null && job.kind === 'alert') {
|
||||
this.handle
|
||||
.prepare("UPDATE discord_subscriptions SET state='failed',error=? WHERE id=?")
|
||||
.run(error.slice(0, 300), job.targetId);
|
||||
this.handle.prepare('DELETE FROM discord_outbox WHERE id=?').run(job.id);
|
||||
}
|
||||
this.error(error);
|
||||
})();
|
||||
}
|
||||
|
||||
retryFailed(): void {
|
||||
this.handle
|
||||
.prepare(
|
||||
'UPDATE discord_outbox SET next_at=?,attempts=0,error=NULL WHERE next_at IS NULL'
|
||||
)
|
||||
.run(this.now());
|
||||
this.handle.prepare('UPDATE discord_health SET last_error=NULL WHERE id=1').run();
|
||||
}
|
||||
|
||||
claimInteraction(id: string): boolean {
|
||||
if (!discordSnowflake(id)) throw new Error('Invalid Discord interaction ID.');
|
||||
return this.handle.transaction(() => {
|
||||
this.handle
|
||||
.prepare('DELETE FROM discord_interactions WHERE expires_at<=?')
|
||||
.run(this.now());
|
||||
if (this.handle.prepare('SELECT 1 FROM discord_interactions WHERE id=?').get(id))
|
||||
return false;
|
||||
if (this.count('SELECT COUNT(*) AS n FROM discord_interactions') >= 10000)
|
||||
throw new Error('Discord is processing too many interactions. Please try later.');
|
||||
this.handle
|
||||
.prepare('INSERT INTO discord_interactions(id,expires_at) VALUES(?,?)')
|
||||
.run(id, this.now() + 15 * 60_000);
|
||||
return true;
|
||||
})();
|
||||
}
|
||||
|
||||
private count(sql: string, ...args: (string | number)[]): number {
|
||||
return (this.handle.prepare(sql).get(...args) as { n: number }).n;
|
||||
}
|
||||
}
|
||||
|
||||
export const discordStore = () => new DiscordStore(db());
|
||||
export const getDiscordSettings = () => discordStore().settings();
|
||||
export const saveDiscordSettings = (input: unknown) => discordStore().saveSettings(input);
|
||||
export const discordStatus = () => discordStore().status();
|
||||
export const subscribeToMap = (input: MapSubscriptionInput) => discordStore().subscribe(input);
|
||||
export const listSubscriptions = (userId: string, guildId: string) =>
|
||||
discordStore().subscriptions(userId, guildId);
|
||||
export const cancelSubscription = (id: string, guildId: string, userId: string) =>
|
||||
discordStore().cancel(id, guildId, userId);
|
||||
export const claimDiscordInteraction = (id: string) => discordStore().claimInteraction(id);
|
||||
@@ -0,0 +1,132 @@
|
||||
/** Discord HTTP failures never include response bodies, which can contain private data. */
|
||||
export class DiscordDeliveryError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number,
|
||||
readonly retryAt: number | null = null,
|
||||
readonly code: number | null = null
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'DiscordDeliveryError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface DiscordTransport {
|
||||
request<T = Record<string, unknown>>(method: string, path: string, body?: unknown): Promise<T>;
|
||||
}
|
||||
|
||||
/** One serialized queue shares Discord's bucket/global cooldowns with every caller. */
|
||||
export class DiscordRest implements DiscordTransport {
|
||||
private tail: Promise<unknown> = Promise.resolve();
|
||||
private globalUntil = 0;
|
||||
private unauthorized = false;
|
||||
private routeBuckets = new Map<string, string>();
|
||||
private blockedUntil = new Map<string, number>();
|
||||
|
||||
constructor(
|
||||
private readonly token: string,
|
||||
private readonly fetcher: typeof fetch = fetch,
|
||||
private readonly now: () => number = Date.now
|
||||
) {}
|
||||
|
||||
request<T = Record<string, unknown>>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const pending = this.tail.then(() => this.send<T>(method, path, body));
|
||||
this.tail = pending.catch(() => undefined);
|
||||
return pending;
|
||||
}
|
||||
|
||||
private async send<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
// Callers supply resource paths, never arbitrary hosts or interaction tokens.
|
||||
if (!/^\/(?:channels|users|applications)\/[A-Za-z0-9_@/-]+$/.test(path))
|
||||
throw new Error('Invalid Discord API path.');
|
||||
if (this.unauthorized)
|
||||
throw new DiscordDeliveryError('Discord bot credentials were rejected.', 401);
|
||||
const segments = path.split('/');
|
||||
const major = `${segments[1]}/${segments[2]}`;
|
||||
const route = `${method}:${major}/${segments
|
||||
.slice(3)
|
||||
.map((part) => (/^\d+$/.test(part) ? ':id' : part))
|
||||
.join('/')}`;
|
||||
const key = this.routeBuckets.get(route) ?? route;
|
||||
const retryAt = Math.max(this.globalUntil, this.blockedUntil.get(key) ?? 0);
|
||||
if (retryAt > this.now())
|
||||
throw new DiscordDeliveryError(
|
||||
'Discord is waiting for a rate limit to reset.',
|
||||
429,
|
||||
retryAt
|
||||
);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await this.fetcher(`https://discord.com/api/v10${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bot ${this.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'ElectricCherry/1.0'
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
redirect: 'error'
|
||||
});
|
||||
} catch {
|
||||
throw new DiscordDeliveryError('Discord could not be reached.', 0, this.now() + 5_000);
|
||||
}
|
||||
const bucket = response.headers.get('X-RateLimit-Bucket');
|
||||
const bucketKey = bucket ? `${major}:${bucket}` : key;
|
||||
if (bucket) this.routeBuckets.set(route, bucketKey);
|
||||
const reset = seconds(response.headers.get('X-RateLimit-Reset-After'));
|
||||
if (response.headers.get('X-RateLimit-Remaining') === '0' && reset !== null)
|
||||
this.blockedUntil.set(bucketKey, this.now() + reset);
|
||||
let data: Record<string, unknown> = {};
|
||||
if (response.status !== 204) {
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch {
|
||||
if (response.ok)
|
||||
throw new DiscordDeliveryError(
|
||||
'Discord returned an invalid response.',
|
||||
502,
|
||||
this.now() + 5_000
|
||||
);
|
||||
}
|
||||
}
|
||||
if (response.status === 429) {
|
||||
const delay = Math.max(
|
||||
1000,
|
||||
seconds(data.retry_after) ??
|
||||
seconds(response.headers.get('Retry-After')) ??
|
||||
reset ??
|
||||
5_000
|
||||
);
|
||||
const until = this.now() + delay;
|
||||
if (data.global === true || response.headers.get('X-RateLimit-Global') === 'true')
|
||||
this.globalUntil = until;
|
||||
else this.blockedUntil.set(bucketKey, until);
|
||||
throw new DiscordDeliveryError('Discord rate limited this request.', 429, until);
|
||||
}
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) this.unauthorized = true;
|
||||
const message =
|
||||
response.status === 401
|
||||
? 'Discord bot credentials were rejected.'
|
||||
: response.status === 403
|
||||
? 'Discord denied permission or the recipient does not accept direct messages.'
|
||||
: response.status === 404
|
||||
? 'Discord channel or message no longer exists.'
|
||||
: `Discord request failed (HTTP ${response.status}).`;
|
||||
throw new DiscordDeliveryError(
|
||||
message,
|
||||
response.status,
|
||||
response.status >= 500 ? this.now() + 5_000 : null,
|
||||
typeof data.code === 'number' ? data.code : null
|
||||
);
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
}
|
||||
|
||||
function seconds(value: unknown): number | null {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? Math.ceil(parsed * 1000) : null;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { createPublicKey, verify } from 'node:crypto';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { boundedBody } from '../request-body.js';
|
||||
import { discordCredentials } from './config.js';
|
||||
import { discordSnowflake } from '../../discord.js';
|
||||
import type { DiscordMessage } from '../../discord.js';
|
||||
|
||||
export const DISCORD_INTERACTION_BODY_LIMIT = 64 * 1024;
|
||||
|
||||
/** Authenticate the exact request bytes before interpreting any interaction fields. */
|
||||
export async function verifiedDiscordBody(
|
||||
request: Request,
|
||||
now = Date.now()
|
||||
): Promise<Record<string, unknown>> {
|
||||
const signature = request.headers.get('x-signature-ed25519') ?? '';
|
||||
const timestamp = request.headers.get('x-signature-timestamp') ?? '';
|
||||
const { publicKey } = discordCredentials();
|
||||
if (
|
||||
!/^[a-fA-F0-9]{128}$/.test(signature) ||
|
||||
!/^[0-9]{10,12}$/.test(timestamp) ||
|
||||
Math.abs(now - Number(timestamp) * 1000) > 5 * 60_000 ||
|
||||
!/^[a-fA-F0-9]{64}$/.test(publicKey)
|
||||
)
|
||||
error(401, 'Invalid Discord signature.');
|
||||
const body = await boundedBody(request, DISCORD_INTERACTION_BODY_LIMIT);
|
||||
const key = createPublicKey({
|
||||
key: Buffer.concat([
|
||||
Buffer.from('302a300506032b6570032100', 'hex'),
|
||||
Buffer.from(publicKey, 'hex')
|
||||
]),
|
||||
format: 'der',
|
||||
type: 'spki'
|
||||
});
|
||||
if (
|
||||
!verify(
|
||||
null,
|
||||
Buffer.concat([Buffer.from(timestamp), body]),
|
||||
key,
|
||||
Buffer.from(signature, 'hex')
|
||||
)
|
||||
)
|
||||
error(401, 'Invalid Discord signature.');
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(body.toString('utf8'));
|
||||
} catch {
|
||||
error(400, 'Invalid Discord interaction.');
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
||||
error(400, 'Invalid Discord interaction.');
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function validInteractionEnvelope(body: Record<string, unknown>): boolean {
|
||||
return (
|
||||
discordSnowflake(body.id) &&
|
||||
body.application_id === discordCredentials().applicationId &&
|
||||
discordSnowflake(body.guild_id) &&
|
||||
typeof body.token === 'string' &&
|
||||
body.token.length >= 10 &&
|
||||
body.token.length <= 1000 &&
|
||||
!!body.member &&
|
||||
typeof body.member === 'object' &&
|
||||
discordSnowflake((body.member as { user?: { id?: unknown } }).user?.id)
|
||||
);
|
||||
}
|
||||
|
||||
/** Interaction webhooks use their short-lived token, never the deployment bot token. */
|
||||
export async function completeDiscordInteraction(
|
||||
applicationId: string,
|
||||
token: string,
|
||||
message: DiscordMessage,
|
||||
fetcher: typeof fetch = fetch,
|
||||
publicationAllowed: () => boolean = () => true
|
||||
): Promise<void> {
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
const response = await fetcher(
|
||||
`https://discord.com/api/v10/webhooks/${applicationId}/${encodeURIComponent(token)}/messages/@original`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...(publicationAllowed()
|
||||
? message
|
||||
: {
|
||||
flags: 32768 | 64,
|
||||
components: [
|
||||
{
|
||||
type: 10,
|
||||
content:
|
||||
'Publication settings changed. Please run your command again.'
|
||||
}
|
||||
]
|
||||
}),
|
||||
allowed_mentions: { parse: [] }
|
||||
}),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
redirect: 'error'
|
||||
}
|
||||
);
|
||||
if (response.ok) return;
|
||||
if (response.status === 429 && attempt < 2) {
|
||||
let delay = Number(response.headers.get('retry-after') ?? NaN);
|
||||
try {
|
||||
const result = (await response.json()) as { retry_after?: number };
|
||||
delay = Number(result.retry_after ?? delay);
|
||||
} catch {
|
||||
/* A valid retry header is sufficient. */
|
||||
}
|
||||
if (Number.isFinite(delay) && delay >= 0 && delay <= 30) {
|
||||
await new Promise((resolve) => setTimeout(resolve, Math.max(250, delay * 1000)));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Do not log a response body or URL: both can contain interaction credentials.
|
||||
throw new Error(`Discord interaction delivery failed (${response.status}).`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import type {
|
||||
DiscordGame,
|
||||
DiscordMessage,
|
||||
DiscordPublicServer,
|
||||
DiscordSettings
|
||||
} from '../../discord.js';
|
||||
import { requireDiscordCredentials } from './config.js';
|
||||
import { discordText, publicFleet } from './projection.js';
|
||||
import { renderBoard } from './render.js';
|
||||
import { DiscordStore, discordStore, type DiscordJob, type DiscordSubscription } from './store.js';
|
||||
import { DiscordDeliveryError, DiscordRest, type DiscordTransport } from './transport.js';
|
||||
|
||||
const TICK_MS = 30_000;
|
||||
const VERIFY_MS = 15 * 60_000;
|
||||
const FRESH_MS = 90_000;
|
||||
let client: { token: string; rest: DiscordRest } | undefined;
|
||||
|
||||
export function discordRest(): DiscordRest {
|
||||
const { botToken } = requireDiscordCredentials();
|
||||
if (!client || client.token !== botToken)
|
||||
client = { token: botToken, rest: new DiscordRest(botToken) };
|
||||
return client.rest;
|
||||
}
|
||||
|
||||
interface WorkerDependencies {
|
||||
store: DiscordStore;
|
||||
transport: () => DiscordTransport;
|
||||
fleet: (settings: DiscordSettings, now: number) => DiscordPublicServer[];
|
||||
render: (game: DiscordGame, servers: DiscordPublicServer[]) => DiscordMessage[];
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
/** A single independent reconciliation loop; it never issues game-server commands. */
|
||||
export class DiscordWorker {
|
||||
private running: Promise<void> | null = null;
|
||||
private readonly now: () => number;
|
||||
constructor(private readonly dependencies: WorkerDependencies) {
|
||||
this.now = dependencies.now ?? Date.now;
|
||||
}
|
||||
|
||||
tick(): Promise<void> {
|
||||
if (this.running) return this.running;
|
||||
this.running = this.run()
|
||||
.catch((error: unknown) => {
|
||||
// No raw fetch/config/SQL exceptions are persisted in operator-facing status.
|
||||
try {
|
||||
this.dependencies.store.error(
|
||||
error instanceof DiscordDeliveryError
|
||||
? error.message
|
||||
: 'Discord delivery could not complete. Check the integration configuration.'
|
||||
);
|
||||
} catch {
|
||||
console.error('[discord] Could not record delivery health.');
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
this.running = null;
|
||||
});
|
||||
return this.running;
|
||||
}
|
||||
|
||||
private async run(): Promise<void> {
|
||||
const { store, fleet, render } = this.dependencies;
|
||||
store.expireSubscriptions();
|
||||
const settings = store.settings();
|
||||
const servers = settings.enabled ? fleet(settings, this.now()) : [];
|
||||
store.reconcileBoards(
|
||||
settings.enabled
|
||||
? (['t5', 't6'] as const)
|
||||
.filter((game) => settings.channels[game])
|
||||
.map((game) => ({
|
||||
game,
|
||||
guildId: settings.guildId,
|
||||
channelId: settings.channels[game],
|
||||
messages: render(game, servers)
|
||||
}))
|
||||
: []
|
||||
);
|
||||
if (settings.enabled) {
|
||||
for (const watch of store.subscriptions()) {
|
||||
if (watch.state !== 'active') continue;
|
||||
if (watch.guildId !== settings.guildId) {
|
||||
store.cancel(watch.id, watch.guildId, watch.userId);
|
||||
continue;
|
||||
}
|
||||
const match = matchingServer(watch, servers, this.now());
|
||||
if (!match) {
|
||||
store.candidate(watch.id, null);
|
||||
continue;
|
||||
}
|
||||
// Persist the server identity, not each telemetry timestamp: two fresh
|
||||
// observations spanning a tick stabilize brief capacity fluctuations.
|
||||
if (watch.candidate?.split('|')[0] !== match.name) {
|
||||
store.candidate(watch.id, `${match.name}|${match.observedAt ?? ''}`);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
watch.candidateAt !== null &&
|
||||
this.now() - watch.candidateAt >= 15_000 &&
|
||||
watch.candidate !== `${match.name}|${match.observedAt ?? ''}`
|
||||
)
|
||||
store.queueAlert(watch, alertMessage(watch, match));
|
||||
}
|
||||
}
|
||||
const jobs = store.jobs(10);
|
||||
const toVerify = store
|
||||
.boards()
|
||||
.find(
|
||||
(board) =>
|
||||
board.messageId &&
|
||||
board.verifiedAt < this.now() - VERIFY_MS &&
|
||||
!jobs.some((job) => job.targetId === board.id)
|
||||
);
|
||||
if (!jobs.length && (!settings.enabled || !toVerify)) return;
|
||||
const rest = this.dependencies.transport();
|
||||
const channelGuilds = new Map<string, string>();
|
||||
const ensureChannel = async (channelId: string, guildId: string) => {
|
||||
if (!channelGuilds.has(channelId)) {
|
||||
const channel = await rest.request<{ guild_id?: string; type?: number }>(
|
||||
'GET',
|
||||
`/channels/${channelId}`
|
||||
);
|
||||
if (![0, 5].includes(channel.type ?? -1))
|
||||
throw new DiscordDeliveryError(
|
||||
'Choose a Discord text or announcement channel for the board.',
|
||||
403
|
||||
);
|
||||
channelGuilds.set(channelId, channel.guild_id ?? '');
|
||||
}
|
||||
if (channelGuilds.get(channelId) !== guildId)
|
||||
throw new DiscordDeliveryError(
|
||||
'The board channel does not belong to the configured Discord server.',
|
||||
403
|
||||
);
|
||||
};
|
||||
for (const job of jobs) {
|
||||
store.attempt();
|
||||
try {
|
||||
await this.deliver(job, rest, ensureChannel);
|
||||
} catch (error) {
|
||||
const failure =
|
||||
error instanceof DiscordDeliveryError
|
||||
? error
|
||||
: new DiscordDeliveryError(
|
||||
'Discord delivery could not complete.',
|
||||
0,
|
||||
this.now() + 5_000
|
||||
);
|
||||
const canRetry =
|
||||
failure.retryAt !== null && (failure.status === 429 || job.attempts < 7);
|
||||
const delay = Math.min(15 * 60_000, 5_000 * 2 ** Math.min(job.attempts, 8));
|
||||
store.failed(
|
||||
job,
|
||||
failure.message,
|
||||
canRetry
|
||||
? Math.max(
|
||||
failure.retryAt!,
|
||||
this.now() + (failure.status === 429 ? 0 : delay)
|
||||
)
|
||||
: null
|
||||
);
|
||||
if (failure.status === 401 || failure.status === 429) break;
|
||||
}
|
||||
}
|
||||
if (settings.enabled && toVerify) {
|
||||
const current = store.settings();
|
||||
if (
|
||||
!current.enabled ||
|
||||
current.guildId !== toVerify.guildId ||
|
||||
current.channels[toVerify.game] !== toVerify.channelId
|
||||
)
|
||||
return;
|
||||
store.attempt();
|
||||
try {
|
||||
await ensureChannel(toVerify.channelId, current.guildId);
|
||||
await rest.request(
|
||||
'GET',
|
||||
`/channels/${toVerify.channelId}/messages/${toVerify.messageId}`
|
||||
);
|
||||
store.verified(toVerify.id);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof DiscordDeliveryError &&
|
||||
error.status === 404 &&
|
||||
error.code === 10008
|
||||
)
|
||||
store.missingMessage(toVerify.id);
|
||||
else {
|
||||
store.verified(toVerify.id); // Check permissions again later, not every tick.
|
||||
store.error(
|
||||
error instanceof DiscordDeliveryError
|
||||
? error.message
|
||||
: 'Discord board verification failed.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async deliver(
|
||||
job: DiscordJob,
|
||||
rest: DiscordTransport,
|
||||
ensureChannel: (channelId: string, guildId: string) => Promise<void>
|
||||
): Promise<void> {
|
||||
const { store, fleet, render } = this.dependencies;
|
||||
const settings = store.settings();
|
||||
if (job.kind === 'alert') {
|
||||
const watch = store.subscription(job.targetId);
|
||||
if (
|
||||
!watch ||
|
||||
watch.state !== 'queued' ||
|
||||
watch.expiresAt <= this.now() ||
|
||||
!settings.enabled ||
|
||||
settings.guildId !== watch.guildId
|
||||
) {
|
||||
store.dropJob(job.id);
|
||||
return;
|
||||
}
|
||||
const match = matchingServer(watch, fleet(settings, this.now()), this.now());
|
||||
if (!match) {
|
||||
store.resumeAlert(watch.id);
|
||||
return;
|
||||
}
|
||||
let channelId = job.channelId;
|
||||
if (!channelId) {
|
||||
const channel = await rest.request<{ id: string }>('POST', '/users/@me/channels', {
|
||||
recipient_id: watch.userId
|
||||
});
|
||||
channelId = channel.id;
|
||||
if (!/^\d+$/.test(channelId))
|
||||
throw new DiscordDeliveryError(
|
||||
'Discord returned an invalid channel.',
|
||||
502,
|
||||
this.now() + 5_000
|
||||
);
|
||||
store.cacheDm(job.id, channelId);
|
||||
}
|
||||
// Recheck opt-in/config after the asynchronous channel lookup.
|
||||
const current = store.settings();
|
||||
const stillWatching = store.subscription(watch.id);
|
||||
if (
|
||||
!current.enabled ||
|
||||
current.guildId !== watch.guildId ||
|
||||
stillWatching?.state !== 'queued' ||
|
||||
stillWatching.expiresAt <= this.now()
|
||||
) {
|
||||
store.dropJob(job.id);
|
||||
return;
|
||||
}
|
||||
const fresh = matchingServer(watch, fleet(current, this.now()), this.now());
|
||||
if (!fresh) {
|
||||
store.resumeAlert(watch.id);
|
||||
return;
|
||||
}
|
||||
await rest.request('POST', `/channels/${channelId}/messages`, {
|
||||
...alertMessage(watch, fresh),
|
||||
nonce: nonce(job.id),
|
||||
enforce_nonce: true
|
||||
});
|
||||
store.delivered(job);
|
||||
return;
|
||||
}
|
||||
const board = store.board(job.targetId);
|
||||
if (!board) {
|
||||
store.dropJob(job.id);
|
||||
return;
|
||||
}
|
||||
if (job.kind === 'delete') {
|
||||
if (board.messageId) {
|
||||
try {
|
||||
await rest.request(
|
||||
'DELETE',
|
||||
`/channels/${board.channelId}/messages/${board.messageId}`
|
||||
);
|
||||
} catch (error) {
|
||||
if (!(error instanceof DiscordDeliveryError && error.status === 404))
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
store.delivered(job);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!settings.enabled ||
|
||||
settings.guildId !== board.guildId ||
|
||||
settings.channels[board.game] !== board.channelId
|
||||
) {
|
||||
store.dropJob(job.id);
|
||||
return;
|
||||
}
|
||||
await ensureChannel(board.channelId, settings.guildId);
|
||||
// Rebuild after awaits so pending jobs cannot publish withdrawn fields or servers.
|
||||
const current = store.settings();
|
||||
if (
|
||||
!current.enabled ||
|
||||
current.guildId !== settings.guildId ||
|
||||
current.channels[board.game] !== board.channelId
|
||||
) {
|
||||
store.dropJob(job.id);
|
||||
return;
|
||||
}
|
||||
const message = render(board.game, fleet(current, this.now()))[board.page];
|
||||
if (!message) {
|
||||
store.dropJob(job.id);
|
||||
return;
|
||||
}
|
||||
const updated = {
|
||||
...job,
|
||||
payload: message,
|
||||
contentHash: createHash('sha256').update(JSON.stringify(message)).digest('hex')
|
||||
};
|
||||
if (board.messageId) {
|
||||
try {
|
||||
await rest.request(
|
||||
'PATCH',
|
||||
`/channels/${board.channelId}/messages/${board.messageId}`,
|
||||
message
|
||||
);
|
||||
store.delivered(updated, board.messageId);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (!(
|
||||
error instanceof DiscordDeliveryError &&
|
||||
error.status === 404 &&
|
||||
error.code === 10008
|
||||
))
|
||||
throw error;
|
||||
store.missingMessage(board.id);
|
||||
}
|
||||
}
|
||||
const result = await rest.request<{ id: string }>(
|
||||
'POST',
|
||||
`/channels/${board.channelId}/messages`,
|
||||
{
|
||||
...message,
|
||||
nonce: nonce(`${board.id}:${store.board(board.id)?.generation ?? 0}`),
|
||||
enforce_nonce: true
|
||||
}
|
||||
);
|
||||
if (!/^\d+$/.test(result.id))
|
||||
throw new DiscordDeliveryError(
|
||||
'Discord returned an invalid message.',
|
||||
502,
|
||||
this.now() + 5_000
|
||||
);
|
||||
store.delivered(updated, result.id);
|
||||
}
|
||||
}
|
||||
|
||||
function matchingServer(
|
||||
watch: DiscordSubscription,
|
||||
servers: DiscordPublicServer[],
|
||||
now: number
|
||||
): DiscordPublicServer | undefined {
|
||||
return servers.find(
|
||||
(server) =>
|
||||
server.game === watch.game &&
|
||||
server.mapKey === watch.mapKey &&
|
||||
server.observedAt !== null &&
|
||||
now - Date.parse(server.observedAt) >= 0 &&
|
||||
now - Date.parse(server.observedAt) <= FRESH_MS &&
|
||||
['empty', 'in-progress'].includes(server.state) &&
|
||||
server.players !== null &&
|
||||
(watch.mode === 'empty'
|
||||
? server.players === 0
|
||||
: server.capacity !== null &&
|
||||
server.capacity > 0 &&
|
||||
server.players < server.capacity)
|
||||
);
|
||||
}
|
||||
|
||||
function alertMessage(watch: DiscordSubscription, server: DiscordPublicServer): DiscordMessage {
|
||||
const safe = (value: string) => discordText(value, 100);
|
||||
return {
|
||||
content: `🍒 **${safe(server.map)}** ${watch.mode === 'empty' ? 'has an empty lobby' : 'has an open player slot'}: **${safe(server.label)}** (${server.players}/${server.capacity ?? '?'}).\nThis describes occupancy; joining an in-progress game may still be restricted.\nYour one-time map watch is complete. Use /servers to browse or /watch to watch again.`,
|
||||
allowed_mentions: { parse: [] }
|
||||
};
|
||||
}
|
||||
|
||||
function nonce(id: string): string {
|
||||
return createHash('sha256').update(id).digest('hex').slice(0, 24);
|
||||
}
|
||||
|
||||
let worker: DiscordWorker | undefined;
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
export function tickDiscordWorker(): Promise<void> {
|
||||
worker ??= new DiscordWorker({
|
||||
store: discordStore(),
|
||||
transport: discordRest,
|
||||
fleet: publicFleet,
|
||||
render: renderBoard
|
||||
});
|
||||
return worker.tick();
|
||||
}
|
||||
export function startDiscordWorker(): void {
|
||||
if (timer) return;
|
||||
void tickDiscordWorker();
|
||||
timer = setInterval(() => {
|
||||
void tickDiscordWorker();
|
||||
}, TICK_MS);
|
||||
timer.unref();
|
||||
}
|
||||
export function stopDiscordWorker(): void {
|
||||
clearInterval(timer);
|
||||
timer = undefined;
|
||||
}
|
||||
export function resetDiscordClient(): void {
|
||||
client = undefined;
|
||||
}
|
||||
export function retryDiscordDelivery(): Promise<void> {
|
||||
resetDiscordClient();
|
||||
discordStore().retryFailed();
|
||||
return tickDiscordWorker();
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { isHttpError, json } from '@sveltejs/kit';
|
||||
import { claimDiscordInteraction, getDiscordSettings } from '$lib/server/discord/store.js';
|
||||
import { privateMessage } from '$lib/server/discord/render.js';
|
||||
import {
|
||||
handleDiscordInteraction,
|
||||
type DiscordInteraction
|
||||
} from '$lib/server/discord/interactions.js';
|
||||
import {
|
||||
completeDiscordInteraction,
|
||||
validInteractionEnvelope,
|
||||
verifiedDiscordBody
|
||||
} from '$lib/server/discord/webhook.js';
|
||||
|
||||
const seen = new Map<string, number>();
|
||||
const actors = new Map<string, number>();
|
||||
let pending = 0;
|
||||
const privateReply = (content: string) => ({
|
||||
type: 4,
|
||||
data: { flags: 64, content, allowed_mentions: { parse: [] } }
|
||||
});
|
||||
|
||||
export async function POST({ request }: { request: Request }) {
|
||||
try {
|
||||
const body = await verifiedDiscordBody(request);
|
||||
if (body.type === 1) return json({ type: 1 });
|
||||
if (![2, 3].includes(body.type as number) || !validInteractionEnvelope(body))
|
||||
return json({ error: 'Invalid Discord interaction.' }, { status: 400 });
|
||||
const settings = getDiscordSettings();
|
||||
if (!settings.enabled || body.guild_id !== settings.guildId)
|
||||
return json(privateReply('Electric Cherry is not enabled for this Discord server.'));
|
||||
const now = Date.now();
|
||||
for (const [key, at] of seen) if (now - at > 5 * 60_000) seen.delete(key);
|
||||
for (const [key, at] of actors) if (now - at > 60_000) actors.delete(key);
|
||||
const id = body.id as string;
|
||||
const actor = (body.member as { user: { id: string } }).user.id;
|
||||
if (seen.has(id)) return json({ type: 5, data: { flags: 64 } });
|
||||
if (pending >= 32 || seen.size >= 10_000 || now - (actors.get(actor) ?? 0) < 1000)
|
||||
return json(privateReply('Please wait a moment before trying again.'));
|
||||
if (!claimDiscordInteraction(id)) return json({ type: 5, data: { flags: 64 } });
|
||||
seen.set(id, now);
|
||||
actors.set(actor, now);
|
||||
pending++;
|
||||
// Acknowledge immediately; report queries and Discord delivery must not consume
|
||||
// the three-second response window. Durable watches are saved by the handler.
|
||||
setImmediate(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const reply = await handleDiscordInteraction(
|
||||
body as unknown as DiscordInteraction
|
||||
);
|
||||
await completeDiscordInteraction(
|
||||
body.application_id as string,
|
||||
body.token as string,
|
||||
reply.data,
|
||||
fetch,
|
||||
() => JSON.stringify(getDiscordSettings()) === JSON.stringify(settings)
|
||||
);
|
||||
} catch {
|
||||
try {
|
||||
await completeDiscordInteraction(
|
||||
body.application_id as string,
|
||||
body.token as string,
|
||||
privateMessage(
|
||||
'Electric Cherry could not complete this request. Please try again.'
|
||||
)
|
||||
);
|
||||
} catch {
|
||||
console.error('[discord] Could not deliver an interaction response.');
|
||||
}
|
||||
} finally {
|
||||
pending--;
|
||||
}
|
||||
})();
|
||||
});
|
||||
return json({ type: 5, data: { flags: 64 } });
|
||||
} catch (cause) {
|
||||
if (isHttpError(cause))
|
||||
return json({ error: cause.body.message }, { status: cause.status });
|
||||
return json({ error: 'Could not process Discord interaction.' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { isHttpError, json } from '@sveltejs/kit';
|
||||
import { validateDiscordSettings } from '$lib/discord.js';
|
||||
import { discordAdminState } from '$lib/server/discord/admin.js';
|
||||
import { requireDiscordCredentials } from '$lib/server/discord/config.js';
|
||||
import { saveDiscordSettings } from '$lib/server/discord/store.js';
|
||||
import { jsonBody } from '$lib/server/request-body.js';
|
||||
import { listServers } from '$lib/server/fleet/servers.js';
|
||||
import { resetDiscordClient } from '$lib/server/discord/worker.js';
|
||||
|
||||
export function GET() {
|
||||
return json(discordAdminState());
|
||||
}
|
||||
|
||||
export async function PUT({ request }: { request: Request }) {
|
||||
try {
|
||||
const settings = validateDiscordSettings(await jsonBody(request, 32 * 1024));
|
||||
if (settings.enabled) requireDiscordCredentials();
|
||||
const known = new Set(listServers().map((server) => server.name));
|
||||
if (settings.publishedServers.some((name) => !known.has(name)))
|
||||
return json(
|
||||
{
|
||||
error: 'One of the selected servers is no longer available. Refresh and try again.'
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
saveDiscordSettings(settings);
|
||||
resetDiscordClient();
|
||||
return json(discordAdminState());
|
||||
} catch (cause) {
|
||||
if (isHttpError(cause)) throw cause;
|
||||
return json(
|
||||
{ error: cause instanceof Error ? cause.message : 'Could not save Discord settings.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { discordCommands } from '$lib/server/discord/commands.js';
|
||||
import { requireDiscordCredentials } from '$lib/server/discord/config.js';
|
||||
import { getDiscordSettings } from '$lib/server/discord/store.js';
|
||||
import { discordRest } from '$lib/server/discord/worker.js';
|
||||
import { discordSnowflake } from '$lib/discord.js';
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
const credentials = requireDiscordCredentials();
|
||||
const settings = getDiscordSettings();
|
||||
if (!discordSnowflake(settings.guildId))
|
||||
return json({ error: 'Save a valid Discord server ID first.' }, { status: 400 });
|
||||
const rest = discordRest();
|
||||
for (const channelId of new Set(Object.values(settings.channels).filter(Boolean))) {
|
||||
const channel = await rest.request<{ guild_id?: string; type?: number }>(
|
||||
'GET',
|
||||
`/channels/${channelId}`
|
||||
);
|
||||
if (channel.guild_id !== settings.guildId || ![0, 5].includes(channel.type ?? -1))
|
||||
return json(
|
||||
{
|
||||
error: 'Choose text or announcement channels in the configured Discord server.'
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
await rest.request(
|
||||
'PUT',
|
||||
`/applications/${credentials.applicationId}/guilds/${settings.guildId}/commands`,
|
||||
discordCommands
|
||||
);
|
||||
return json({ ok: true, message: 'Discord commands registered for this server.' });
|
||||
} catch {
|
||||
// Discord error bodies and URLs can include tokens; never relay them to the UI.
|
||||
return json(
|
||||
{
|
||||
error: 'Could not register commands. Check the bot credentials, server membership and channel permissions, then try again.'
|
||||
},
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,721 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { canReadAdmin } from '$lib/auth.js';
|
||||
import {
|
||||
discordSnowflake,
|
||||
type DiscordAdminState,
|
||||
type DiscordGame,
|
||||
type DiscordPublicServer,
|
||||
type DiscordSettings
|
||||
} from '$lib/discord.js';
|
||||
import { gameTitle } from '$ui/server-labels.js';
|
||||
import { countLabel } from '$ui/plural.js';
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
import Field from '$ui/components/primitives/Field.svelte';
|
||||
import Input from '$ui/components/primitives/Input.svelte';
|
||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
|
||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||
|
||||
const games: DiscordGame[] = ['t5', 't6'];
|
||||
let adminState = $state<DiscordAdminState | null>(null);
|
||||
let draft = $state<DiscordSettings | null>(null);
|
||||
let loading = $state(true);
|
||||
let refreshing = $state(false);
|
||||
let saving = $state(false);
|
||||
let registering = $state(false);
|
||||
let error = $state('');
|
||||
let actionError = $state('');
|
||||
let notice = $state('');
|
||||
let origin = $state('');
|
||||
let requestVersion = 0;
|
||||
const administrator = $derived($page.data.user?.role === 'admin');
|
||||
const canView = $derived(canReadAdmin($page.data.user?.role));
|
||||
const busy = $derived(saving || registering);
|
||||
const dirty = $derived(
|
||||
!!adminState && !!draft && JSON.stringify(draft) !== JSON.stringify(adminState.settings)
|
||||
);
|
||||
const credentialsReady = $derived(
|
||||
!!adminState &&
|
||||
discordSnowflake(adminState.credentials.applicationId) &&
|
||||
adminState.credentials.publicKeyConfigured &&
|
||||
adminState.credentials.botTokenConfigured
|
||||
);
|
||||
const unavailableSelections = $derived(
|
||||
draft?.publishedServers.filter(
|
||||
(name) => !adminState?.servers.some((server) => server.name === name)
|
||||
) ?? []
|
||||
);
|
||||
|
||||
onMount(() => {
|
||||
origin = window.location.origin;
|
||||
if (!canView) {
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
return pollVisible((signal) => load(signal), 30_000);
|
||||
});
|
||||
|
||||
async function load(signal?: AbortSignal) {
|
||||
if (busy) return;
|
||||
const version = ++requestVersion;
|
||||
refreshing = true;
|
||||
try {
|
||||
const response = await fetch('/api/integrations/discord', { signal });
|
||||
const body = await response.json();
|
||||
if (!response.ok) throw new Error(body.error ?? 'Could not load Discord settings.');
|
||||
if (version !== requestVersion) return;
|
||||
adminState = body as DiscordAdminState;
|
||||
if (!draft) draft = structuredClone(body.settings);
|
||||
error = '';
|
||||
return true;
|
||||
} catch (cause) {
|
||||
if (version !== requestVersion || signal?.aborted) return;
|
||||
error = cause instanceof Error ? cause.message : 'Could not load Discord settings.';
|
||||
return false;
|
||||
} finally {
|
||||
if (version === requestVersion) {
|
||||
refreshing = false;
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function chooseServer(name: string, checked: boolean) {
|
||||
if (!draft || !administrator) return;
|
||||
draft.publishedServers = checked
|
||||
? [...new Set([...draft.publishedServers, name])].sort()
|
||||
: draft.publishedServers.filter((selected) => selected !== name);
|
||||
}
|
||||
|
||||
function discard() {
|
||||
if (!adminState || busy) return;
|
||||
draft = $state.snapshot(adminState.settings);
|
||||
notice = '';
|
||||
}
|
||||
|
||||
async function save(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (!draft || !administrator || busy) return;
|
||||
++requestVersion;
|
||||
refreshing = false;
|
||||
saving = true;
|
||||
error = '';
|
||||
actionError = '';
|
||||
notice = '';
|
||||
try {
|
||||
const response = await fetch('/api/integrations/discord', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(draft)
|
||||
});
|
||||
const body = await response.json();
|
||||
if (!response.ok) throw new Error(body.error ?? 'Could not save Discord settings.');
|
||||
const saved = body as DiscordAdminState;
|
||||
adminState = saved;
|
||||
draft = structuredClone(saved.settings);
|
||||
notice = saved.settings.enabled
|
||||
? 'Settings saved. The controller will update your Discord boards.'
|
||||
: 'Settings saved. Discord is disabled.';
|
||||
} catch (cause) {
|
||||
actionError =
|
||||
cause instanceof Error ? cause.message : 'Could not save Discord settings.';
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function registerCommands() {
|
||||
if (!administrator || busy || dirty) return;
|
||||
registering = true;
|
||||
actionError = '';
|
||||
notice = '';
|
||||
try {
|
||||
const response = await fetch('/api/integrations/discord/register', { method: 'POST' });
|
||||
const body = await response.json();
|
||||
if (!response.ok) throw new Error(body.error ?? 'Could not register Discord commands.');
|
||||
notice = 'Commands registered in your configured Discord server.';
|
||||
} catch (cause) {
|
||||
actionError =
|
||||
cause instanceof Error ? cause.message : 'Could not register Discord commands.';
|
||||
} finally {
|
||||
registering = false;
|
||||
}
|
||||
await load();
|
||||
}
|
||||
|
||||
function previewState(server: DiscordPublicServer) {
|
||||
if (server.state === 'empty') return 'empty-lobby';
|
||||
if (server.state === 'in-progress') return 'running';
|
||||
return server.state;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Discord · Electric Cherry</title></svelte:head>
|
||||
|
||||
<section class="page">
|
||||
<SectionHeader
|
||||
title="Discord"
|
||||
subtitle="Publish live server boards, private browsing, leaderboards, and map availability alerts."
|
||||
/>
|
||||
{#if !canView}
|
||||
<p class="ui-error" role="alert">
|
||||
Administrator access is required to view this integration.
|
||||
</p>
|
||||
{:else}
|
||||
{#if error}
|
||||
<div class="feedback">
|
||||
<p class="ui-error" role="alert">{error}</p>
|
||||
<Button size="sm" onclick={() => load()} disabled={busy || refreshing}
|
||||
>Retry loading</Button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
{#if notice}<p class="notice" role="status">{notice}</p>{/if}
|
||||
{#if actionError}<p class="ui-error" role="alert">{actionError}</p>{/if}
|
||||
{#if loading && !adminState}
|
||||
<p role="status">Loading Discord settings…</p>
|
||||
{:else if adminState && draft}
|
||||
{#if !administrator}
|
||||
<p class="muted">
|
||||
You have read-only access. An administrator can change this integration.
|
||||
</p>
|
||||
{/if}
|
||||
<div class="layout">
|
||||
<form class="configuration" onsubmit={save} aria-busy={saving}>
|
||||
<fieldset class="panel" disabled={!administrator || busy}>
|
||||
<legend>Connection and channels</legend>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" bind:checked={draft.enabled} />
|
||||
Enable Discord companion
|
||||
</label>
|
||||
<p class="help">
|
||||
Enabling publishes the selected servers to your configured channels.
|
||||
</p>
|
||||
<Field
|
||||
label="Discord server ID"
|
||||
hint="Enable Developer Mode in Discord, then right-click your Discord server and copy its ID."
|
||||
>
|
||||
<Input
|
||||
bind:value={draft.guildId}
|
||||
inputmode="numeric"
|
||||
pattern={'[1-9][0-9]{16,19}'}
|
||||
maxlength={20}
|
||||
autocomplete="off"
|
||||
mono
|
||||
/>
|
||||
</Field>
|
||||
<div class="two-columns">
|
||||
{#each games as game}
|
||||
<Field
|
||||
label={`${gameTitle(game)} channel ID`}
|
||||
hint="Leave empty to omit this game’s board."
|
||||
>
|
||||
<Input
|
||||
bind:value={draft.channels[game]}
|
||||
inputmode="numeric"
|
||||
pattern={'[1-9][0-9]{16,19}'}
|
||||
maxlength={20}
|
||||
autocomplete="off"
|
||||
mono
|
||||
/>
|
||||
</Field>
|
||||
{/each}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="panel" disabled={!administrator || busy}>
|
||||
<legend>Published servers</legend>
|
||||
<p class="help">
|
||||
Only selected servers appear in Discord browsing, boards, and
|
||||
availability alerts. New servers remain private until selected.
|
||||
</p>
|
||||
{#if adminState.servers.length === 0}<p class="muted">
|
||||
No supported game servers are available.
|
||||
</p>{/if}
|
||||
{#each games as game}
|
||||
{@const choices = adminState.servers.filter(
|
||||
(server) => server.game === game
|
||||
)}
|
||||
{#if choices.length}
|
||||
<div class="server-group">
|
||||
<h3>{gameTitle(game)}</h3>
|
||||
{#each choices as server (server.name)}
|
||||
<label class="server-choice">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.publishedServers.includes(
|
||||
server.name
|
||||
)}
|
||||
onchange={(event) =>
|
||||
chooseServer(
|
||||
server.name,
|
||||
event.currentTarget.checked
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
><strong>{server.label}</strong><small
|
||||
>{server.name}</small
|
||||
></span
|
||||
>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{#if unavailableSelections.length}
|
||||
<div class="server-group">
|
||||
<h3>No longer in the fleet</h3>
|
||||
{#each unavailableSelections as name (name)}
|
||||
<label class="server-choice">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked
|
||||
onchange={(event) =>
|
||||
chooseServer(name, event.currentTarget.checked)}
|
||||
/>
|
||||
<span
|
||||
>{name}<small
|
||||
>Remove this selection to stop publishing it.</small
|
||||
></span
|
||||
>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<p class="help">
|
||||
{countLabel(draft.publishedServers.length, 'server')} selected.
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="panel" disabled={!administrator || busy}>
|
||||
<legend>Information shared with members</legend>
|
||||
<label class="checkbox"
|
||||
><input type="checkbox" bind:checked={draft.showPlayers} />Show player
|
||||
names in server details</label
|
||||
>
|
||||
<label class="checkbox"
|
||||
><input type="checkbox" bind:checked={draft.showAddresses} />Show public
|
||||
server connection addresses</label
|
||||
>
|
||||
<label class="checkbox"
|
||||
><input
|
||||
type="checkbox"
|
||||
bind:checked={draft.leaderboardEnabled}
|
||||
/>Publish fleet leaderboards</label
|
||||
>
|
||||
<p class="help">
|
||||
Leaderboards publish player names and recorded statistics from the
|
||||
entire fleet, including servers outside the selection above. Keep this
|
||||
off if those results should stay private.
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
{#if administrator}
|
||||
<div class="save-bar">
|
||||
<span class="muted"
|
||||
>{dirty ? 'Unsaved changes' : 'All changes saved'}</span
|
||||
>
|
||||
<div class="actions">
|
||||
<Button variant="ghost" onclick={discard} disabled={!dirty || busy}
|
||||
>Discard</Button
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
loading={saving}
|
||||
disabled={(!dirty && !adminState.status.lastError) || busy}
|
||||
>{adminState.status.lastError
|
||||
? 'Save and retry'
|
||||
: 'Save changes'}</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</form>
|
||||
|
||||
<aside class="details">
|
||||
<section class="panel" aria-labelledby="setup-heading">
|
||||
<h2 id="setup-heading">Bot setup</h2>
|
||||
<p class="help">
|
||||
Create a Discord application and configure its credentials in the
|
||||
controller environment. Restart the controller after changing
|
||||
credentials.
|
||||
</p>
|
||||
<dl class="readiness">
|
||||
<div>
|
||||
<dt>Application ID</dt>
|
||||
<dd>
|
||||
<StatusBadge
|
||||
state={discordSnowflake(
|
||||
adminState.credentials.applicationId
|
||||
)
|
||||
? 'ready'
|
||||
: 'manual'}
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Public key</dt>
|
||||
<dd>
|
||||
<StatusBadge
|
||||
state={adminState.credentials.publicKeyConfigured
|
||||
? 'ready'
|
||||
: 'manual'}
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Bot token</dt>
|
||||
<dd>
|
||||
<StatusBadge
|
||||
state={adminState.credentials.botTokenConfigured
|
||||
? 'ready'
|
||||
: 'manual'}
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{#if adminState.credentials.applicationId}<p class="application-id">
|
||||
Application ID: <code>{adminState.credentials.applicationId}</code>
|
||||
</p>{/if}
|
||||
<Field
|
||||
label="Interactions endpoint"
|
||||
hint="Set this HTTPS URL in your application’s Discord Developer Portal."
|
||||
>
|
||||
<Input value={`${origin}/api/discord/interactions`} readonly mono />
|
||||
</Field>
|
||||
<p class="help">
|
||||
Install the bot with the bot and applications.commands scopes. Allow
|
||||
View Channel, Send Messages, and Read Message History in each board
|
||||
channel.
|
||||
</p>
|
||||
{#if administrator}
|
||||
<Button
|
||||
onclick={registerCommands}
|
||||
loading={registering}
|
||||
disabled={busy ||
|
||||
dirty ||
|
||||
!credentialsReady ||
|
||||
!adminState.settings.guildId}>Register commands</Button
|
||||
>
|
||||
<p class="help">
|
||||
Registers commands in the saved Discord server. Save pending changes
|
||||
first.
|
||||
</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="panel" aria-labelledby="delivery-heading">
|
||||
<div class="panel-heading">
|
||||
<h2 id="delivery-heading">Delivery health</h2>
|
||||
<StatusBadge
|
||||
state={!adminState.settings.enabled
|
||||
? 'disabled'
|
||||
: adminState.status.lastError
|
||||
? 'attention'
|
||||
: adminState.status.lastSuccessAt
|
||||
? 'ready'
|
||||
: 'waiting'}
|
||||
/>
|
||||
</div>
|
||||
{#if adminState.status.lastError}<p class="ui-error" role="alert">
|
||||
{adminState.status.lastError}
|
||||
</p>{/if}
|
||||
<dl class="readiness">
|
||||
<div>
|
||||
<dt>Last attempt</dt>
|
||||
<dd>
|
||||
<RelativeTime
|
||||
value={adminState.status.lastAttemptAt}
|
||||
missing="Not recorded"
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Last success</dt>
|
||||
<dd>
|
||||
<RelativeTime
|
||||
value={adminState.status.lastSuccessAt}
|
||||
missing="Not recorded"
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Active watches</dt>
|
||||
<dd>{adminState.status.activeSubscriptions}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Pending alerts</dt>
|
||||
<dd>{adminState.status.pendingNotifications}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{#each adminState.status.boards as board (board.game)}
|
||||
<p class="board-link">
|
||||
<a
|
||||
href={`https://discord.com/channels/${adminState.settings.guildId}/${board.channelId}`}
|
||||
target="_blank"
|
||||
rel="noreferrer">{gameTitle(board.game)} board</a
|
||||
><span>{countLabel(board.messageCount, 'message')}</span>
|
||||
</p>
|
||||
{/each}
|
||||
<Button
|
||||
size="sm"
|
||||
onclick={() => load()}
|
||||
loading={refreshing}
|
||||
disabled={busy || refreshing}>Refresh status</Button
|
||||
>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<section class="panel preview" aria-labelledby="preview-heading">
|
||||
<div class="panel-heading">
|
||||
<h2 id="preview-heading">Published server preview</h2>
|
||||
<span class="muted">Saved settings</span>
|
||||
</div>
|
||||
<p class="help">
|
||||
This shows the information available to Discord from your saved selection. Empty
|
||||
slots do not establish whether a game accepts new players.
|
||||
</p>
|
||||
{#if !adminState.settings.enabled}<p class="muted">
|
||||
Discord is disabled. This preview is visible only in Electric Cherry.
|
||||
</p>{/if}
|
||||
{#if !adminState.preview.t5.length && !adminState.preview.t6.length}
|
||||
<p class="muted">Select servers and save your changes to see a preview.</p>
|
||||
{:else}
|
||||
<div class="preview-games">
|
||||
{#each games as game}
|
||||
{#if adminState.preview[game].length}
|
||||
<section
|
||||
class="game-preview"
|
||||
aria-label={`${gameTitle(game)} preview`}
|
||||
>
|
||||
<h3>{gameTitle(game)}</h3>
|
||||
{#each adminState.preview[game] as server (server.name)}
|
||||
<article class="preview-server">
|
||||
<div class="panel-heading">
|
||||
<strong>{server.label}</strong><StatusBadge
|
||||
state={previewState(server)}
|
||||
/>
|
||||
</div>
|
||||
<p>
|
||||
{server.map} · {server.players ?? '?'} / {server.capacity ??
|
||||
'?'} players{server.round === null
|
||||
? ''
|
||||
: ` · Round ${server.round}`}
|
||||
</p>
|
||||
{#if server.playerNames.length}<p class="muted">
|
||||
{server.playerNames.join(', ')}
|
||||
</p>{/if}
|
||||
{#if server.connection}<code class="connection"
|
||||
>{server.connection}</code
|
||||
>{/if}
|
||||
<small
|
||||
>Last observed: <RelativeTime
|
||||
value={server.observedAt}
|
||||
missing="Not recorded"
|
||||
/></small
|
||||
>
|
||||
</article>
|
||||
{/each}
|
||||
</section>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.page,
|
||||
.configuration,
|
||||
.details {
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
min-width: 0;
|
||||
}
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.5fr) minmax(300px, 1fr);
|
||||
gap: var(--space-5);
|
||||
align-items: start;
|
||||
}
|
||||
.panel {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: var(--space-5);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
}
|
||||
legend,
|
||||
h2 {
|
||||
font-size: var(--text-md);
|
||||
font-weight: var(--weight-semibold);
|
||||
margin: 0;
|
||||
}
|
||||
legend {
|
||||
padding: 0 var(--space-2);
|
||||
}
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.help,
|
||||
.muted,
|
||||
small,
|
||||
dt {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.help,
|
||||
small {
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.notice {
|
||||
color: var(--adminState-online);
|
||||
}
|
||||
.checkbox,
|
||||
.server-choice {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
gap: var(--space-3);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.5;
|
||||
}
|
||||
input[type='checkbox'] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: 0 0 18px;
|
||||
margin: 2px 0 0;
|
||||
accent-color: var(--accent-primary);
|
||||
}
|
||||
input[type='checkbox']:focus-visible {
|
||||
outline: 2px solid var(--accent-primary);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.server-choice span {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
}
|
||||
.server-choice strong {
|
||||
font-weight: var(--weight-medium);
|
||||
}
|
||||
.server-choice small {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.server-group {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.two-columns,
|
||||
.preview-games {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.actions,
|
||||
.panel-heading,
|
||||
.save-bar,
|
||||
.feedback {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.panel-heading,
|
||||
.save-bar {
|
||||
justify-content: space-between;
|
||||
}
|
||||
.save-bar {
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-raised);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.readiness {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
margin: 0;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.readiness > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
dd {
|
||||
margin: 0;
|
||||
text-align: right;
|
||||
}
|
||||
.application-id {
|
||||
font-size: var(--text-xs);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.board-link {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.board-link span {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.game-preview {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
align-content: start;
|
||||
min-width: 0;
|
||||
}
|
||||
.preview-server {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-sunken);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.preview-server strong {
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.connection {
|
||||
font-size: var(--text-xs);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.layout {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.details {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
align-items: start;
|
||||
}
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.details,
|
||||
.two-columns,
|
||||
.preview-games {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.panel {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,229 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||
import { flushSync, mount, unmount } from 'svelte';
|
||||
import { writable } from 'svelte/store';
|
||||
import DiscordPage from '../../src/routes/settings/integrations/discord/+page.svelte';
|
||||
import type { DiscordAdminState, DiscordSettings } from '../../src/lib/discord.js';
|
||||
|
||||
const page = writable({ data: { user: { role: 'admin' } } });
|
||||
vi.mock('$app/stores', () => ({
|
||||
page: { subscribe: (run: Parameters<typeof page.subscribe>[0]) => page.subscribe(run) }
|
||||
}));
|
||||
|
||||
let component: ReturnType<typeof mount> | undefined;
|
||||
beforeEach(() => {
|
||||
vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible');
|
||||
});
|
||||
afterEach(async () => {
|
||||
if (component) await unmount(component);
|
||||
component = undefined;
|
||||
document.body.replaceChildren();
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const serverName = 't5_zombie_theater_1';
|
||||
const guildId = '12345678901234567';
|
||||
const channelId = '23456789012345678';
|
||||
function snapshot(): DiscordAdminState {
|
||||
return {
|
||||
settings: {
|
||||
enabled: false,
|
||||
guildId,
|
||||
channels: { t5: channelId, t6: '' },
|
||||
publishedServers: [],
|
||||
showPlayers: false,
|
||||
showAddresses: false,
|
||||
leaderboardEnabled: false
|
||||
},
|
||||
credentials: {
|
||||
applicationId: '34567890123456789',
|
||||
publicKeyConfigured: true,
|
||||
botTokenConfigured: true
|
||||
},
|
||||
status: {
|
||||
lastAttemptAt: null,
|
||||
lastSuccessAt: null,
|
||||
lastError: null,
|
||||
boards: [],
|
||||
activeSubscriptions: 0,
|
||||
pendingNotifications: 0
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
name: serverName,
|
||||
game: 't5',
|
||||
label: 'Kino der Toten · Lobby 1',
|
||||
map: 'Kino der Toten'
|
||||
}
|
||||
],
|
||||
preview: { t5: [], t6: [] }
|
||||
};
|
||||
}
|
||||
|
||||
const text = (node: Element) => node.textContent?.replace(/\s+/g, ' ').trim();
|
||||
const findButton = (label: string) =>
|
||||
[...document.querySelectorAll('button')].find((node) => text(node) === label);
|
||||
const button = (label: string) => {
|
||||
const node = findButton(label);
|
||||
expect(node, `Missing button: ${label}`).toBeDefined();
|
||||
return node!;
|
||||
};
|
||||
const checkbox = (label: string) => {
|
||||
const node = [...document.querySelectorAll('label')].find((node) =>
|
||||
text(node)?.includes(label)
|
||||
);
|
||||
const input = node?.querySelector<HTMLInputElement>('input[type="checkbox"]');
|
||||
expect(input, `Missing checkbox: ${label}`).toBeTruthy();
|
||||
return input!;
|
||||
};
|
||||
const field = (label: string) => {
|
||||
const node = [...document.querySelectorAll('label')].find((node) => text(node) === label)!;
|
||||
const input = document.getElementById(node.htmlFor) as HTMLInputElement;
|
||||
expect(input?.tagName).toBe('INPUT');
|
||||
return input;
|
||||
};
|
||||
function enter(label: string, value: string) {
|
||||
const input = field(label);
|
||||
input.value = value;
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
flushSync();
|
||||
}
|
||||
function toggle(label: string) {
|
||||
checkbox(label).click();
|
||||
flushSync();
|
||||
}
|
||||
async function show(role = 'admin') {
|
||||
page.set({ data: { user: { role } } });
|
||||
component = mount(DiscordPage, { target: document.body });
|
||||
flushSync();
|
||||
await vi.waitFor(() => {
|
||||
flushSync();
|
||||
expect(document.querySelector('form')).not.toBeNull();
|
||||
});
|
||||
}
|
||||
|
||||
it('saves explicit publication choices without credentials and restores saved values on discard', async () => {
|
||||
const initial = snapshot();
|
||||
let submitted: DiscordSettings | undefined;
|
||||
const fetch = vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
if (init?.method === 'PUT') {
|
||||
submitted = JSON.parse(String(init.body)) as DiscordSettings;
|
||||
return Response.json({ ...initial, settings: submitted });
|
||||
}
|
||||
return Response.json(initial);
|
||||
});
|
||||
vi.stubGlobal('fetch', fetch);
|
||||
await show();
|
||||
|
||||
expect(checkbox('Enable Discord companion').checked).toBe(false);
|
||||
expect(checkbox(serverName).checked).toBe(false);
|
||||
expect(checkbox('Show player names').checked).toBe(false);
|
||||
expect(checkbox('Show public server connection addresses').checked).toBe(false);
|
||||
expect(checkbox('Publish fleet leaderboards').checked).toBe(false);
|
||||
expect(document.body.textContent).toContain('including servers outside the selection above');
|
||||
|
||||
toggle('Enable Discord companion');
|
||||
toggle(serverName);
|
||||
toggle('Show player names');
|
||||
toggle('Publish fleet leaderboards');
|
||||
enter('Discord server ID', '45678901234567890');
|
||||
expect(button('Register commands').disabled).toBe(true);
|
||||
button('Save changes').click();
|
||||
await vi.waitFor(() => expect(document.body.textContent).toContain('Settings saved.'));
|
||||
|
||||
expect(submitted).toEqual({
|
||||
...initial.settings,
|
||||
enabled: true,
|
||||
guildId: '45678901234567890',
|
||||
publishedServers: [serverName],
|
||||
showPlayers: true,
|
||||
leaderboardEnabled: true
|
||||
});
|
||||
expect(fetch.mock.calls.filter(([, init]) => init?.method === 'PUT')).toHaveLength(1);
|
||||
expect(fetch.mock.calls.every(([url]) => url === '/api/integrations/discord')).toBe(true);
|
||||
expect(button('Save changes').disabled).toBe(true);
|
||||
|
||||
toggle('Show player names');
|
||||
toggle(serverName);
|
||||
button('Discard').click();
|
||||
flushSync();
|
||||
expect(checkbox('Show player names').checked).toBe(true);
|
||||
expect(checkbox(serverName).checked).toBe(true);
|
||||
expect(button('Save changes').disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps Super Viewer controls read-only and rejects a directly submitted mutation', async () => {
|
||||
const fetch = vi.fn(async (_url: string, _init?: RequestInit) => Response.json(snapshot()));
|
||||
vi.stubGlobal('fetch', fetch);
|
||||
await show('super_viewer');
|
||||
|
||||
expect(document.body.textContent).toContain('You have read-only access.');
|
||||
for (const group of document.querySelectorAll('fieldset')) expect(group.disabled).toBe(true);
|
||||
expect(findButton('Save changes')).toBeUndefined();
|
||||
expect(findButton('Register commands')).toBeUndefined();
|
||||
document
|
||||
.querySelector('form')!
|
||||
.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
||||
flushSync();
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
button('Refresh status').click();
|
||||
await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
|
||||
expect(fetch.mock.calls.every((args) => !(args[1] as RequestInit | undefined)?.method)).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves publication edits and the last snapshot across polling failures and recovery', async () => {
|
||||
vi.useFakeTimers();
|
||||
const initial = snapshot();
|
||||
const updated = snapshot();
|
||||
updated.status.activeSubscriptions = 3;
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(Response.json(initial))
|
||||
.mockRejectedValueOnce(new Error('Discord status unavailable.'))
|
||||
.mockResolvedValueOnce(Response.json(updated));
|
||||
vi.stubGlobal('fetch', fetch);
|
||||
await show();
|
||||
toggle(serverName);
|
||||
enter('Discord server ID', '45678901234567890');
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
flushSync();
|
||||
expect(document.querySelector('[role="alert"]')?.textContent).toContain(
|
||||
'Discord status unavailable.'
|
||||
);
|
||||
expect(document.body.textContent).toContain('Kino der Toten · Lobby 1');
|
||||
expect(checkbox(serverName).checked).toBe(true);
|
||||
expect(field('Discord server ID').value).toBe('45678901234567890');
|
||||
|
||||
button('Retry loading').click();
|
||||
await vi.waitFor(() => expect(document.querySelector('[role="alert"]')).toBeNull());
|
||||
expect(checkbox(serverName).checked).toBe(true);
|
||||
expect(field('Discord server ID').value).toBe('45678901234567890');
|
||||
expect(button('Save changes').disabled).toBe(false);
|
||||
expect(document.body.textContent).toContain('Unsaved changes');
|
||||
});
|
||||
|
||||
it('registers commands only on an explicit action and retains its error through successful refreshes', async () => {
|
||||
const fetch = vi.fn(async (url: string) =>
|
||||
url.endsWith('/register')
|
||||
? Response.json({ error: 'Command registration denied.' }, { status: 502 })
|
||||
: Response.json(snapshot())
|
||||
);
|
||||
vi.stubGlobal('fetch', fetch);
|
||||
await show();
|
||||
expect(fetch.mock.calls.some(([url]) => url.endsWith('/register'))).toBe(false);
|
||||
button('Register commands').click();
|
||||
await vi.waitFor(() =>
|
||||
expect(document.body.textContent).toContain('Command registration denied.')
|
||||
);
|
||||
await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(3));
|
||||
expect(fetch.mock.calls[1]).toEqual(['/api/integrations/discord/register', { method: 'POST' }]);
|
||||
button('Refresh status').click();
|
||||
await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(4));
|
||||
expect(document.querySelector('[role="alert"]')?.textContent).toContain(
|
||||
'Command registration denied.'
|
||||
);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,471 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
defaultDiscordSettings,
|
||||
type DiscordMessage,
|
||||
type DiscordSettings
|
||||
} from '../../src/lib/discord.js';
|
||||
import type { FleetServer } from '../../src/lib/fleet-overview.js';
|
||||
import type { LeaderboardResponse } from '../../src/lib/leaderboard.js';
|
||||
import { gameEvent } from '../helpers/telemetry.js';
|
||||
|
||||
vi.mock('../../src/lib/server/fleet/snapshot.js', () => ({ fleetSnapshot: vi.fn() }));
|
||||
vi.mock('../../src/lib/server/reports/index.js', () => ({ leaderboardReport: vi.fn() }));
|
||||
vi.mock('../../src/lib/server/discord/store.js', () => ({
|
||||
getDiscordSettings: vi.fn(),
|
||||
subscribeToMap: vi.fn(),
|
||||
listSubscriptions: vi.fn(),
|
||||
cancelSubscription: vi.fn()
|
||||
}));
|
||||
|
||||
import { fleetSnapshot } from '../../src/lib/server/fleet/snapshot.js';
|
||||
import { leaderboardReport } from '../../src/lib/server/reports/index.js';
|
||||
import {
|
||||
getDiscordSettings,
|
||||
subscribeToMap,
|
||||
listSubscriptions,
|
||||
cancelSubscription
|
||||
} from '../../src/lib/server/discord/store.js';
|
||||
import {
|
||||
discordServerKey,
|
||||
discordText,
|
||||
projectFleet
|
||||
} from '../../src/lib/server/discord/projection.js';
|
||||
import {
|
||||
browserId,
|
||||
filterServers,
|
||||
renderBoard,
|
||||
renderBrowser,
|
||||
renderServer
|
||||
} from '../../src/lib/server/discord/render.js';
|
||||
import {
|
||||
handleDiscordInteraction,
|
||||
type DiscordInteraction
|
||||
} from '../../src/lib/server/discord/interactions.js';
|
||||
import { discordCommands, discordMaps } from '../../src/lib/server/discord/commands.js';
|
||||
|
||||
const now = Date.parse('2026-10-18T20:00:00Z');
|
||||
const stamp = new Date(now - 10000).toISOString();
|
||||
const guildId = '111111111111111111';
|
||||
const userId = '222222222222222222';
|
||||
const applicationId = '333333333333333333';
|
||||
const name = 't5_zombie_theater_1';
|
||||
let settings: DiscordSettings;
|
||||
|
||||
function server(overrides: Partial<FleetServer> = {}): FleetServer {
|
||||
return {
|
||||
name,
|
||||
game: 't5',
|
||||
map: 'zombie_theater',
|
||||
map_slug: 'theater',
|
||||
game_mode: 'zombie',
|
||||
health: 'online',
|
||||
last_players: 2,
|
||||
capacity: 4,
|
||||
capacity_observed_at: stamp,
|
||||
last_map: 'zombie_theater',
|
||||
last_gametype: 'zombie',
|
||||
last_seen_at: stamp,
|
||||
last_error: null,
|
||||
held: [],
|
||||
active_since: new Date(now - 60000).toISOString(),
|
||||
inactive_since: null,
|
||||
game_started_at: new Date(now - 60000).toISOString(),
|
||||
game_ended_at: null,
|
||||
roster: [
|
||||
{
|
||||
name: '@everyone <@123> **spoof**',
|
||||
guid: 'private-guid',
|
||||
score: 1000,
|
||||
num: 0,
|
||||
ping: 10
|
||||
}
|
||||
],
|
||||
telemetry: { ...gameEvent('heartbeat', 28, 2), receivedAt: stamp },
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function interaction(data: DiscordInteraction['data'], type = 2): DiscordInteraction {
|
||||
return {
|
||||
type,
|
||||
application_id: applicationId,
|
||||
guild_id: guildId,
|
||||
member: { user: { id: userId } },
|
||||
data
|
||||
};
|
||||
}
|
||||
|
||||
function components(message: DiscordMessage): Record<string, unknown>[] {
|
||||
const result: Record<string, unknown>[] = [];
|
||||
const visit = (component: Record<string, unknown>) => {
|
||||
result.push(component);
|
||||
for (const child of (component.components ?? []) as Record<string, unknown>[]) visit(child);
|
||||
if (component.accessory) visit(component.accessory as Record<string, unknown>);
|
||||
};
|
||||
for (const component of message.components ?? []) visit(component);
|
||||
return result;
|
||||
}
|
||||
|
||||
function messageText(message: DiscordMessage): string {
|
||||
return components(message)
|
||||
.map((component) => component.content ?? '')
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(now);
|
||||
vi.stubEnv('EC_DISCORD_APPLICATION_ID', applicationId);
|
||||
settings = {
|
||||
...structuredClone(defaultDiscordSettings),
|
||||
enabled: true,
|
||||
guildId,
|
||||
channels: { t5: '444444444444444444', t6: '' },
|
||||
publishedServers: [name]
|
||||
};
|
||||
vi.mocked(getDiscordSettings).mockImplementation(() => settings);
|
||||
vi.mocked(fleetSnapshot).mockReturnValue({ servers: [server()] } as ReturnType<
|
||||
typeof fleetSnapshot
|
||||
>);
|
||||
vi.mocked(subscribeToMap).mockReturnValue({ id: 'watch-123', expiresAt: now + 86400000 });
|
||||
vi.mocked(listSubscriptions).mockReturnValue([]);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe('Discord publication boundary', () => {
|
||||
it('projects only opted-in servers and explicitly approved fields', () => {
|
||||
const raw = {
|
||||
...server(),
|
||||
host: { id: 'internal-host' },
|
||||
endpoint: { host: 'secret-rcon', port: 28960 },
|
||||
network: { publicAddress: 'games.example.com', publicPort: 28960 },
|
||||
secret: 'token-value'
|
||||
};
|
||||
const projected = projectFleet(
|
||||
[raw as unknown as FleetServer, server({ name: 'unpublished' })],
|
||||
settings,
|
||||
now
|
||||
);
|
||||
expect(projected).toHaveLength(1);
|
||||
expect(projected[0]).toMatchObject({
|
||||
name,
|
||||
state: 'in-progress',
|
||||
players: 2,
|
||||
capacity: 4,
|
||||
round: 28,
|
||||
mapKey: 't5:zombie_theater',
|
||||
playerNames: [],
|
||||
connection: null
|
||||
});
|
||||
for (const secret of [
|
||||
'internal-host',
|
||||
'secret-rcon',
|
||||
'private-guid',
|
||||
'token-value',
|
||||
'@everyone'
|
||||
])
|
||||
expect(JSON.stringify(projected)).not.toContain(secret);
|
||||
const optedIn = projectFleet(
|
||||
[raw as unknown as FleetServer],
|
||||
{ ...settings, showPlayers: true, showAddresses: true },
|
||||
now
|
||||
)[0];
|
||||
expect(optedIn.playerNames).toHaveLength(1);
|
||||
expect(optedIn.connection).toBe('games.example.com:28960');
|
||||
expect(JSON.stringify(optedIn)).not.toContain('private-guid');
|
||||
expect(
|
||||
projectFleet([server()], { ...settings, showAddresses: true }, now)[0].connection
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ last_players: 0 }, 'empty', 0],
|
||||
[{ last_players: 4 }, 'full', 4],
|
||||
[{ last_players: null }, 'unknown', null],
|
||||
[{ health: 'starting' }, 'starting', null],
|
||||
[{ health: 'offline' }, 'offline', null],
|
||||
[{ health: 'stale' }, 'stale', null],
|
||||
[{ last_seen_at: new Date(now - 100000).toISOString() }, 'stale', null],
|
||||
[{ last_error: 'timeout: secret diagnostics' }, 'stale', null]
|
||||
] as const)('reports truthful state for %j', (change, state, players) => {
|
||||
const projected = projectFleet([server(change)], settings, now)[0];
|
||||
expect(projected).toMatchObject({ state, players });
|
||||
if (players === null)
|
||||
expect(projected).toMatchObject({ capacity: null, round: null, playerNames: [] });
|
||||
});
|
||||
|
||||
it('never treats stale or previous-process capacity as an available space', () => {
|
||||
for (const change of [
|
||||
{ capacity_observed_at: new Date(now - 100000).toISOString() },
|
||||
{ telemetry_after: new Date(now - 5000).toISOString() }
|
||||
]) {
|
||||
const projected = projectFleet([server(change)], settings, now);
|
||||
expect(projected[0].capacity).toBeNull();
|
||||
expect(filterServers(projected, { status: 'space' })).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves T6 variants and drops registered variants after a live map change', () => {
|
||||
const town = server({
|
||||
name: 't6_zombie_town_1',
|
||||
game: 't6',
|
||||
map: 'zm_transit',
|
||||
last_map: 'zm_transit',
|
||||
map_slug: 'town',
|
||||
telemetry: undefined
|
||||
});
|
||||
const setup = { ...settings, publishedServers: [town.name] };
|
||||
expect(projectFleet([town], setup, now)[0]).toMatchObject({
|
||||
map: 'Town Survival',
|
||||
mapKey: 't6:zm_transit:town'
|
||||
});
|
||||
expect(projectFleet([{ ...town, last_map: 'zm_tomb' }], setup, now)[0]).toMatchObject({
|
||||
map: 'Origins',
|
||||
mapKey: 't6:zm_tomb:tomb'
|
||||
});
|
||||
});
|
||||
|
||||
it('neutralizes mentions, markdown links and control characters in rendered player names', () => {
|
||||
const projected = projectFleet([server()], { ...settings, showPlayers: true }, now)[0];
|
||||
const message = renderServer(projected);
|
||||
expect(message.allowed_mentions).toEqual({ parse: [] });
|
||||
expect(messageText(message)).not.toContain('@everyone');
|
||||
expect(messageText(message)).not.toContain('<@123>');
|
||||
expect(discordText('[link](https://example.com)\n@here')).toBe(
|
||||
'\\[link\\]\\(https://example.com\\) @\u200bhere'
|
||||
);
|
||||
expect(JSON.stringify(message)).not.toContain('private-guid');
|
||||
});
|
||||
});
|
||||
|
||||
describe('native Discord messages', () => {
|
||||
it('paginates large boards within Discord limits with unique component IDs', () => {
|
||||
const fleet = Array.from({ length: 100 }, (_, i) => ({
|
||||
...projectFleet([server()], settings, now)[0],
|
||||
name: `server-${i}`,
|
||||
label: '*'.repeat(128),
|
||||
map: '_'.repeat(255),
|
||||
players: Number.MAX_SAFE_INTEGER,
|
||||
capacity: Number.MAX_SAFE_INTEGER,
|
||||
round: Number.MAX_SAFE_INTEGER
|
||||
}));
|
||||
const boards = renderBoard('t5', fleet);
|
||||
expect(boards).toHaveLength(13);
|
||||
for (const message of [
|
||||
...boards,
|
||||
renderBrowser(fleet),
|
||||
renderBrowser(fleet, { page: 1 })
|
||||
]) {
|
||||
const all = components(message);
|
||||
expect(all.length).toBeLessThanOrEqual(40);
|
||||
expect(messageText(message).length).toBeLessThanOrEqual(4000);
|
||||
const ids = all.flatMap((component) =>
|
||||
typeof component.custom_id === 'string' ? [component.custom_id] : []
|
||||
);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
expect(ids.every((id) => id.length <= 100)).toBe(true);
|
||||
expect(message).not.toHaveProperty('content');
|
||||
expect(message).not.toHaveProperty('embeds');
|
||||
}
|
||||
expect(boards.every((message) => message.flags === 32768)).toBe(true);
|
||||
expect(renderBrowser(fleet).flags).toBe(32832);
|
||||
});
|
||||
|
||||
it('defines only supported guild commands with bounded choices and required options first', () => {
|
||||
expect(discordCommands.map((command) => command.name)).toEqual([
|
||||
'servers',
|
||||
'server',
|
||||
'leaderboard',
|
||||
'watch',
|
||||
'help'
|
||||
]);
|
||||
expect(JSON.stringify(discordCommands)).not.toContain('autocomplete');
|
||||
interface Schema {
|
||||
name: string;
|
||||
description: string;
|
||||
required?: boolean;
|
||||
options?: Schema[];
|
||||
choices?: { name: string; value: string | number }[];
|
||||
}
|
||||
const inspect = (entries: Schema[]) => {
|
||||
expect(entries.length).toBeLessThanOrEqual(25);
|
||||
expect(new Set(entries.map((entry) => entry.name)).size).toBe(entries.length);
|
||||
let optional = false;
|
||||
for (const entry of entries) {
|
||||
expect(entry.name).toMatch(/^[a-z_-]{1,32}$/);
|
||||
expect(entry.description.length).toBeLessThanOrEqual(100);
|
||||
if (entry.required) expect(optional).toBe(false);
|
||||
else optional = true;
|
||||
if (entry.options) inspect(entry.options);
|
||||
if (entry.choices) {
|
||||
expect(entry.choices.length).toBeLessThanOrEqual(25);
|
||||
for (const choice of entry.choices) {
|
||||
expect(choice.name.length).toBeLessThanOrEqual(100);
|
||||
expect(String(choice.value).length).toBeLessThanOrEqual(100);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
inspect(discordCommands);
|
||||
for (const map of discordMaps) {
|
||||
for (const status of ['all', 'empty', 'space', 'full', 'in-progress'] as const) {
|
||||
expect(
|
||||
`${browserId({ map: map.value, status, page: 100000 })}:next`.length
|
||||
).toBeLessThanOrEqual(100);
|
||||
}
|
||||
}
|
||||
expect(
|
||||
discordCommands.find((command) => command.name === 'watch')?.options?.[0]
|
||||
).toMatchObject({
|
||||
name: 'add',
|
||||
options: [
|
||||
expect.objectContaining({ name: 'game', required: true }),
|
||||
expect.objectContaining({ name: 'map', required: true }),
|
||||
expect.objectContaining({ name: 'condition', required: true })
|
||||
]
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Discord interaction isolation', () => {
|
||||
it.each(['guild', 'user', 'application', 'disabled'] as const)(
|
||||
'rejects unauthorized %s before reading fleet or reports',
|
||||
async (failure) => {
|
||||
const input = interaction({ name: 'servers' });
|
||||
if (failure === 'guild') input.guild_id = '555555555555555555';
|
||||
if (failure === 'user') input.member = {};
|
||||
if (failure === 'application') input.application_id = '555555555555555555';
|
||||
if (failure === 'disabled') settings.enabled = false;
|
||||
const result = await handleDiscordInteraction(input);
|
||||
expect(result.type).toBe(4);
|
||||
expect(result.data.flags).toBe(32832);
|
||||
expect(fleetSnapshot).not.toHaveBeenCalled();
|
||||
expect(leaderboardReport).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('opens board details privately and refuses old unpublished IDs', async () => {
|
||||
const result = await handleDiscordInteraction(
|
||||
interaction({ custom_id: `ec:d:${discordServerKey(name)}` }, 3)
|
||||
);
|
||||
expect(result.type).toBe(4);
|
||||
expect(result.data.flags).toBe(32832);
|
||||
expect(messageText(result.data)).toContain('Round 28');
|
||||
settings.publishedServers = [];
|
||||
expect(
|
||||
messageText(
|
||||
(
|
||||
await handleDiscordInteraction(
|
||||
interaction({ custom_id: `ec:d:${discordServerKey(name)}` }, 3)
|
||||
)
|
||||
).data
|
||||
)
|
||||
).toContain('no longer published');
|
||||
});
|
||||
|
||||
it('keeps filtered pagination private and rejects forged filters', async () => {
|
||||
const result = await handleDiscordInteraction(
|
||||
interaction(
|
||||
{
|
||||
custom_id: browserId({
|
||||
game: 't5',
|
||||
map: 't5:zombie_theater',
|
||||
status: 'empty',
|
||||
page: 900
|
||||
})
|
||||
},
|
||||
3
|
||||
)
|
||||
);
|
||||
expect(result.data.flags).toBe(32832);
|
||||
expect(messageText(result.data)).toContain('0 matching servers');
|
||||
const bad = await handleDiscordInteraction(interaction({ custom_id: 'ec:b:t7:all:0:' }, 3));
|
||||
expect(messageText(bad.data)).toContain('invalid');
|
||||
});
|
||||
|
||||
it('creates one-shot watches only for published maps and scopes cancellation to the actor', async () => {
|
||||
const result = await handleDiscordInteraction(
|
||||
interaction({ custom_id: `ec:w:empty:${discordServerKey('t5:zombie_theater')}` }, 3)
|
||||
);
|
||||
expect(subscribeToMap).toHaveBeenCalledWith({
|
||||
guildId,
|
||||
userId,
|
||||
game: 't5',
|
||||
mapKey: 't5:zombie_theater',
|
||||
mode: 'empty'
|
||||
});
|
||||
expect(messageText(result.data)).toContain('one DM');
|
||||
vi.mocked(subscribeToMap).mockClear();
|
||||
settings.publishedServers = [];
|
||||
await handleDiscordInteraction(
|
||||
interaction({ custom_id: `ec:w:space:${discordServerKey('t5:zombie_theater')}` }, 3)
|
||||
);
|
||||
expect(subscribeToMap).not.toHaveBeenCalled();
|
||||
await handleDiscordInteraction(
|
||||
interaction({
|
||||
name: 'watch',
|
||||
options: [{ name: 'cancel', options: [{ name: 'id', value: 'other-watch' }] }]
|
||||
})
|
||||
);
|
||||
expect(cancelSubscription).toHaveBeenCalledWith('other-watch', guildId, userId);
|
||||
});
|
||||
|
||||
it('requires explicit leaderboard publication and reuses the qualified report query without leaking identities', async () => {
|
||||
const input = interaction({
|
||||
name: 'leaderboard',
|
||||
options: [
|
||||
{ name: 'game', value: 't5' },
|
||||
{ name: 'map', value: 't5:zombie_theater' },
|
||||
{ name: 'metric', value: 'highestRound' },
|
||||
{ name: 'squad', value: 2 },
|
||||
{ name: 'page', value: 3 }
|
||||
]
|
||||
});
|
||||
await handleDiscordInteraction(input);
|
||||
expect(leaderboardReport).not.toHaveBeenCalled();
|
||||
settings.leaderboardEnabled = true;
|
||||
vi.mocked(leaderboardReport).mockResolvedValue({
|
||||
metric: 'highestRound',
|
||||
offset: 20,
|
||||
total: 21,
|
||||
limit: 10,
|
||||
maps: [{ key: 't5:zombie_theater', name: 'Kino Der Toten' }],
|
||||
entries: [
|
||||
{
|
||||
rank: 21,
|
||||
name: '@everyone',
|
||||
guid: 'private-guid',
|
||||
highestRound: 30,
|
||||
records: { server: 'private-server' }
|
||||
}
|
||||
]
|
||||
} as unknown as LeaderboardResponse);
|
||||
const result = await handleDiscordInteraction(input);
|
||||
expect(leaderboardReport).toHaveBeenCalledWith({
|
||||
game: 't5',
|
||||
map: 't5:zombie_theater',
|
||||
metric: 'highestRound',
|
||||
players: 2,
|
||||
offset: 20,
|
||||
limit: 10
|
||||
});
|
||||
expect(messageText(result.data)).toContain('Completed matches');
|
||||
expect(messageText(result.data)).not.toContain('@everyone');
|
||||
expect(JSON.stringify(result)).not.toContain('private-guid');
|
||||
expect(JSON.stringify(result)).not.toContain('private-server');
|
||||
});
|
||||
|
||||
it('drops an in-flight leaderboard when publication is disabled', async () => {
|
||||
settings.leaderboardEnabled = true;
|
||||
vi.mocked(leaderboardReport).mockImplementation(async () => {
|
||||
settings.leaderboardEnabled = false;
|
||||
return {} as LeaderboardResponse;
|
||||
});
|
||||
const result = await handleDiscordInteraction(interaction({ name: 'leaderboard' }));
|
||||
expect(messageText(result.data)).toContain('no longer enabled');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user