Compare commits

...
4 Commits
Author SHA1 Message Date
josh d715173719 Adjust Discord publication and lobby capacity defaults
CI / checks (push) Failing after 7m21s
2026-10-18 17:23:50 -04:00
josh fd00ef5357 Add native Discord boards and availability alerts
CI / checks (push) Failing after 7m41s
2026-10-18 16:55:49 -04:00
josh f2b2541b46 Compact archived evidence transcripts (Refs #159)
CI / checks (push) Failing after 7m22s
2026-10-18 16:20:29 -04:00
josh a7f1c65d17 Separate moderation case status from decisions
CI / checks (push) Failing after 7m24s
2026-10-17 21:30:43 -04:00
56 changed files with 5466 additions and 334 deletions
+7
View File
@@ -16,6 +16,13 @@ EC_SETUP_TOKEN=
# Game reporters send Authorization: Bearer <token> or X-EC-Telemetry-Token.
# Telemetry ingestion is disabled until a token of at least 32 chars is set.
EC_TELEMETRY_TOKEN=
# Optional native Discord companion. Keep these in the controller environment;
# enable the integration and optionally exclude servers under Admin → Discord.
# The application ID and public key are in the Discord Developer Portal.
# The bot token is a secret: never put it in channel messages or screenshots.
EC_DISCORD_APPLICATION_ID=
EC_DISCORD_PUBLIC_KEY=
EC_DISCORD_BOT_TOKEN=
# Bank credentials are generated and injected automatically per native instance.
EC_BANK_MAX_BALANCE=250000
# Persistent SQLite metadata, game-file storage, and beta archives.
+3
View File
@@ -15,6 +15,9 @@ services:
ORIGIN: ${EC_PUBLIC_URL:-http://localhost:3000}
EC_SETUP_TOKEN: ${EC_SETUP_TOKEN:-}
EC_TELEMETRY_TOKEN: ${EC_TELEMETRY_TOKEN:-}
EC_DISCORD_APPLICATION_ID: ${EC_DISCORD_APPLICATION_ID:-}
EC_DISCORD_PUBLIC_KEY: ${EC_DISCORD_PUBLIC_KEY:-}
EC_DISCORD_BOT_TOKEN: ${EC_DISCORD_BOT_TOKEN:-}
EC_BANK_MAX_BALANCE: ${EC_BANK_MAX_BALANCE:-250000}
EC_AUTH_ALLOW_HTTP: ${EC_AUTH_ALLOW_HTTP:-false}
# Enable only behind a trusted proxy that sets the client-address header.
+1
View File
@@ -11,6 +11,7 @@ and launch your first server.
- [Native hosts](guides/native-hosts.md) — enrollment, game files, capacity, agent updates, and recovery.
- [Users and access](guides/authentication.md) — roles, sessions, telemetry credentials, and password recovery.
- [Fleet settings](guides/settings.md) — concurrency, retention, and cleanup.
- [Discord companion](guides/discord.md) — live server boards, private browsing, leaderboards, and availability alerts.
### Fleet operations
+163
View File
@@ -0,0 +1,163 @@
# 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. Once enabled, it publishes all current
and newly created game servers unless an administrator excludes them.
## 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 uncheck any
game servers you want to exclude. 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
All current and newly created game servers are included by default. Uncheck a
server to exclude it from boards, private server browsing, server details, and
availability watches. Exclusions are retained if a game server is removed, so a
server with the same name remains excluded if it returns. Check an excluded
server again to remove that exclusion.
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
excluded servers. 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.
Capacity defaults to four players, matching the fleet UI, until a fresh reported
capacity overrides it. A default capacity does not make stale player counts
current or allow a stale observation to trigger an availability alert.
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 fewer
players than the current capacity, using the four-player default when no fresh
capacity override is available. 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.
+17 -13
View File
@@ -132,18 +132,22 @@ priority and assign it to an eligible administrator or moderator. Filter the cas
queue by status, priority, or assignment; active cases appear first, ordered by
priority and most recent update.
Move active cases between **Open**, **In review**, and **Escalated**, recording a
reason for workflow changes. Close a case with one of these outcomes: **Resolved**,
**Dismissed**, **Warning issued**, **Restriction applied**, **No action needed**,
**Duplicate**, **Appeal upheld**, or **Appeal denied**. Every decision requires a
reason. Reopen a closed case when new evidence or an appeal needs review; earlier
decisions remain in its history. Editing case details does not reopen a case.
Use **Change status** to move a case between **Open**, **In progress**, and
**Closed**, recording a reason for each transition. Assignment is separate:
a case can have an owner at any stage. To resume a closed case, change its status
to Open or In progress. Evidence, assessments, actions, and earlier decisions
remain in its history. Editing case details does not change its status.
Existing closed outcomes are migrated to Closed. Previously escalated cases
become In progress. Their previous statuses are recorded in history; existing
decision reasons, action references, assignments, and follow-ups are preserved.
Use **Edit assessment** to keep a concise current summary, list the applicable
rules, and record a finding for each concern: hate speech, harassment, threats,
safeguarding, spam, cheating, or another concern. Each finding is **Substantiated**,
**Inconclusive**, or **Unsubstantiated**, with a short rationale. Findings describe
the evidence; the case's workflow status and final outcome remain separate.
the evidence; case status tracks whether work remains. Record the decision and
its rationale in the assessment summary.
Every saved assessment retains its author, timestamp, and complete previous
revision. Assessments can be revised after closure without reopening the case.
@@ -174,9 +178,9 @@ external messaging delivery.
Apply a ban or mute directly from an active case. The case fixes the target game
and GUID and records the resulting restriction alongside the evidence. The same
role, scope, and duration limits apply as elsewhere in Moderation. Applying a
restriction leaves the case active so staff can finish reviewing it. Selecting a
closure outcome records a decision; it does not itself send a warning, create a
restriction, or revoke one. Linked restrictions can be revoked separately with
restriction leaves the case active so staff can finish reviewing it. Changing case status
records workflow progress; it does not send a warning, create a restriction, or
revoke one. Linked restrictions can be revoked separately with
the required permission and reason, including after a case closes. Reopening a
case also leaves its restrictions in place. Check enforcement coverage after
applying or revoking a restriction.
@@ -186,9 +190,9 @@ mute already recorded for this player with the case. This preserves the original
action, author, time, delivery state, and expiry; it does not issue another action
or extend a restriction. The same action can support more than one related case.
Linking requires the current case revision and a reason, and appears in its timeline.
When closing with **Warning issued** or **Restriction applied**, optionally reference
one of the linked records. Warning references require a confirmed warning; failed
or uncertain delivery remains visible without being presented as a confirmed warning.
Linked records show what actually happened, including failed or uncertain delivery.
They remain visible when a case closes; status changes do not require choosing
or re-entering an action.
Staff notes are explicitly labeled as notes. In match chat, select up to 100
messages and choose **Attach to case** to open a case or add them to an existing
+2
View File
@@ -7,6 +7,7 @@ import { invalidateFleet } from '$lib/server/pipeline/bus.js';
import { startPerformanceMonitor } from '$lib/server/performance.js';
import { withOperationSource } from '$lib/server/hosts/operation-context.js';
import { withHostRequestContext } from '$lib/server/hosts/request-context.js';
import { startDiscordWorker } from '$lib/server/discord/worker.js';
let booted = false;
let rejectionLoggerRegistered = false;
@@ -21,6 +22,7 @@ function boot(): void {
migrate();
interruptDeployments();
startWorker();
startDiscordWorker();
startPerformanceMonitor();
booted = true;
}
+107
View File
@@ -0,0 +1,107 @@
export type DiscordGame = 't5' | 't6';
export interface DiscordSettings {
enabled: boolean;
guildId: string;
channels: Record<DiscordGame, string>;
excludedServers: string[];
showPlayers: boolean;
showAddresses: boolean;
leaderboardEnabled: boolean;
}
export const defaultDiscordSettings: DiscordSettings = {
enabled: false,
guildId: '',
channels: { t5: '', t6: '' },
excludedServers: [],
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.excludedServers) ||
input.excludedServers.length > 100 ||
input.excludedServers.some(
(name) => typeof name !== 'string' || !/^[A-Za-z0-9_.-]{1,128}$/.test(name)
)
)
throw new Error('Choose up to 100 servers to exclude from Discord.');
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 },
excludedServers: [...new Set(input.excludedServers 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[]>;
}
+9 -9
View File
@@ -6,6 +6,7 @@ export type ModerationActionKind = 'warn' | 'message' | 'kick' | 'announce';
export type ModerationState =
'queued' | 'delivered' | 'confirmed' | 'failed' | 'expired' | 'unknown';
/** Legacy decisions retained for historical events and older API clients, never lifecycle states. */
export const moderationCaseOutcomes = [
'resolved',
'dismissed',
@@ -16,12 +17,7 @@ export const moderationCaseOutcomes = [
'appeal_upheld',
'appeal_denied'
] as const;
export const moderationCaseStatuses = [
'open',
'in_review',
'escalated',
...moderationCaseOutcomes
] as const;
export const moderationCaseStatuses = ['open', 'in_review', 'closed'] as const;
export type ModerationCaseStatus = (typeof moderationCaseStatuses)[number];
export type ModerationCasePriority = 'low' | 'normal' | 'high' | 'urgent';
export const moderationCaseRelations = ['same_incident', 'appeal_of', 'duplicate_of'] as const;
@@ -31,9 +27,13 @@ export const moderationCaseRelationLabels: Record<ModerationCaseRelation, string
appeal_of: 'Appeal of',
duplicate_of: 'Duplicate of'
};
export const moderationCaseStatusLabels: Record<ModerationCaseStatus, string> = {
export const moderationCaseStatusLabels: Record<
ModerationCaseStatus | (typeof moderationCaseOutcomes)[number] | 'escalated',
string
> = {
open: 'Open',
in_review: 'In review',
in_review: 'In progress',
closed: 'Closed',
escalated: 'Escalated',
resolved: 'Resolved',
dismissed: 'Dismissed',
@@ -45,7 +45,7 @@ export const moderationCaseStatusLabels: Record<ModerationCaseStatus, string> =
appeal_denied: 'Appeal denied'
};
export function isModerationCaseOpen(status: ModerationCaseStatus): boolean {
return status === 'open' || status === 'in_review' || status === 'escalated';
return status === 'open' || status === 'in_review';
}
export const moderationConcernCategories = [
+1
View File
@@ -84,6 +84,7 @@ export const navigationGroups: NavigationGroup[] = [
items: [
{ label: 'Users', href: '/settings/users' },
{ label: 'Keys', href: '/server-keys', shortcut: 'k' },
{ label: 'Discord', href: '/settings/integrations/discord' },
{
label: 'Settings',
href: '/settings',
+2
View File
@@ -43,6 +43,7 @@ export function routePermission(routeId: string | null, method: string): RoutePe
if (['/api/bank/[action]', '/api/afk/[action]'].includes(routeId) && method === 'POST')
return 'machine';
if (routeId === '/api/telemetry' && method === 'POST') return 'machine';
if (routeId === '/api/discord/interactions' && method === 'POST') return 'machine';
if (routeId === '/api/moderation/runtime' && method === 'POST') return 'machine';
if (routeId === '/api/leaderboard' && reading) return 'public';
if ((routeId === '/login' || routeId === '/setup') && reading) return 'public';
@@ -63,6 +64,7 @@ export function routePermission(routeId: string | null, method: string): RoutePe
if (
within(routeId, '/api/users') ||
within(routeId, '/api/integrations') ||
within(routeId, '/settings') ||
within(routeId, '/api/settings') ||
within(routeId, '/api/server-keys') ||
+16 -1
View File
@@ -26,6 +26,8 @@ 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 { migrateDiscordPublicationDefaults } from '../discord/publication-defaults.js';
import { migrateBankCredentials } from './bank-credentials.js';
import { migrateTemperatures } from '../hosts/temperatures.js';
import {
@@ -35,7 +37,8 @@ import {
migrateModerationCaseAssessments,
migrateModerationCaseRelationships,
migrateModerationCaseFollowups,
migrateModerationCaseReferenceIndexes
migrateModerationCaseReferenceIndexes,
migrateModerationCaseStates
} from '../moderation/schema.js';
import type { Migration } from './migration-runner.js';
@@ -203,6 +206,18 @@ export const migrations: readonly Migration[] = [
version: 35,
name: 'moderation case foreign key reference indexes',
run: migrateModerationCaseReferenceIndexes
},
{
version: 36,
name: 'moderation case lifecycle states',
mode: 'foreign-keys-off',
run: migrateModerationCaseStates
},
{ version: 37, name: 'Discord boards and availability subscriptions', run: migrateDiscord },
{
version: 38,
name: 'Discord default server publication',
run: migrateDiscordPublicationDefaults
}
];
+27
View File
@@ -0,0 +1,27 @@
import type { DiscordAdminState } from '../../discord.js';
import { fleetIdentity } from '../../fleet-overview.js';
import { listServers } from '../fleet/servers.js';
import { discordCredentialStatus } from './config.js';
import { publicFleet } from './projection.js';
import { discordStatus, getDiscordSettings } from './store.js';
export function discordAdminState(): DiscordAdminState {
const settings = getDiscordSettings();
const published = publicFleet(settings);
return {
settings,
credentials: discordCredentialStatus(),
status: discordStatus(),
servers: listServers().flatMap((server) => {
const identity = fleetIdentity(server);
if (identity.game !== 't5' && identity.game !== 't6') return [];
return [
{ name: server.name, game: identity.game, label: identity.title, map: identity.map }
];
}),
preview: {
t5: published.filter((server) => server.game === 't5'),
t6: published.filter((server) => server.game === 't6')
}
};
}
+131
View File
@@ -0,0 +1,131 @@
import { leaderboardMetrics } from '../../leaderboard.js';
import { zombieMaps } from '../../map-aliases.js';
const games = [
{ name: 'Black Ops I', value: 't5' },
{ name: 'Black Ops II', value: 't6' }
];
export const discordMaps = (['t5', 't6'] as const).flatMap((game) =>
zombieMaps(game).map((map) => ({
name: `${game === 't5' ? 'BO1' : 'BO2'} · ${map.friendly}`,
value: [game, map.canonical, ...(game === 't6' ? [map.slug] : [])].join(':')
}))
);
const gameOption = { type: 3, name: 'game', description: 'Game', choices: games };
const mapOption = { type: 3, name: 'map', description: 'Map and ruleset', choices: discordMaps };
const pageOption = {
type: 4,
name: 'page',
description: 'Page number',
min_value: 1,
max_value: 100001
};
/** Guild commands only: no Gateway connection or privileged member/content intents. */
export const discordCommands = [
{
name: 'servers',
description: 'Browse Electric Cherry servers privately',
type: 1,
options: [
gameOption,
mapOption,
{
type: 3,
name: 'status',
description: 'Observed occupancy',
choices: [
{ name: 'All', value: 'all' },
{ name: 'Empty', value: 'empty' },
{ name: 'Space available', value: 'space' },
{ name: 'Full', value: 'full' },
{ name: 'In progress', value: 'in-progress' }
]
},
pageOption
]
},
{
name: 'server',
description: 'Show details of a published server',
type: 1,
options: [
{
type: 3,
name: 'name',
description: 'Published server name (or use a Details button)',
required: true,
max_length: 128
}
]
},
{
name: 'leaderboard',
description: 'View Electric Cherry completed-game rankings',
type: 1,
options: [
gameOption,
mapOption,
{
type: 3,
name: 'metric',
description: 'Ranking metric',
choices: leaderboardMetrics.map((metric) => ({
name: metric.label,
value: metric.key
}))
},
{
type: 4,
name: 'squad',
description: 'Number of players at match start',
min_value: 1,
max_value: 4
},
pageOption
]
},
{
name: 'watch',
description: 'Receive one private map availability notification',
type: 1,
options: [
{
type: 1,
name: 'add',
description: 'Watch a map for up to 24 hours',
options: [
{ ...gameOption, required: true },
{ ...mapOption, required: true },
{
type: 3,
name: 'condition',
description: 'Observed occupancy to watch for',
required: true,
choices: [
{ name: 'An empty lobby', value: 'empty' },
{ name: 'A lobby with space available', value: 'space' }
]
}
]
},
{ type: 1, name: 'list', description: 'List your active watches' },
{
type: 1,
name: 'cancel',
description: 'Cancel one of your watches',
options: [
{
type: 3,
name: 'id',
description: 'Watch ID shown by /watch list',
required: true,
max_length: 100
}
]
}
]
},
{ name: 'help', description: 'How to use the Electric Cherry companion', type: 1 }
];
+30
View File
@@ -0,0 +1,30 @@
import { discordSnowflake } from '../../discord.js';
/** Credentials belong to the deployment environment and never to browser settings. */
export function discordCredentials() {
return {
applicationId: (process.env.EC_DISCORD_APPLICATION_ID ?? '').trim(),
publicKey: (process.env.EC_DISCORD_PUBLIC_KEY ?? '').trim(),
botToken: (process.env.EC_DISCORD_BOT_TOKEN ?? '').trim()
};
}
export function discordCredentialStatus() {
const credentials = discordCredentials();
return {
applicationId: credentials.applicationId,
publicKeyConfigured: /^[a-fA-F0-9]{64}$/.test(credentials.publicKey),
botTokenConfigured: !!credentials.botToken
};
}
export function requireDiscordCredentials(): ReturnType<typeof discordCredentials> {
const credentials = discordCredentials();
if (
!discordSnowflake(credentials.applicationId) ||
!/^[a-fA-F0-9]{64}$/.test(credentials.publicKey) ||
!credentials.botToken
)
throw new Error('Configure the Discord application ID, public key and bot token first.');
return credentials;
}
+322
View File
@@ -0,0 +1,322 @@
import {
discordSnowflake,
type DiscordGame,
type DiscordMessage,
type DiscordPublicServer
} from '../../discord.js';
import {
leaderboardMetrics,
type LeaderboardMetric,
type LeaderboardOptions,
type LeaderboardPlayerCount
} from '../../leaderboard.js';
import { leaderboardReport } from '../reports/index.js';
import { discordCredentials } from './config.js';
import { discordMaps } from './commands.js';
import { discordServerKey, discordText, publicFleet } from './projection.js';
import {
cancelSubscription,
getDiscordSettings,
listSubscriptions,
subscribeToMap
} from './store.js';
import {
LEADERBOARD_PAGE_SIZE,
privateMessage,
renderBrowser,
renderLeaderboard,
renderServer,
renderWatchChoice,
renderWatchMaps,
type BrowserFilter,
type BrowserStatus
} from './render.js';
interface DiscordOption {
type?: number;
name: string;
value?: string | number;
options?: DiscordOption[];
}
export interface DiscordInteraction {
type: number;
application_id?: string;
guild_id?: string;
member?: { user?: { id?: string } };
data?: { name?: string; options?: DiscordOption[]; custom_id?: string; values?: string[] };
}
export interface DiscordInteractionResponse {
type: 4;
data: DiscordMessage;
}
class InputError extends Error {}
const response = (data: DiscordMessage): DiscordInteractionResponse => ({ type: 4, data });
export const privateDiscordError = (message: string) =>
response(privateMessage(discordText(message, 400)));
const statuses: BrowserStatus[] = ['all', 'empty', 'space', 'full', 'in-progress'];
function optionValues(options: DiscordOption[] | undefined): Map<string, string | number> {
if (options !== undefined && (!Array.isArray(options) || options.length > 25))
throw new InputError('Invalid command options.');
const values = new Map<string, string | number>();
for (const option of options ?? []) {
if (
!option ||
typeof option.name !== 'string' ||
values.has(option.name) ||
!['string', 'number'].includes(typeof option.value)
)
throw new InputError('Invalid command options.');
values.set(option.name, option.value!);
}
return values;
}
function stringOption(
values: Map<string, string | number>,
key: string,
limit = 128
): string | undefined {
const value = values.get(key);
if (value === undefined) return undefined;
if (typeof value !== 'string' || !value.trim() || value.length > limit)
throw new InputError(`Invalid ${key} option.`);
return value.trim();
}
function scope(values: Map<string, string | number>): { game?: DiscordGame; map?: string } {
const game = stringOption(values, 'game');
const map = stringOption(values, 'map');
if (game && game !== 't5' && game !== 't6')
throw new InputError('Choose Black Ops I or Black Ops II.');
if (map && !discordMaps.some((entry) => entry.value === map))
throw new InputError('Choose a map from the command options.');
if (game && map && !map.startsWith(`${game}:`))
throw new InputError('Choose a map from the selected game.');
return { game: (game ?? map?.slice(0, 2)) as DiscordGame | undefined, map };
}
function page(values: Map<string, string | number>): number {
const value = values.get('page') ?? 1;
if (!Number.isSafeInteger(value) || Number(value) < 1 || Number(value) > 100001)
throw new InputError('Choose a page between 1 and 100001.');
return Number(value) - 1;
}
function watchMap(
servers: DiscordPublicServer[],
key: string,
game?: DiscordGame
): DiscordPublicServer {
const found = servers.find(
(server) =>
(server.mapKey === key || discordServerKey(server.mapKey) === key) &&
(!game || server.game === game)
);
if (!found)
throw new InputError('That map is no longer published. Browse the current server list.');
if (!/^[A-Za-z0-9_.:-]{1,100}$/.test(found.mapKey))
throw new InputError('A map watch is unavailable for this reported map.');
return found;
}
async function leaderboard(values: Map<string, string | number>): Promise<DiscordMessage> {
const settings = getDiscordSettings();
if (!settings.leaderboardEnabled)
return privateMessage('Leaderboard publication is not enabled for this Discord server.');
const metric = stringOption(values, 'metric') ?? 'highestRound';
if (!leaderboardMetrics.some((entry) => entry.key === metric))
throw new InputError('Choose a ranking metric from the command options.');
const squad = values.get('squad');
if (
squad !== undefined &&
(!Number.isSafeInteger(squad) || Number(squad) < 1 || Number(squad) > 4)
)
throw new InputError('Squad size must be between 1 and 4.');
const query: LeaderboardOptions = {
...scope(values),
metric: metric as LeaderboardMetric,
players: squad as LeaderboardPlayerCount | undefined,
limit: LEADERBOARD_PAGE_SIZE,
offset: page(values) * LEADERBOARD_PAGE_SIZE
};
const report = await leaderboardReport(query);
// Publishing may have been disabled while the report worker was running.
const current = getDiscordSettings();
if (!current.enabled || current.guildId !== settings.guildId || !current.leaderboardEnabled)
return privateMessage('Leaderboard publication is no longer enabled.');
return renderLeaderboard(report, query);
}
function addWatch(
servers: DiscordPublicServer[],
key: string,
mode: string | undefined,
guildId: string,
userId: string,
game?: DiscordGame
): DiscordMessage {
if (mode !== 'empty' && mode !== 'space')
throw new InputError('Choose an empty-lobby or space-available watch.');
const server = watchMap(servers, key, game);
let watch: { id: string; expiresAt: number };
try {
watch = subscribeToMap({ guildId, userId, game: server.game, mapKey: server.mapKey, mode });
} catch (error) {
if (
error instanceof Error &&
(error.message.startsWith('You already have 10') ||
error.message.startsWith('Map watches are currently full'))
)
throw new InputError(error.message);
throw error;
}
return privateMessage(
`Watching **${discordText(server.map)}** for ${mode === 'empty' ? 'an empty lobby' : 'space available'}.\nYou will receive one DM after stable observations. Enable DMs from this server to receive it. This does not guarantee match admission.\nExpires <t:${Math.floor(watch.expiresAt / 1000)}:R> · Cancel with /watch cancel\nWatch ID: ${discordText(watch.id)}`
);
}
function parseBrowserId(id: string): BrowserFilter {
const match =
/^ec:b:(all|t5|t6):(all|empty|space|full|in-progress):(\d{1,6}):([A-Za-z0-9_-]{0,70})(?::(?:prev|next))?$/.exec(
id
);
if (!match) throw new InputError('This browser control is invalid. Run /servers again.');
const map = Buffer.from(match[4], 'base64url').toString();
if (map && !discordMaps.some((entry) => entry.value === map))
throw new InputError('This map filter is unavailable. Run /servers again.');
return {
game: match[1] === 'all' ? undefined : (match[1] as DiscordGame),
status: match[2] as BrowserStatus,
page: Number(match[3]),
map: map || undefined
};
}
/** All responses are private new messages; components never mutate the shared board. */
export async function handleDiscordInteraction(
interaction: DiscordInteraction
): Promise<DiscordInteractionResponse> {
const userId = interaction.member?.user?.id;
if (
!discordSnowflake(userId) ||
!discordSnowflake(interaction.guild_id) ||
interaction.application_id !== discordCredentials().applicationId
)
return privateDiscordError('This interaction is not available here.');
const settings = getDiscordSettings();
if (!settings.enabled || settings.guildId !== interaction.guild_id)
return privateDiscordError('Electric Cherry is not enabled for this Discord server.');
if (interaction.type !== 2 && interaction.type !== 3)
return privateDiscordError('This interaction is not supported.');
const data = interaction.data;
if (!data || typeof data !== 'object')
return privateDiscordError('This interaction is invalid.');
try {
if (interaction.type === 2 && data.name === 'leaderboard')
return response(await leaderboard(optionValues(data.options)));
if (interaction.type === 2 && data.name === 'help')
return response(
privateMessage(
'## 🍒 Electric Cherry\n/servers — browse live servers privately\n/server — inspect a published server\n/leaderboard — completed-game rankings, when enabled\n/watch add — one DM when a map has an empty lobby or space\n/watch list · /watch cancel — manage your watches\n\nWatches expire after 24 hours. Observed spaces do not guarantee that a match accepts new players.'
)
);
const servers = publicFleet(settings);
if (interaction.type === 3) {
const id = data.custom_id;
if (typeof id !== 'string' || id.length > 100)
throw new InputError('This control is invalid.');
if (id.startsWith('ec:b:')) return response(renderBrowser(servers, parseBrowserId(id)));
if (/^ec:d:[a-f0-9]{20}$/.test(id)) {
const server = servers.find(
(entry) => discordServerKey(entry.name) === id.slice(5)
);
if (!server) throw new InputError('This server is no longer published.');
return response(renderServer(server));
}
if (/^ec:maps:(t5|t6)$/.test(id))
return response(renderWatchMaps(servers, id.slice(8) as DiscordGame));
if (
id === 'ec:map' &&
Array.isArray(data.values) &&
data.values.length === 1 &&
typeof data.values[0] === 'string'
)
return response(renderWatchChoice(watchMap(servers, data.values[0])));
const watch = /^ec:w:(empty|space):([a-f0-9]{20})$/.exec(id);
if (watch)
return response(addWatch(servers, watch[2], watch[1], settings.guildId, userId));
if (/^ec:leader:(t5|t6)$/.test(id))
return response(await leaderboard(new Map([['game', id.slice(10)]])));
throw new InputError(
'This control is no longer available. Run /help for current commands.'
);
}
if (data.name === 'watch') {
if (!Array.isArray(data.options) || data.options.length !== 1)
throw new InputError('Choose watch add, list, or cancel.');
const command = data.options[0];
const values = optionValues(command.options);
if (command.name === 'add') {
const selected = scope(values);
if (!selected.game || !selected.map) throw new InputError('Choose a game and map.');
return response(
addWatch(
servers,
selected.map,
stringOption(values, 'condition'),
settings.guildId,
userId,
selected.game
)
);
}
if (command.name === 'cancel') {
const id = stringOption(values, 'id', 100);
if (!id) throw new InputError('Provide the watch ID shown by /watch list.');
return response(
privateMessage(
cancelSubscription(id, settings.guildId, userId)
? 'Watch cancelled.'
: 'No active watch with that ID belongs to you.'
)
);
}
if (command.name === 'list') {
const watches = listSubscriptions(userId, settings.guildId);
return response(
privateMessage(
`## Your map watches\n${watches.length ? watches.map((watch) => `**${discordText(servers.find((server) => server.mapKey === watch.mapKey)?.map ?? discordMaps.find((map) => map.value === watch.mapKey)?.name ?? 'Unpublished map', 60)}** · ${watch.mode === 'empty' ? 'Empty lobby' : 'Space available'} · Expires <t:${Math.floor(watch.expiresAt / 1000)}:R>\nID: ${discordText(watch.id)}`).join('\n\n') : 'No active watches. Use /watch add to create one.'}`
)
);
}
throw new InputError('Choose watch add, list, or cancel.');
}
const values = optionValues(data.options);
if (data.name === 'servers') {
const status = stringOption(values, 'status') ?? 'all';
if (!statuses.includes(status as BrowserStatus))
throw new InputError('Choose an occupancy filter from the command options.');
return response(
renderBrowser(servers, {
...scope(values),
status: status as BrowserStatus,
page: page(values)
})
);
}
if (data.name === 'server') {
const name = stringOption(values, 'name');
const server = servers.find((entry) => entry.name === name || entry.label === name);
if (!server)
throw new InputError(
'That server is not published. Use /servers and click Details.'
);
return response(renderServer(server));
}
throw new InputError('This command is not supported. Run /help for available commands.');
} catch (error) {
if (error instanceof InputError) return privateDiscordError(error.message);
throw error;
}
}
+139
View File
@@ -0,0 +1,139 @@
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;
};
export const DEFAULT_DISCORD_LOBBY_CAPACITY = 4;
/** 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 excluded = new Set(settings.excludedServers);
return servers
.flatMap((server): DiscordPublicServer[] => {
if (excluded.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) &&
(!server.telemetry_after ||
Date.parse(server.last_seen_at ?? '') > Date.parse(server.telemetry_after));
const players =
fresh && Number.isSafeInteger(server.last_players) && server.last_players! >= 0
? server.last_players
: null;
// These lobbies have four slots when the live status omits the limit.
// Occupancy still needs fresh, current-process evidence below.
const capacity = currentCapacity(server, now) ?? DEFAULT_DISCORD_LOBBY_CAPACITY;
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,24 @@
import type Database from 'better-sqlite3';
/** Replace the old opt-in default while preserving an existing nonempty selection. */
export function migrateDiscordPublicationDefaults(handle: Database.Database): void {
const row = handle.prepare('SELECT value FROM discord_configuration WHERE id=1').get() as
{ value: string } | undefined;
if (!row) return;
const settings = JSON.parse(row.value) as Record<string, unknown>;
if ('excludedServers' in settings) return;
const published = new Set(settings.publishedServers as string[]);
settings.excludedServers = published.size
? (
handle
.prepare('SELECT name FROM servers WHERE destroyed_at IS NULL ORDER BY name')
.all() as { name: string }[]
)
.map((server) => server.name)
.filter((name) => !published.has(name))
: [];
delete settings.publishedServers;
handle
.prepare('UPDATE discord_configuration SET value=? WHERE id=1')
.run(JSON.stringify(settings));
}
+285
View File
@@ -0,0 +1,285 @@
import type { DiscordGame, DiscordMessage, DiscordPublicServer } from '../../discord.js';
import type { LeaderboardOptions, LeaderboardResponse } from '../../leaderboard.js';
import { leaderboardMetrics } from '../../leaderboard.js';
import {
efficiencyRequirement,
formatEfficiencyValue,
isEfficiencyMetric
} from '../../leaderboard-efficiency.js';
import { MIN_RECORDED_ROUND } from '../../match-history.js';
import { gameTitle } from '../../ui/server-labels.js';
import { DEFAULT_DISCORD_LOBBY_CAPACITY, 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 ?? DEFAULT_DISCORD_LOBBY_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 lobby capacity. Neither guarantees that a match accepts new players.\nOne DM · Expires in 24 hours`
),
row(
button('Empty lobby', `ec:w:empty:${discordServerKey(server.mapKey)}`),
button('Space available', `ec:w:space:${discordServerKey(server.mapKey)}`)
)
]);
}
export function renderLeaderboard(
report: LeaderboardResponse,
query: LeaderboardOptions
): DiscordMessage {
const metric = report.metric;
const label = leaderboardMetrics.find((item) => item.key === metric)!.label;
const map = report.maps.find((item) => item.key === query.map)?.name;
const items = report.entries.map((entry) => {
const value = isEfficiencyMetric(metric)
? formatEfficiencyValue(entry[metric], metric)
: entry[metric].toLocaleString('en-US');
return `**${entry.rank}.** ${discordText(entry.name, 60)} — **${value}**`;
});
return discordMessage([
container([
text(
`## ${label}\n${query.game ? gameTitle(query.game) : 'All games'}${map ? ` · ${discordText(map)}` : ''}${query.players ? ` · ${query.players}-player starts` : ''}\n${items.length ? items.join('\n') : 'No qualifying records in this scope.'}`
),
text(
`-# Completed matches with player records reaching round ${MIN_RECORDED_ROUND} or later.${isEfficiencyMetric(metric) ? ` ${efficiencyRequirement(metric)}.` : ''}\n-# Page ${Math.floor(report.offset / LEADERBOARD_PAGE_SIZE) + 1} · ${report.total} ranked players. Use /leaderboard to change filters or page.`
)
])
]);
}
+43
View File
@@ -0,0 +1,43 @@
import type Database from 'better-sqlite3';
export function migrateDiscord(handle: Database.Database): void {
handle.exec(`
CREATE TABLE IF NOT EXISTS discord_configuration (
id INTEGER PRIMARY KEY CHECK (id=1), value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS discord_health (
id INTEGER PRIMARY KEY CHECK (id=1), last_attempt_at INTEGER,
last_success_at INTEGER, last_error TEXT
);
INSERT OR IGNORE INTO discord_health(id) VALUES(1);
CREATE TABLE IF NOT EXISTS discord_boards (
id TEXT PRIMARY KEY, game TEXT NOT NULL CHECK(game IN ('t5','t6')),
guild_id TEXT NOT NULL, channel_id TEXT NOT NULL, page INTEGER NOT NULL, message_id TEXT,
content_hash TEXT, verified_at INTEGER NOT NULL DEFAULT 0, generation INTEGER NOT NULL DEFAULT 0,
UNIQUE(game,guild_id,channel_id,page)
);
CREATE TABLE IF NOT EXISTS discord_subscriptions (
id TEXT PRIMARY KEY, guild_id TEXT NOT NULL, user_id TEXT NOT NULL,
game TEXT NOT NULL CHECK(game IN ('t5','t6')), map_key TEXT NOT NULL,
mode TEXT NOT NULL CHECK(mode IN ('empty','space')),
state TEXT NOT NULL CHECK(state IN ('active','queued','delivered','failed','cancelled','expired')),
created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL,
candidate TEXT, candidate_at INTEGER, error TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS discord_subscription_active
ON discord_subscriptions(guild_id,user_id,game,map_key,mode)
WHERE state IN ('active','queued');
CREATE INDEX IF NOT EXISTS discord_subscription_expiry ON discord_subscriptions(expires_at);
CREATE TABLE IF NOT EXISTS discord_outbox (
id TEXT PRIMARY KEY, kind TEXT NOT NULL CHECK(kind IN ('board','delete','alert')),
target_id TEXT NOT NULL UNIQUE, payload TEXT NOT NULL, content_hash TEXT,
channel_id TEXT, attempts INTEGER NOT NULL DEFAULT 0,
next_at INTEGER, created_at INTEGER NOT NULL, error TEXT
);
CREATE INDEX IF NOT EXISTS discord_outbox_due ON discord_outbox(next_at);
CREATE TABLE IF NOT EXISTS discord_interactions (
id TEXT PRIMARY KEY, expires_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS discord_interactions_expiry ON discord_interactions(expires_at);
`);
}
+503
View File
@@ -0,0 +1,503 @@
import { createHash, randomUUID } from 'node:crypto';
import type Database from 'better-sqlite3';
import {
defaultDiscordSettings,
discordSnowflake,
validateDiscordSettings,
type DiscordDeliveryStatus,
type DiscordGame,
type DiscordMessage,
type DiscordSettings
} from '../../discord.js';
import { db } from '../db/index.js';
export interface DiscordSubscription {
id: string;
guildId: string;
userId: string;
game: DiscordGame;
mapKey: string;
mode: 'empty' | 'space';
state: 'active' | 'queued' | 'delivered' | 'failed' | 'cancelled' | 'expired';
expiresAt: number;
candidate: string | null;
candidateAt: number | null;
error: string | null;
}
export interface DiscordBoard {
id: string;
game: DiscordGame;
guildId: string;
channelId: string;
page: number;
messageId: string | null;
contentHash: string | null;
verifiedAt: number;
generation: number;
}
export interface DiscordJob {
id: string;
kind: 'board' | 'delete' | 'alert';
targetId: string;
payload: DiscordMessage;
contentHash: string | null;
channelId: string | null;
attempts: number;
createdAt: number;
}
export interface MapSubscriptionInput {
guildId: string;
userId: string;
game: DiscordGame;
mapKey: string;
mode: 'empty' | 'space';
}
const subscriptionColumns = `id,guild_id AS guildId,user_id AS userId,game,map_key AS mapKey,
mode,state,expires_at AS expiresAt,candidate,candidate_at AS candidateAt,error`;
const boardColumns = `id,game,guild_id AS guildId,channel_id AS channelId,page,message_id AS messageId,
content_hash AS contentHash,verified_at AS verifiedAt,generation`;
/** Synchronous, bounded transactions; delivery never holds a database transaction open. */
export class DiscordStore {
constructor(
readonly handle: Database.Database,
readonly now: () => number = Date.now
) {}
settings(): DiscordSettings {
const row = this.handle
.prepare('SELECT value FROM discord_configuration WHERE id=1')
.get() as { value: string } | undefined;
return row
? validateDiscordSettings(JSON.parse(row.value))
: structuredClone(defaultDiscordSettings);
}
saveSettings(input: unknown): DiscordSettings {
const settings = validateDiscordSettings(input);
this.handle.transaction(() => {
this.handle
.prepare(
'INSERT INTO discord_configuration(id,value) VALUES(1,?) ON CONFLICT(id) DO UPDATE SET value=excluded.value'
)
.run(JSON.stringify(settings));
this.handle
.prepare(
"UPDATE discord_subscriptions SET state='cancelled' WHERE state IN ('active','queued') AND (?=0 OR guild_id!=?)"
)
.run(Number(settings.enabled), settings.guildId);
this.handle
.prepare(
"DELETE FROM discord_outbox WHERE kind='alert' AND target_id IN (SELECT id FROM discord_subscriptions WHERE state!='queued')"
)
.run();
this.retryFailed();
})();
return settings;
}
status(): DiscordDeliveryStatus {
const row = this.handle.prepare('SELECT * FROM discord_health WHERE id=1').get() as {
last_attempt_at: number | null;
last_success_at: number | null;
last_error: string | null;
};
const iso = (value: number | null) =>
value === null ? null : new Date(value).toISOString();
return {
lastAttemptAt: iso(row.last_attempt_at),
lastSuccessAt: iso(row.last_success_at),
lastError:
row.last_error ??
(
this.handle
.prepare(
"SELECT error FROM discord_subscriptions WHERE state='failed' AND expires_at>? ORDER BY created_at DESC LIMIT 1"
)
.get(this.now()) as { error: string } | undefined
)?.error ??
null,
boards: this.handle
.prepare(
'SELECT game,channel_id AS channelId,COUNT(message_id) AS messageCount FROM discord_boards GROUP BY game,channel_id'
)
.all() as DiscordDeliveryStatus['boards'],
activeSubscriptions: this.count(
"SELECT COUNT(*) AS n FROM discord_subscriptions WHERE state IN ('active','queued') AND expires_at>?",
this.now()
),
pendingNotifications: this.count(
"SELECT COUNT(*) AS n FROM discord_outbox WHERE kind='alert' AND next_at IS NOT NULL"
)
};
}
subscribe(input: MapSubscriptionInput): { id: string; expiresAt: number } {
if (
!discordSnowflake(input.userId) ||
!discordSnowflake(input.guildId) ||
!['t5', 't6'].includes(input.game) ||
!['empty', 'space'].includes(input.mode) ||
typeof input.mapKey !== 'string' ||
!/^[a-zA-Z0-9_.:-]{1,100}$/.test(input.mapKey)
)
throw new Error('Invalid map watch.');
const settings = this.settings();
if (!settings.enabled || settings.guildId !== input.guildId)
throw new Error('Discord map watches are not enabled for this server.');
return this.handle.transaction(() => {
this.expireSubscriptions();
const existing = this.handle
.prepare(
"SELECT id,expires_at AS expiresAt FROM discord_subscriptions WHERE guild_id=? AND user_id=? AND game=? AND map_key=? AND mode=? AND state IN ('active','queued')"
)
.get(input.guildId, input.userId, input.game, input.mapKey, input.mode) as
{ id: string; expiresAt: number } | undefined;
if (existing) return existing;
if (
this.count(
"SELECT COUNT(*) AS n FROM discord_subscriptions WHERE user_id=? AND state IN ('active','queued')",
input.userId
) >= 10
)
throw new Error(
'You already have 10 active map watches. Cancel one before adding another.'
);
if (
this.count(
"SELECT COUNT(*) AS n FROM discord_subscriptions WHERE state IN ('active','queued')"
) >= 1000
)
throw new Error('Map watches are currently full. Please try later.');
const id = randomUUID();
const expiresAt = this.now() + 24 * 60 * 60_000;
this.handle
.prepare(
"INSERT INTO discord_subscriptions(id,guild_id,user_id,game,map_key,mode,state,created_at,expires_at) VALUES(?,?,?,?,?,?,'active',?,?)"
)
.run(
id,
input.guildId,
input.userId,
input.game,
input.mapKey,
input.mode,
this.now(),
expiresAt
);
return { id, expiresAt };
})();
}
subscriptions(userId?: string, guildId?: string): DiscordSubscription[] {
this.expireSubscriptions();
return this.handle
.prepare(
`SELECT ${subscriptionColumns} FROM discord_subscriptions WHERE state IN ('active','queued') ${userId ? 'AND user_id=? AND guild_id=?' : ''} ORDER BY created_at LIMIT 1000`
)
.all(...(userId ? [userId, guildId ?? ''] : [])) as DiscordSubscription[];
}
subscription(id: string): DiscordSubscription | undefined {
return this.handle
.prepare(`SELECT ${subscriptionColumns} FROM discord_subscriptions WHERE id=?`)
.get(id) as DiscordSubscription | undefined;
}
cancel(id: string, guildId: string, userId: string): boolean {
return this.handle.transaction(() => {
const updated = this.handle
.prepare(
"UPDATE discord_subscriptions SET state='cancelled' WHERE id=? AND guild_id=? AND user_id=? AND state IN ('active','queued')"
)
.run(id, guildId, userId).changes;
if (updated)
this.handle
.prepare("DELETE FROM discord_outbox WHERE kind='alert' AND target_id=?")
.run(id);
return updated > 0;
})();
}
expireSubscriptions(): void {
this.handle
.prepare(
"UPDATE discord_subscriptions SET state='expired' WHERE state IN ('active','queued') AND expires_at<=?"
)
.run(this.now());
this.handle
.prepare(
"DELETE FROM discord_outbox WHERE kind='alert' AND target_id IN (SELECT id FROM discord_subscriptions WHERE state NOT IN ('active','queued'))"
)
.run();
this.handle
.prepare(
"DELETE FROM discord_subscriptions WHERE state NOT IN ('active','queued') AND (expires_at<? OR id IN (SELECT id FROM discord_subscriptions ORDER BY created_at DESC LIMIT -1 OFFSET 10000))"
)
.run(this.now() - 7 * 86400_000);
}
candidate(id: string, candidate: string | null): void {
this.handle
.prepare(
"UPDATE discord_subscriptions SET candidate=?,candidate_at=CASE WHEN candidate IS ? THEN candidate_at ELSE ? END WHERE id=? AND state='active'"
)
.run(candidate, candidate, candidate ? this.now() : null, id);
}
queueAlert(subscription: DiscordSubscription, message: DiscordMessage): void {
this.handle.transaction(() => {
const current = this.subscription(subscription.id);
if (!current || current.state !== 'active' || current.expiresAt <= this.now()) return;
this.enqueue('alert', subscription.id, message, null);
this.handle
.prepare("UPDATE discord_subscriptions SET state='queued' WHERE id=?")
.run(subscription.id);
})();
}
boards(): DiscordBoard[] {
return this.handle
.prepare(`SELECT ${boardColumns} FROM discord_boards ORDER BY game,channel_id,page`)
.all() as DiscordBoard[];
}
board(id: string): DiscordBoard | undefined {
return this.handle
.prepare(`SELECT ${boardColumns} FROM discord_boards WHERE id=?`)
.get(id) as DiscordBoard | undefined;
}
reconcileBoards(
desired: {
game: DiscordGame;
guildId: string;
channelId: string;
messages: DiscordMessage[];
}[]
): void {
this.handle.transaction(() => {
const retained = new Set<string>();
for (const group of desired) {
for (const [page, message] of group.messages.entries()) {
let board = this.boards().find(
(item) =>
item.game === group.game &&
item.guildId === group.guildId &&
item.channelId === group.channelId &&
item.page === page
);
if (!board) {
const id = randomUUID();
this.handle
.prepare(
'INSERT INTO discord_boards(id,game,guild_id,channel_id,page) VALUES(?,?,?,?,?)'
)
.run(id, group.game, group.guildId, group.channelId, page);
board = this.board(id)!;
}
retained.add(board.id);
this.handle
.prepare("DELETE FROM discord_outbox WHERE target_id=? AND kind='delete'")
.run(board.id);
const hash = createHash('sha256').update(JSON.stringify(message)).digest('hex');
if (board.contentHash !== hash || !board.messageId)
this.enqueue('board', board.id, message, hash);
else
this.handle
.prepare(
"DELETE FROM discord_outbox WHERE target_id=? AND kind='board'"
)
.run(board.id);
}
}
for (const board of this.boards()) {
if (!retained.has(board.id)) {
if (board.messageId)
this.enqueue('delete', board.id, { allowed_mentions: { parse: [] } }, null);
else this.removeBoard(board.id);
}
}
})();
}
private enqueue(
kind: DiscordJob['kind'],
targetId: string,
payload: DiscordMessage,
hash: string | null
): void {
const existing = this.handle
.prepare('SELECT kind FROM discord_outbox WHERE target_id=?')
.get(targetId) as { kind: string } | undefined;
if (!existing && this.count('SELECT COUNT(*) AS n FROM discord_outbox') >= 500)
throw new Error('Discord delivery queue is full.');
this.handle
.prepare(
`INSERT INTO discord_outbox(id,kind,target_id,payload,content_hash,next_at,created_at) VALUES(?,?,?,?,?,?,?)
ON CONFLICT(target_id) DO UPDATE SET kind=excluded.kind,payload=excluded.payload,content_hash=excluded.content_hash,
attempts=CASE WHEN kind!=excluded.kind THEN 0 ELSE attempts END,
next_at=CASE WHEN kind!=excluded.kind THEN excluded.next_at ELSE next_at END`
)
.run(
randomUUID(),
kind,
targetId,
JSON.stringify(payload),
hash,
this.now(),
this.now()
);
}
jobs(limit = 10): DiscordJob[] {
return (
this.handle
.prepare(
`SELECT id,kind,target_id AS targetId,payload,content_hash AS contentHash,
channel_id AS channelId,attempts,created_at AS createdAt FROM discord_outbox WHERE next_at<=? ORDER BY CASE kind WHEN 'delete' THEN 0 WHEN 'alert' THEN 1 ELSE 2 END,created_at LIMIT ?`
)
.all(this.now(), limit) as (Omit<DiscordJob, 'payload'> & { payload: string })[]
).map((row) => ({ ...row, payload: JSON.parse(row.payload) }));
}
cacheDm(jobId: string, channelId: string): void {
this.handle
.prepare('UPDATE discord_outbox SET channel_id=? WHERE id=?')
.run(channelId, jobId);
}
dropJob(id: string): void {
this.handle.prepare('DELETE FROM discord_outbox WHERE id=?').run(id);
}
resumeAlert(id: string): void {
this.handle.transaction(() => {
this.handle
.prepare("DELETE FROM discord_outbox WHERE kind='alert' AND target_id=?")
.run(id);
this.handle
.prepare(
"UPDATE discord_subscriptions SET state='active',candidate=NULL,candidate_at=NULL WHERE id=? AND state='queued'"
)
.run(id);
})();
}
delivered(job: DiscordJob, messageId?: string): void {
this.handle.transaction(() => {
if (job.kind === 'board')
this.handle
.prepare(
'UPDATE discord_boards SET message_id=?,content_hash=?,verified_at=? WHERE id=?'
)
.run(messageId, job.contentHash, this.now(), job.targetId);
if (job.kind === 'delete')
this.handle.prepare('DELETE FROM discord_boards WHERE id=?').run(job.targetId);
if (job.kind === 'alert')
this.handle
.prepare(
"UPDATE discord_subscriptions SET state='delivered',error=NULL WHERE id=? AND state='queued'"
)
.run(job.targetId);
// A newer desired board can replace a payload while an HTTP call is in flight.
this.handle
.prepare('DELETE FROM discord_outbox WHERE id=? AND payload=?')
.run(job.id, JSON.stringify(job.payload));
this.handle
.prepare(
'UPDATE discord_health SET last_success_at=?,last_error=CASE WHEN EXISTS(SELECT 1 FROM discord_outbox WHERE error IS NOT NULL) THEN last_error ELSE NULL END WHERE id=1'
)
.run(this.now());
})();
}
removeBoard(id: string): void {
this.handle.prepare('DELETE FROM discord_outbox WHERE target_id=?').run(id);
this.handle.prepare('DELETE FROM discord_boards WHERE id=?').run(id);
}
missingMessage(id: string): void {
this.handle
.prepare(
'UPDATE discord_boards SET message_id=NULL,content_hash=NULL,verified_at=0,generation=generation+1 WHERE id=?'
)
.run(id);
}
verified(id: string): void {
this.handle
.prepare('UPDATE discord_boards SET verified_at=? WHERE id=?')
.run(this.now(), id);
}
attempt(): void {
this.handle
.prepare('UPDATE discord_health SET last_attempt_at=? WHERE id=1')
.run(this.now());
}
error(message: string): void {
this.handle
.prepare('UPDATE discord_health SET last_error=? WHERE id=1')
.run(message.slice(0, 300));
}
failed(job: DiscordJob, error: string, retryAt: number | null): void {
this.handle.transaction(() => {
this.handle
.prepare(
'UPDATE discord_outbox SET attempts=attempts+1,next_at=?,error=? WHERE id=?'
)
.run(retryAt, error.slice(0, 300), job.id);
if (retryAt === null && job.kind === 'alert') {
this.handle
.prepare("UPDATE discord_subscriptions SET state='failed',error=? WHERE id=?")
.run(error.slice(0, 300), job.targetId);
this.handle.prepare('DELETE FROM discord_outbox WHERE id=?').run(job.id);
}
this.error(error);
})();
}
retryFailed(): void {
this.handle
.prepare(
'UPDATE discord_outbox SET next_at=?,attempts=0,error=NULL WHERE next_at IS NULL'
)
.run(this.now());
this.handle.prepare('UPDATE discord_health SET last_error=NULL WHERE id=1').run();
}
claimInteraction(id: string): boolean {
if (!discordSnowflake(id)) throw new Error('Invalid Discord interaction ID.');
return this.handle.transaction(() => {
this.handle
.prepare('DELETE FROM discord_interactions WHERE expires_at<=?')
.run(this.now());
if (this.handle.prepare('SELECT 1 FROM discord_interactions WHERE id=?').get(id))
return false;
if (this.count('SELECT COUNT(*) AS n FROM discord_interactions') >= 10000)
throw new Error('Discord is processing too many interactions. Please try later.');
this.handle
.prepare('INSERT INTO discord_interactions(id,expires_at) VALUES(?,?)')
.run(id, this.now() + 15 * 60_000);
return true;
})();
}
private count(sql: string, ...args: (string | number)[]): number {
return (this.handle.prepare(sql).get(...args) as { n: number }).n;
}
}
export const discordStore = () => new DiscordStore(db());
export const getDiscordSettings = () => discordStore().settings();
export const saveDiscordSettings = (input: unknown) => discordStore().saveSettings(input);
export const discordStatus = () => discordStore().status();
export const subscribeToMap = (input: MapSubscriptionInput) => discordStore().subscribe(input);
export const listSubscriptions = (userId: string, guildId: string) =>
discordStore().subscriptions(userId, guildId);
export const cancelSubscription = (id: string, guildId: string, userId: string) =>
discordStore().cancel(id, guildId, userId);
export const claimDiscordInteraction = (id: string) => discordStore().claimInteraction(id);
+132
View File
@@ -0,0 +1,132 @@
/** Discord HTTP failures never include response bodies, which can contain private data. */
export class DiscordDeliveryError extends Error {
constructor(
message: string,
readonly status: number,
readonly retryAt: number | null = null,
readonly code: number | null = null
) {
super(message);
this.name = 'DiscordDeliveryError';
}
}
export interface DiscordTransport {
request<T = Record<string, unknown>>(method: string, path: string, body?: unknown): Promise<T>;
}
/** One serialized queue shares Discord's bucket/global cooldowns with every caller. */
export class DiscordRest implements DiscordTransport {
private tail: Promise<unknown> = Promise.resolve();
private globalUntil = 0;
private unauthorized = false;
private routeBuckets = new Map<string, string>();
private blockedUntil = new Map<string, number>();
constructor(
private readonly token: string,
private readonly fetcher: typeof fetch = fetch,
private readonly now: () => number = Date.now
) {}
request<T = Record<string, unknown>>(method: string, path: string, body?: unknown): Promise<T> {
const pending = this.tail.then(() => this.send<T>(method, path, body));
this.tail = pending.catch(() => undefined);
return pending;
}
private async send<T>(method: string, path: string, body?: unknown): Promise<T> {
// Callers supply resource paths, never arbitrary hosts or interaction tokens.
if (!/^\/(?:channels|users|applications)\/[A-Za-z0-9_@/-]+$/.test(path))
throw new Error('Invalid Discord API path.');
if (this.unauthorized)
throw new DiscordDeliveryError('Discord bot credentials were rejected.', 401);
const segments = path.split('/');
const major = `${segments[1]}/${segments[2]}`;
const route = `${method}:${major}/${segments
.slice(3)
.map((part) => (/^\d+$/.test(part) ? ':id' : part))
.join('/')}`;
const key = this.routeBuckets.get(route) ?? route;
const retryAt = Math.max(this.globalUntil, this.blockedUntil.get(key) ?? 0);
if (retryAt > this.now())
throw new DiscordDeliveryError(
'Discord is waiting for a rate limit to reset.',
429,
retryAt
);
let response: Response;
try {
response = await this.fetcher(`https://discord.com/api/v10${path}`, {
method,
headers: {
Authorization: `Bot ${this.token}`,
'Content-Type': 'application/json',
'User-Agent': 'ElectricCherry/1.0'
},
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(10_000),
redirect: 'error'
});
} catch {
throw new DiscordDeliveryError('Discord could not be reached.', 0, this.now() + 5_000);
}
const bucket = response.headers.get('X-RateLimit-Bucket');
const bucketKey = bucket ? `${major}:${bucket}` : key;
if (bucket) this.routeBuckets.set(route, bucketKey);
const reset = seconds(response.headers.get('X-RateLimit-Reset-After'));
if (response.headers.get('X-RateLimit-Remaining') === '0' && reset !== null)
this.blockedUntil.set(bucketKey, this.now() + reset);
let data: Record<string, unknown> = {};
if (response.status !== 204) {
try {
data = await response.json();
} catch {
if (response.ok)
throw new DiscordDeliveryError(
'Discord returned an invalid response.',
502,
this.now() + 5_000
);
}
}
if (response.status === 429) {
const delay = Math.max(
1000,
seconds(data.retry_after) ??
seconds(response.headers.get('Retry-After')) ??
reset ??
5_000
);
const until = this.now() + delay;
if (data.global === true || response.headers.get('X-RateLimit-Global') === 'true')
this.globalUntil = until;
else this.blockedUntil.set(bucketKey, until);
throw new DiscordDeliveryError('Discord rate limited this request.', 429, until);
}
if (!response.ok) {
if (response.status === 401) this.unauthorized = true;
const message =
response.status === 401
? 'Discord bot credentials were rejected.'
: response.status === 403
? 'Discord denied permission or the recipient does not accept direct messages.'
: response.status === 404
? 'Discord channel or message no longer exists.'
: `Discord request failed (HTTP ${response.status}).`;
throw new DiscordDeliveryError(
message,
response.status,
response.status >= 500 ? this.now() + 5_000 : null,
typeof data.code === 'number' ? data.code : null
);
}
return data as T;
}
}
function seconds(value: unknown): number | null {
if (value === null || value === undefined || value === '') return null;
const parsed = Number(value);
return Number.isFinite(parsed) && parsed >= 0 ? Math.ceil(parsed * 1000) : null;
}
+118
View File
@@ -0,0 +1,118 @@
import { createPublicKey, verify } from 'node:crypto';
import { error } from '@sveltejs/kit';
import { boundedBody } from '../request-body.js';
import { discordCredentials } from './config.js';
import { discordSnowflake } from '../../discord.js';
import type { DiscordMessage } from '../../discord.js';
export const DISCORD_INTERACTION_BODY_LIMIT = 64 * 1024;
/** Authenticate the exact request bytes before interpreting any interaction fields. */
export async function verifiedDiscordBody(
request: Request,
now = Date.now()
): Promise<Record<string, unknown>> {
const signature = request.headers.get('x-signature-ed25519') ?? '';
const timestamp = request.headers.get('x-signature-timestamp') ?? '';
const { publicKey } = discordCredentials();
if (
!/^[a-fA-F0-9]{128}$/.test(signature) ||
!/^[0-9]{10,12}$/.test(timestamp) ||
Math.abs(now - Number(timestamp) * 1000) > 5 * 60_000 ||
!/^[a-fA-F0-9]{64}$/.test(publicKey)
)
error(401, 'Invalid Discord signature.');
const body = await boundedBody(request, DISCORD_INTERACTION_BODY_LIMIT);
const key = createPublicKey({
key: Buffer.concat([
Buffer.from('302a300506032b6570032100', 'hex'),
Buffer.from(publicKey, 'hex')
]),
format: 'der',
type: 'spki'
});
if (
!verify(
null,
Buffer.concat([Buffer.from(timestamp), body]),
key,
Buffer.from(signature, 'hex')
)
)
error(401, 'Invalid Discord signature.');
let parsed: unknown;
try {
parsed = JSON.parse(body.toString('utf8'));
} catch {
error(400, 'Invalid Discord interaction.');
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
error(400, 'Invalid Discord interaction.');
return parsed as Record<string, unknown>;
}
export function validInteractionEnvelope(body: Record<string, unknown>): boolean {
return (
discordSnowflake(body.id) &&
body.application_id === discordCredentials().applicationId &&
discordSnowflake(body.guild_id) &&
typeof body.token === 'string' &&
body.token.length >= 10 &&
body.token.length <= 1000 &&
!!body.member &&
typeof body.member === 'object' &&
discordSnowflake((body.member as { user?: { id?: unknown } }).user?.id)
);
}
/** Interaction webhooks use their short-lived token, never the deployment bot token. */
export async function completeDiscordInteraction(
applicationId: string,
token: string,
message: DiscordMessage,
fetcher: typeof fetch = fetch,
publicationAllowed: () => boolean = () => true
): Promise<void> {
for (let attempt = 0; attempt < 3; attempt++) {
const response = await fetcher(
`https://discord.com/api/v10/webhooks/${applicationId}/${encodeURIComponent(token)}/messages/@original`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...(publicationAllowed()
? message
: {
flags: 32768 | 64,
components: [
{
type: 10,
content:
'Publication settings changed. Please run your command again.'
}
]
}),
allowed_mentions: { parse: [] }
}),
signal: AbortSignal.timeout(10_000),
redirect: 'error'
}
);
if (response.ok) return;
if (response.status === 429 && attempt < 2) {
let delay = Number(response.headers.get('retry-after') ?? NaN);
try {
const result = (await response.json()) as { retry_after?: number };
delay = Number(result.retry_after ?? delay);
} catch {
/* A valid retry header is sufficient. */
}
if (Number.isFinite(delay) && delay >= 0 && delay <= 30) {
await new Promise((resolve) => setTimeout(resolve, Math.max(250, delay * 1000)));
continue;
}
}
// Do not log a response body or URL: both can contain interaction credentials.
throw new Error(`Discord interaction delivery failed (${response.status}).`);
}
}
+415
View File
@@ -0,0 +1,415 @@
import { createHash } from 'node:crypto';
import type {
DiscordGame,
DiscordMessage,
DiscordPublicServer,
DiscordSettings
} from '../../discord.js';
import { requireDiscordCredentials } from './config.js';
import { DEFAULT_DISCORD_LOBBY_CAPACITY, 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 ?? DEFAULT_DISCORD_LOBBY_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();
}
+5 -7
View File
@@ -11,6 +11,7 @@ import {
canRevokeModerationRestriction,
isModerationCaseOpen,
moderationCaseOutcomes,
moderationCaseStatuses,
moderationCaseStatusLabels,
type ModerationCaseStatus,
type ModerationCasePriority,
@@ -438,13 +439,9 @@ function updateCase(actor: AuthUser, input: Record<string, unknown>, now: string
const status = input.status === undefined ? row.status : input.status;
if (
input.status !== undefined &&
status !== 'open' &&
status !== 'in_review' &&
status !== 'escalated'
!moderationCaseStatuses.includes(status as ModerationCaseStatus)
)
throw new AuthError('Choose an active case status, or close the case with an outcome.');
if (input.status !== undefined && !isModerationCaseOpen(row.status))
throw new AuthError('Use reopen to reactivate a closed case.', 409);
throw new AuthError('Choose Open, In progress, or Closed.');
const reason = textField(input.reason, 'Status change reason', 4000, status === row.status);
const changes: Record<string, unknown> = {};
if (title !== row.title) changes.title = { from: row.title, to: title };
@@ -585,6 +582,7 @@ export function mutateModeration(actorId: string, input: Record<string, unknown>
notifyCase(actor.id, row.id, 'evidence', now);
return { id: row.id };
}
// Older clients may still record decisions, but every closure now stores only Closed.
if (input.op === 'resolve-case') {
const row = caseRow(input.id);
if (row.revision !== input.revision || !isModerationCaseOpen(row.status))
@@ -620,7 +618,7 @@ export function mutateModeration(actorId: string, input: Record<string, unknown>
.prepare(
'UPDATE moderation_cases SET status=?,updated_at=?,revision=revision+1 WHERE id=?'
)
.run(outcome, now, row.id);
.run('closed', now, row.id);
moderationEvent(
actor,
`case.${outcome}`,
+2 -2
View File
@@ -114,7 +114,7 @@ export function moderationOverview(actorId: string, query: ModerationQuery): Mod
if (query.view === 'cases') {
if (query.caseStatus === 'active' || query.caseStatus === 'closed') {
where.push(
`p.status ${query.caseStatus === 'closed' ? 'NOT ' : ''}IN ('open','in_review','escalated')`
`p.status ${query.caseStatus === 'closed' ? 'NOT ' : ''}IN ('open','in_review')`
);
} else if (query.caseStatus) {
where.push('p.status=?');
@@ -220,7 +220,7 @@ export function moderationOverview(actorId: string, query: ModerationQuery): Mod
result.cases = rows<Omit<ModerationCase, 'evidence'>>(
'moderation_cases',
'p.id,p.game,p.guid,p.title,p.status,p.priority,p.assignee_id AS assigneeId,(SELECT username FROM auth_users WHERE id=p.assignee_id) AS assignee,p.actor,p.created_at AS createdAt,p.updated_at AS updatedAt,p.revision,p.review_at AS reviewAt,p.review_reason AS reviewReason,p.review_sanction_id AS reviewSanctionId',
"CASE WHEN p.review_at<=? THEN 0 ELSE 1 END,CASE WHEN p.review_at<=? THEN p.review_at END,CASE WHEN p.status IN ('open','in_review','escalated') THEN 0 ELSE 1 END,CASE p.priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'normal' THEN 2 ELSE 3 END,p.updated_at DESC,p.id",
"CASE WHEN p.review_at<=? THEN 0 ELSE 1 END,CASE WHEN p.review_at<=? THEN p.review_at END,CASE WHEN p.status IN ('open','in_review') THEN 0 ELSE 1 END,CASE p.priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'normal' THEN 2 ELSE 3 END,p.updated_at DESC,p.id",
[now, now]
).map((c) => ({
...c,
+39
View File
@@ -235,3 +235,42 @@ export function migrateModerationCaseReferenceIndexes(handle: Database.Database)
ON moderation_case_notifications(case_id);
`);
}
/** Normalize lifecycle state while retaining every prior decision and child record. */
export function migrateModerationCaseStates(handle: Database.Database): void {
if (handle.pragma('foreign_keys', { simple: true }))
throw new Error('The case status migration requires foreign keys to be disabled');
const objects = handle
.prepare(
`SELECT sql FROM sqlite_master
WHERE tbl_name='moderation_cases' AND type IN ('index','trigger') AND sql IS NOT NULL`
)
.all() as { sql: string }[];
// Capture old statuses even for imported cases without a corresponding decision event.
handle.exec(`INSERT INTO moderation_events(game,guid,actor,action,subject_id,detail,created_at)
SELECT game,guid,'Maxis','case.status_migrated',id,
json_object('from',status,'to',CASE WHEN status='escalated' THEN 'in_review' ELSE 'closed' END),
strftime('%Y-%m-%dT%H:%M:%fZ','now') FROM moderation_cases
WHERE status NOT IN ('open','in_review');
CREATE TABLE moderation_cases_state (
id TEXT PRIMARY KEY, game TEXT NOT NULL, guid TEXT NOT NULL, title TEXT NOT NULL,
status TEXT NOT NULL CHECK(status IN ('open','in_review','closed')),
actor_id TEXT NOT NULL, actor TEXT NOT NULL,
created_at TEXT NOT NULL, updated_at TEXT NOT NULL, revision INTEGER NOT NULL DEFAULT 1,
priority TEXT NOT NULL DEFAULT 'normal' CHECK(priority IN ('low','normal','high','urgent')),
assignee_id TEXT REFERENCES auth_users(id) ON DELETE SET NULL,
review_at TEXT, review_reason TEXT NOT NULL DEFAULT '',
review_sanction_id TEXT REFERENCES moderation_sanctions(id) ON DELETE SET NULL
);
INSERT INTO moderation_cases_state
SELECT id,game,guid,title,
CASE WHEN status IN ('open','in_review') THEN status
WHEN status='escalated' THEN 'in_review' ELSE 'closed' END,
actor_id,actor,created_at,updated_at,
revision+CASE WHEN status IN ('open','in_review') THEN 0 ELSE 1 END,
priority,assignee_id,review_at,review_reason,review_sanction_id FROM moderation_cases;
DROP TABLE moderation_cases;
ALTER TABLE moderation_cases_state RENAME TO moderation_cases;
`);
for (const object of objects) handle.exec(object.sql);
}
@@ -1,55 +1,102 @@
<script lang="ts">
import type { ContextualChatEntry } from '$lib/game-chat.js';
import { timestampISO } from '$lib/time-zone.js';
import { serverIdentity } from '$ui/server-labels.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import Badge from '../primitives/Badge.svelte';
let {
entries,
selectedMessageIds = [],
highlightedMessageIds = []
highlightedMessageIds = [],
matchId,
server,
startedAt
}: {
entries: ContextualChatEntry[];
selectedMessageIds?: string[];
highlightedMessageIds?: string[];
matchId?: number | null;
server?: string | null;
startedAt?: string | null;
} = $props();
const messages = $derived(entries.filter((entry) => entry.type === 'message'));
const context = $derived.by(() => {
const times = messages
.map((entry) => timestampISO(entry.context.realTime))
.filter((time): time is string => !!time)
.sort();
const dates = [...new Set(times.map((time) => $timeFormat.date(time)))];
const rounds = messages
.map((entry) => entry.context.round)
.filter((round): round is number => Number.isInteger(round));
const firstRound = Math.min(...rounds);
const lastRound = Math.max(...rounds);
return [
...(server ? [serverIdentity(server).map] : []),
...(matchId ? [`Match #${matchId}`] : []),
dates.length
? dates.length === 1
? dates[0]
: `${dates[0]} – ${dates[dates.length - 1]}`
: timestampISO(startedAt)
? `Match started ${$timeFormat.date(startedAt)}`
: 'Date not recorded',
rounds.length
? `${firstRound === lastRound ? `Round ${firstRound}` : `Rounds ${firstRound}–${lastRound}`}${rounds.length < messages.length ? ' (some unknown)' : ''}`
: 'Round not recorded',
'Times are estimates.'
].join(' · ');
});
</script>
<p class="excerpt-note">
Selected messages and surrounding context in archive order. Messages between separate selections
may be omitted.
</p>
<div class="excerpt-context">
<p>{context}</p>
<p>
Highlighted lines are subject messages. Messages between separate selections may be omitted.
</p>
</div>
<ol class="excerpt" aria-label="Preserved conversation excerpt">
{#each entries as entry}
{#if entry.type === 'message'}
{@const highlighted =
!!entry.messageId && highlightedMessageIds.includes(entry.messageId)}
{@const selected = !!entry.messageId && selectedMessageIds.includes(entry.messageId)}
<li class:highlighted>
<div class="author">
<strong>{entry.message.player}</strong>
{#if entry.message.channel === 'team'}<Badge>Team chat</Badge>{/if}
{#if highlighted}<Badge variant="warning">Subject message</Badge>
{:else if selected}<Badge>Selected context</Badge>
{:else}<span>Surrounding context</span>{/if}
</div>
<div class="metadata">
<span>GUID {entry.message.guid}</span>
{#if entry.context.realTime}<span
>Estimated time <time datetime={entry.context.realTime}
>{$timeFormat.dateTime(entry.context.realTime)}</time
></span
>{:else}<span>Message time unknown</span>{/if}
<span>Log {entry.message.time}</span>
{#if entry.context.round !== null}<span>Round {entry.context.round}</span>{/if}
</div>
<p>{entry.message.message}</p>
{#if entry.messageId}<details>
<summary>Message identifier</summary><code>{entry.messageId}</code>
</details>{/if}
</li>
{/if}
{#each messages as entry}
{@const highlighted = !!entry.messageId && highlightedMessageIds.includes(entry.messageId)}
{@const selected = !!entry.messageId && selectedMessageIds.includes(entry.messageId)}
{@const realTime = timestampISO(entry.context.realTime)}
<li class:highlighted>
<span class="message-time">
{#if realTime}<time datetime={realTime} title={$timeFormat.dateTime(realTime)}
>{$timeFormat.clockTime(realTime)}</time
>{:else}<span title="Message time unknown">Unknown time</span>{/if}
</span>
<strong class="author">{entry.message.player}</strong>
<p class="message">
<span class="sr-only"
>{highlighted
? 'Subject message: '
: selected
? 'Selected context: '
: 'Surrounding context: '}</span
>{#if entry.message.channel === 'team'}<Badge>Team chat</Badge>{' '}{/if}{entry
.message.message}
</p>
</li>
{/each}
</ol>
<details class="message-details">
<summary>Message details</summary>
<ol aria-label="Preserved message details">
{#each messages as entry}
<li>
<strong>{entry.message.player}</strong>
<span>GUID <code>{entry.message.guid}</code></span>
<span>Original log time · {entry.message.time}</span>
{#if Number.isInteger(entry.context.round)}<span>Round {entry.context.round}</span
>{/if}
{#if entry.messageId}<span>Message identifier <code>{entry.messageId}</code></span
>{/if}
</li>
{/each}
</ol>
</details>
<style>
.excerpt {
@@ -59,50 +106,63 @@
margin: 0;
padding: 0;
}
li {
padding: var(--space-3);
border-left: 3px solid var(--border-subtle);
.excerpt > li {
display: grid;
grid-template-columns: 7.5em minmax(6em, 1fr) minmax(0, 3fr);
align-items: baseline;
gap: var(--space-3);
padding: var(--space-2) var(--space-3);
border-left: 3px solid transparent;
background: var(--surface-sunken);
min-width: 0;
font-size: var(--text-sm);
line-height: var(--leading-body);
}
li.highlighted {
.excerpt > li.highlighted {
border-left-color: var(--state-warning);
background: var(--surface-raised);
}
.author,
.metadata {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
.message {
min-width: 0;
overflow-wrap: anywhere;
}
.author > span,
.metadata {
color: var(--text-secondary);
font-size: var(--text-xs);
.message {
white-space: pre-wrap;
}
.metadata {
margin-top: var(--space-1);
.message-time {
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
}
p {
white-space: pre-wrap;
overflow-wrap: anywhere;
margin: var(--space-2) 0 0;
line-height: var(--leading-body);
margin: 0;
}
details {
margin-top: var(--space-2);
.excerpt-context,
.message-details {
font-size: var(--text-xs);
color: var(--text-secondary);
line-height: var(--leading-body);
overflow-wrap: anywhere;
}
.message-details ol {
padding-left: var(--space-5);
}
.message-details li {
padding: var(--space-2) 0;
}
.message-details li > span {
display: block;
}
summary {
cursor: pointer;
}
.excerpt-note {
margin: 0;
font-size: var(--text-xs);
color: var(--text-secondary);
@media (max-width: 640px) {
.excerpt > li {
grid-template-columns: 7.5em minmax(0, 1fr);
gap: var(--space-1) var(--space-3);
}
.message {
grid-column: 1 / -1;
}
}
</style>
@@ -250,7 +250,12 @@
{excerpt.length} messages in this excerpt. Selected subject messages are highlighted.
Messages already preserved as evidence in this case are not attached again.
</p>
<EvidenceMessages entries={excerpt} {selectedMessageIds} {highlightedMessageIds} />
<EvidenceMessages
entries={excerpt}
{matchId}
{selectedMessageIds}
{highlightedMessageIds}
/>
</fieldset>
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
{#if uncertain}<p class="hint">
@@ -1,17 +1,14 @@
<script lang="ts">
import { untrack } from 'svelte';
import {
isModerationCaseOpen,
moderationCaseOutcomes,
moderationCaseStatuses,
moderationCaseStatusLabels,
type ModerationCoverage,
type ModerationGame,
type ModerationScope,
type ModerationCase,
type ModerationCaseAction
type ModerationCase
} from '$lib/moderation.js';
import { moderationActionName, moderationGameName } from '$ui/moderation.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import { moderationGameName } from '$ui/moderation.js';
import { serverTitle } from '$ui/server-labels.js';
import { confirmAction } from '$ui/confirm.js';
import Dialog from '../primitives/Dialog.svelte';
@@ -29,7 +26,6 @@
admin,
seed = {},
assignees = [],
linkedActions = [],
onsubmitted
}: {
open?: boolean;
@@ -40,14 +36,12 @@
| 'note'
| 'revoke'
| 'case-note'
| 'resolve'
| 'reopen'
| 'case-status'
| 'edit-case'
| 'evidence';
coverage: ModerationCoverage[];
admin: boolean;
assignees?: { id: string; username: string }[];
linkedActions?: ModerationCaseAction[];
seed?: {
game?: ModerationGame;
guid?: string;
@@ -81,26 +75,10 @@
let status = $state(untrack(() => seed.status ?? 'open'));
let matchId = $state<number | undefined>(untrack(() => seed.matchId));
let message = $state(untrack(() => seed.message ?? ''));
const statusUnchanged = $derived(mode === 'case-status' && status === seed.status);
const lockedTarget = $derived(mode === 'restriction' && !!seed.caseId);
const stateChanged = $derived(mode === 'edit-case' && status !== seed.status);
const reasonBytes = $derived(new TextEncoder().encode(reason.trim()).length);
const gameText = $derived(mode === 'restriction' || mode === 'action');
let resolution = $state('resolved');
let actionReference = $state('');
const referenceActions = $derived(
linkedActions.filter((item) =>
resolution === 'warning_issued'
? item.type === 'action' && item.kind === 'warn' && item.state === 'confirmed'
: resolution === 'restriction_applied' && item.type === 'restriction'
)
);
const referencedAction = $derived(
referenceActions.find((item) => `${item.type}:${item.id}` === actionReference)
);
$effect(() => {
void resolution;
actionReference = '';
});
let busy = $state(false);
let error = $state('');
let uncertain = $state(false);
@@ -113,8 +91,7 @@
note: 'Add private note',
revoke: 'Revoke restriction',
'case-note': 'Add case note',
resolve: 'Review case',
reopen: 'Reopen case',
'case-status': 'Change status',
'edit-case': 'Edit case',
evidence: 'Attach archived evidence'
}[mode]
@@ -184,13 +161,6 @@
if (mode === 'note') return { op: 'note', game, guid: guid.trim(), text: note.trim() };
if (mode === 'case-note')
return { op: 'case-note', id: seed.id, revision: seed.revision, text: note.trim() };
if (mode === 'reopen')
return {
op: 'reopen-case',
id: seed.id,
revision: seed.revision,
reason: reason.trim()
};
if (mode === 'edit-case')
return {
op: 'update-case',
@@ -201,7 +171,6 @@
...(assigneeId !== (seed.assigneeId ?? '')
? { assigneeId: assigneeId || null }
: {}),
...(isModerationCaseOpen(status) ? { status } : {}),
reason: reason.trim()
};
if (mode === 'evidence')
@@ -219,14 +188,11 @@
reason: reason.trim()
};
return {
op: 'resolve-case',
op: 'update-case',
id: seed.id,
revision: seed.revision,
status: resolution,
reason: reason.trim(),
...(referencedAction
? { actionReference: { type: referencedAction.type, id: referencedAction.id } }
: {})
status,
reason: reason.trim()
};
}
@@ -392,7 +358,7 @@
</p>{/if}
{#if lockedTarget}<p class="hint">
This restriction will be linked to the case and its recorded player identity.
Applying it does not close the case; record the outcome when your review is
Applying it does not close the case; change its status when your work is
complete.
</p>{/if}
{#if mode === 'case' || mode === 'edit-case'}
@@ -447,26 +413,8 @@
/></Field
>
</div>
{#if isModerationCaseOpen(seed.status ?? 'open')}
<Field label="Workflow status"
><Select
bind:value={status}
options={[
{ value: 'open', label: moderationCaseStatusLabels.open },
{ value: 'in_review', label: moderationCaseStatusLabels.in_review },
{ value: 'escalated', label: moderationCaseStatusLabels.escalated }
]}
/></Field
>
{:else}<p class="hint">
The recorded outcome is preserved. Reopen the case to resume review.
</p>{/if}
<Field
label="Change reason"
required={stateChanged}
hint="Required when changing workflow status."
>
<Textarea bind:value={reason} required={stateChanged} rows={3} />
<Field label="Change reason" hint="Optional note about these edits.">
<Textarea bind:value={reason} rows={3} />
</Field>
{/if}
{#if mode === 'evidence'}
@@ -481,55 +429,29 @@
><Textarea bind:value={message} required rows={4} maxlength={4096} /></Field
>
{/if}
{#if mode === 'resolve'}<Field label="Outcome"
{#if mode === 'case-status'}
<Field label="Status"
><Select
bind:value={resolution}
options={moderationCaseOutcomes.map((value) => ({
bind:value={status}
options={moderationCaseStatuses.map((value) => ({
value,
label: moderationCaseStatusLabels[value]
}))}
/></Field
>{/if}
{#if mode === 'resolve'}<p class="hint">
This records the decision and closes the case. Apply or revoke restrictions
separately, and send warnings through Live action. Choosing an outcome does not
change enforcement or send a warning.
</p>{/if}
{#if mode === 'resolve' && ['warning_issued', 'restriction_applied'].includes(resolution)}
<Field
label="Supporting action"
hint="Optional. Reference an existing linked action as the basis for this outcome."
>
<Select
bind:value={actionReference}
options={[
{ value: '', label: 'No action reference' },
...referenceActions.map((item) => ({
value: `${item.type}:${item.id}`,
label: `${moderationActionName(item.kind)} · ${$timeFormat.dateTime(item.createdAt)} · ${item.reason}`
}))
]}
/>
</Field>
{#if !referenceActions.length}<p class="hint">
{resolution === 'warning_issued'
? 'No confirmed warnings are linked to this case.'
: 'No restrictions are linked to this case.'} Use “Link existing action” on
the case to add a prior action.
</p>{/if}
<p class="hint">
Track whether this case is open, in progress, or closed. Findings and recorded
actions stay in the case history.
</p>
{/if}
{#if mode === 'reopen'}<p class="hint">
Reopening returns the case to Open and preserves its evidence, restrictions, and
earlier decisions.
</p>{/if}
{#if ['restriction', 'action', 'revoke', 'resolve', 'reopen'].includes(mode)}
{#if ['restriction', 'action', 'revoke', 'case-status'].includes(mode)}
<Field
label={mode === 'action' && ['message', 'announce'].includes(action)
? 'Message'
: mode === 'restriction' || mode === 'action'
? 'Player-visible reason'
: mode === 'reopen'
? 'Reopening reason'
: mode === 'case-status'
? 'Change reason'
: 'Review reason'}
hint={gameText
? `${reasonBytes} / 240 bytes. Some characters use more than one byte.`
@@ -561,7 +483,7 @@
<div class="actions">
<Button disabled={busy} onclick={() => (open = false)}>Cancel</Button><Button
type="submit"
disabled={!uncertain && actionUnavailable}
disabled={!uncertain && (actionUnavailable || statusUnchanged)}
variant={mode === 'restriction' || mode === 'revoke' ? 'danger' : 'primary'}
loading={busy}>{uncertain ? 'Retry same request' : 'Save'}</Button
>
+2 -2
View File
@@ -30,8 +30,8 @@ export function preservedConversation(value: unknown): {
} | null {
if (!value || typeof value !== 'object') return null;
const snapshot = value as Record<string, unknown>;
if (!Array.isArray(snapshot.entries)) return null;
const entries = snapshot.entries.filter(
const candidates = Array.isArray(snapshot.entries) ? snapshot.entries : [snapshot.entry];
const entries = candidates.filter(
(entry): entry is Extract<ContextualChatEntry, { type: 'message' }> => {
if (!entry || typeof entry !== 'object' || entry.type !== 'message') return false;
return (
+4 -1
View File
@@ -70,6 +70,7 @@ export const moderationActionLabels: Record<string, string> = {
'case.appeal_denied': 'Appeal denied',
'case.reopened': 'Case reopened',
'case.updated': 'Case updated',
'case.status_migrated': 'Case status migrated',
'case.assessed': 'Assessment revised',
'case.review_scheduled': 'Follow-up updated',
'schedule-case-review': 'Follow-up updated',
@@ -137,8 +138,10 @@ export function moderationEventDetail(detail: string, action: string): string {
if (typeof value.reason === 'string') changes.push(value.reason);
return changes.join('\n') || detail;
}
if (action === 'case.status_migrated')
return `Previous status: ${display(value.from, true)} → ${display(value.to, true)}. Earlier decisions and actions are preserved.`;
if (action === 'case.reopened' && typeof value.reason === 'string')
return `Previous outcome: ${display(value.from, true)}\n${value.reason}`;
return `Previous status: ${display(value.from, true)}\n${value.reason}`;
if (
action === 'case.evidence_attached' &&
typeof value.count === 'number' &&
+2 -1
View File
@@ -23,7 +23,8 @@ const states: Record<string, [string, StatusTone]> = {
'moderation-active': ['Active', 'warning'],
'moderation-revoked': ['Revoked', 'neutral'],
'moderation-open': ['Open', 'info'],
'moderation-in_review': ['In review', 'info'],
'moderation-in_review': ['In progress', 'info'],
'moderation-closed': ['Closed', 'neutral'],
'moderation-escalated': ['Escalated', 'warning'],
'moderation-resolved': ['Resolved', 'online'],
'moderation-dismissed': ['Dismissed', 'neutral'],
@@ -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,27 @@
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 { 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();
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 }
);
}
}
-1
View File
@@ -388,7 +388,6 @@
options={[
{ value: '', label: 'All statuses' },
{ value: 'active', label: 'Active cases' },
{ value: 'closed', label: 'Closed cases' },
...moderationCaseStatuses.map((status) => ({
value: status,
label: moderationCaseStatusLabels[status]
+23 -56
View File
@@ -150,7 +150,7 @@
function submitted() {
notice =
mode === 'restriction'
? 'Restriction saved and linked to this case. Review enforcement coverage below. The case remains open until you record an outcome.'
? 'Restriction saved and linked to this case. Review enforcement coverage below. The case remains open until you change its status.'
: mode === 'revoke'
? 'Revocation saved. Servers apply it when they receive the latest policy.'
: 'Case updated. The change is recorded in the timeline.';
@@ -189,17 +189,6 @@
typeof value.entry?.message?.player === 'string'
? (value.entry.message.player as string)
: null,
time:
typeof value.entry?.message?.time === 'string'
? (value.entry.message.time as string)
: null,
realTime:
typeof value.entry?.context?.realTime === 'string'
? (value.entry.context.realTime as string)
: null,
round: Number.isInteger(value.entry?.context?.round)
? (value.entry.context.round as number)
: null,
original: JSON.stringify(value, null, 2),
excerpt: preservedConversation(value)
};
@@ -226,12 +215,9 @@
/>
<Button onclick={() => refresh()} loading={refreshing} disabled={busy}>Refresh</Button>
{#if permitted}
{#if active}<Button variant="primary" onclick={() => openDialog('resolve')}
>Review case</Button
>
{:else}<Button variant="primary" onclick={() => openDialog('reopen')}
>Reopen case</Button
>{/if}
<Button variant="primary" onclick={() => openDialog('case-status')}
>Change status</Button
>
{/if}
{/snippet}
</SectionHeader>
@@ -305,45 +291,32 @@
{@const archive = archiveContext(evidence.snapshot)}
<li class="record">
<div class="row">
<strong>{archive?.player ?? 'Archived message'}</strong
>{#if archive?.round !== null && archive?.round !== undefined}<Badge
>Round {archive.round}</Badge
>{/if}
<strong>{archive?.player ?? 'Archived conversation'}</strong>
</div>
{#if archive?.excerpt}
<EvidenceMessages {...archive.excerpt} />
<EvidenceMessages
{...archive.excerpt}
matchId={archive.matchId}
server={archive.server}
startedAt={archive.startedAt}
/>
{:else}<blockquote>{evidence.text}</blockquote>{/if}
<p class="hint">{evidence.source}</p>
{#if archive}<div class="source-context">
{#if archive.matchId}<a
href={contextHref(
`/matches/${archive.matchId}?matchArchive=1`,
$page.url
)}>View match #{archive.matchId}</a
>{/if}
{#if archive.realTime}<span
>Estimated message time · {$timeFormat.dateTime(
archive.realTime
)}</span
>{/if}
{#if archive.time}<span
>Original log time · {archive.time}</span
>{/if}
{#if archive.startedAt}<span
>Match started · {$timeFormat.dateTime(
archive.startedAt
)}</span
>{/if}
</div>{/if}
{#if archive?.matchId}<a
href={contextHref(
`/matches/${archive.matchId}?matchArchive=1`,
$page.url
)}>View match #{archive.matchId}</a
>{/if}
<p class="hint">
Preserved by {evidence.actor} · <RelativeTime
value={evidence.createdAt}
/>
</p>
{#if archive}<details>
<summary>Preserved archive snapshot</summary>
<pre>{archive.original}</pre>
</details>{/if}
<details>
<summary>Technical details</summary>
<p class="hint">{evidence.source}</p>
<pre>{archive?.original ?? evidence.snapshot}</pre>
</details>
</li>
{/each}
</ol>
@@ -568,7 +541,7 @@
</li>{/each}
</ol>{:else}<p class="hint">No restrictions are linked to this case.</p>{/if}
<p class="hint">
Applying or revoking a restriction does not change the case outcome.
Applying or revoking a restriction does not change the case status.
</p>
</section>
@@ -604,7 +577,6 @@
{admin}
coverage={report.coverage}
assignees={report.assignees}
linkedActions={report.linkedActions}
onsubmitted={submitted}
/>{/key}{/if}
@@ -698,15 +670,10 @@
}
.hint,
dt,
.source-context,
summary {
color: var(--text-secondary);
font-size: var(--text-sm);
}
.source-context {
display: grid;
gap: var(--space-2);
}
.notice {
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
@@ -0,0 +1,727 @@
<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 unavailableExclusions = $derived(
draft?.excludedServers.filter(
(name) => !adminState?.servers.some((server) => server.name === name)
) ?? []
);
const includedServerCount = $derived(
adminState?.servers.filter((server) => !draft?.excludedServers.includes(server.name))
.length ?? 0
);
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.excludedServers = checked
? draft.excludedServers.filter((excluded) => excluded !== name)
: [...new Set([...draft.excludedServers, name])].sort();
}
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 all servers except your exclusions to the 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">
All current and new servers are published by default. Uncheck a server
to exclude it from Discord browsing, boards, and availability alerts.
</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.excludedServers.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 unavailableExclusions.length}
<div class="server-group">
<h3>Excluded servers no longer in the fleet</h3>
{#each unavailableExclusions as name (name)}
<label class="server-choice">
<input
type="checkbox"
checked={false}
onchange={(event) =>
chooseServer(name, event.currentTarget.checked)}
/>
<span
>{name}<small
>Excluded if this server returns. Check to remove
the exclusion.</small
></span
>
</label>
{/each}
</div>
{/if}
<p class="help">
{countLabel(includedServerCount, 'server')} included.
</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 excluded servers. 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 with your saved exclusions.
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">No servers are included by the saved settings.</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 ??
4} 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>
+293
View File
@@ -0,0 +1,293 @@
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: '' },
excludedServers: [],
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: [
{
name: serverName,
game: 't5',
label: 'Kino der Toten · Lobby 1',
map: 'Kino der Toten',
mapKey: 't5:zombie_theater',
state: 'empty',
players: 0,
capacity: 4,
round: null,
observedAt: '2026-10-18T20:00:00.000Z',
playerNames: [],
connection: null
}
],
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('includes servers by default, saves an exclusion without credentials, and restores it on discard', async () => {
const initial = snapshot();
initial.preview.t5[0].capacity = null;
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(true);
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 excluded servers');
expect(document.body.textContent).toContain('1 server included.');
expect(document.querySelector('.preview-server')?.textContent?.replace(/\s+/g, ' ')).toContain(
'0 / 4 players'
);
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',
excludedServers: [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(false);
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('automatically includes new servers on polling while retaining draft exclusions and recovering from errors', async () => {
vi.useFakeTimers();
const initial = snapshot();
const updated = snapshot();
updated.status.activeSubscriptions = 3;
const newServer = 't6_zm_tomb_1';
updated.servers.push({
name: newServer,
game: 't6',
label: 'Origins · Lobby 1',
map: 'Origins'
});
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(false);
expect(field('Discord server ID').value).toBe('45678901234567890');
await vi.advanceTimersByTimeAsync(60_000);
await vi.waitFor(() => expect(document.querySelector('[role="alert"]')).toBeNull());
expect(checkbox(serverName).checked).toBe(false);
expect(checkbox(newServer).checked).toBe(true);
expect(document.body.textContent).toContain('1 server included.');
expect(field('Discord server ID').value).toBe('45678901234567890');
expect(button('Save changes').disabled).toBe(false);
expect(document.body.textContent).toContain('Unsaved changes');
});
it('retains exclusions for removed servers until the administrator explicitly removes them', async () => {
const initial = snapshot();
const removedServer = 't5_zombie_cosmodrome_1';
initial.settings.excludedServers = [removedServer];
const writes: DiscordSettings[] = [];
vi.stubGlobal(
'fetch',
vi.fn(async (_url: string, init?: RequestInit) => {
if (init?.method === 'PUT') {
const settings = JSON.parse(String(init.body)) as DiscordSettings;
writes.push(settings);
return Response.json({ ...initial, settings });
}
return Response.json(initial);
})
);
await show();
expect(checkbox(removedServer).checked).toBe(false);
expect(document.body.textContent).toContain('Excluded if this server returns.');
expect(document.body.textContent).toContain('1 server included.');
toggle('Show player names');
button('Save changes').click();
await vi.waitFor(() => expect(button('Save changes').disabled).toBe(true));
expect(writes[0].excludedServers).toEqual([removedServer]);
toggle(removedServer);
button('Save changes').click();
await vi.waitFor(() => expect(writes).toHaveLength(2));
expect(writes[1].excludedServers).toEqual([]);
expect(document.body.textContent).not.toContain('Excluded servers no longer in the fleet');
});
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.'
);
});
+1 -1
View File
@@ -83,7 +83,7 @@ it('selects messages across participants, previews their context, and attaches t
);
expect(fetch.mock.calls[0][0]).toContain('game=t5&guid=11');
expect(document.querySelector('dialog')?.textContent).toContain('GUID 22');
expect(document.querySelector('dialog')?.textContent).toContain('Message time unknown');
expect(document.querySelector('dialog')?.textContent).toContain('Unknown time');
const excerpt = document.querySelector('dialog [aria-label="Preserved conversation excerpt"]')!;
expect(excerpt.querySelectorAll('li')).toHaveLength(3);
expect(excerpt.querySelector('.highlighted')?.textContent).toContain('A concerning reply');
+115 -53
View File
@@ -205,13 +205,25 @@ it('preserves original evidence context, linked restrictions, and staff history
expect(document.body.textContent).toContain('Read-only moderation access');
expect(document.body.textContent).toContain('Round 9');
expect(document.body.textContent).toContain('Original log time · 12:34');
const evidence = document.querySelector('[aria-labelledby="evidence-title"]')!;
expect(
evidence.querySelector('[aria-label="Preserved conversation excerpt"]')?.textContent
).toContain('abusive message');
const technical = [...evidence.querySelectorAll('details')].find(
(node) => node.querySelector('summary')?.textContent === 'Technical details'
)!;
expect(technical.open).toBe(false);
expect(technical.textContent).toContain('Archived chat · theater · match 17');
expect(JSON.parse(technical.querySelector('pre')!.textContent!)).toEqual(
JSON.parse(detail().case.evidence[0].snapshot!)
);
expect(document.body.textContent).toContain('Check the earlier warning.');
expect(document.body.textContent).toContain('Prior warning ignored');
expect(document.body.textContent).toContain('Policy pending');
expect(document.querySelector('a[href^="/matches/17"]')).not.toBeNull();
for (const label of [
'Edit case',
'Review case',
'Change status',
'Reopen case',
'Restrict player',
'Revoke restriction',
@@ -223,6 +235,58 @@ it('preserves original evidence context, linked restrictions, and staff history
expect(document.querySelector('dialog')).toBeNull();
});
it('summarizes a preserved conversation across midnight and keeps identifiers on demand', () => {
const initial = detail();
const archive = JSON.parse(initial.case.evidence[0].snapshot!);
archive.entries = [
{
...archive.entry,
messageId: 'subject-message',
context: { realTime: '2026-10-16T03:59:00Z', round: 4 },
message: { ...archive.entry.message, message: '<b>First message</b>', channel: 'team' }
},
{
...archive.entry,
messageId: 'witness-message',
context: { realTime: '2026-10-16T04:01:00Z', round: 9 },
message: { ...archive.entry.message, guid: '42', player: 'Witness', message: 'Reply' }
},
{
...archive.entry,
messageId: 'unknown-time',
context: { realTime: null, round: null }
}
];
archive.selectedMessageIds = ['subject-message', 'witness-message'];
archive.highlightedMessageIds = ['subject-message'];
initial.case.evidence[0].snapshot = JSON.stringify(archive);
show(initial);
const evidence = document.querySelector('[aria-labelledby="evidence-title"]')!;
const excerpt = evidence.querySelector('[aria-label="Preserved conversation excerpt"]')!;
const rows = excerpt.querySelectorAll('li');
expect(rows).toHaveLength(3);
expect(rows[0].classList.contains('highlighted')).toBe(true);
expect(rows[1].classList.contains('highlighted')).toBe(false);
expect(rows[0].textContent).toContain('Team chat');
expect(rows[0].textContent).toContain('<b>First message</b>');
expect(rows[0].querySelector('b')).toBeNull();
expect(rows[1].textContent).toContain('Selected context');
expect(rows[2].textContent).toContain('Unknown time');
expect(excerpt.textContent).not.toContain(guid);
expect(excerpt.textContent).not.toContain('subject-message');
expect(excerpt.textContent).not.toContain('Round');
const context = excerpt.previousElementSibling!.textContent!;
expect(context).toContain('Kino Der Toten · Match #17');
expect(context).toContain('Oct 15, 2026 – Oct 16, 2026');
expect(context).toContain('Rounds 4–9 (some unknown)');
expect(context).toContain('Times are estimates.');
const details = excerpt.nextElementSibling as HTMLDetailsElement;
expect(details.open).toBe(false);
expect(details.textContent).toContain(`GUID ${guid}`);
expect(details.textContent).toContain('Message identifier subject-message');
expect(details.textContent).toContain('Message identifier witness-message');
});
it('locks case restrictions to the recorded identity and revision without closing the case', async () => {
const updated = detail();
updated.case.revision = 5;
@@ -251,12 +315,12 @@ it('locks case restrictions to the recorded identity and revision without closin
durationMinutes: 60
});
expect(fetch.mock.calls[1][0]).toBe('/api/moderation/cases/case-1');
expect(button('Review case')).toBeDefined();
expect(button('Change status')).toBeDefined();
});
it('records specific outcomes and reopens a closed case with a reason and the latest revision', async () => {
it('closes and reopens through lifecycle status with a reason and the latest revision', async () => {
const closed = detail();
closed.case.status = 'warning_issued';
closed.case.status = 'closed';
closed.case.revision = 5;
const reopened = detail();
reopened.case.status = 'open';
@@ -269,37 +333,34 @@ it('records specific outcomes and reopens a closed case with a reason and the la
.mockResolvedValueOnce(response(reopened));
vi.stubGlobal('fetch', fetch);
show();
button('Review case').click();
button('Change status').click();
await tick();
expect([...field<HTMLSelectElement>('Outcome').options].map((option) => option.value)).toEqual([
'resolved',
'dismissed',
'warning_issued',
'restriction_applied',
'no_action',
'duplicate',
'appeal_upheld',
'appeal_denied'
expect([...field<HTMLSelectElement>('Status').options].map((option) => option.value)).toEqual([
'open',
'in_review',
'closed'
]);
select('Outcome', 'warning_issued');
enter(field('Review reason'), 'Warning was sufficient');
select('Status', 'closed');
enter(field('Change reason'), 'Warning was sufficient');
submit();
await vi.waitFor(() => expect(button('Reopen case')).toBeDefined());
await vi.waitFor(() => expect(document.querySelector('dialog')?.open).not.toBe(true));
expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({
op: 'resolve-case',
op: 'update-case',
id: 'case-1',
revision: 4,
status: 'warning_issued',
status: 'closed',
reason: 'Warning was sufficient'
});
expect(button('Restrict player')).toBeUndefined();
button('Reopen case').click();
button('Change status').click();
await tick();
enter(field('Reopening reason'), 'New evidence received');
select('Status', 'open');
enter(field('Change reason'), 'New evidence received');
submit();
await vi.waitFor(() => expect(button('Review case')).toBeDefined());
await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(4));
expect(JSON.parse(fetch.mock.calls[2][1].body)).toMatchObject({
op: 'reopen-case',
op: 'update-case',
status: 'open',
id: 'case-1',
revision: 5,
reason: 'New evidence received'
@@ -307,7 +368,7 @@ it('records specific outcomes and reopens a closed case with a reason and the la
expect(document.body.textContent).toContain('Check the earlier warning.');
});
it('edits priority, assignment, and review workflow with a required transition reason', async () => {
it('edits priority and assignment separately from lifecycle status', async () => {
const fetch = vi
.fn()
.mockResolvedValueOnce(response({ ok: true, id: 'case-1' }))
@@ -319,8 +380,8 @@ it('edits priority, assignment, and review workflow with a required transition r
enter(field('Case title'), 'Urgent report');
select('Priority', 'urgent');
select('Assigned to', 'staff-2');
select('Workflow status', 'escalated');
expect(field<HTMLTextAreaElement>('Change reason').required).toBe(true);
expect(document.querySelector('dialog')?.textContent).not.toContain('Workflow status');
expect(field<HTMLTextAreaElement>('Change reason').required).toBe(false);
enter(field('Change reason'), 'Needs administrator review');
submit();
await vi.waitFor(() => expect(fetch).toHaveBeenCalled());
@@ -331,14 +392,13 @@ it('edits priority, assignment, and review workflow with a required transition r
title: 'Urgent report',
priority: 'urgent',
assigneeId: 'staff-2',
status: 'escalated',
reason: 'Needs administrator review'
});
});
it('preserves an unavailable assignee when editing metadata on a closed case', async () => {
const initial = detail();
initial.case.status = 'no_action';
initial.case.status = 'closed';
initial.case.assigneeId = 'disabled-staff';
initial.case.assignee = 'Former staff';
const fetch = vi
@@ -431,13 +491,11 @@ it('links a prior mute with its recorded outcome without issuing another restric
expect(button('Link existing action').disabled).toBe(true);
});
it.each([
['warning_issued', 'action:warning-1', 'action', 'warning-1'],
['restriction_applied', 'restriction:restriction-1', 'restriction', 'restriction-1']
] as const)(
'references a linked action when recording %s',
async (outcome, selection, type, id) => {
it.each(['in_review', 'closed'] as const)(
'changes status to %s without replacing findings or issuing an action',
async (status) => {
const initial = detail();
initial.case.status = 'open';
initial.linkedActions = [
{
id: 'warning-1',
@@ -476,31 +534,34 @@ it.each([
.mockResolvedValue(response(initial));
vi.stubGlobal('fetch', fetch);
show(initial);
button('Review case').click();
button('Change status').click();
await tick();
select('Outcome', outcome);
const options = [...field<HTMLSelectElement>('Supporting action').options].map(
(option) => option.value
);
expect(options).toEqual(['', selection]);
select('Supporting action', selection);
enter(field('Review reason'), 'Reviewed the recorded action');
expect(button('Save').disabled).toBe(true);
select('Status', status);
await tick();
expect(button('Save').disabled).toBe(false);
expect(document.querySelector('dialog')?.textContent).not.toContain('Supporting action');
enter(field('Change reason'), 'Reviewed the recorded action');
submit();
await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({
op: 'resolve-case',
op: 'update-case',
id: 'case-1',
revision: 4,
status: outcome,
actionReference: { type, id }
status
});
expect(JSON.parse(fetch.mock.calls[0][1].body)).not.toHaveProperty('actionReference');
expect(document.querySelector('[aria-labelledby="actions-title"]')?.textContent).toContain(
'Confirmed warning'
);
}
);
it('retains a conflicted review and lets staff refresh before making another decision', async () => {
const updated = detail();
updated.case.revision = 8;
updated.case.status = 'escalated';
updated.case.title = 'Updated case after concurrent edit';
updated.case.status = 'in_review';
const fetch = vi
.fn()
.mockResolvedValueOnce(
@@ -509,28 +570,29 @@ it('retains a conflicted review and lets staff refresh before making another dec
.mockResolvedValue(response(updated));
vi.stubGlobal('fetch', fetch);
show();
button('Review case').click();
button('Change status').click();
await tick();
select('Outcome', 'appeal_denied');
enter(field('Review reason'), 'Evidence supports the restriction');
select('Status', 'closed');
enter(field('Change reason'), 'Evidence supports the restriction');
submit();
await vi.waitFor(() =>
expect(document.querySelector('dialog [role="alert"]')?.textContent).toContain(
'This case changed.'
)
);
expect(field<HTMLSelectElement>('Outcome').value).toBe('appeal_denied');
expect(field<HTMLSelectElement>('Status').value).toBe('closed');
expect(document.querySelector('dialog')!.open).toBe(true);
expect(button('Reopen case')).toBeUndefined();
button('Cancel').click();
await tick();
button('Refresh').click();
await vi.waitFor(() =>
expect(document.querySelector('.case-summary')?.textContent).toContain('Escalated')
expect(document.querySelector('h1')?.textContent).toBe('Updated case after concurrent edit')
);
button('Review case').click();
button('Change status').click();
await tick();
enter(field('Review reason'), 'Reviewed new context');
select('Status', 'closed');
enter(field('Change reason'), 'Reviewed new context');
submit();
await vi.waitFor(() => expect(fetch.mock.calls.length).toBeGreaterThanOrEqual(3));
expect(JSON.parse(fetch.mock.calls[2][1].body).revision).toBe(8);
+1 -1
View File
@@ -21,7 +21,7 @@ const item: ModerationCase = {
game: 't5',
guid: '123',
title: 'Chat review',
status: 'resolved',
status: 'closed',
priority: 'normal',
assignee: null,
assigneeId: null,
+2 -2
View File
@@ -353,7 +353,7 @@ it('links case records to workspaces and preserves case filters while paging', (
actor: 'Moderator',
createdAt: '2026-10-16T12:00:00Z'
},
status: 'escalated',
status: 'in_review',
priority: 'high',
assigneeId: 'staff-1',
assignee: 'Moderator',
@@ -369,7 +369,7 @@ it('links case records to workspaces and preserves case filters while paging', (
expect(document.querySelector('a[href="/moderation/cases/case-1"]')?.textContent).toBe(
'Chat investigation'
);
expect(document.querySelector('#moderation-results')?.textContent).toContain('Escalated');
expect(document.querySelector('#moderation-results')?.textContent).toContain('In progress');
expect(document.querySelector('#moderation-results')?.textContent).toContain('Moderator');
expect(document.querySelector('#moderation-results')?.textContent).toContain(
'The threat remains ambiguous.'
+417
View File
@@ -0,0 +1,417 @@
import Database from 'better-sqlite3';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
defaultDiscordSettings,
type DiscordMessage,
type DiscordPublicServer
} from '../../src/lib/discord.js';
import { migrateDiscord } from '../../src/lib/server/discord/schema.js';
import { DiscordStore } from '../../src/lib/server/discord/store.js';
import {
DiscordDeliveryError,
DiscordRest,
type DiscordTransport
} from '../../src/lib/server/discord/transport.js';
import { DiscordWorker } from '../../src/lib/server/discord/worker.js';
const guildId = '12345678901234567';
const channelId = '23456789012345678';
const userId = '34567890123456789';
const messageId = '45678901234567890';
const dmId = '56789012345678901';
let handle: Database.Database;
let store: DiscordStore;
let now: number;
let server: DiscordPublicServer;
const message = (content: string): DiscordMessage => ({ content, allowed_mentions: { parse: [] } });
const watchInput = {
guildId,
userId,
game: 't5' as const,
mapKey: 't5:zombie_theater',
mode: 'empty' as const
};
beforeEach(() => {
handle = new Database(':memory:');
migrateDiscord(handle);
now = Date.parse('2026-10-18T20:00:00Z');
store = new DiscordStore(handle, () => now);
store.saveSettings({
...defaultDiscordSettings,
enabled: true,
guildId,
channels: { t5: channelId, t6: '' },
excludedServers: []
});
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.excludedServers.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(), excludedServers: [server.name] });
});
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();
});
});
+522
View File
@@ -0,0 +1,522 @@
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: '' },
excludedServers: []
};
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('publishes current and newly added servers by default while respecting exclusions', () => {
const original = server();
const added = server({ name: 't5_zombie_theater_2' });
expect(projectFleet([original], settings, now).map((item) => item.name)).toEqual([name]);
expect(projectFleet([original, added], settings, now).map((item) => item.name)).toEqual([
name,
added.name
]);
expect(
projectFleet([original, added], { ...settings, excludedServers: [name] }, now).map(
(item) => item.name
)
).toEqual([added.name]);
});
it('excludes unpublished servers and projects only 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, excludedServers: ['unpublished'] },
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: 4, round: null, playerNames: [] });
});
it('uses the four-player lobby limit when current capacity is unavailable', () => {
for (const change of [
{ capacity: null, capacity_observed_at: null },
{ capacity: 0 },
{ capacity: 8, capacity_observed_at: new Date(now - 100000).toISOString() },
{
capacity: 8,
capacity_observed_at: new Date(now - 20000).toISOString(),
telemetry_after: new Date(now - 15000).toISOString()
}
]) {
const projected = projectFleet([server(change)], settings, now);
expect(projected[0].capacity).toBe(4);
expect(filterServers(projected, { status: 'space' })).toEqual(projected);
for (const message of [
...renderBoard('t5', projected),
renderBrowser(projected),
renderServer(projected[0])
]) {
expect(messageText(message)).toContain('2/4 players');
expect(messageText(message)).not.toContain('2/?');
}
}
const full = projectFleet([server({ last_players: 4, capacity: null })], settings, now);
expect(full[0]).toMatchObject({ capacity: 4, state: 'full' });
expect(filterServers(full, { status: 'space' })).toEqual([]);
});
it('uses a current measured capacity when available', () => {
const projected = projectFleet([server({ capacity: 8, last_players: 4 })], settings, now);
expect(projected[0]).toMatchObject({ capacity: 8, state: 'in-progress' });
expect(messageText(renderServer(projected[0]))).toContain('4/8 players');
expect(filterServers(projected, { status: 'space' })).toEqual(projected);
});
it('never treats stale or previous-process occupancy as an available space', () => {
for (const change of [
{ last_seen_at: new Date(now - 100000).toISOString() },
{ health: 'stale' as const },
{ telemetry_after: new Date(now - 5000).toISOString() }
]) {
const projected = projectFleet([server({ ...change, capacity: null })], settings, now);
expect(projected[0]).toMatchObject({ capacity: 4, players: null });
expect(filterServers(projected, { status: 'space' })).toEqual([]);
expect(messageText(renderServer(projected[0]))).toContain('Players unknown');
}
});
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
});
expect(projectFleet([town], settings, now)[0]).toMatchObject({
map: 'Town Survival',
mapKey: 't6:zm_transit:town'
});
expect(projectFleet([{ ...town, last_map: 'zm_tomb' }], settings, 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.excludedServers = [name];
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.excludedServers = [name];
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');
});
});
+58
View File
@@ -0,0 +1,58 @@
import Database from 'better-sqlite3';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { defaultDiscordSettings } from '../../src/lib/discord.js';
import { migrateDiscord } from '../../src/lib/server/discord/schema.js';
import { migrateDiscordPublicationDefaults } from '../../src/lib/server/discord/publication-defaults.js';
import { DiscordStore } from '../../src/lib/server/discord/store.js';
let handle: Database.Database;
let store: DiscordStore;
beforeEach(() => {
handle = new Database(':memory:');
migrateDiscord(handle);
handle.exec(`
CREATE TABLE servers (name TEXT PRIMARY KEY, destroyed_at TEXT);
INSERT INTO servers VALUES ('kino', NULL), ('town', NULL), ('retired', '2026-10-18');
`);
store = new DiscordStore(handle);
});
afterEach(() => handle.close());
function legacySettings(publishedServers: string[]) {
const value: Record<string, unknown> = { ...defaultDiscordSettings, publishedServers };
delete value.excludedServers;
handle
.prepare('INSERT INTO discord_configuration(id,value) VALUES(1,?)')
.run(JSON.stringify(value));
}
describe('Discord publication defaults upgrade', () => {
it('includes all servers on new installations without creating configuration', () => {
migrateDiscordPublicationDefaults(handle);
expect(store.settings()).toEqual(defaultDiscordSettings);
expect(store.settings().excludedServers).toEqual([]);
expect(handle.prepare('SELECT * FROM discord_configuration').all()).toEqual([]);
});
it('replaces the previously empty default selection without enabling Discord or optional sharing', () => {
legacySettings([]);
migrateDiscordPublicationDefaults(handle);
expect(store.settings()).toEqual(defaultDiscordSettings);
const value = (
handle.prepare('SELECT value FROM discord_configuration').get() as { value: string }
).value;
expect(JSON.parse(value)).not.toHaveProperty('publishedServers');
});
it('preserves existing nonempty selections as exclusions, and retains later opt-outs on replay', () => {
legacySettings(['kino']);
migrateDiscordPublicationDefaults(handle);
expect(store.settings().excludedServers).toEqual(['town']);
handle.exec("INSERT INTO servers VALUES ('moon', NULL)");
expect(store.settings().excludedServers).not.toContain('moon');
store.saveSettings({ ...store.settings(), excludedServers: ['kino', 'town'] });
const before = handle.prepare('SELECT * FROM discord_configuration').all();
migrateDiscordPublicationDefaults(handle);
expect(handle.prepare('SELECT * FROM discord_configuration').all()).toEqual(before);
});
});
+251
View File
@@ -0,0 +1,251 @@
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 all servers with sharing disabled and validates bounded exclusions', () => {
expect(validateDiscordSettings(defaultDiscordSettings)).toEqual(defaultDiscordSettings);
expect(defaultDiscordSettings.enabled).toBe(false);
expect(defaultDiscordSettings.excludedServers).toEqual([]);
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,
excludedServers: ['../../secrets']
})
).toThrow();
expect(() =>
validateDiscordSettings({
...defaultDiscordSettings,
excludedServers: Array(101).fill('t5_map_1')
})
).toThrow();
expect(
validateDiscordSettings({
...defaultDiscordSettings,
excludedServers: ['t5_map_2', 't5_map_1', 't5_map_1']
}).excludedServers
).toEqual(['t5_map_1', 't5_map_2']);
});
it('limits browser configuration writes to administrators and exempts only exact signed POSTs', () => {
for (const route of [
'/api/integrations/discord',
'/api/integrations/discord/register',
'/settings/integrations/discord'
]) {
expect(routePermission(route, 'GET')).toBe('admin-read');
expect(roleAllows('super_viewer', routePermission(route, 'PUT'))).toBe(false);
expect(roleAllows('operator', routePermission(route, 'GET'))).toBe(false);
expect(roleAllows('admin', routePermission(route, 'PUT'))).toBe(true);
}
expect(routePermission('/api/discord/interactions', 'POST')).toBe('machine');
expect(routePermission('/api/discord/interactions', 'GET')).not.toBe('machine');
expect(routePermission('/api/discord/interactions/other', 'POST')).not.toBe('machine');
expect(routePermission('/api/discord/interactions-extra', 'POST')).not.toBe('machine');
});
});
+17 -2
View File
@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../src/lib/server/db/migrate.js', () => ({ migrate: vi.fn() }));
vi.mock('../../src/lib/server/pipeline/worker.js', () => ({ startWorker: vi.fn() }));
vi.mock('../../src/lib/server/discord/worker.js', () => ({ startDiscordWorker: vi.fn() }));
vi.mock('../../src/lib/server/provisioning/history.js', () => ({ interruptDeployments: vi.fn() }));
vi.mock('../../src/lib/server/auth/guard.js', () => ({
authenticationHandle: vi.fn(async () => new Response('OK'))
@@ -24,7 +25,13 @@ afterEach(() => {
});
describe('controller error handling', () => {
it.each(['migration', 'deployment recovery', 'worker startup', 'performance monitor'] as const)(
it.each([
'migration',
'deployment recovery',
'worker startup',
'Discord startup',
'performance monitor'
] as const)(
'retries failed %s before serving requests and boots only once after recovery',
async (phase) => {
const { handle } = await import('../../src/hooks.server.js');
@@ -32,13 +39,21 @@ describe('controller error handling', () => {
const { interruptDeployments } =
await import('../../src/lib/server/provisioning/history.js');
const { startWorker } = await import('../../src/lib/server/pipeline/worker.js');
const { startDiscordWorker } = await import('../../src/lib/server/discord/worker.js');
const { startPerformanceMonitor } = await import('../../src/lib/server/performance.js');
const { authenticationHandle } = await import('../../src/lib/server/auth/guard.js');
const steps = [migrate, interruptDeployments, startWorker, startPerformanceMonitor];
const steps = [
migrate,
interruptDeployments,
startWorker,
startDiscordWorker,
startPerformanceMonitor
];
const failedStep = [
'migration',
'deployment recovery',
'worker startup',
'Discord startup',
'performance monitor'
].indexOf(phase);
const failure = new Error('database or disk is full');
+3 -3
View File
@@ -94,7 +94,7 @@ it('preserves current and historical assessments with case revisions, including
};
await mutate(assess(id, 3, updated), 'admin');
const data = await detail(id, 'super_viewer');
expect(data.case).toMatchObject({ revision: 4, status: 'no_action' });
expect(data.case).toMatchObject({ revision: 4, status: 'closed' });
expect(data.assessment).toEqual({
...updated,
revision: 4,
@@ -253,8 +253,8 @@ it('adds assessment storage to an existing database without altering case record
)
.run(clock, clock);
const before = legacy.prepare('SELECT * FROM moderation_cases').all();
runMigrations(legacy, migrations);
runMigrations(legacy, migrations);
runMigrations(legacy, migrations.slice(0, 32));
runMigrations(legacy, migrations.slice(0, 32));
expect(legacy.prepare('SELECT * FROM moderation_cases').all()).toMatchObject(before);
const insert = legacy.prepare(
'INSERT INTO moderation_case_assessments VALUES(?,?,?,?,?,?)'
+3 -3
View File
@@ -164,7 +164,7 @@ it('links historical live actions to closed cases and exposes their real deliver
const before = enforcement();
await mutate(link(id, 2, 'action', warningId));
const data = await detail(id, 'super_viewer');
expect(data.case).toMatchObject({ status: 'no_action', revision: 3 });
expect(data.case).toMatchObject({ status: 'closed', revision: 3 });
expect(data.linkedActions).toMatchObject([{ id: warningId, kind: 'warn', state: 'confirmed' }]);
expect(data.availableActions).toMatchObject([{ id: failedId, state: 'failed' }]);
expect(data.events.map((event) => event.action)).toContain('action.confirmed');
@@ -246,7 +246,7 @@ it('requires a linked confirmed warning for a cited warning outcome and preserve
const before = enforcement();
await mutate(resolve(5, 'action', confirmed));
const data = await detail(id);
expect(data.case).toMatchObject({ status: 'warning_issued', revision: 6 });
expect(data.case).toMatchObject({ status: 'closed', revision: 6 });
expect(JSON.parse(data.events[0].detail)).toEqual({
reason: 'Decision after evidence review',
actionReference: { type: 'action', id: confirmed }
@@ -268,7 +268,7 @@ it('allows a historical expired restriction created from the original case to su
reason: 'Earlier mute addressed the incident',
actionReference: { type: 'restriction', id: sanction.id }
});
expect((await detail(id)).case.status).toBe('restriction_applied');
expect((await detail(id)).case.status).toBe('closed');
expect(enforcement()).toEqual(before);
});
+3 -3
View File
@@ -26,8 +26,8 @@ it('filters case queues by workflow, priority and ownership while prioritizing a
for (const [title, priority, status, owner] of [
['Normal report', 'normal', 'open', null],
['Urgent review', 'urgent', 'in_review', 'first'],
['High escalation', 'high', 'escalated', 'second'],
['Closed urgent appeal', 'urgent', 'appeal_upheld', 'first']
['High escalation', 'high', 'in_review', 'second'],
['Closed urgent appeal', 'urgent', 'closed', 'first']
]) {
const { id } = mutateModeration('first', {
op: 'create-case',
@@ -46,7 +46,7 @@ it('filters case queues by workflow, priority and ownership while prioritizing a
expect(read().cases.map((item) => item.id)).toEqual([ids[1], ids[2], ids[0], ids[3]]);
expect(read('caseStatus=active').total).toBe(3);
expect(read('caseStatus=closed').cases.map((item) => item.id)).toEqual([ids[3]]);
expect(read('caseStatus=escalated&priority=high').cases.map((item) => item.id)).toEqual([
expect(read('caseStatus=in_review&priority=high').cases.map((item) => item.id)).toEqual([
ids[2]
]);
expect(read('assignedTo=me').cases.map((item) => item.id)).toEqual([ids[1], ids[3]]);
@@ -156,7 +156,7 @@ it('enforces case existence, staff access, revisions, self-link rejection and su
reason: 'Review complete'
});
await mutate(link(a.id, 2, b.id));
expect((await detail(a.id)).case).toMatchObject({ status: 'no_action', revision: 3 });
expect((await detail(a.id)).case).toMatchObject({ status: 'closed', revision: 3 });
await expect(detail(a.id, 'viewer')).rejects.toMatchObject({ status: 403 });
});
+157
View File
@@ -0,0 +1,157 @@
import Database from 'better-sqlite3';
import { randomUUID } from 'node:crypto';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { moderationCaseOutcomes, moderationCaseStatuses } from '../../src/lib/moderation.js';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
beforeEach(async () => {
vi.resetModules();
fixture = await newFixture([]);
});
afterEach(async () => fixture.cleanup());
it('migrates every legacy state while preserving assignments, decisions, evidence, actions and relationships', async () => {
const { migrations } = await import('../../src/lib/server/db/migrations.js');
const { runMigrations } = await import('../../src/lib/server/db/migration-runner.js');
const handle = new Database(':memory:');
handle.pragma('foreign_keys = ON');
try {
runMigrations(handle, migrations.slice(0, 35));
handle.exec(`INSERT INTO auth_users(id,username,display_name,password_hash,role,created_at,updated_at)
VALUES('staff','staff','Staff','unused','moderator',0,0)`);
const statuses = ['open', 'in_review', 'escalated', ...moderationCaseOutcomes];
for (const status of statuses) {
handle
.prepare(
`INSERT INTO moderation_cases(id,game,guid,title,status,actor_id,actor,created_at,updated_at,revision,assignee_id,review_at)
VALUES(?,'t5','123','Existing case',?,'staff','Staff','2026-10-17','2026-10-17',7,'staff','2026-11-01')`
)
.run(status, status);
}
handle.exec(`
INSERT INTO moderation_events(game,guid,actor,action,subject_id,detail,created_at)
VALUES('t5','123','Staff','case.warning_issued','warning_issued','Original decision','2026-10-17');
INSERT INTO moderation_evidence VALUES('e','warning_issued','Original excerpt','Archived chat','{"entry":"unchanged"}','staff','Staff','2026-10-17');
INSERT INTO moderation_sanctions(id,game,guid,kind,scope,reason,note,actor_id,actor,created_at,case_id)
VALUES('s','t5','123','mute','game','Original action','','staff','Staff','2026-10-17','warning_issued');
UPDATE moderation_cases SET review_sanction_id='s' WHERE id='warning_issued';
INSERT INTO moderation_case_action_links VALUES('warning_issued','restriction','s','Staff','2026-10-17');
INSERT INTO moderation_case_relationships VALUES('r','open','warning_issued','same_incident','staff','Staff','2026-10-17',NULL);
INSERT INTO moderation_case_assessments VALUES('warning_issued',6,'{"summary":"Existing finding"}','staff','Staff','2026-10-17');
INSERT INTO moderation_case_notifications(user_id,case_id,kind,created_at) VALUES('staff','warning_issued','evidence','2026-10-17');
`);
const children = [
'moderation_evidence',
'moderation_sanctions',
'moderation_case_action_links',
'moderation_case_relationships',
'moderation_case_assessments',
'moderation_case_notifications'
];
const before = children.map((table) => handle.prepare(`SELECT * FROM ${table}`).all());
runMigrations(handle, migrations);
runMigrations(handle, migrations);
expect(children.map((table) => handle.prepare(`SELECT * FROM ${table}`).all())).toEqual(
before
);
for (const old of statuses) {
const expected =
old === 'open'
? 'open'
: ['in_review', 'escalated'].includes(old)
? 'in_review'
: 'closed';
expect(
handle
.prepare(
'SELECT status,revision,assignee_id,review_at FROM moderation_cases WHERE id=?'
)
.get(old)
).toEqual({
status: expected,
revision: old === expected ? 7 : 8,
assignee_id: 'staff',
review_at: '2026-11-01'
});
}
const events = handle
.prepare("SELECT detail FROM moderation_events WHERE action='case.status_migrated'")
.all() as { detail: string }[];
expect(events.map((event) => JSON.parse(event.detail).from).sort()).toEqual(
['escalated', ...moderationCaseOutcomes].sort()
);
expect(
handle
.prepare("SELECT detail FROM moderation_events WHERE action='case.warning_issued'")
.get()
).toEqual({ detail: 'Original decision' });
expect(() =>
handle.exec("UPDATE moderation_cases SET status='warning_issued' WHERE id='open'")
).toThrow('CHECK');
expect(handle.pragma('foreign_key_check')).toEqual([]);
} finally {
handle.close();
}
});
it('changes lifecycle independently of ownership, findings and enforcement with revision and retry protection', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
const { mutateModeration } = await import('../../src/lib/server/moderation/actions.js');
const { moderationCaseDetail } = await import('../../src/lib/server/moderation/cases.js');
const handle = db();
for (const role of ['moderator', 'super_viewer'])
handle
.prepare(
`INSERT INTO auth_users(id,username,display_name,password_hash,role,created_at,updated_at)
VALUES(?,?,?,'unused',?,0,0)`
)
.run(role, role, role, role);
const mutate = (input: Record<string, unknown>, actor = 'moderator') =>
mutateModeration(actor, { idempotencyKey: randomUUID(), ...input });
const { id } = mutate({
op: 'create-case',
game: 't5',
guid: '123',
title: 'Reported conduct',
assigneeId: 'moderator'
});
mutate({
op: 'assess-case',
id,
revision: 1,
summary: 'Ambiguous context',
rules: [],
findings: [{ category: 'threats', finding: 'inconclusive', detail: 'Need more context' }]
});
const original = moderationCaseDetail('moderator', id);
let revision = 2;
for (const status of ['in_review', 'closed', 'open', 'closed', 'in_review']) {
const request = {
op: 'update-case',
id,
revision,
status,
reason: 'Workflow changed',
idempotencyKey: randomUUID()
};
expect(() => mutate({ ...request, reason: '' })).toThrow('Status change reason');
expect(() => mutate({ ...request, revision: revision - 1 })).toThrow('changed');
expect(() => mutate(request, 'super_viewer')).toThrow('Moderation access');
mutate(request);
mutate(request);
revision++;
const data = moderationCaseDetail('moderator', id);
expect(data.case).toMatchObject({ status, revision, assigneeId: 'moderator' });
expect(data.assessmentHistory).toEqual(original.assessmentHistory);
expect(data.case.evidence).toEqual(original.case.evidence);
expect(data.sanctions).toEqual(original.sanctions);
expect(data.events[0]).toMatchObject({ action: 'case.updated' });
}
for (const status of [...moderationCaseOutcomes, 'assigned', 'escalated'])
expect(() =>
mutate({ op: 'update-case', id, revision, status, reason: 'Invalid state' })
).toThrow('Choose Open');
expect(moderationCaseStatuses).toEqual(['open', 'in_review', 'closed']);
expect(handle.prepare('SELECT COUNT(*) AS n FROM moderation_actions').get()).toEqual({ n: 0 });
});
+7 -7
View File
@@ -134,8 +134,8 @@ it('upgrades legacy cases without changing evidence, events, requests, or restri
.all()
).toEqual(
['dismissed', 'open', 'resolved'].map((status) => ({
status,
revision: 7,
status: status === 'open' ? 'open' : 'closed',
revision: status === 'open' ? 7 : 8,
priority: 'normal',
assignee_id: null
}))
@@ -146,7 +146,7 @@ it('upgrades legacy cases without changing evidence, events, requests, or restri
.get('{"original":true}')
).toEqual({ count: 3 });
expect(legacy.prepare('SELECT COUNT(*) AS count FROM moderation_events').get()).toEqual({
count: 3
count: 5
});
expect(legacy.prepare('SELECT result FROM moderation_requests').get()).toEqual({
result: '{"ok":true,"id":"open"}'
@@ -245,12 +245,12 @@ it('audits title, ownership, priority and active state updates with optimistic r
id,
revision: 3,
assigneeId: null,
status: 'escalated',
status: 'open',
reason: 'Needs administrator review'
});
expect((await detail(id)).case).toMatchObject({
revision: 4,
status: 'escalated',
status: 'open',
assigneeId: null
});
await expect(
@@ -267,9 +267,9 @@ it('supports every outcome and reopening while preserving each prior decision',
);
await mutate({ op: 'resolve-case', id, revision, status, reason: `Decision ${status}` });
revision++;
expect((await detail(id)).case.status).toBe(status);
expect((await detail(id)).case.status).toBe('closed');
await expect(mutate({ op: 'update-case', id, revision, status: 'open' })).rejects.toThrow(
'Use reopen'
'Status change reason'
);
await expect(
mutate({ op: 'reopen-case', id, revision: revision - 1, reason: 'Old version' })
+1 -1
View File
@@ -185,7 +185,7 @@ it('prioritizes overdue closed cases and recalculates due and scheduled queues u
closed.id,
upcoming.id
]);
expect((await overview()).cases[0].status).toBe('no_action');
expect((await overview()).cases[0].status).toBe('closed');
await mutate(schedule(closed.id, 3, null));
expect((await overview('creator', 'review=due')).cases.map((item) => item.id)).toEqual([
upcoming.id
+13 -2
View File
@@ -95,14 +95,14 @@ it('presents case decisions and ownership changes as readable history while pres
'case.updated'
)
).toBe(
'Title: Report → Appeal review\nAssigned to: Unassigned → Moderator\nStatus: Open → In review\nReviewing additional evidence'
'Title: Report → Appeal review\nAssigned to: Unassigned → Moderator\nStatus: Open → In progress\nReviewing additional evidence'
);
expect(
moderationEventDetail(
JSON.stringify({ from: 'appeal_denied', reason: 'New evidence' }),
'case.reopened'
)
).toBe('Previous outcome: Appeal denied\nNew evidence');
).toBe('Previous status: Appeal denied\nNew evidence');
expect(moderationEventDetail('Decision with {unusual text}', 'case.resolved')).toBe(
'Decision with {unusual text}'
);
@@ -110,3 +110,14 @@ it('presents case decisions and ownership changes as readable history while pres
moderationEventDetail(JSON.stringify({ count: 2, matchId: 42 }), 'case.evidence_attached')
).toBe('2 archived chat entries attached from match #42.');
});
it('explains migrated status without hiding the historical decision', () => {
expect(
moderationEventDetail(
JSON.stringify({ from: 'warning_issued', to: 'closed' }),
'case.status_migrated'
)
).toBe(
'Previous status: Warning issued → Closed. Earlier decisions and actions are preserved.'
);
});