Compare commits
4
Commits
48653af3dc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d715173719 | ||
|
|
fd00ef5357 | ||
|
|
f2b2541b46 | ||
|
|
a7f1c65d17 |
@@ -16,6 +16,13 @@ EC_SETUP_TOKEN=
|
|||||||
# Game reporters send Authorization: Bearer <token> or X-EC-Telemetry-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.
|
# Telemetry ingestion is disabled until a token of at least 32 chars is set.
|
||||||
EC_TELEMETRY_TOKEN=
|
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.
|
# Bank credentials are generated and injected automatically per native instance.
|
||||||
EC_BANK_MAX_BALANCE=250000
|
EC_BANK_MAX_BALANCE=250000
|
||||||
# Persistent SQLite metadata, game-file storage, and beta archives.
|
# Persistent SQLite metadata, game-file storage, and beta archives.
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ services:
|
|||||||
ORIGIN: ${EC_PUBLIC_URL:-http://localhost:3000}
|
ORIGIN: ${EC_PUBLIC_URL:-http://localhost:3000}
|
||||||
EC_SETUP_TOKEN: ${EC_SETUP_TOKEN:-}
|
EC_SETUP_TOKEN: ${EC_SETUP_TOKEN:-}
|
||||||
EC_TELEMETRY_TOKEN: ${EC_TELEMETRY_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_BANK_MAX_BALANCE: ${EC_BANK_MAX_BALANCE:-250000}
|
||||||
EC_AUTH_ALLOW_HTTP: ${EC_AUTH_ALLOW_HTTP:-false}
|
EC_AUTH_ALLOW_HTTP: ${EC_AUTH_ALLOW_HTTP:-false}
|
||||||
# Enable only behind a trusted proxy that sets the client-address header.
|
# Enable only behind a trusted proxy that sets the client-address header.
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ and launch your first server.
|
|||||||
- [Native hosts](guides/native-hosts.md) — enrollment, game files, capacity, agent updates, and recovery.
|
- [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.
|
- [Users and access](guides/authentication.md) — roles, sessions, telemetry credentials, and password recovery.
|
||||||
- [Fleet settings](guides/settings.md) — concurrency, retention, and cleanup.
|
- [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
|
### Fleet operations
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
queue by status, priority, or assignment; active cases appear first, ordered by
|
||||||
priority and most recent update.
|
priority and most recent update.
|
||||||
|
|
||||||
Move active cases between **Open**, **In review**, and **Escalated**, recording a
|
Use **Change status** to move a case between **Open**, **In progress**, and
|
||||||
reason for workflow changes. Close a case with one of these outcomes: **Resolved**,
|
**Closed**, recording a reason for each transition. Assignment is separate:
|
||||||
**Dismissed**, **Warning issued**, **Restriction applied**, **No action needed**,
|
a case can have an owner at any stage. To resume a closed case, change its status
|
||||||
**Duplicate**, **Appeal upheld**, or **Appeal denied**. Every decision requires a
|
to Open or In progress. Evidence, assessments, actions, and earlier decisions
|
||||||
reason. Reopen a closed case when new evidence or an appeal needs review; earlier
|
remain in its history. Editing case details does not change its status.
|
||||||
decisions remain in its history. Editing case details does not reopen a case.
|
|
||||||
|
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
|
Use **Edit assessment** to keep a concise current summary, list the applicable
|
||||||
rules, and record a finding for each concern: hate speech, harassment, threats,
|
rules, and record a finding for each concern: hate speech, harassment, threats,
|
||||||
safeguarding, spam, cheating, or another concern. Each finding is **Substantiated**,
|
safeguarding, spam, cheating, or another concern. Each finding is **Substantiated**,
|
||||||
**Inconclusive**, or **Unsubstantiated**, with a short rationale. Findings describe
|
**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
|
Every saved assessment retains its author, timestamp, and complete previous
|
||||||
revision. Assessments can be revised after closure without reopening the case.
|
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
|
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
|
and GUID and records the resulting restriction alongside the evidence. The same
|
||||||
role, scope, and duration limits apply as elsewhere in Moderation. Applying a
|
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
|
restriction leaves the case active so staff can finish reviewing it. Changing case status
|
||||||
closure outcome records a decision; it does not itself send a warning, create a
|
records workflow progress; it does not send a warning, create a restriction, or
|
||||||
restriction, or revoke one. Linked restrictions can be revoked separately with
|
revoke one. Linked restrictions can be revoked separately with
|
||||||
the required permission and reason, including after a case closes. Reopening a
|
the required permission and reason, including after a case closes. Reopening a
|
||||||
case also leaves its restrictions in place. Check enforcement coverage after
|
case also leaves its restrictions in place. Check enforcement coverage after
|
||||||
applying or revoking a restriction.
|
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
|
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.
|
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.
|
Linking requires the current case revision and a reason, and appears in its timeline.
|
||||||
When closing with **Warning issued** or **Restriction applied**, optionally reference
|
Linked records show what actually happened, including failed or uncertain delivery.
|
||||||
one of the linked records. Warning references require a confirmed warning; failed
|
They remain visible when a case closes; status changes do not require choosing
|
||||||
or uncertain delivery remains visible without being presented as a confirmed warning.
|
or re-entering an action.
|
||||||
|
|
||||||
Staff notes are explicitly labeled as notes. In match chat, select up to 100
|
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
|
messages and choose **Attach to case** to open a case or add them to an existing
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { invalidateFleet } from '$lib/server/pipeline/bus.js';
|
|||||||
import { startPerformanceMonitor } from '$lib/server/performance.js';
|
import { startPerformanceMonitor } from '$lib/server/performance.js';
|
||||||
import { withOperationSource } from '$lib/server/hosts/operation-context.js';
|
import { withOperationSource } from '$lib/server/hosts/operation-context.js';
|
||||||
import { withHostRequestContext } from '$lib/server/hosts/request-context.js';
|
import { withHostRequestContext } from '$lib/server/hosts/request-context.js';
|
||||||
|
import { startDiscordWorker } from '$lib/server/discord/worker.js';
|
||||||
|
|
||||||
let booted = false;
|
let booted = false;
|
||||||
let rejectionLoggerRegistered = false;
|
let rejectionLoggerRegistered = false;
|
||||||
@@ -21,6 +22,7 @@ function boot(): void {
|
|||||||
migrate();
|
migrate();
|
||||||
interruptDeployments();
|
interruptDeployments();
|
||||||
startWorker();
|
startWorker();
|
||||||
|
startDiscordWorker();
|
||||||
startPerformanceMonitor();
|
startPerformanceMonitor();
|
||||||
booted = true;
|
booted = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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[]>;
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ export type ModerationActionKind = 'warn' | 'message' | 'kick' | 'announce';
|
|||||||
export type ModerationState =
|
export type ModerationState =
|
||||||
'queued' | 'delivered' | 'confirmed' | 'failed' | 'expired' | 'unknown';
|
'queued' | 'delivered' | 'confirmed' | 'failed' | 'expired' | 'unknown';
|
||||||
|
|
||||||
|
/** Legacy decisions retained for historical events and older API clients, never lifecycle states. */
|
||||||
export const moderationCaseOutcomes = [
|
export const moderationCaseOutcomes = [
|
||||||
'resolved',
|
'resolved',
|
||||||
'dismissed',
|
'dismissed',
|
||||||
@@ -16,12 +17,7 @@ export const moderationCaseOutcomes = [
|
|||||||
'appeal_upheld',
|
'appeal_upheld',
|
||||||
'appeal_denied'
|
'appeal_denied'
|
||||||
] as const;
|
] as const;
|
||||||
export const moderationCaseStatuses = [
|
export const moderationCaseStatuses = ['open', 'in_review', 'closed'] as const;
|
||||||
'open',
|
|
||||||
'in_review',
|
|
||||||
'escalated',
|
|
||||||
...moderationCaseOutcomes
|
|
||||||
] as const;
|
|
||||||
export type ModerationCaseStatus = (typeof moderationCaseStatuses)[number];
|
export type ModerationCaseStatus = (typeof moderationCaseStatuses)[number];
|
||||||
export type ModerationCasePriority = 'low' | 'normal' | 'high' | 'urgent';
|
export type ModerationCasePriority = 'low' | 'normal' | 'high' | 'urgent';
|
||||||
export const moderationCaseRelations = ['same_incident', 'appeal_of', 'duplicate_of'] as const;
|
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',
|
appeal_of: 'Appeal of',
|
||||||
duplicate_of: 'Duplicate of'
|
duplicate_of: 'Duplicate of'
|
||||||
};
|
};
|
||||||
export const moderationCaseStatusLabels: Record<ModerationCaseStatus, string> = {
|
export const moderationCaseStatusLabels: Record<
|
||||||
|
ModerationCaseStatus | (typeof moderationCaseOutcomes)[number] | 'escalated',
|
||||||
|
string
|
||||||
|
> = {
|
||||||
open: 'Open',
|
open: 'Open',
|
||||||
in_review: 'In review',
|
in_review: 'In progress',
|
||||||
|
closed: 'Closed',
|
||||||
escalated: 'Escalated',
|
escalated: 'Escalated',
|
||||||
resolved: 'Resolved',
|
resolved: 'Resolved',
|
||||||
dismissed: 'Dismissed',
|
dismissed: 'Dismissed',
|
||||||
@@ -45,7 +45,7 @@ export const moderationCaseStatusLabels: Record<ModerationCaseStatus, string> =
|
|||||||
appeal_denied: 'Appeal denied'
|
appeal_denied: 'Appeal denied'
|
||||||
};
|
};
|
||||||
export function isModerationCaseOpen(status: ModerationCaseStatus): boolean {
|
export function isModerationCaseOpen(status: ModerationCaseStatus): boolean {
|
||||||
return status === 'open' || status === 'in_review' || status === 'escalated';
|
return status === 'open' || status === 'in_review';
|
||||||
}
|
}
|
||||||
|
|
||||||
export const moderationConcernCategories = [
|
export const moderationConcernCategories = [
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ export const navigationGroups: NavigationGroup[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ label: 'Users', href: '/settings/users' },
|
{ label: 'Users', href: '/settings/users' },
|
||||||
{ label: 'Keys', href: '/server-keys', shortcut: 'k' },
|
{ label: 'Keys', href: '/server-keys', shortcut: 'k' },
|
||||||
|
{ label: 'Discord', href: '/settings/integrations/discord' },
|
||||||
{
|
{
|
||||||
label: 'Settings',
|
label: 'Settings',
|
||||||
href: '/settings',
|
href: '/settings',
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export function routePermission(routeId: string | null, method: string): RoutePe
|
|||||||
if (['/api/bank/[action]', '/api/afk/[action]'].includes(routeId) && method === 'POST')
|
if (['/api/bank/[action]', '/api/afk/[action]'].includes(routeId) && method === 'POST')
|
||||||
return 'machine';
|
return 'machine';
|
||||||
if (routeId === '/api/telemetry' && 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/moderation/runtime' && method === 'POST') return 'machine';
|
||||||
if (routeId === '/api/leaderboard' && reading) return 'public';
|
if (routeId === '/api/leaderboard' && reading) return 'public';
|
||||||
if ((routeId === '/login' || routeId === '/setup') && 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 (
|
if (
|
||||||
within(routeId, '/api/users') ||
|
within(routeId, '/api/users') ||
|
||||||
|
within(routeId, '/api/integrations') ||
|
||||||
within(routeId, '/settings') ||
|
within(routeId, '/settings') ||
|
||||||
within(routeId, '/api/settings') ||
|
within(routeId, '/api/settings') ||
|
||||||
within(routeId, '/api/server-keys') ||
|
within(routeId, '/api/server-keys') ||
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ import { migrateLeaderboardReports } from './leaderboard-reports.js';
|
|||||||
import { migratePlayerScoreTotal } from './player-score-total.js';
|
import { migratePlayerScoreTotal } from './player-score-total.js';
|
||||||
import { migrateBank } from './bank.js';
|
import { migrateBank } from './bank.js';
|
||||||
import { migrateAfk } from './afk.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 { migrateBankCredentials } from './bank-credentials.js';
|
||||||
import { migrateTemperatures } from '../hosts/temperatures.js';
|
import { migrateTemperatures } from '../hosts/temperatures.js';
|
||||||
import {
|
import {
|
||||||
@@ -35,7 +37,8 @@ import {
|
|||||||
migrateModerationCaseAssessments,
|
migrateModerationCaseAssessments,
|
||||||
migrateModerationCaseRelationships,
|
migrateModerationCaseRelationships,
|
||||||
migrateModerationCaseFollowups,
|
migrateModerationCaseFollowups,
|
||||||
migrateModerationCaseReferenceIndexes
|
migrateModerationCaseReferenceIndexes,
|
||||||
|
migrateModerationCaseStates
|
||||||
} from '../moderation/schema.js';
|
} from '../moderation/schema.js';
|
||||||
import type { Migration } from './migration-runner.js';
|
import type { Migration } from './migration-runner.js';
|
||||||
|
|
||||||
@@ -203,6 +206,18 @@ export const migrations: readonly Migration[] = [
|
|||||||
version: 35,
|
version: 35,
|
||||||
name: 'moderation case foreign key reference indexes',
|
name: 'moderation case foreign key reference indexes',
|
||||||
run: migrateModerationCaseReferenceIndexes
|
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
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { DiscordAdminState } from '../../discord.js';
|
||||||
|
import { fleetIdentity } from '../../fleet-overview.js';
|
||||||
|
import { listServers } from '../fleet/servers.js';
|
||||||
|
import { discordCredentialStatus } from './config.js';
|
||||||
|
import { publicFleet } from './projection.js';
|
||||||
|
import { discordStatus, getDiscordSettings } from './store.js';
|
||||||
|
|
||||||
|
export function discordAdminState(): DiscordAdminState {
|
||||||
|
const settings = getDiscordSettings();
|
||||||
|
const published = publicFleet(settings);
|
||||||
|
return {
|
||||||
|
settings,
|
||||||
|
credentials: discordCredentialStatus(),
|
||||||
|
status: discordStatus(),
|
||||||
|
servers: listServers().flatMap((server) => {
|
||||||
|
const identity = fleetIdentity(server);
|
||||||
|
if (identity.game !== 't5' && identity.game !== 't6') return [];
|
||||||
|
return [
|
||||||
|
{ name: server.name, game: identity.game, label: identity.title, map: identity.map }
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
preview: {
|
||||||
|
t5: published.filter((server) => server.game === 't5'),
|
||||||
|
t6: published.filter((server) => server.game === 't6')
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { leaderboardMetrics } from '../../leaderboard.js';
|
||||||
|
import { zombieMaps } from '../../map-aliases.js';
|
||||||
|
|
||||||
|
const games = [
|
||||||
|
{ name: 'Black Ops I', value: 't5' },
|
||||||
|
{ name: 'Black Ops II', value: 't6' }
|
||||||
|
];
|
||||||
|
export const discordMaps = (['t5', 't6'] as const).flatMap((game) =>
|
||||||
|
zombieMaps(game).map((map) => ({
|
||||||
|
name: `${game === 't5' ? 'BO1' : 'BO2'} · ${map.friendly}`,
|
||||||
|
value: [game, map.canonical, ...(game === 't6' ? [map.slug] : [])].join(':')
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const gameOption = { type: 3, name: 'game', description: 'Game', choices: games };
|
||||||
|
const mapOption = { type: 3, name: 'map', description: 'Map and ruleset', choices: discordMaps };
|
||||||
|
const pageOption = {
|
||||||
|
type: 4,
|
||||||
|
name: 'page',
|
||||||
|
description: 'Page number',
|
||||||
|
min_value: 1,
|
||||||
|
max_value: 100001
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Guild commands only: no Gateway connection or privileged member/content intents. */
|
||||||
|
export const discordCommands = [
|
||||||
|
{
|
||||||
|
name: 'servers',
|
||||||
|
description: 'Browse Electric Cherry servers privately',
|
||||||
|
type: 1,
|
||||||
|
options: [
|
||||||
|
gameOption,
|
||||||
|
mapOption,
|
||||||
|
{
|
||||||
|
type: 3,
|
||||||
|
name: 'status',
|
||||||
|
description: 'Observed occupancy',
|
||||||
|
choices: [
|
||||||
|
{ name: 'All', value: 'all' },
|
||||||
|
{ name: 'Empty', value: 'empty' },
|
||||||
|
{ name: 'Space available', value: 'space' },
|
||||||
|
{ name: 'Full', value: 'full' },
|
||||||
|
{ name: 'In progress', value: 'in-progress' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
pageOption
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'server',
|
||||||
|
description: 'Show details of a published server',
|
||||||
|
type: 1,
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
type: 3,
|
||||||
|
name: 'name',
|
||||||
|
description: 'Published server name (or use a Details button)',
|
||||||
|
required: true,
|
||||||
|
max_length: 128
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'leaderboard',
|
||||||
|
description: 'View Electric Cherry completed-game rankings',
|
||||||
|
type: 1,
|
||||||
|
options: [
|
||||||
|
gameOption,
|
||||||
|
mapOption,
|
||||||
|
{
|
||||||
|
type: 3,
|
||||||
|
name: 'metric',
|
||||||
|
description: 'Ranking metric',
|
||||||
|
choices: leaderboardMetrics.map((metric) => ({
|
||||||
|
name: metric.label,
|
||||||
|
value: metric.key
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 4,
|
||||||
|
name: 'squad',
|
||||||
|
description: 'Number of players at match start',
|
||||||
|
min_value: 1,
|
||||||
|
max_value: 4
|
||||||
|
},
|
||||||
|
pageOption
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'watch',
|
||||||
|
description: 'Receive one private map availability notification',
|
||||||
|
type: 1,
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
type: 1,
|
||||||
|
name: 'add',
|
||||||
|
description: 'Watch a map for up to 24 hours',
|
||||||
|
options: [
|
||||||
|
{ ...gameOption, required: true },
|
||||||
|
{ ...mapOption, required: true },
|
||||||
|
{
|
||||||
|
type: 3,
|
||||||
|
name: 'condition',
|
||||||
|
description: 'Observed occupancy to watch for',
|
||||||
|
required: true,
|
||||||
|
choices: [
|
||||||
|
{ name: 'An empty lobby', value: 'empty' },
|
||||||
|
{ name: 'A lobby with space available', value: 'space' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ type: 1, name: 'list', description: 'List your active watches' },
|
||||||
|
{
|
||||||
|
type: 1,
|
||||||
|
name: 'cancel',
|
||||||
|
description: 'Cancel one of your watches',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
type: 3,
|
||||||
|
name: 'id',
|
||||||
|
description: 'Watch ID shown by /watch list',
|
||||||
|
required: true,
|
||||||
|
max_length: 100
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ name: 'help', description: 'How to use the Electric Cherry companion', type: 1 }
|
||||||
|
];
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { discordSnowflake } from '../../discord.js';
|
||||||
|
|
||||||
|
/** Credentials belong to the deployment environment and never to browser settings. */
|
||||||
|
export function discordCredentials() {
|
||||||
|
return {
|
||||||
|
applicationId: (process.env.EC_DISCORD_APPLICATION_ID ?? '').trim(),
|
||||||
|
publicKey: (process.env.EC_DISCORD_PUBLIC_KEY ?? '').trim(),
|
||||||
|
botToken: (process.env.EC_DISCORD_BOT_TOKEN ?? '').trim()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function discordCredentialStatus() {
|
||||||
|
const credentials = discordCredentials();
|
||||||
|
return {
|
||||||
|
applicationId: credentials.applicationId,
|
||||||
|
publicKeyConfigured: /^[a-fA-F0-9]{64}$/.test(credentials.publicKey),
|
||||||
|
botTokenConfigured: !!credentials.botToken
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireDiscordCredentials(): ReturnType<typeof discordCredentials> {
|
||||||
|
const credentials = discordCredentials();
|
||||||
|
if (
|
||||||
|
!discordSnowflake(credentials.applicationId) ||
|
||||||
|
!/^[a-fA-F0-9]{64}$/.test(credentials.publicKey) ||
|
||||||
|
!credentials.botToken
|
||||||
|
)
|
||||||
|
throw new Error('Configure the Discord application ID, public key and bot token first.');
|
||||||
|
return credentials;
|
||||||
|
}
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
import {
|
||||||
|
discordSnowflake,
|
||||||
|
type DiscordGame,
|
||||||
|
type DiscordMessage,
|
||||||
|
type DiscordPublicServer
|
||||||
|
} from '../../discord.js';
|
||||||
|
import {
|
||||||
|
leaderboardMetrics,
|
||||||
|
type LeaderboardMetric,
|
||||||
|
type LeaderboardOptions,
|
||||||
|
type LeaderboardPlayerCount
|
||||||
|
} from '../../leaderboard.js';
|
||||||
|
import { leaderboardReport } from '../reports/index.js';
|
||||||
|
import { discordCredentials } from './config.js';
|
||||||
|
import { discordMaps } from './commands.js';
|
||||||
|
import { discordServerKey, discordText, publicFleet } from './projection.js';
|
||||||
|
import {
|
||||||
|
cancelSubscription,
|
||||||
|
getDiscordSettings,
|
||||||
|
listSubscriptions,
|
||||||
|
subscribeToMap
|
||||||
|
} from './store.js';
|
||||||
|
import {
|
||||||
|
LEADERBOARD_PAGE_SIZE,
|
||||||
|
privateMessage,
|
||||||
|
renderBrowser,
|
||||||
|
renderLeaderboard,
|
||||||
|
renderServer,
|
||||||
|
renderWatchChoice,
|
||||||
|
renderWatchMaps,
|
||||||
|
type BrowserFilter,
|
||||||
|
type BrowserStatus
|
||||||
|
} from './render.js';
|
||||||
|
|
||||||
|
interface DiscordOption {
|
||||||
|
type?: number;
|
||||||
|
name: string;
|
||||||
|
value?: string | number;
|
||||||
|
options?: DiscordOption[];
|
||||||
|
}
|
||||||
|
export interface DiscordInteraction {
|
||||||
|
type: number;
|
||||||
|
application_id?: string;
|
||||||
|
guild_id?: string;
|
||||||
|
member?: { user?: { id?: string } };
|
||||||
|
data?: { name?: string; options?: DiscordOption[]; custom_id?: string; values?: string[] };
|
||||||
|
}
|
||||||
|
export interface DiscordInteractionResponse {
|
||||||
|
type: 4;
|
||||||
|
data: DiscordMessage;
|
||||||
|
}
|
||||||
|
class InputError extends Error {}
|
||||||
|
const response = (data: DiscordMessage): DiscordInteractionResponse => ({ type: 4, data });
|
||||||
|
export const privateDiscordError = (message: string) =>
|
||||||
|
response(privateMessage(discordText(message, 400)));
|
||||||
|
const statuses: BrowserStatus[] = ['all', 'empty', 'space', 'full', 'in-progress'];
|
||||||
|
|
||||||
|
function optionValues(options: DiscordOption[] | undefined): Map<string, string | number> {
|
||||||
|
if (options !== undefined && (!Array.isArray(options) || options.length > 25))
|
||||||
|
throw new InputError('Invalid command options.');
|
||||||
|
const values = new Map<string, string | number>();
|
||||||
|
for (const option of options ?? []) {
|
||||||
|
if (
|
||||||
|
!option ||
|
||||||
|
typeof option.name !== 'string' ||
|
||||||
|
values.has(option.name) ||
|
||||||
|
!['string', 'number'].includes(typeof option.value)
|
||||||
|
)
|
||||||
|
throw new InputError('Invalid command options.');
|
||||||
|
values.set(option.name, option.value!);
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringOption(
|
||||||
|
values: Map<string, string | number>,
|
||||||
|
key: string,
|
||||||
|
limit = 128
|
||||||
|
): string | undefined {
|
||||||
|
const value = values.get(key);
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
if (typeof value !== 'string' || !value.trim() || value.length > limit)
|
||||||
|
throw new InputError(`Invalid ${key} option.`);
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scope(values: Map<string, string | number>): { game?: DiscordGame; map?: string } {
|
||||||
|
const game = stringOption(values, 'game');
|
||||||
|
const map = stringOption(values, 'map');
|
||||||
|
if (game && game !== 't5' && game !== 't6')
|
||||||
|
throw new InputError('Choose Black Ops I or Black Ops II.');
|
||||||
|
if (map && !discordMaps.some((entry) => entry.value === map))
|
||||||
|
throw new InputError('Choose a map from the command options.');
|
||||||
|
if (game && map && !map.startsWith(`${game}:`))
|
||||||
|
throw new InputError('Choose a map from the selected game.');
|
||||||
|
return { game: (game ?? map?.slice(0, 2)) as DiscordGame | undefined, map };
|
||||||
|
}
|
||||||
|
|
||||||
|
function page(values: Map<string, string | number>): number {
|
||||||
|
const value = values.get('page') ?? 1;
|
||||||
|
if (!Number.isSafeInteger(value) || Number(value) < 1 || Number(value) > 100001)
|
||||||
|
throw new InputError('Choose a page between 1 and 100001.');
|
||||||
|
return Number(value) - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function watchMap(
|
||||||
|
servers: DiscordPublicServer[],
|
||||||
|
key: string,
|
||||||
|
game?: DiscordGame
|
||||||
|
): DiscordPublicServer {
|
||||||
|
const found = servers.find(
|
||||||
|
(server) =>
|
||||||
|
(server.mapKey === key || discordServerKey(server.mapKey) === key) &&
|
||||||
|
(!game || server.game === game)
|
||||||
|
);
|
||||||
|
if (!found)
|
||||||
|
throw new InputError('That map is no longer published. Browse the current server list.');
|
||||||
|
if (!/^[A-Za-z0-9_.:-]{1,100}$/.test(found.mapKey))
|
||||||
|
throw new InputError('A map watch is unavailable for this reported map.');
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function leaderboard(values: Map<string, string | number>): Promise<DiscordMessage> {
|
||||||
|
const settings = getDiscordSettings();
|
||||||
|
if (!settings.leaderboardEnabled)
|
||||||
|
return privateMessage('Leaderboard publication is not enabled for this Discord server.');
|
||||||
|
const metric = stringOption(values, 'metric') ?? 'highestRound';
|
||||||
|
if (!leaderboardMetrics.some((entry) => entry.key === metric))
|
||||||
|
throw new InputError('Choose a ranking metric from the command options.');
|
||||||
|
const squad = values.get('squad');
|
||||||
|
if (
|
||||||
|
squad !== undefined &&
|
||||||
|
(!Number.isSafeInteger(squad) || Number(squad) < 1 || Number(squad) > 4)
|
||||||
|
)
|
||||||
|
throw new InputError('Squad size must be between 1 and 4.');
|
||||||
|
const query: LeaderboardOptions = {
|
||||||
|
...scope(values),
|
||||||
|
metric: metric as LeaderboardMetric,
|
||||||
|
players: squad as LeaderboardPlayerCount | undefined,
|
||||||
|
limit: LEADERBOARD_PAGE_SIZE,
|
||||||
|
offset: page(values) * LEADERBOARD_PAGE_SIZE
|
||||||
|
};
|
||||||
|
const report = await leaderboardReport(query);
|
||||||
|
// Publishing may have been disabled while the report worker was running.
|
||||||
|
const current = getDiscordSettings();
|
||||||
|
if (!current.enabled || current.guildId !== settings.guildId || !current.leaderboardEnabled)
|
||||||
|
return privateMessage('Leaderboard publication is no longer enabled.');
|
||||||
|
return renderLeaderboard(report, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addWatch(
|
||||||
|
servers: DiscordPublicServer[],
|
||||||
|
key: string,
|
||||||
|
mode: string | undefined,
|
||||||
|
guildId: string,
|
||||||
|
userId: string,
|
||||||
|
game?: DiscordGame
|
||||||
|
): DiscordMessage {
|
||||||
|
if (mode !== 'empty' && mode !== 'space')
|
||||||
|
throw new InputError('Choose an empty-lobby or space-available watch.');
|
||||||
|
const server = watchMap(servers, key, game);
|
||||||
|
let watch: { id: string; expiresAt: number };
|
||||||
|
try {
|
||||||
|
watch = subscribeToMap({ guildId, userId, game: server.game, mapKey: server.mapKey, mode });
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
error instanceof Error &&
|
||||||
|
(error.message.startsWith('You already have 10') ||
|
||||||
|
error.message.startsWith('Map watches are currently full'))
|
||||||
|
)
|
||||||
|
throw new InputError(error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return privateMessage(
|
||||||
|
`Watching **${discordText(server.map)}** for ${mode === 'empty' ? 'an empty lobby' : 'space available'}.\nYou will receive one DM after stable observations. Enable DMs from this server to receive it. This does not guarantee match admission.\nExpires <t:${Math.floor(watch.expiresAt / 1000)}:R> · Cancel with /watch cancel\nWatch ID: ${discordText(watch.id)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBrowserId(id: string): BrowserFilter {
|
||||||
|
const match =
|
||||||
|
/^ec:b:(all|t5|t6):(all|empty|space|full|in-progress):(\d{1,6}):([A-Za-z0-9_-]{0,70})(?::(?:prev|next))?$/.exec(
|
||||||
|
id
|
||||||
|
);
|
||||||
|
if (!match) throw new InputError('This browser control is invalid. Run /servers again.');
|
||||||
|
const map = Buffer.from(match[4], 'base64url').toString();
|
||||||
|
if (map && !discordMaps.some((entry) => entry.value === map))
|
||||||
|
throw new InputError('This map filter is unavailable. Run /servers again.');
|
||||||
|
return {
|
||||||
|
game: match[1] === 'all' ? undefined : (match[1] as DiscordGame),
|
||||||
|
status: match[2] as BrowserStatus,
|
||||||
|
page: Number(match[3]),
|
||||||
|
map: map || undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All responses are private new messages; components never mutate the shared board. */
|
||||||
|
export async function handleDiscordInteraction(
|
||||||
|
interaction: DiscordInteraction
|
||||||
|
): Promise<DiscordInteractionResponse> {
|
||||||
|
const userId = interaction.member?.user?.id;
|
||||||
|
if (
|
||||||
|
!discordSnowflake(userId) ||
|
||||||
|
!discordSnowflake(interaction.guild_id) ||
|
||||||
|
interaction.application_id !== discordCredentials().applicationId
|
||||||
|
)
|
||||||
|
return privateDiscordError('This interaction is not available here.');
|
||||||
|
const settings = getDiscordSettings();
|
||||||
|
if (!settings.enabled || settings.guildId !== interaction.guild_id)
|
||||||
|
return privateDiscordError('Electric Cherry is not enabled for this Discord server.');
|
||||||
|
if (interaction.type !== 2 && interaction.type !== 3)
|
||||||
|
return privateDiscordError('This interaction is not supported.');
|
||||||
|
const data = interaction.data;
|
||||||
|
if (!data || typeof data !== 'object')
|
||||||
|
return privateDiscordError('This interaction is invalid.');
|
||||||
|
try {
|
||||||
|
if (interaction.type === 2 && data.name === 'leaderboard')
|
||||||
|
return response(await leaderboard(optionValues(data.options)));
|
||||||
|
if (interaction.type === 2 && data.name === 'help')
|
||||||
|
return response(
|
||||||
|
privateMessage(
|
||||||
|
'## 🍒 Electric Cherry\n/servers — browse live servers privately\n/server — inspect a published server\n/leaderboard — completed-game rankings, when enabled\n/watch add — one DM when a map has an empty lobby or space\n/watch list · /watch cancel — manage your watches\n\nWatches expire after 24 hours. Observed spaces do not guarantee that a match accepts new players.'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const servers = publicFleet(settings);
|
||||||
|
if (interaction.type === 3) {
|
||||||
|
const id = data.custom_id;
|
||||||
|
if (typeof id !== 'string' || id.length > 100)
|
||||||
|
throw new InputError('This control is invalid.');
|
||||||
|
if (id.startsWith('ec:b:')) return response(renderBrowser(servers, parseBrowserId(id)));
|
||||||
|
if (/^ec:d:[a-f0-9]{20}$/.test(id)) {
|
||||||
|
const server = servers.find(
|
||||||
|
(entry) => discordServerKey(entry.name) === id.slice(5)
|
||||||
|
);
|
||||||
|
if (!server) throw new InputError('This server is no longer published.');
|
||||||
|
return response(renderServer(server));
|
||||||
|
}
|
||||||
|
if (/^ec:maps:(t5|t6)$/.test(id))
|
||||||
|
return response(renderWatchMaps(servers, id.slice(8) as DiscordGame));
|
||||||
|
if (
|
||||||
|
id === 'ec:map' &&
|
||||||
|
Array.isArray(data.values) &&
|
||||||
|
data.values.length === 1 &&
|
||||||
|
typeof data.values[0] === 'string'
|
||||||
|
)
|
||||||
|
return response(renderWatchChoice(watchMap(servers, data.values[0])));
|
||||||
|
const watch = /^ec:w:(empty|space):([a-f0-9]{20})$/.exec(id);
|
||||||
|
if (watch)
|
||||||
|
return response(addWatch(servers, watch[2], watch[1], settings.guildId, userId));
|
||||||
|
if (/^ec:leader:(t5|t6)$/.test(id))
|
||||||
|
return response(await leaderboard(new Map([['game', id.slice(10)]])));
|
||||||
|
throw new InputError(
|
||||||
|
'This control is no longer available. Run /help for current commands.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (data.name === 'watch') {
|
||||||
|
if (!Array.isArray(data.options) || data.options.length !== 1)
|
||||||
|
throw new InputError('Choose watch add, list, or cancel.');
|
||||||
|
const command = data.options[0];
|
||||||
|
const values = optionValues(command.options);
|
||||||
|
if (command.name === 'add') {
|
||||||
|
const selected = scope(values);
|
||||||
|
if (!selected.game || !selected.map) throw new InputError('Choose a game and map.');
|
||||||
|
return response(
|
||||||
|
addWatch(
|
||||||
|
servers,
|
||||||
|
selected.map,
|
||||||
|
stringOption(values, 'condition'),
|
||||||
|
settings.guildId,
|
||||||
|
userId,
|
||||||
|
selected.game
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (command.name === 'cancel') {
|
||||||
|
const id = stringOption(values, 'id', 100);
|
||||||
|
if (!id) throw new InputError('Provide the watch ID shown by /watch list.');
|
||||||
|
return response(
|
||||||
|
privateMessage(
|
||||||
|
cancelSubscription(id, settings.guildId, userId)
|
||||||
|
? 'Watch cancelled.'
|
||||||
|
: 'No active watch with that ID belongs to you.'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (command.name === 'list') {
|
||||||
|
const watches = listSubscriptions(userId, settings.guildId);
|
||||||
|
return response(
|
||||||
|
privateMessage(
|
||||||
|
`## Your map watches\n${watches.length ? watches.map((watch) => `**${discordText(servers.find((server) => server.mapKey === watch.mapKey)?.map ?? discordMaps.find((map) => map.value === watch.mapKey)?.name ?? 'Unpublished map', 60)}** · ${watch.mode === 'empty' ? 'Empty lobby' : 'Space available'} · Expires <t:${Math.floor(watch.expiresAt / 1000)}:R>\nID: ${discordText(watch.id)}`).join('\n\n') : 'No active watches. Use /watch add to create one.'}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new InputError('Choose watch add, list, or cancel.');
|
||||||
|
}
|
||||||
|
const values = optionValues(data.options);
|
||||||
|
if (data.name === 'servers') {
|
||||||
|
const status = stringOption(values, 'status') ?? 'all';
|
||||||
|
if (!statuses.includes(status as BrowserStatus))
|
||||||
|
throw new InputError('Choose an occupancy filter from the command options.');
|
||||||
|
return response(
|
||||||
|
renderBrowser(servers, {
|
||||||
|
...scope(values),
|
||||||
|
status: status as BrowserStatus,
|
||||||
|
page: page(values)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (data.name === 'server') {
|
||||||
|
const name = stringOption(values, 'name');
|
||||||
|
const server = servers.find((entry) => entry.name === name || entry.label === name);
|
||||||
|
if (!server)
|
||||||
|
throw new InputError(
|
||||||
|
'That server is not published. Use /servers and click Details.'
|
||||||
|
);
|
||||||
|
return response(renderServer(server));
|
||||||
|
}
|
||||||
|
throw new InputError('This command is not supported. Run /help for available commands.');
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof InputError) return privateDiscordError(error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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));
|
||||||
|
}
|
||||||
@@ -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.`
|
||||||
|
)
|
||||||
|
])
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type Database from 'better-sqlite3';
|
||||||
|
|
||||||
|
export function migrateDiscord(handle: Database.Database): void {
|
||||||
|
handle.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS discord_configuration (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id=1), value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS discord_health (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id=1), last_attempt_at INTEGER,
|
||||||
|
last_success_at INTEGER, last_error TEXT
|
||||||
|
);
|
||||||
|
INSERT OR IGNORE INTO discord_health(id) VALUES(1);
|
||||||
|
CREATE TABLE IF NOT EXISTS discord_boards (
|
||||||
|
id TEXT PRIMARY KEY, game TEXT NOT NULL CHECK(game IN ('t5','t6')),
|
||||||
|
guild_id TEXT NOT NULL, channel_id TEXT NOT NULL, page INTEGER NOT NULL, message_id TEXT,
|
||||||
|
content_hash TEXT, verified_at INTEGER NOT NULL DEFAULT 0, generation INTEGER NOT NULL DEFAULT 0,
|
||||||
|
UNIQUE(game,guild_id,channel_id,page)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS discord_subscriptions (
|
||||||
|
id TEXT PRIMARY KEY, guild_id TEXT NOT NULL, user_id TEXT NOT NULL,
|
||||||
|
game TEXT NOT NULL CHECK(game IN ('t5','t6')), map_key TEXT NOT NULL,
|
||||||
|
mode TEXT NOT NULL CHECK(mode IN ('empty','space')),
|
||||||
|
state TEXT NOT NULL CHECK(state IN ('active','queued','delivered','failed','cancelled','expired')),
|
||||||
|
created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL,
|
||||||
|
candidate TEXT, candidate_at INTEGER, error TEXT
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS discord_subscription_active
|
||||||
|
ON discord_subscriptions(guild_id,user_id,game,map_key,mode)
|
||||||
|
WHERE state IN ('active','queued');
|
||||||
|
CREATE INDEX IF NOT EXISTS discord_subscription_expiry ON discord_subscriptions(expires_at);
|
||||||
|
CREATE TABLE IF NOT EXISTS discord_outbox (
|
||||||
|
id TEXT PRIMARY KEY, kind TEXT NOT NULL CHECK(kind IN ('board','delete','alert')),
|
||||||
|
target_id TEXT NOT NULL UNIQUE, payload TEXT NOT NULL, content_hash TEXT,
|
||||||
|
channel_id TEXT, attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
next_at INTEGER, created_at INTEGER NOT NULL, error TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS discord_outbox_due ON discord_outbox(next_at);
|
||||||
|
CREATE TABLE IF NOT EXISTS discord_interactions (
|
||||||
|
id TEXT PRIMARY KEY, expires_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS discord_interactions_expiry ON discord_interactions(expires_at);
|
||||||
|
`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,503 @@
|
|||||||
|
import { createHash, randomUUID } from 'node:crypto';
|
||||||
|
import type Database from 'better-sqlite3';
|
||||||
|
import {
|
||||||
|
defaultDiscordSettings,
|
||||||
|
discordSnowflake,
|
||||||
|
validateDiscordSettings,
|
||||||
|
type DiscordDeliveryStatus,
|
||||||
|
type DiscordGame,
|
||||||
|
type DiscordMessage,
|
||||||
|
type DiscordSettings
|
||||||
|
} from '../../discord.js';
|
||||||
|
import { db } from '../db/index.js';
|
||||||
|
|
||||||
|
export interface DiscordSubscription {
|
||||||
|
id: string;
|
||||||
|
guildId: string;
|
||||||
|
userId: string;
|
||||||
|
game: DiscordGame;
|
||||||
|
mapKey: string;
|
||||||
|
mode: 'empty' | 'space';
|
||||||
|
state: 'active' | 'queued' | 'delivered' | 'failed' | 'cancelled' | 'expired';
|
||||||
|
expiresAt: number;
|
||||||
|
candidate: string | null;
|
||||||
|
candidateAt: number | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
export interface DiscordBoard {
|
||||||
|
id: string;
|
||||||
|
game: DiscordGame;
|
||||||
|
guildId: string;
|
||||||
|
channelId: string;
|
||||||
|
page: number;
|
||||||
|
messageId: string | null;
|
||||||
|
contentHash: string | null;
|
||||||
|
verifiedAt: number;
|
||||||
|
generation: number;
|
||||||
|
}
|
||||||
|
export interface DiscordJob {
|
||||||
|
id: string;
|
||||||
|
kind: 'board' | 'delete' | 'alert';
|
||||||
|
targetId: string;
|
||||||
|
payload: DiscordMessage;
|
||||||
|
contentHash: string | null;
|
||||||
|
channelId: string | null;
|
||||||
|
attempts: number;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
export interface MapSubscriptionInput {
|
||||||
|
guildId: string;
|
||||||
|
userId: string;
|
||||||
|
game: DiscordGame;
|
||||||
|
mapKey: string;
|
||||||
|
mode: 'empty' | 'space';
|
||||||
|
}
|
||||||
|
const subscriptionColumns = `id,guild_id AS guildId,user_id AS userId,game,map_key AS mapKey,
|
||||||
|
mode,state,expires_at AS expiresAt,candidate,candidate_at AS candidateAt,error`;
|
||||||
|
const boardColumns = `id,game,guild_id AS guildId,channel_id AS channelId,page,message_id AS messageId,
|
||||||
|
content_hash AS contentHash,verified_at AS verifiedAt,generation`;
|
||||||
|
|
||||||
|
/** Synchronous, bounded transactions; delivery never holds a database transaction open. */
|
||||||
|
export class DiscordStore {
|
||||||
|
constructor(
|
||||||
|
readonly handle: Database.Database,
|
||||||
|
readonly now: () => number = Date.now
|
||||||
|
) {}
|
||||||
|
|
||||||
|
settings(): DiscordSettings {
|
||||||
|
const row = this.handle
|
||||||
|
.prepare('SELECT value FROM discord_configuration WHERE id=1')
|
||||||
|
.get() as { value: string } | undefined;
|
||||||
|
return row
|
||||||
|
? validateDiscordSettings(JSON.parse(row.value))
|
||||||
|
: structuredClone(defaultDiscordSettings);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveSettings(input: unknown): DiscordSettings {
|
||||||
|
const settings = validateDiscordSettings(input);
|
||||||
|
this.handle.transaction(() => {
|
||||||
|
this.handle
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO discord_configuration(id,value) VALUES(1,?) ON CONFLICT(id) DO UPDATE SET value=excluded.value'
|
||||||
|
)
|
||||||
|
.run(JSON.stringify(settings));
|
||||||
|
this.handle
|
||||||
|
.prepare(
|
||||||
|
"UPDATE discord_subscriptions SET state='cancelled' WHERE state IN ('active','queued') AND (?=0 OR guild_id!=?)"
|
||||||
|
)
|
||||||
|
.run(Number(settings.enabled), settings.guildId);
|
||||||
|
this.handle
|
||||||
|
.prepare(
|
||||||
|
"DELETE FROM discord_outbox WHERE kind='alert' AND target_id IN (SELECT id FROM discord_subscriptions WHERE state!='queued')"
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
this.retryFailed();
|
||||||
|
})();
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
status(): DiscordDeliveryStatus {
|
||||||
|
const row = this.handle.prepare('SELECT * FROM discord_health WHERE id=1').get() as {
|
||||||
|
last_attempt_at: number | null;
|
||||||
|
last_success_at: number | null;
|
||||||
|
last_error: string | null;
|
||||||
|
};
|
||||||
|
const iso = (value: number | null) =>
|
||||||
|
value === null ? null : new Date(value).toISOString();
|
||||||
|
return {
|
||||||
|
lastAttemptAt: iso(row.last_attempt_at),
|
||||||
|
lastSuccessAt: iso(row.last_success_at),
|
||||||
|
lastError:
|
||||||
|
row.last_error ??
|
||||||
|
(
|
||||||
|
this.handle
|
||||||
|
.prepare(
|
||||||
|
"SELECT error FROM discord_subscriptions WHERE state='failed' AND expires_at>? ORDER BY created_at DESC LIMIT 1"
|
||||||
|
)
|
||||||
|
.get(this.now()) as { error: string } | undefined
|
||||||
|
)?.error ??
|
||||||
|
null,
|
||||||
|
boards: this.handle
|
||||||
|
.prepare(
|
||||||
|
'SELECT game,channel_id AS channelId,COUNT(message_id) AS messageCount FROM discord_boards GROUP BY game,channel_id'
|
||||||
|
)
|
||||||
|
.all() as DiscordDeliveryStatus['boards'],
|
||||||
|
activeSubscriptions: this.count(
|
||||||
|
"SELECT COUNT(*) AS n FROM discord_subscriptions WHERE state IN ('active','queued') AND expires_at>?",
|
||||||
|
this.now()
|
||||||
|
),
|
||||||
|
pendingNotifications: this.count(
|
||||||
|
"SELECT COUNT(*) AS n FROM discord_outbox WHERE kind='alert' AND next_at IS NOT NULL"
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(input: MapSubscriptionInput): { id: string; expiresAt: number } {
|
||||||
|
if (
|
||||||
|
!discordSnowflake(input.userId) ||
|
||||||
|
!discordSnowflake(input.guildId) ||
|
||||||
|
!['t5', 't6'].includes(input.game) ||
|
||||||
|
!['empty', 'space'].includes(input.mode) ||
|
||||||
|
typeof input.mapKey !== 'string' ||
|
||||||
|
!/^[a-zA-Z0-9_.:-]{1,100}$/.test(input.mapKey)
|
||||||
|
)
|
||||||
|
throw new Error('Invalid map watch.');
|
||||||
|
const settings = this.settings();
|
||||||
|
if (!settings.enabled || settings.guildId !== input.guildId)
|
||||||
|
throw new Error('Discord map watches are not enabled for this server.');
|
||||||
|
return this.handle.transaction(() => {
|
||||||
|
this.expireSubscriptions();
|
||||||
|
const existing = this.handle
|
||||||
|
.prepare(
|
||||||
|
"SELECT id,expires_at AS expiresAt FROM discord_subscriptions WHERE guild_id=? AND user_id=? AND game=? AND map_key=? AND mode=? AND state IN ('active','queued')"
|
||||||
|
)
|
||||||
|
.get(input.guildId, input.userId, input.game, input.mapKey, input.mode) as
|
||||||
|
{ id: string; expiresAt: number } | undefined;
|
||||||
|
if (existing) return existing;
|
||||||
|
if (
|
||||||
|
this.count(
|
||||||
|
"SELECT COUNT(*) AS n FROM discord_subscriptions WHERE user_id=? AND state IN ('active','queued')",
|
||||||
|
input.userId
|
||||||
|
) >= 10
|
||||||
|
)
|
||||||
|
throw new Error(
|
||||||
|
'You already have 10 active map watches. Cancel one before adding another.'
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
this.count(
|
||||||
|
"SELECT COUNT(*) AS n FROM discord_subscriptions WHERE state IN ('active','queued')"
|
||||||
|
) >= 1000
|
||||||
|
)
|
||||||
|
throw new Error('Map watches are currently full. Please try later.');
|
||||||
|
const id = randomUUID();
|
||||||
|
const expiresAt = this.now() + 24 * 60 * 60_000;
|
||||||
|
this.handle
|
||||||
|
.prepare(
|
||||||
|
"INSERT INTO discord_subscriptions(id,guild_id,user_id,game,map_key,mode,state,created_at,expires_at) VALUES(?,?,?,?,?,?,'active',?,?)"
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
id,
|
||||||
|
input.guildId,
|
||||||
|
input.userId,
|
||||||
|
input.game,
|
||||||
|
input.mapKey,
|
||||||
|
input.mode,
|
||||||
|
this.now(),
|
||||||
|
expiresAt
|
||||||
|
);
|
||||||
|
return { id, expiresAt };
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
subscriptions(userId?: string, guildId?: string): DiscordSubscription[] {
|
||||||
|
this.expireSubscriptions();
|
||||||
|
return this.handle
|
||||||
|
.prepare(
|
||||||
|
`SELECT ${subscriptionColumns} FROM discord_subscriptions WHERE state IN ('active','queued') ${userId ? 'AND user_id=? AND guild_id=?' : ''} ORDER BY created_at LIMIT 1000`
|
||||||
|
)
|
||||||
|
.all(...(userId ? [userId, guildId ?? ''] : [])) as DiscordSubscription[];
|
||||||
|
}
|
||||||
|
|
||||||
|
subscription(id: string): DiscordSubscription | undefined {
|
||||||
|
return this.handle
|
||||||
|
.prepare(`SELECT ${subscriptionColumns} FROM discord_subscriptions WHERE id=?`)
|
||||||
|
.get(id) as DiscordSubscription | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel(id: string, guildId: string, userId: string): boolean {
|
||||||
|
return this.handle.transaction(() => {
|
||||||
|
const updated = this.handle
|
||||||
|
.prepare(
|
||||||
|
"UPDATE discord_subscriptions SET state='cancelled' WHERE id=? AND guild_id=? AND user_id=? AND state IN ('active','queued')"
|
||||||
|
)
|
||||||
|
.run(id, guildId, userId).changes;
|
||||||
|
if (updated)
|
||||||
|
this.handle
|
||||||
|
.prepare("DELETE FROM discord_outbox WHERE kind='alert' AND target_id=?")
|
||||||
|
.run(id);
|
||||||
|
return updated > 0;
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
expireSubscriptions(): void {
|
||||||
|
this.handle
|
||||||
|
.prepare(
|
||||||
|
"UPDATE discord_subscriptions SET state='expired' WHERE state IN ('active','queued') AND expires_at<=?"
|
||||||
|
)
|
||||||
|
.run(this.now());
|
||||||
|
this.handle
|
||||||
|
.prepare(
|
||||||
|
"DELETE FROM discord_outbox WHERE kind='alert' AND target_id IN (SELECT id FROM discord_subscriptions WHERE state NOT IN ('active','queued'))"
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
this.handle
|
||||||
|
.prepare(
|
||||||
|
"DELETE FROM discord_subscriptions WHERE state NOT IN ('active','queued') AND (expires_at<? OR id IN (SELECT id FROM discord_subscriptions ORDER BY created_at DESC LIMIT -1 OFFSET 10000))"
|
||||||
|
)
|
||||||
|
.run(this.now() - 7 * 86400_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
candidate(id: string, candidate: string | null): void {
|
||||||
|
this.handle
|
||||||
|
.prepare(
|
||||||
|
"UPDATE discord_subscriptions SET candidate=?,candidate_at=CASE WHEN candidate IS ? THEN candidate_at ELSE ? END WHERE id=? AND state='active'"
|
||||||
|
)
|
||||||
|
.run(candidate, candidate, candidate ? this.now() : null, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
queueAlert(subscription: DiscordSubscription, message: DiscordMessage): void {
|
||||||
|
this.handle.transaction(() => {
|
||||||
|
const current = this.subscription(subscription.id);
|
||||||
|
if (!current || current.state !== 'active' || current.expiresAt <= this.now()) return;
|
||||||
|
this.enqueue('alert', subscription.id, message, null);
|
||||||
|
this.handle
|
||||||
|
.prepare("UPDATE discord_subscriptions SET state='queued' WHERE id=?")
|
||||||
|
.run(subscription.id);
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
boards(): DiscordBoard[] {
|
||||||
|
return this.handle
|
||||||
|
.prepare(`SELECT ${boardColumns} FROM discord_boards ORDER BY game,channel_id,page`)
|
||||||
|
.all() as DiscordBoard[];
|
||||||
|
}
|
||||||
|
|
||||||
|
board(id: string): DiscordBoard | undefined {
|
||||||
|
return this.handle
|
||||||
|
.prepare(`SELECT ${boardColumns} FROM discord_boards WHERE id=?`)
|
||||||
|
.get(id) as DiscordBoard | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
reconcileBoards(
|
||||||
|
desired: {
|
||||||
|
game: DiscordGame;
|
||||||
|
guildId: string;
|
||||||
|
channelId: string;
|
||||||
|
messages: DiscordMessage[];
|
||||||
|
}[]
|
||||||
|
): void {
|
||||||
|
this.handle.transaction(() => {
|
||||||
|
const retained = new Set<string>();
|
||||||
|
for (const group of desired) {
|
||||||
|
for (const [page, message] of group.messages.entries()) {
|
||||||
|
let board = this.boards().find(
|
||||||
|
(item) =>
|
||||||
|
item.game === group.game &&
|
||||||
|
item.guildId === group.guildId &&
|
||||||
|
item.channelId === group.channelId &&
|
||||||
|
item.page === page
|
||||||
|
);
|
||||||
|
if (!board) {
|
||||||
|
const id = randomUUID();
|
||||||
|
this.handle
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO discord_boards(id,game,guild_id,channel_id,page) VALUES(?,?,?,?,?)'
|
||||||
|
)
|
||||||
|
.run(id, group.game, group.guildId, group.channelId, page);
|
||||||
|
board = this.board(id)!;
|
||||||
|
}
|
||||||
|
retained.add(board.id);
|
||||||
|
this.handle
|
||||||
|
.prepare("DELETE FROM discord_outbox WHERE target_id=? AND kind='delete'")
|
||||||
|
.run(board.id);
|
||||||
|
const hash = createHash('sha256').update(JSON.stringify(message)).digest('hex');
|
||||||
|
if (board.contentHash !== hash || !board.messageId)
|
||||||
|
this.enqueue('board', board.id, message, hash);
|
||||||
|
else
|
||||||
|
this.handle
|
||||||
|
.prepare(
|
||||||
|
"DELETE FROM discord_outbox WHERE target_id=? AND kind='board'"
|
||||||
|
)
|
||||||
|
.run(board.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const board of this.boards()) {
|
||||||
|
if (!retained.has(board.id)) {
|
||||||
|
if (board.messageId)
|
||||||
|
this.enqueue('delete', board.id, { allowed_mentions: { parse: [] } }, null);
|
||||||
|
else this.removeBoard(board.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
private enqueue(
|
||||||
|
kind: DiscordJob['kind'],
|
||||||
|
targetId: string,
|
||||||
|
payload: DiscordMessage,
|
||||||
|
hash: string | null
|
||||||
|
): void {
|
||||||
|
const existing = this.handle
|
||||||
|
.prepare('SELECT kind FROM discord_outbox WHERE target_id=?')
|
||||||
|
.get(targetId) as { kind: string } | undefined;
|
||||||
|
if (!existing && this.count('SELECT COUNT(*) AS n FROM discord_outbox') >= 500)
|
||||||
|
throw new Error('Discord delivery queue is full.');
|
||||||
|
this.handle
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO discord_outbox(id,kind,target_id,payload,content_hash,next_at,created_at) VALUES(?,?,?,?,?,?,?)
|
||||||
|
ON CONFLICT(target_id) DO UPDATE SET kind=excluded.kind,payload=excluded.payload,content_hash=excluded.content_hash,
|
||||||
|
attempts=CASE WHEN kind!=excluded.kind THEN 0 ELSE attempts END,
|
||||||
|
next_at=CASE WHEN kind!=excluded.kind THEN excluded.next_at ELSE next_at END`
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
randomUUID(),
|
||||||
|
kind,
|
||||||
|
targetId,
|
||||||
|
JSON.stringify(payload),
|
||||||
|
hash,
|
||||||
|
this.now(),
|
||||||
|
this.now()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
jobs(limit = 10): DiscordJob[] {
|
||||||
|
return (
|
||||||
|
this.handle
|
||||||
|
.prepare(
|
||||||
|
`SELECT id,kind,target_id AS targetId,payload,content_hash AS contentHash,
|
||||||
|
channel_id AS channelId,attempts,created_at AS createdAt FROM discord_outbox WHERE next_at<=? ORDER BY CASE kind WHEN 'delete' THEN 0 WHEN 'alert' THEN 1 ELSE 2 END,created_at LIMIT ?`
|
||||||
|
)
|
||||||
|
.all(this.now(), limit) as (Omit<DiscordJob, 'payload'> & { payload: string })[]
|
||||||
|
).map((row) => ({ ...row, payload: JSON.parse(row.payload) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheDm(jobId: string, channelId: string): void {
|
||||||
|
this.handle
|
||||||
|
.prepare('UPDATE discord_outbox SET channel_id=? WHERE id=?')
|
||||||
|
.run(channelId, jobId);
|
||||||
|
}
|
||||||
|
|
||||||
|
dropJob(id: string): void {
|
||||||
|
this.handle.prepare('DELETE FROM discord_outbox WHERE id=?').run(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
resumeAlert(id: string): void {
|
||||||
|
this.handle.transaction(() => {
|
||||||
|
this.handle
|
||||||
|
.prepare("DELETE FROM discord_outbox WHERE kind='alert' AND target_id=?")
|
||||||
|
.run(id);
|
||||||
|
this.handle
|
||||||
|
.prepare(
|
||||||
|
"UPDATE discord_subscriptions SET state='active',candidate=NULL,candidate_at=NULL WHERE id=? AND state='queued'"
|
||||||
|
)
|
||||||
|
.run(id);
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
delivered(job: DiscordJob, messageId?: string): void {
|
||||||
|
this.handle.transaction(() => {
|
||||||
|
if (job.kind === 'board')
|
||||||
|
this.handle
|
||||||
|
.prepare(
|
||||||
|
'UPDATE discord_boards SET message_id=?,content_hash=?,verified_at=? WHERE id=?'
|
||||||
|
)
|
||||||
|
.run(messageId, job.contentHash, this.now(), job.targetId);
|
||||||
|
if (job.kind === 'delete')
|
||||||
|
this.handle.prepare('DELETE FROM discord_boards WHERE id=?').run(job.targetId);
|
||||||
|
if (job.kind === 'alert')
|
||||||
|
this.handle
|
||||||
|
.prepare(
|
||||||
|
"UPDATE discord_subscriptions SET state='delivered',error=NULL WHERE id=? AND state='queued'"
|
||||||
|
)
|
||||||
|
.run(job.targetId);
|
||||||
|
// A newer desired board can replace a payload while an HTTP call is in flight.
|
||||||
|
this.handle
|
||||||
|
.prepare('DELETE FROM discord_outbox WHERE id=? AND payload=?')
|
||||||
|
.run(job.id, JSON.stringify(job.payload));
|
||||||
|
this.handle
|
||||||
|
.prepare(
|
||||||
|
'UPDATE discord_health SET last_success_at=?,last_error=CASE WHEN EXISTS(SELECT 1 FROM discord_outbox WHERE error IS NOT NULL) THEN last_error ELSE NULL END WHERE id=1'
|
||||||
|
)
|
||||||
|
.run(this.now());
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
removeBoard(id: string): void {
|
||||||
|
this.handle.prepare('DELETE FROM discord_outbox WHERE target_id=?').run(id);
|
||||||
|
this.handle.prepare('DELETE FROM discord_boards WHERE id=?').run(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
missingMessage(id: string): void {
|
||||||
|
this.handle
|
||||||
|
.prepare(
|
||||||
|
'UPDATE discord_boards SET message_id=NULL,content_hash=NULL,verified_at=0,generation=generation+1 WHERE id=?'
|
||||||
|
)
|
||||||
|
.run(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
verified(id: string): void {
|
||||||
|
this.handle
|
||||||
|
.prepare('UPDATE discord_boards SET verified_at=? WHERE id=?')
|
||||||
|
.run(this.now(), id);
|
||||||
|
}
|
||||||
|
|
||||||
|
attempt(): void {
|
||||||
|
this.handle
|
||||||
|
.prepare('UPDATE discord_health SET last_attempt_at=? WHERE id=1')
|
||||||
|
.run(this.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
error(message: string): void {
|
||||||
|
this.handle
|
||||||
|
.prepare('UPDATE discord_health SET last_error=? WHERE id=1')
|
||||||
|
.run(message.slice(0, 300));
|
||||||
|
}
|
||||||
|
|
||||||
|
failed(job: DiscordJob, error: string, retryAt: number | null): void {
|
||||||
|
this.handle.transaction(() => {
|
||||||
|
this.handle
|
||||||
|
.prepare(
|
||||||
|
'UPDATE discord_outbox SET attempts=attempts+1,next_at=?,error=? WHERE id=?'
|
||||||
|
)
|
||||||
|
.run(retryAt, error.slice(0, 300), job.id);
|
||||||
|
if (retryAt === null && job.kind === 'alert') {
|
||||||
|
this.handle
|
||||||
|
.prepare("UPDATE discord_subscriptions SET state='failed',error=? WHERE id=?")
|
||||||
|
.run(error.slice(0, 300), job.targetId);
|
||||||
|
this.handle.prepare('DELETE FROM discord_outbox WHERE id=?').run(job.id);
|
||||||
|
}
|
||||||
|
this.error(error);
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
retryFailed(): void {
|
||||||
|
this.handle
|
||||||
|
.prepare(
|
||||||
|
'UPDATE discord_outbox SET next_at=?,attempts=0,error=NULL WHERE next_at IS NULL'
|
||||||
|
)
|
||||||
|
.run(this.now());
|
||||||
|
this.handle.prepare('UPDATE discord_health SET last_error=NULL WHERE id=1').run();
|
||||||
|
}
|
||||||
|
|
||||||
|
claimInteraction(id: string): boolean {
|
||||||
|
if (!discordSnowflake(id)) throw new Error('Invalid Discord interaction ID.');
|
||||||
|
return this.handle.transaction(() => {
|
||||||
|
this.handle
|
||||||
|
.prepare('DELETE FROM discord_interactions WHERE expires_at<=?')
|
||||||
|
.run(this.now());
|
||||||
|
if (this.handle.prepare('SELECT 1 FROM discord_interactions WHERE id=?').get(id))
|
||||||
|
return false;
|
||||||
|
if (this.count('SELECT COUNT(*) AS n FROM discord_interactions') >= 10000)
|
||||||
|
throw new Error('Discord is processing too many interactions. Please try later.');
|
||||||
|
this.handle
|
||||||
|
.prepare('INSERT INTO discord_interactions(id,expires_at) VALUES(?,?)')
|
||||||
|
.run(id, this.now() + 15 * 60_000);
|
||||||
|
return true;
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
private count(sql: string, ...args: (string | number)[]): number {
|
||||||
|
return (this.handle.prepare(sql).get(...args) as { n: number }).n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const discordStore = () => new DiscordStore(db());
|
||||||
|
export const getDiscordSettings = () => discordStore().settings();
|
||||||
|
export const saveDiscordSettings = (input: unknown) => discordStore().saveSettings(input);
|
||||||
|
export const discordStatus = () => discordStore().status();
|
||||||
|
export const subscribeToMap = (input: MapSubscriptionInput) => discordStore().subscribe(input);
|
||||||
|
export const listSubscriptions = (userId: string, guildId: string) =>
|
||||||
|
discordStore().subscriptions(userId, guildId);
|
||||||
|
export const cancelSubscription = (id: string, guildId: string, userId: string) =>
|
||||||
|
discordStore().cancel(id, guildId, userId);
|
||||||
|
export const claimDiscordInteraction = (id: string) => discordStore().claimInteraction(id);
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
/** Discord HTTP failures never include response bodies, which can contain private data. */
|
||||||
|
export class DiscordDeliveryError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
readonly status: number,
|
||||||
|
readonly retryAt: number | null = null,
|
||||||
|
readonly code: number | null = null
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'DiscordDeliveryError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscordTransport {
|
||||||
|
request<T = Record<string, unknown>>(method: string, path: string, body?: unknown): Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One serialized queue shares Discord's bucket/global cooldowns with every caller. */
|
||||||
|
export class DiscordRest implements DiscordTransport {
|
||||||
|
private tail: Promise<unknown> = Promise.resolve();
|
||||||
|
private globalUntil = 0;
|
||||||
|
private unauthorized = false;
|
||||||
|
private routeBuckets = new Map<string, string>();
|
||||||
|
private blockedUntil = new Map<string, number>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly token: string,
|
||||||
|
private readonly fetcher: typeof fetch = fetch,
|
||||||
|
private readonly now: () => number = Date.now
|
||||||
|
) {}
|
||||||
|
|
||||||
|
request<T = Record<string, unknown>>(method: string, path: string, body?: unknown): Promise<T> {
|
||||||
|
const pending = this.tail.then(() => this.send<T>(method, path, body));
|
||||||
|
this.tail = pending.catch(() => undefined);
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async send<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||||
|
// Callers supply resource paths, never arbitrary hosts or interaction tokens.
|
||||||
|
if (!/^\/(?:channels|users|applications)\/[A-Za-z0-9_@/-]+$/.test(path))
|
||||||
|
throw new Error('Invalid Discord API path.');
|
||||||
|
if (this.unauthorized)
|
||||||
|
throw new DiscordDeliveryError('Discord bot credentials were rejected.', 401);
|
||||||
|
const segments = path.split('/');
|
||||||
|
const major = `${segments[1]}/${segments[2]}`;
|
||||||
|
const route = `${method}:${major}/${segments
|
||||||
|
.slice(3)
|
||||||
|
.map((part) => (/^\d+$/.test(part) ? ':id' : part))
|
||||||
|
.join('/')}`;
|
||||||
|
const key = this.routeBuckets.get(route) ?? route;
|
||||||
|
const retryAt = Math.max(this.globalUntil, this.blockedUntil.get(key) ?? 0);
|
||||||
|
if (retryAt > this.now())
|
||||||
|
throw new DiscordDeliveryError(
|
||||||
|
'Discord is waiting for a rate limit to reset.',
|
||||||
|
429,
|
||||||
|
retryAt
|
||||||
|
);
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await this.fetcher(`https://discord.com/api/v10${path}`, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bot ${this.token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'User-Agent': 'ElectricCherry/1.0'
|
||||||
|
},
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
|
redirect: 'error'
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw new DiscordDeliveryError('Discord could not be reached.', 0, this.now() + 5_000);
|
||||||
|
}
|
||||||
|
const bucket = response.headers.get('X-RateLimit-Bucket');
|
||||||
|
const bucketKey = bucket ? `${major}:${bucket}` : key;
|
||||||
|
if (bucket) this.routeBuckets.set(route, bucketKey);
|
||||||
|
const reset = seconds(response.headers.get('X-RateLimit-Reset-After'));
|
||||||
|
if (response.headers.get('X-RateLimit-Remaining') === '0' && reset !== null)
|
||||||
|
this.blockedUntil.set(bucketKey, this.now() + reset);
|
||||||
|
let data: Record<string, unknown> = {};
|
||||||
|
if (response.status !== 204) {
|
||||||
|
try {
|
||||||
|
data = await response.json();
|
||||||
|
} catch {
|
||||||
|
if (response.ok)
|
||||||
|
throw new DiscordDeliveryError(
|
||||||
|
'Discord returned an invalid response.',
|
||||||
|
502,
|
||||||
|
this.now() + 5_000
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (response.status === 429) {
|
||||||
|
const delay = Math.max(
|
||||||
|
1000,
|
||||||
|
seconds(data.retry_after) ??
|
||||||
|
seconds(response.headers.get('Retry-After')) ??
|
||||||
|
reset ??
|
||||||
|
5_000
|
||||||
|
);
|
||||||
|
const until = this.now() + delay;
|
||||||
|
if (data.global === true || response.headers.get('X-RateLimit-Global') === 'true')
|
||||||
|
this.globalUntil = until;
|
||||||
|
else this.blockedUntil.set(bucketKey, until);
|
||||||
|
throw new DiscordDeliveryError('Discord rate limited this request.', 429, until);
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) this.unauthorized = true;
|
||||||
|
const message =
|
||||||
|
response.status === 401
|
||||||
|
? 'Discord bot credentials were rejected.'
|
||||||
|
: response.status === 403
|
||||||
|
? 'Discord denied permission or the recipient does not accept direct messages.'
|
||||||
|
: response.status === 404
|
||||||
|
? 'Discord channel or message no longer exists.'
|
||||||
|
: `Discord request failed (HTTP ${response.status}).`;
|
||||||
|
throw new DiscordDeliveryError(
|
||||||
|
message,
|
||||||
|
response.status,
|
||||||
|
response.status >= 500 ? this.now() + 5_000 : null,
|
||||||
|
typeof data.code === 'number' ? data.code : null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return data as T;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function seconds(value: unknown): number | null {
|
||||||
|
if (value === null || value === undefined || value === '') return null;
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) && parsed >= 0 ? Math.ceil(parsed * 1000) : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { createPublicKey, verify } from 'node:crypto';
|
||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import { boundedBody } from '../request-body.js';
|
||||||
|
import { discordCredentials } from './config.js';
|
||||||
|
import { discordSnowflake } from '../../discord.js';
|
||||||
|
import type { DiscordMessage } from '../../discord.js';
|
||||||
|
|
||||||
|
export const DISCORD_INTERACTION_BODY_LIMIT = 64 * 1024;
|
||||||
|
|
||||||
|
/** Authenticate the exact request bytes before interpreting any interaction fields. */
|
||||||
|
export async function verifiedDiscordBody(
|
||||||
|
request: Request,
|
||||||
|
now = Date.now()
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
const signature = request.headers.get('x-signature-ed25519') ?? '';
|
||||||
|
const timestamp = request.headers.get('x-signature-timestamp') ?? '';
|
||||||
|
const { publicKey } = discordCredentials();
|
||||||
|
if (
|
||||||
|
!/^[a-fA-F0-9]{128}$/.test(signature) ||
|
||||||
|
!/^[0-9]{10,12}$/.test(timestamp) ||
|
||||||
|
Math.abs(now - Number(timestamp) * 1000) > 5 * 60_000 ||
|
||||||
|
!/^[a-fA-F0-9]{64}$/.test(publicKey)
|
||||||
|
)
|
||||||
|
error(401, 'Invalid Discord signature.');
|
||||||
|
const body = await boundedBody(request, DISCORD_INTERACTION_BODY_LIMIT);
|
||||||
|
const key = createPublicKey({
|
||||||
|
key: Buffer.concat([
|
||||||
|
Buffer.from('302a300506032b6570032100', 'hex'),
|
||||||
|
Buffer.from(publicKey, 'hex')
|
||||||
|
]),
|
||||||
|
format: 'der',
|
||||||
|
type: 'spki'
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
!verify(
|
||||||
|
null,
|
||||||
|
Buffer.concat([Buffer.from(timestamp), body]),
|
||||||
|
key,
|
||||||
|
Buffer.from(signature, 'hex')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
error(401, 'Invalid Discord signature.');
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(body.toString('utf8'));
|
||||||
|
} catch {
|
||||||
|
error(400, 'Invalid Discord interaction.');
|
||||||
|
}
|
||||||
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
||||||
|
error(400, 'Invalid Discord interaction.');
|
||||||
|
return parsed as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validInteractionEnvelope(body: Record<string, unknown>): boolean {
|
||||||
|
return (
|
||||||
|
discordSnowflake(body.id) &&
|
||||||
|
body.application_id === discordCredentials().applicationId &&
|
||||||
|
discordSnowflake(body.guild_id) &&
|
||||||
|
typeof body.token === 'string' &&
|
||||||
|
body.token.length >= 10 &&
|
||||||
|
body.token.length <= 1000 &&
|
||||||
|
!!body.member &&
|
||||||
|
typeof body.member === 'object' &&
|
||||||
|
discordSnowflake((body.member as { user?: { id?: unknown } }).user?.id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Interaction webhooks use their short-lived token, never the deployment bot token. */
|
||||||
|
export async function completeDiscordInteraction(
|
||||||
|
applicationId: string,
|
||||||
|
token: string,
|
||||||
|
message: DiscordMessage,
|
||||||
|
fetcher: typeof fetch = fetch,
|
||||||
|
publicationAllowed: () => boolean = () => true
|
||||||
|
): Promise<void> {
|
||||||
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
|
const response = await fetcher(
|
||||||
|
`https://discord.com/api/v10/webhooks/${applicationId}/${encodeURIComponent(token)}/messages/@original`,
|
||||||
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
...(publicationAllowed()
|
||||||
|
? message
|
||||||
|
: {
|
||||||
|
flags: 32768 | 64,
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: 10,
|
||||||
|
content:
|
||||||
|
'Publication settings changed. Please run your command again.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
allowed_mentions: { parse: [] }
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
|
redirect: 'error'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (response.ok) return;
|
||||||
|
if (response.status === 429 && attempt < 2) {
|
||||||
|
let delay = Number(response.headers.get('retry-after') ?? NaN);
|
||||||
|
try {
|
||||||
|
const result = (await response.json()) as { retry_after?: number };
|
||||||
|
delay = Number(result.retry_after ?? delay);
|
||||||
|
} catch {
|
||||||
|
/* A valid retry header is sufficient. */
|
||||||
|
}
|
||||||
|
if (Number.isFinite(delay) && delay >= 0 && delay <= 30) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, Math.max(250, delay * 1000)));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Do not log a response body or URL: both can contain interaction credentials.
|
||||||
|
throw new Error(`Discord interaction delivery failed (${response.status}).`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import type {
|
||||||
|
DiscordGame,
|
||||||
|
DiscordMessage,
|
||||||
|
DiscordPublicServer,
|
||||||
|
DiscordSettings
|
||||||
|
} from '../../discord.js';
|
||||||
|
import { requireDiscordCredentials } from './config.js';
|
||||||
|
import { 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();
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
canRevokeModerationRestriction,
|
canRevokeModerationRestriction,
|
||||||
isModerationCaseOpen,
|
isModerationCaseOpen,
|
||||||
moderationCaseOutcomes,
|
moderationCaseOutcomes,
|
||||||
|
moderationCaseStatuses,
|
||||||
moderationCaseStatusLabels,
|
moderationCaseStatusLabels,
|
||||||
type ModerationCaseStatus,
|
type ModerationCaseStatus,
|
||||||
type ModerationCasePriority,
|
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;
|
const status = input.status === undefined ? row.status : input.status;
|
||||||
if (
|
if (
|
||||||
input.status !== undefined &&
|
input.status !== undefined &&
|
||||||
status !== 'open' &&
|
!moderationCaseStatuses.includes(status as ModerationCaseStatus)
|
||||||
status !== 'in_review' &&
|
|
||||||
status !== 'escalated'
|
|
||||||
)
|
)
|
||||||
throw new AuthError('Choose an active case status, or close the case with an outcome.');
|
throw new AuthError('Choose Open, In progress, or Closed.');
|
||||||
if (input.status !== undefined && !isModerationCaseOpen(row.status))
|
|
||||||
throw new AuthError('Use reopen to reactivate a closed case.', 409);
|
|
||||||
const reason = textField(input.reason, 'Status change reason', 4000, status === row.status);
|
const reason = textField(input.reason, 'Status change reason', 4000, status === row.status);
|
||||||
const changes: Record<string, unknown> = {};
|
const changes: Record<string, unknown> = {};
|
||||||
if (title !== row.title) changes.title = { from: row.title, to: title };
|
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);
|
notifyCase(actor.id, row.id, 'evidence', now);
|
||||||
return { id: row.id };
|
return { id: row.id };
|
||||||
}
|
}
|
||||||
|
// Older clients may still record decisions, but every closure now stores only Closed.
|
||||||
if (input.op === 'resolve-case') {
|
if (input.op === 'resolve-case') {
|
||||||
const row = caseRow(input.id);
|
const row = caseRow(input.id);
|
||||||
if (row.revision !== input.revision || !isModerationCaseOpen(row.status))
|
if (row.revision !== input.revision || !isModerationCaseOpen(row.status))
|
||||||
@@ -620,7 +618,7 @@ export function mutateModeration(actorId: string, input: Record<string, unknown>
|
|||||||
.prepare(
|
.prepare(
|
||||||
'UPDATE moderation_cases SET status=?,updated_at=?,revision=revision+1 WHERE id=?'
|
'UPDATE moderation_cases SET status=?,updated_at=?,revision=revision+1 WHERE id=?'
|
||||||
)
|
)
|
||||||
.run(outcome, now, row.id);
|
.run('closed', now, row.id);
|
||||||
moderationEvent(
|
moderationEvent(
|
||||||
actor,
|
actor,
|
||||||
`case.${outcome}`,
|
`case.${outcome}`,
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ export function moderationOverview(actorId: string, query: ModerationQuery): Mod
|
|||||||
if (query.view === 'cases') {
|
if (query.view === 'cases') {
|
||||||
if (query.caseStatus === 'active' || query.caseStatus === 'closed') {
|
if (query.caseStatus === 'active' || query.caseStatus === 'closed') {
|
||||||
where.push(
|
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) {
|
} else if (query.caseStatus) {
|
||||||
where.push('p.status=?');
|
where.push('p.status=?');
|
||||||
@@ -220,7 +220,7 @@ export function moderationOverview(actorId: string, query: ModerationQuery): Mod
|
|||||||
result.cases = rows<Omit<ModerationCase, 'evidence'>>(
|
result.cases = rows<Omit<ModerationCase, 'evidence'>>(
|
||||||
'moderation_cases',
|
'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',
|
'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]
|
[now, now]
|
||||||
).map((c) => ({
|
).map((c) => ({
|
||||||
...c,
|
...c,
|
||||||
|
|||||||
@@ -235,3 +235,42 @@ export function migrateModerationCaseReferenceIndexes(handle: Database.Database)
|
|||||||
ON moderation_case_notifications(case_id);
|
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">
|
<script lang="ts">
|
||||||
import type { ContextualChatEntry } from '$lib/game-chat.js';
|
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 { timeFormat } from '$ui/stores/time-zone.js';
|
||||||
import Badge from '../primitives/Badge.svelte';
|
import Badge from '../primitives/Badge.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
entries,
|
entries,
|
||||||
selectedMessageIds = [],
|
selectedMessageIds = [],
|
||||||
highlightedMessageIds = []
|
highlightedMessageIds = [],
|
||||||
|
matchId,
|
||||||
|
server,
|
||||||
|
startedAt
|
||||||
}: {
|
}: {
|
||||||
entries: ContextualChatEntry[];
|
entries: ContextualChatEntry[];
|
||||||
selectedMessageIds?: string[];
|
selectedMessageIds?: string[];
|
||||||
highlightedMessageIds?: string[];
|
highlightedMessageIds?: string[];
|
||||||
|
matchId?: number | null;
|
||||||
|
server?: string | null;
|
||||||
|
startedAt?: string | null;
|
||||||
} = $props();
|
} = $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>
|
</script>
|
||||||
|
|
||||||
<p class="excerpt-note">
|
<div class="excerpt-context">
|
||||||
Selected messages and surrounding context in archive order. Messages between separate selections
|
<p>{context}</p>
|
||||||
may be omitted.
|
<p>
|
||||||
|
Highlighted lines are subject messages. Messages between separate selections may be omitted.
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
<ol class="excerpt" aria-label="Preserved conversation excerpt">
|
<ol class="excerpt" aria-label="Preserved conversation excerpt">
|
||||||
{#each entries as entry}
|
{#each messages as entry}
|
||||||
{#if entry.type === 'message'}
|
{@const highlighted = !!entry.messageId && highlightedMessageIds.includes(entry.messageId)}
|
||||||
{@const highlighted =
|
|
||||||
!!entry.messageId && highlightedMessageIds.includes(entry.messageId)}
|
|
||||||
{@const selected = !!entry.messageId && selectedMessageIds.includes(entry.messageId)}
|
{@const selected = !!entry.messageId && selectedMessageIds.includes(entry.messageId)}
|
||||||
|
{@const realTime = timestampISO(entry.context.realTime)}
|
||||||
<li class:highlighted>
|
<li class:highlighted>
|
||||||
<div class="author">
|
<span class="message-time">
|
||||||
<strong>{entry.message.player}</strong>
|
{#if realTime}<time datetime={realTime} title={$timeFormat.dateTime(realTime)}
|
||||||
{#if entry.message.channel === 'team'}<Badge>Team chat</Badge>{/if}
|
>{$timeFormat.clockTime(realTime)}</time
|
||||||
{#if highlighted}<Badge variant="warning">Subject message</Badge>
|
>{:else}<span title="Message time unknown">Unknown time</span>{/if}
|
||||||
{:else if selected}<Badge>Selected context</Badge>
|
</span>
|
||||||
{:else}<span>Surrounding context</span>{/if}
|
<strong class="author">{entry.message.player}</strong>
|
||||||
</div>
|
<p class="message">
|
||||||
<div class="metadata">
|
<span class="sr-only"
|
||||||
<span>GUID {entry.message.guid}</span>
|
>{highlighted
|
||||||
{#if entry.context.realTime}<span
|
? 'Subject message: '
|
||||||
>Estimated time <time datetime={entry.context.realTime}
|
: selected
|
||||||
>{$timeFormat.dateTime(entry.context.realTime)}</time
|
? 'Selected context: '
|
||||||
></span
|
: 'Surrounding context: '}</span
|
||||||
>{:else}<span>Message time unknown</span>{/if}
|
>{#if entry.message.channel === 'team'}<Badge>Team chat</Badge>{' '}{/if}{entry
|
||||||
<span>Log {entry.message.time}</span>
|
.message.message}
|
||||||
{#if entry.context.round !== null}<span>Round {entry.context.round}</span>{/if}
|
</p>
|
||||||
</div>
|
|
||||||
<p>{entry.message.message}</p>
|
|
||||||
{#if entry.messageId}<details>
|
|
||||||
<summary>Message identifier</summary><code>{entry.messageId}</code>
|
|
||||||
</details>{/if}
|
|
||||||
</li>
|
</li>
|
||||||
{/if}
|
|
||||||
{/each}
|
{/each}
|
||||||
</ol>
|
</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>
|
<style>
|
||||||
.excerpt {
|
.excerpt {
|
||||||
@@ -59,50 +106,63 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
li {
|
.excerpt > li {
|
||||||
padding: var(--space-3);
|
display: grid;
|
||||||
border-left: 3px solid var(--border-subtle);
|
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);
|
background: var(--surface-sunken);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
line-height: var(--leading-body);
|
||||||
}
|
}
|
||||||
li.highlighted {
|
.excerpt > li.highlighted {
|
||||||
border-left-color: var(--state-warning);
|
border-left-color: var(--state-warning);
|
||||||
background: var(--surface-raised);
|
background: var(--surface-raised);
|
||||||
}
|
}
|
||||||
.author,
|
.author,
|
||||||
.metadata {
|
.message {
|
||||||
display: flex;
|
min-width: 0;
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
.author > span,
|
.message {
|
||||||
.metadata {
|
white-space: pre-wrap;
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
}
|
}
|
||||||
.metadata {
|
.message-time {
|
||||||
margin-top: var(--space-1);
|
color: var(--text-secondary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
p {
|
p {
|
||||||
white-space: pre-wrap;
|
margin: 0;
|
||||||
overflow-wrap: anywhere;
|
|
||||||
margin: var(--space-2) 0 0;
|
|
||||||
line-height: var(--leading-body);
|
|
||||||
}
|
}
|
||||||
details {
|
.excerpt-context,
|
||||||
margin-top: var(--space-2);
|
.message-details {
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
line-height: var(--leading-body);
|
||||||
overflow-wrap: anywhere;
|
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 {
|
summary {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.excerpt-note {
|
@media (max-width: 640px) {
|
||||||
margin: 0;
|
.excerpt > li {
|
||||||
font-size: var(--text-xs);
|
grid-template-columns: 7.5em minmax(0, 1fr);
|
||||||
color: var(--text-secondary);
|
gap: var(--space-1) var(--space-3);
|
||||||
|
}
|
||||||
|
.message {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -250,7 +250,12 @@
|
|||||||
{excerpt.length} messages in this excerpt. Selected subject messages are highlighted.
|
{excerpt.length} messages in this excerpt. Selected subject messages are highlighted.
|
||||||
Messages already preserved as evidence in this case are not attached again.
|
Messages already preserved as evidence in this case are not attached again.
|
||||||
</p>
|
</p>
|
||||||
<EvidenceMessages entries={excerpt} {selectedMessageIds} {highlightedMessageIds} />
|
<EvidenceMessages
|
||||||
|
entries={excerpt}
|
||||||
|
{matchId}
|
||||||
|
{selectedMessageIds}
|
||||||
|
{highlightedMessageIds}
|
||||||
|
/>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
|
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
|
||||||
{#if uncertain}<p class="hint">
|
{#if uncertain}<p class="hint">
|
||||||
|
|||||||
@@ -1,17 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import {
|
import {
|
||||||
isModerationCaseOpen,
|
moderationCaseStatuses,
|
||||||
moderationCaseOutcomes,
|
|
||||||
moderationCaseStatusLabels,
|
moderationCaseStatusLabels,
|
||||||
type ModerationCoverage,
|
type ModerationCoverage,
|
||||||
type ModerationGame,
|
type ModerationGame,
|
||||||
type ModerationScope,
|
type ModerationScope,
|
||||||
type ModerationCase,
|
type ModerationCase
|
||||||
type ModerationCaseAction
|
|
||||||
} from '$lib/moderation.js';
|
} from '$lib/moderation.js';
|
||||||
import { moderationActionName, moderationGameName } from '$ui/moderation.js';
|
import { moderationGameName } from '$ui/moderation.js';
|
||||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
|
||||||
import { serverTitle } from '$ui/server-labels.js';
|
import { serverTitle } from '$ui/server-labels.js';
|
||||||
import { confirmAction } from '$ui/confirm.js';
|
import { confirmAction } from '$ui/confirm.js';
|
||||||
import Dialog from '../primitives/Dialog.svelte';
|
import Dialog from '../primitives/Dialog.svelte';
|
||||||
@@ -29,7 +26,6 @@
|
|||||||
admin,
|
admin,
|
||||||
seed = {},
|
seed = {},
|
||||||
assignees = [],
|
assignees = [],
|
||||||
linkedActions = [],
|
|
||||||
onsubmitted
|
onsubmitted
|
||||||
}: {
|
}: {
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
@@ -40,14 +36,12 @@
|
|||||||
| 'note'
|
| 'note'
|
||||||
| 'revoke'
|
| 'revoke'
|
||||||
| 'case-note'
|
| 'case-note'
|
||||||
| 'resolve'
|
| 'case-status'
|
||||||
| 'reopen'
|
|
||||||
| 'edit-case'
|
| 'edit-case'
|
||||||
| 'evidence';
|
| 'evidence';
|
||||||
coverage: ModerationCoverage[];
|
coverage: ModerationCoverage[];
|
||||||
admin: boolean;
|
admin: boolean;
|
||||||
assignees?: { id: string; username: string }[];
|
assignees?: { id: string; username: string }[];
|
||||||
linkedActions?: ModerationCaseAction[];
|
|
||||||
seed?: {
|
seed?: {
|
||||||
game?: ModerationGame;
|
game?: ModerationGame;
|
||||||
guid?: string;
|
guid?: string;
|
||||||
@@ -81,26 +75,10 @@
|
|||||||
let status = $state(untrack(() => seed.status ?? 'open'));
|
let status = $state(untrack(() => seed.status ?? 'open'));
|
||||||
let matchId = $state<number | undefined>(untrack(() => seed.matchId));
|
let matchId = $state<number | undefined>(untrack(() => seed.matchId));
|
||||||
let message = $state(untrack(() => seed.message ?? ''));
|
let message = $state(untrack(() => seed.message ?? ''));
|
||||||
|
const statusUnchanged = $derived(mode === 'case-status' && status === seed.status);
|
||||||
const lockedTarget = $derived(mode === 'restriction' && !!seed.caseId);
|
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 reasonBytes = $derived(new TextEncoder().encode(reason.trim()).length);
|
||||||
const gameText = $derived(mode === 'restriction' || mode === 'action');
|
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 busy = $state(false);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
let uncertain = $state(false);
|
let uncertain = $state(false);
|
||||||
@@ -113,8 +91,7 @@
|
|||||||
note: 'Add private note',
|
note: 'Add private note',
|
||||||
revoke: 'Revoke restriction',
|
revoke: 'Revoke restriction',
|
||||||
'case-note': 'Add case note',
|
'case-note': 'Add case note',
|
||||||
resolve: 'Review case',
|
'case-status': 'Change status',
|
||||||
reopen: 'Reopen case',
|
|
||||||
'edit-case': 'Edit case',
|
'edit-case': 'Edit case',
|
||||||
evidence: 'Attach archived evidence'
|
evidence: 'Attach archived evidence'
|
||||||
}[mode]
|
}[mode]
|
||||||
@@ -184,13 +161,6 @@
|
|||||||
if (mode === 'note') return { op: 'note', game, guid: guid.trim(), text: note.trim() };
|
if (mode === 'note') return { op: 'note', game, guid: guid.trim(), text: note.trim() };
|
||||||
if (mode === 'case-note')
|
if (mode === 'case-note')
|
||||||
return { op: 'case-note', id: seed.id, revision: seed.revision, text: note.trim() };
|
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')
|
if (mode === 'edit-case')
|
||||||
return {
|
return {
|
||||||
op: 'update-case',
|
op: 'update-case',
|
||||||
@@ -201,7 +171,6 @@
|
|||||||
...(assigneeId !== (seed.assigneeId ?? '')
|
...(assigneeId !== (seed.assigneeId ?? '')
|
||||||
? { assigneeId: assigneeId || null }
|
? { assigneeId: assigneeId || null }
|
||||||
: {}),
|
: {}),
|
||||||
...(isModerationCaseOpen(status) ? { status } : {}),
|
|
||||||
reason: reason.trim()
|
reason: reason.trim()
|
||||||
};
|
};
|
||||||
if (mode === 'evidence')
|
if (mode === 'evidence')
|
||||||
@@ -219,14 +188,11 @@
|
|||||||
reason: reason.trim()
|
reason: reason.trim()
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
op: 'resolve-case',
|
op: 'update-case',
|
||||||
id: seed.id,
|
id: seed.id,
|
||||||
revision: seed.revision,
|
revision: seed.revision,
|
||||||
status: resolution,
|
status,
|
||||||
reason: reason.trim(),
|
reason: reason.trim()
|
||||||
...(referencedAction
|
|
||||||
? { actionReference: { type: referencedAction.type, id: referencedAction.id } }
|
|
||||||
: {})
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -392,7 +358,7 @@
|
|||||||
</p>{/if}
|
</p>{/if}
|
||||||
{#if lockedTarget}<p class="hint">
|
{#if lockedTarget}<p class="hint">
|
||||||
This restriction will be linked to the case and its recorded player identity.
|
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.
|
complete.
|
||||||
</p>{/if}
|
</p>{/if}
|
||||||
{#if mode === 'case' || mode === 'edit-case'}
|
{#if mode === 'case' || mode === 'edit-case'}
|
||||||
@@ -447,26 +413,8 @@
|
|||||||
/></Field
|
/></Field
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
{#if isModerationCaseOpen(seed.status ?? 'open')}
|
<Field label="Change reason" hint="Optional note about these edits.">
|
||||||
<Field label="Workflow status"
|
<Textarea bind:value={reason} rows={3} />
|
||||||
><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>
|
</Field>
|
||||||
{/if}
|
{/if}
|
||||||
{#if mode === 'evidence'}
|
{#if mode === 'evidence'}
|
||||||
@@ -481,55 +429,29 @@
|
|||||||
><Textarea bind:value={message} required rows={4} maxlength={4096} /></Field
|
><Textarea bind:value={message} required rows={4} maxlength={4096} /></Field
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
{#if mode === 'resolve'}<Field label="Outcome"
|
{#if mode === 'case-status'}
|
||||||
|
<Field label="Status"
|
||||||
><Select
|
><Select
|
||||||
bind:value={resolution}
|
bind:value={status}
|
||||||
options={moderationCaseOutcomes.map((value) => ({
|
options={moderationCaseStatuses.map((value) => ({
|
||||||
value,
|
value,
|
||||||
label: moderationCaseStatusLabels[value]
|
label: moderationCaseStatusLabels[value]
|
||||||
}))}
|
}))}
|
||||||
/></Field
|
/></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
|
<p class="hint">
|
||||||
bind:value={actionReference}
|
Track whether this case is open, in progress, or closed. Findings and recorded
|
||||||
options={[
|
actions stay in the case history.
|
||||||
{ value: '', label: 'No action reference' },
|
</p>
|
||||||
...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}
|
|
||||||
{/if}
|
{/if}
|
||||||
{#if mode === 'reopen'}<p class="hint">
|
{#if ['restriction', 'action', 'revoke', 'case-status'].includes(mode)}
|
||||||
Reopening returns the case to Open and preserves its evidence, restrictions, and
|
|
||||||
earlier decisions.
|
|
||||||
</p>{/if}
|
|
||||||
{#if ['restriction', 'action', 'revoke', 'resolve', 'reopen'].includes(mode)}
|
|
||||||
<Field
|
<Field
|
||||||
label={mode === 'action' && ['message', 'announce'].includes(action)
|
label={mode === 'action' && ['message', 'announce'].includes(action)
|
||||||
? 'Message'
|
? 'Message'
|
||||||
: mode === 'restriction' || mode === 'action'
|
: mode === 'restriction' || mode === 'action'
|
||||||
? 'Player-visible reason'
|
? 'Player-visible reason'
|
||||||
: mode === 'reopen'
|
: mode === 'case-status'
|
||||||
? 'Reopening reason'
|
? 'Change reason'
|
||||||
: 'Review reason'}
|
: 'Review reason'}
|
||||||
hint={gameText
|
hint={gameText
|
||||||
? `${reasonBytes} / 240 bytes. Some characters use more than one byte.`
|
? `${reasonBytes} / 240 bytes. Some characters use more than one byte.`
|
||||||
@@ -561,7 +483,7 @@
|
|||||||
<div class="actions">
|
<div class="actions">
|
||||||
<Button disabled={busy} onclick={() => (open = false)}>Cancel</Button><Button
|
<Button disabled={busy} onclick={() => (open = false)}>Cancel</Button><Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!uncertain && actionUnavailable}
|
disabled={!uncertain && (actionUnavailable || statusUnchanged)}
|
||||||
variant={mode === 'restriction' || mode === 'revoke' ? 'danger' : 'primary'}
|
variant={mode === 'restriction' || mode === 'revoke' ? 'danger' : 'primary'}
|
||||||
loading={busy}>{uncertain ? 'Retry same request' : 'Save'}</Button
|
loading={busy}>{uncertain ? 'Retry same request' : 'Save'}</Button
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ export function preservedConversation(value: unknown): {
|
|||||||
} | null {
|
} | null {
|
||||||
if (!value || typeof value !== 'object') return null;
|
if (!value || typeof value !== 'object') return null;
|
||||||
const snapshot = value as Record<string, unknown>;
|
const snapshot = value as Record<string, unknown>;
|
||||||
if (!Array.isArray(snapshot.entries)) return null;
|
const candidates = Array.isArray(snapshot.entries) ? snapshot.entries : [snapshot.entry];
|
||||||
const entries = snapshot.entries.filter(
|
const entries = candidates.filter(
|
||||||
(entry): entry is Extract<ContextualChatEntry, { type: 'message' }> => {
|
(entry): entry is Extract<ContextualChatEntry, { type: 'message' }> => {
|
||||||
if (!entry || typeof entry !== 'object' || entry.type !== 'message') return false;
|
if (!entry || typeof entry !== 'object' || entry.type !== 'message') return false;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export const moderationActionLabels: Record<string, string> = {
|
|||||||
'case.appeal_denied': 'Appeal denied',
|
'case.appeal_denied': 'Appeal denied',
|
||||||
'case.reopened': 'Case reopened',
|
'case.reopened': 'Case reopened',
|
||||||
'case.updated': 'Case updated',
|
'case.updated': 'Case updated',
|
||||||
|
'case.status_migrated': 'Case status migrated',
|
||||||
'case.assessed': 'Assessment revised',
|
'case.assessed': 'Assessment revised',
|
||||||
'case.review_scheduled': 'Follow-up updated',
|
'case.review_scheduled': 'Follow-up updated',
|
||||||
'schedule-case-review': '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);
|
if (typeof value.reason === 'string') changes.push(value.reason);
|
||||||
return changes.join('\n') || detail;
|
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')
|
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 (
|
if (
|
||||||
action === 'case.evidence_attached' &&
|
action === 'case.evidence_attached' &&
|
||||||
typeof value.count === 'number' &&
|
typeof value.count === 'number' &&
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ const states: Record<string, [string, StatusTone]> = {
|
|||||||
'moderation-active': ['Active', 'warning'],
|
'moderation-active': ['Active', 'warning'],
|
||||||
'moderation-revoked': ['Revoked', 'neutral'],
|
'moderation-revoked': ['Revoked', 'neutral'],
|
||||||
'moderation-open': ['Open', 'info'],
|
'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-escalated': ['Escalated', 'warning'],
|
||||||
'moderation-resolved': ['Resolved', 'online'],
|
'moderation-resolved': ['Resolved', 'online'],
|
||||||
'moderation-dismissed': ['Dismissed', 'neutral'],
|
'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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -388,7 +388,6 @@
|
|||||||
options={[
|
options={[
|
||||||
{ value: '', label: 'All statuses' },
|
{ value: '', label: 'All statuses' },
|
||||||
{ value: 'active', label: 'Active cases' },
|
{ value: 'active', label: 'Active cases' },
|
||||||
{ value: 'closed', label: 'Closed cases' },
|
|
||||||
...moderationCaseStatuses.map((status) => ({
|
...moderationCaseStatuses.map((status) => ({
|
||||||
value: status,
|
value: status,
|
||||||
label: moderationCaseStatusLabels[status]
|
label: moderationCaseStatusLabels[status]
|
||||||
|
|||||||
@@ -150,7 +150,7 @@
|
|||||||
function submitted() {
|
function submitted() {
|
||||||
notice =
|
notice =
|
||||||
mode === 'restriction'
|
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'
|
: mode === 'revoke'
|
||||||
? 'Revocation saved. Servers apply it when they receive the latest policy.'
|
? 'Revocation saved. Servers apply it when they receive the latest policy.'
|
||||||
: 'Case updated. The change is recorded in the timeline.';
|
: 'Case updated. The change is recorded in the timeline.';
|
||||||
@@ -189,17 +189,6 @@
|
|||||||
typeof value.entry?.message?.player === 'string'
|
typeof value.entry?.message?.player === 'string'
|
||||||
? (value.entry.message.player as string)
|
? (value.entry.message.player as string)
|
||||||
: null,
|
: 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),
|
original: JSON.stringify(value, null, 2),
|
||||||
excerpt: preservedConversation(value)
|
excerpt: preservedConversation(value)
|
||||||
};
|
};
|
||||||
@@ -226,12 +215,9 @@
|
|||||||
/>
|
/>
|
||||||
<Button onclick={() => refresh()} loading={refreshing} disabled={busy}>Refresh</Button>
|
<Button onclick={() => refresh()} loading={refreshing} disabled={busy}>Refresh</Button>
|
||||||
{#if permitted}
|
{#if permitted}
|
||||||
{#if active}<Button variant="primary" onclick={() => openDialog('resolve')}
|
<Button variant="primary" onclick={() => openDialog('case-status')}
|
||||||
>Review case</Button
|
>Change status</Button
|
||||||
>
|
>
|
||||||
{:else}<Button variant="primary" onclick={() => openDialog('reopen')}
|
|
||||||
>Reopen case</Button
|
|
||||||
>{/if}
|
|
||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</SectionHeader>
|
</SectionHeader>
|
||||||
@@ -305,45 +291,32 @@
|
|||||||
{@const archive = archiveContext(evidence.snapshot)}
|
{@const archive = archiveContext(evidence.snapshot)}
|
||||||
<li class="record">
|
<li class="record">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<strong>{archive?.player ?? 'Archived message'}</strong
|
<strong>{archive?.player ?? 'Archived conversation'}</strong>
|
||||||
>{#if archive?.round !== null && archive?.round !== undefined}<Badge
|
|
||||||
>Round {archive.round}</Badge
|
|
||||||
>{/if}
|
|
||||||
</div>
|
</div>
|
||||||
{#if archive?.excerpt}
|
{#if archive?.excerpt}
|
||||||
<EvidenceMessages {...archive.excerpt} />
|
<EvidenceMessages
|
||||||
|
{...archive.excerpt}
|
||||||
|
matchId={archive.matchId}
|
||||||
|
server={archive.server}
|
||||||
|
startedAt={archive.startedAt}
|
||||||
|
/>
|
||||||
{:else}<blockquote>{evidence.text}</blockquote>{/if}
|
{:else}<blockquote>{evidence.text}</blockquote>{/if}
|
||||||
<p class="hint">{evidence.source}</p>
|
{#if archive?.matchId}<a
|
||||||
{#if archive}<div class="source-context">
|
|
||||||
{#if archive.matchId}<a
|
|
||||||
href={contextHref(
|
href={contextHref(
|
||||||
`/matches/${archive.matchId}?matchArchive=1`,
|
`/matches/${archive.matchId}?matchArchive=1`,
|
||||||
$page.url
|
$page.url
|
||||||
)}>View match #{archive.matchId}</a
|
)}>View match #{archive.matchId}</a
|
||||||
>{/if}
|
>{/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}
|
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
Preserved by {evidence.actor} · <RelativeTime
|
Preserved by {evidence.actor} · <RelativeTime
|
||||||
value={evidence.createdAt}
|
value={evidence.createdAt}
|
||||||
/>
|
/>
|
||||||
</p>
|
</p>
|
||||||
{#if archive}<details>
|
<details>
|
||||||
<summary>Preserved archive snapshot</summary>
|
<summary>Technical details</summary>
|
||||||
<pre>{archive.original}</pre>
|
<p class="hint">{evidence.source}</p>
|
||||||
</details>{/if}
|
<pre>{archive?.original ?? evidence.snapshot}</pre>
|
||||||
|
</details>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ol>
|
</ol>
|
||||||
@@ -568,7 +541,7 @@
|
|||||||
</li>{/each}
|
</li>{/each}
|
||||||
</ol>{:else}<p class="hint">No restrictions are linked to this case.</p>{/if}
|
</ol>{:else}<p class="hint">No restrictions are linked to this case.</p>{/if}
|
||||||
<p class="hint">
|
<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>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -604,7 +577,6 @@
|
|||||||
{admin}
|
{admin}
|
||||||
coverage={report.coverage}
|
coverage={report.coverage}
|
||||||
assignees={report.assignees}
|
assignees={report.assignees}
|
||||||
linkedActions={report.linkedActions}
|
|
||||||
onsubmitted={submitted}
|
onsubmitted={submitted}
|
||||||
/>{/key}{/if}
|
/>{/key}{/if}
|
||||||
|
|
||||||
@@ -698,15 +670,10 @@
|
|||||||
}
|
}
|
||||||
.hint,
|
.hint,
|
||||||
dt,
|
dt,
|
||||||
.source-context,
|
|
||||||
summary {
|
summary {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
}
|
}
|
||||||
.source-context {
|
|
||||||
display: grid;
|
|
||||||
gap: var(--space-2);
|
|
||||||
}
|
|
||||||
.notice {
|
.notice {
|
||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
border-radius: var(--radius-md);
|
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>
|
||||||
@@ -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.'
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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(fetch.mock.calls[0][0]).toContain('game=t5&guid=11');
|
||||||
expect(document.querySelector('dialog')?.textContent).toContain('GUID 22');
|
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"]')!;
|
const excerpt = document.querySelector('dialog [aria-label="Preserved conversation excerpt"]')!;
|
||||||
expect(excerpt.querySelectorAll('li')).toHaveLength(3);
|
expect(excerpt.querySelectorAll('li')).toHaveLength(3);
|
||||||
expect(excerpt.querySelector('.highlighted')?.textContent).toContain('A concerning reply');
|
expect(excerpt.querySelector('.highlighted')?.textContent).toContain('A concerning reply');
|
||||||
|
|||||||
@@ -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('Read-only moderation access');
|
||||||
expect(document.body.textContent).toContain('Round 9');
|
expect(document.body.textContent).toContain('Round 9');
|
||||||
expect(document.body.textContent).toContain('Original log time · 12:34');
|
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('Check the earlier warning.');
|
||||||
expect(document.body.textContent).toContain('Prior warning ignored');
|
expect(document.body.textContent).toContain('Prior warning ignored');
|
||||||
expect(document.body.textContent).toContain('Policy pending');
|
expect(document.body.textContent).toContain('Policy pending');
|
||||||
expect(document.querySelector('a[href^="/matches/17"]')).not.toBeNull();
|
expect(document.querySelector('a[href^="/matches/17"]')).not.toBeNull();
|
||||||
for (const label of [
|
for (const label of [
|
||||||
'Edit case',
|
'Edit case',
|
||||||
'Review case',
|
'Change status',
|
||||||
'Reopen case',
|
'Reopen case',
|
||||||
'Restrict player',
|
'Restrict player',
|
||||||
'Revoke restriction',
|
'Revoke restriction',
|
||||||
@@ -223,6 +235,58 @@ it('preserves original evidence context, linked restrictions, and staff history
|
|||||||
expect(document.querySelector('dialog')).toBeNull();
|
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 () => {
|
it('locks case restrictions to the recorded identity and revision without closing the case', async () => {
|
||||||
const updated = detail();
|
const updated = detail();
|
||||||
updated.case.revision = 5;
|
updated.case.revision = 5;
|
||||||
@@ -251,12 +315,12 @@ it('locks case restrictions to the recorded identity and revision without closin
|
|||||||
durationMinutes: 60
|
durationMinutes: 60
|
||||||
});
|
});
|
||||||
expect(fetch.mock.calls[1][0]).toBe('/api/moderation/cases/case-1');
|
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();
|
const closed = detail();
|
||||||
closed.case.status = 'warning_issued';
|
closed.case.status = 'closed';
|
||||||
closed.case.revision = 5;
|
closed.case.revision = 5;
|
||||||
const reopened = detail();
|
const reopened = detail();
|
||||||
reopened.case.status = 'open';
|
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));
|
.mockResolvedValueOnce(response(reopened));
|
||||||
vi.stubGlobal('fetch', fetch);
|
vi.stubGlobal('fetch', fetch);
|
||||||
show();
|
show();
|
||||||
button('Review case').click();
|
button('Change status').click();
|
||||||
await tick();
|
await tick();
|
||||||
expect([...field<HTMLSelectElement>('Outcome').options].map((option) => option.value)).toEqual([
|
expect([...field<HTMLSelectElement>('Status').options].map((option) => option.value)).toEqual([
|
||||||
'resolved',
|
'open',
|
||||||
'dismissed',
|
'in_review',
|
||||||
'warning_issued',
|
'closed'
|
||||||
'restriction_applied',
|
|
||||||
'no_action',
|
|
||||||
'duplicate',
|
|
||||||
'appeal_upheld',
|
|
||||||
'appeal_denied'
|
|
||||||
]);
|
]);
|
||||||
select('Outcome', 'warning_issued');
|
select('Status', 'closed');
|
||||||
enter(field('Review reason'), 'Warning was sufficient');
|
enter(field('Change reason'), 'Warning was sufficient');
|
||||||
submit();
|
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({
|
expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({
|
||||||
op: 'resolve-case',
|
op: 'update-case',
|
||||||
id: 'case-1',
|
id: 'case-1',
|
||||||
revision: 4,
|
revision: 4,
|
||||||
status: 'warning_issued',
|
status: 'closed',
|
||||||
reason: 'Warning was sufficient'
|
reason: 'Warning was sufficient'
|
||||||
});
|
});
|
||||||
expect(button('Restrict player')).toBeUndefined();
|
expect(button('Restrict player')).toBeUndefined();
|
||||||
button('Reopen case').click();
|
button('Change status').click();
|
||||||
await tick();
|
await tick();
|
||||||
enter(field('Reopening reason'), 'New evidence received');
|
select('Status', 'open');
|
||||||
|
enter(field('Change reason'), 'New evidence received');
|
||||||
submit();
|
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({
|
expect(JSON.parse(fetch.mock.calls[2][1].body)).toMatchObject({
|
||||||
op: 'reopen-case',
|
op: 'update-case',
|
||||||
|
status: 'open',
|
||||||
id: 'case-1',
|
id: 'case-1',
|
||||||
revision: 5,
|
revision: 5,
|
||||||
reason: 'New evidence received'
|
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.');
|
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
|
const fetch = vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValueOnce(response({ ok: true, id: 'case-1' }))
|
.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');
|
enter(field('Case title'), 'Urgent report');
|
||||||
select('Priority', 'urgent');
|
select('Priority', 'urgent');
|
||||||
select('Assigned to', 'staff-2');
|
select('Assigned to', 'staff-2');
|
||||||
select('Workflow status', 'escalated');
|
expect(document.querySelector('dialog')?.textContent).not.toContain('Workflow status');
|
||||||
expect(field<HTMLTextAreaElement>('Change reason').required).toBe(true);
|
expect(field<HTMLTextAreaElement>('Change reason').required).toBe(false);
|
||||||
enter(field('Change reason'), 'Needs administrator review');
|
enter(field('Change reason'), 'Needs administrator review');
|
||||||
submit();
|
submit();
|
||||||
await vi.waitFor(() => expect(fetch).toHaveBeenCalled());
|
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',
|
title: 'Urgent report',
|
||||||
priority: 'urgent',
|
priority: 'urgent',
|
||||||
assigneeId: 'staff-2',
|
assigneeId: 'staff-2',
|
||||||
status: 'escalated',
|
|
||||||
reason: 'Needs administrator review'
|
reason: 'Needs administrator review'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves an unavailable assignee when editing metadata on a closed case', async () => {
|
it('preserves an unavailable assignee when editing metadata on a closed case', async () => {
|
||||||
const initial = detail();
|
const initial = detail();
|
||||||
initial.case.status = 'no_action';
|
initial.case.status = 'closed';
|
||||||
initial.case.assigneeId = 'disabled-staff';
|
initial.case.assigneeId = 'disabled-staff';
|
||||||
initial.case.assignee = 'Former staff';
|
initial.case.assignee = 'Former staff';
|
||||||
const fetch = vi
|
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);
|
expect(button('Link existing action').disabled).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each(['in_review', 'closed'] as const)(
|
||||||
['warning_issued', 'action:warning-1', 'action', 'warning-1'],
|
'changes status to %s without replacing findings or issuing an action',
|
||||||
['restriction_applied', 'restriction:restriction-1', 'restriction', 'restriction-1']
|
async (status) => {
|
||||||
] as const)(
|
|
||||||
'references a linked action when recording %s',
|
|
||||||
async (outcome, selection, type, id) => {
|
|
||||||
const initial = detail();
|
const initial = detail();
|
||||||
|
initial.case.status = 'open';
|
||||||
initial.linkedActions = [
|
initial.linkedActions = [
|
||||||
{
|
{
|
||||||
id: 'warning-1',
|
id: 'warning-1',
|
||||||
@@ -476,31 +534,34 @@ it.each([
|
|||||||
.mockResolvedValue(response(initial));
|
.mockResolvedValue(response(initial));
|
||||||
vi.stubGlobal('fetch', fetch);
|
vi.stubGlobal('fetch', fetch);
|
||||||
show(initial);
|
show(initial);
|
||||||
button('Review case').click();
|
button('Change status').click();
|
||||||
await tick();
|
await tick();
|
||||||
select('Outcome', outcome);
|
expect(button('Save').disabled).toBe(true);
|
||||||
const options = [...field<HTMLSelectElement>('Supporting action').options].map(
|
select('Status', status);
|
||||||
(option) => option.value
|
await tick();
|
||||||
);
|
expect(button('Save').disabled).toBe(false);
|
||||||
expect(options).toEqual(['', selection]);
|
expect(document.querySelector('dialog')?.textContent).not.toContain('Supporting action');
|
||||||
select('Supporting action', selection);
|
enter(field('Change reason'), 'Reviewed the recorded action');
|
||||||
enter(field('Review reason'), 'Reviewed the recorded action');
|
|
||||||
submit();
|
submit();
|
||||||
await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
|
await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
|
||||||
expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({
|
expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({
|
||||||
op: 'resolve-case',
|
op: 'update-case',
|
||||||
id: 'case-1',
|
id: 'case-1',
|
||||||
revision: 4,
|
revision: 4,
|
||||||
status: outcome,
|
status
|
||||||
actionReference: { type, id }
|
|
||||||
});
|
});
|
||||||
|
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 () => {
|
it('retains a conflicted review and lets staff refresh before making another decision', async () => {
|
||||||
const updated = detail();
|
const updated = detail();
|
||||||
updated.case.revision = 8;
|
updated.case.revision = 8;
|
||||||
updated.case.status = 'escalated';
|
updated.case.title = 'Updated case after concurrent edit';
|
||||||
|
updated.case.status = 'in_review';
|
||||||
const fetch = vi
|
const fetch = vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValueOnce(
|
.mockResolvedValueOnce(
|
||||||
@@ -509,28 +570,29 @@ it('retains a conflicted review and lets staff refresh before making another dec
|
|||||||
.mockResolvedValue(response(updated));
|
.mockResolvedValue(response(updated));
|
||||||
vi.stubGlobal('fetch', fetch);
|
vi.stubGlobal('fetch', fetch);
|
||||||
show();
|
show();
|
||||||
button('Review case').click();
|
button('Change status').click();
|
||||||
await tick();
|
await tick();
|
||||||
select('Outcome', 'appeal_denied');
|
select('Status', 'closed');
|
||||||
enter(field('Review reason'), 'Evidence supports the restriction');
|
enter(field('Change reason'), 'Evidence supports the restriction');
|
||||||
submit();
|
submit();
|
||||||
await vi.waitFor(() =>
|
await vi.waitFor(() =>
|
||||||
expect(document.querySelector('dialog [role="alert"]')?.textContent).toContain(
|
expect(document.querySelector('dialog [role="alert"]')?.textContent).toContain(
|
||||||
'This case changed.'
|
'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(document.querySelector('dialog')!.open).toBe(true);
|
||||||
expect(button('Reopen case')).toBeUndefined();
|
expect(button('Reopen case')).toBeUndefined();
|
||||||
button('Cancel').click();
|
button('Cancel').click();
|
||||||
await tick();
|
await tick();
|
||||||
button('Refresh').click();
|
button('Refresh').click();
|
||||||
await vi.waitFor(() =>
|
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();
|
await tick();
|
||||||
enter(field('Review reason'), 'Reviewed new context');
|
select('Status', 'closed');
|
||||||
|
enter(field('Change reason'), 'Reviewed new context');
|
||||||
submit();
|
submit();
|
||||||
await vi.waitFor(() => expect(fetch.mock.calls.length).toBeGreaterThanOrEqual(3));
|
await vi.waitFor(() => expect(fetch.mock.calls.length).toBeGreaterThanOrEqual(3));
|
||||||
expect(JSON.parse(fetch.mock.calls[2][1].body).revision).toBe(8);
|
expect(JSON.parse(fetch.mock.calls[2][1].body).revision).toBe(8);
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const item: ModerationCase = {
|
|||||||
game: 't5',
|
game: 't5',
|
||||||
guid: '123',
|
guid: '123',
|
||||||
title: 'Chat review',
|
title: 'Chat review',
|
||||||
status: 'resolved',
|
status: 'closed',
|
||||||
priority: 'normal',
|
priority: 'normal',
|
||||||
assignee: null,
|
assignee: null,
|
||||||
assigneeId: null,
|
assigneeId: null,
|
||||||
|
|||||||
@@ -353,7 +353,7 @@ it('links case records to workspaces and preserves case filters while paging', (
|
|||||||
actor: 'Moderator',
|
actor: 'Moderator',
|
||||||
createdAt: '2026-10-16T12:00:00Z'
|
createdAt: '2026-10-16T12:00:00Z'
|
||||||
},
|
},
|
||||||
status: 'escalated',
|
status: 'in_review',
|
||||||
priority: 'high',
|
priority: 'high',
|
||||||
assigneeId: 'staff-1',
|
assigneeId: 'staff-1',
|
||||||
assignee: 'Moderator',
|
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(
|
expect(document.querySelector('a[href="/moderation/cases/case-1"]')?.textContent).toBe(
|
||||||
'Chat investigation'
|
'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('Moderator');
|
||||||
expect(document.querySelector('#moderation-results')?.textContent).toContain(
|
expect(document.querySelector('#moderation-results')?.textContent).toContain(
|
||||||
'The threat remains ambiguous.'
|
'The threat remains ambiguous.'
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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/db/migrate.js', () => ({ migrate: vi.fn() }));
|
||||||
vi.mock('../../src/lib/server/pipeline/worker.js', () => ({ startWorker: 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/provisioning/history.js', () => ({ interruptDeployments: vi.fn() }));
|
||||||
vi.mock('../../src/lib/server/auth/guard.js', () => ({
|
vi.mock('../../src/lib/server/auth/guard.js', () => ({
|
||||||
authenticationHandle: vi.fn(async () => new Response('OK'))
|
authenticationHandle: vi.fn(async () => new Response('OK'))
|
||||||
@@ -24,7 +25,13 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('controller error handling', () => {
|
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',
|
'retries failed %s before serving requests and boots only once after recovery',
|
||||||
async (phase) => {
|
async (phase) => {
|
||||||
const { handle } = await import('../../src/hooks.server.js');
|
const { handle } = await import('../../src/hooks.server.js');
|
||||||
@@ -32,13 +39,21 @@ describe('controller error handling', () => {
|
|||||||
const { interruptDeployments } =
|
const { interruptDeployments } =
|
||||||
await import('../../src/lib/server/provisioning/history.js');
|
await import('../../src/lib/server/provisioning/history.js');
|
||||||
const { startWorker } = await import('../../src/lib/server/pipeline/worker.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 { startPerformanceMonitor } = await import('../../src/lib/server/performance.js');
|
||||||
const { authenticationHandle } = await import('../../src/lib/server/auth/guard.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 = [
|
const failedStep = [
|
||||||
'migration',
|
'migration',
|
||||||
'deployment recovery',
|
'deployment recovery',
|
||||||
'worker startup',
|
'worker startup',
|
||||||
|
'Discord startup',
|
||||||
'performance monitor'
|
'performance monitor'
|
||||||
].indexOf(phase);
|
].indexOf(phase);
|
||||||
const failure = new Error('database or disk is full');
|
const failure = new Error('database or disk is full');
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ it('preserves current and historical assessments with case revisions, including
|
|||||||
};
|
};
|
||||||
await mutate(assess(id, 3, updated), 'admin');
|
await mutate(assess(id, 3, updated), 'admin');
|
||||||
const data = await detail(id, 'super_viewer');
|
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({
|
expect(data.assessment).toEqual({
|
||||||
...updated,
|
...updated,
|
||||||
revision: 4,
|
revision: 4,
|
||||||
@@ -253,8 +253,8 @@ it('adds assessment storage to an existing database without altering case record
|
|||||||
)
|
)
|
||||||
.run(clock, clock);
|
.run(clock, clock);
|
||||||
const before = legacy.prepare('SELECT * FROM moderation_cases').all();
|
const before = legacy.prepare('SELECT * FROM moderation_cases').all();
|
||||||
runMigrations(legacy, migrations);
|
runMigrations(legacy, migrations.slice(0, 32));
|
||||||
runMigrations(legacy, migrations);
|
runMigrations(legacy, migrations.slice(0, 32));
|
||||||
expect(legacy.prepare('SELECT * FROM moderation_cases').all()).toMatchObject(before);
|
expect(legacy.prepare('SELECT * FROM moderation_cases').all()).toMatchObject(before);
|
||||||
const insert = legacy.prepare(
|
const insert = legacy.prepare(
|
||||||
'INSERT INTO moderation_case_assessments VALUES(?,?,?,?,?,?)'
|
'INSERT INTO moderation_case_assessments VALUES(?,?,?,?,?,?)'
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ it('links historical live actions to closed cases and exposes their real deliver
|
|||||||
const before = enforcement();
|
const before = enforcement();
|
||||||
await mutate(link(id, 2, 'action', warningId));
|
await mutate(link(id, 2, 'action', warningId));
|
||||||
const data = await detail(id, 'super_viewer');
|
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.linkedActions).toMatchObject([{ id: warningId, kind: 'warn', state: 'confirmed' }]);
|
||||||
expect(data.availableActions).toMatchObject([{ id: failedId, state: 'failed' }]);
|
expect(data.availableActions).toMatchObject([{ id: failedId, state: 'failed' }]);
|
||||||
expect(data.events.map((event) => event.action)).toContain('action.confirmed');
|
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();
|
const before = enforcement();
|
||||||
await mutate(resolve(5, 'action', confirmed));
|
await mutate(resolve(5, 'action', confirmed));
|
||||||
const data = await detail(id);
|
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({
|
expect(JSON.parse(data.events[0].detail)).toEqual({
|
||||||
reason: 'Decision after evidence review',
|
reason: 'Decision after evidence review',
|
||||||
actionReference: { type: 'action', id: confirmed }
|
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',
|
reason: 'Earlier mute addressed the incident',
|
||||||
actionReference: { type: 'restriction', id: sanction.id }
|
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);
|
expect(enforcement()).toEqual(before);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ it('filters case queues by workflow, priority and ownership while prioritizing a
|
|||||||
for (const [title, priority, status, owner] of [
|
for (const [title, priority, status, owner] of [
|
||||||
['Normal report', 'normal', 'open', null],
|
['Normal report', 'normal', 'open', null],
|
||||||
['Urgent review', 'urgent', 'in_review', 'first'],
|
['Urgent review', 'urgent', 'in_review', 'first'],
|
||||||
['High escalation', 'high', 'escalated', 'second'],
|
['High escalation', 'high', 'in_review', 'second'],
|
||||||
['Closed urgent appeal', 'urgent', 'appeal_upheld', 'first']
|
['Closed urgent appeal', 'urgent', 'closed', 'first']
|
||||||
]) {
|
]) {
|
||||||
const { id } = mutateModeration('first', {
|
const { id } = mutateModeration('first', {
|
||||||
op: 'create-case',
|
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().cases.map((item) => item.id)).toEqual([ids[1], ids[2], ids[0], ids[3]]);
|
||||||
expect(read('caseStatus=active').total).toBe(3);
|
expect(read('caseStatus=active').total).toBe(3);
|
||||||
expect(read('caseStatus=closed').cases.map((item) => item.id)).toEqual([ids[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]
|
ids[2]
|
||||||
]);
|
]);
|
||||||
expect(read('assignedTo=me').cases.map((item) => item.id)).toEqual([ids[1], ids[3]]);
|
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'
|
reason: 'Review complete'
|
||||||
});
|
});
|
||||||
await mutate(link(a.id, 2, b.id));
|
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 });
|
await expect(detail(a.id, 'viewer')).rejects.toMatchObject({ status: 403 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
@@ -134,8 +134,8 @@ it('upgrades legacy cases without changing evidence, events, requests, or restri
|
|||||||
.all()
|
.all()
|
||||||
).toEqual(
|
).toEqual(
|
||||||
['dismissed', 'open', 'resolved'].map((status) => ({
|
['dismissed', 'open', 'resolved'].map((status) => ({
|
||||||
status,
|
status: status === 'open' ? 'open' : 'closed',
|
||||||
revision: 7,
|
revision: status === 'open' ? 7 : 8,
|
||||||
priority: 'normal',
|
priority: 'normal',
|
||||||
assignee_id: null
|
assignee_id: null
|
||||||
}))
|
}))
|
||||||
@@ -146,7 +146,7 @@ it('upgrades legacy cases without changing evidence, events, requests, or restri
|
|||||||
.get('{"original":true}')
|
.get('{"original":true}')
|
||||||
).toEqual({ count: 3 });
|
).toEqual({ count: 3 });
|
||||||
expect(legacy.prepare('SELECT COUNT(*) AS count FROM moderation_events').get()).toEqual({
|
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({
|
expect(legacy.prepare('SELECT result FROM moderation_requests').get()).toEqual({
|
||||||
result: '{"ok":true,"id":"open"}'
|
result: '{"ok":true,"id":"open"}'
|
||||||
@@ -245,12 +245,12 @@ it('audits title, ownership, priority and active state updates with optimistic r
|
|||||||
id,
|
id,
|
||||||
revision: 3,
|
revision: 3,
|
||||||
assigneeId: null,
|
assigneeId: null,
|
||||||
status: 'escalated',
|
status: 'open',
|
||||||
reason: 'Needs administrator review'
|
reason: 'Needs administrator review'
|
||||||
});
|
});
|
||||||
expect((await detail(id)).case).toMatchObject({
|
expect((await detail(id)).case).toMatchObject({
|
||||||
revision: 4,
|
revision: 4,
|
||||||
status: 'escalated',
|
status: 'open',
|
||||||
assigneeId: null
|
assigneeId: null
|
||||||
});
|
});
|
||||||
await expect(
|
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}` });
|
await mutate({ op: 'resolve-case', id, revision, status, reason: `Decision ${status}` });
|
||||||
revision++;
|
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(
|
await expect(mutate({ op: 'update-case', id, revision, status: 'open' })).rejects.toThrow(
|
||||||
'Use reopen'
|
'Status change reason'
|
||||||
);
|
);
|
||||||
await expect(
|
await expect(
|
||||||
mutate({ op: 'reopen-case', id, revision: revision - 1, reason: 'Old version' })
|
mutate({ op: 'reopen-case', id, revision: revision - 1, reason: 'Old version' })
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ it('prioritizes overdue closed cases and recalculates due and scheduled queues u
|
|||||||
closed.id,
|
closed.id,
|
||||||
upcoming.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));
|
await mutate(schedule(closed.id, 3, null));
|
||||||
expect((await overview('creator', 'review=due')).cases.map((item) => item.id)).toEqual([
|
expect((await overview('creator', 'review=due')).cases.map((item) => item.id)).toEqual([
|
||||||
upcoming.id
|
upcoming.id
|
||||||
|
|||||||
@@ -95,14 +95,14 @@ it('presents case decisions and ownership changes as readable history while pres
|
|||||||
'case.updated'
|
'case.updated'
|
||||||
)
|
)
|
||||||
).toBe(
|
).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(
|
expect(
|
||||||
moderationEventDetail(
|
moderationEventDetail(
|
||||||
JSON.stringify({ from: 'appeal_denied', reason: 'New evidence' }),
|
JSON.stringify({ from: 'appeal_denied', reason: 'New evidence' }),
|
||||||
'case.reopened'
|
'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(
|
expect(moderationEventDetail('Decision with {unusual text}', 'case.resolved')).toBe(
|
||||||
'Decision with {unusual text}'
|
'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')
|
moderationEventDetail(JSON.stringify({ count: 2, matchId: 42 }), 'case.evidence_attached')
|
||||||
).toBe('2 archived chat entries attached from match #42.');
|
).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.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user