This commit is contained in:
@@ -43,6 +43,7 @@ export function routePermission(routeId: string | null, method: string): RoutePe
|
||||
if (['/api/bank/[action]', '/api/afk/[action]'].includes(routeId) && method === 'POST')
|
||||
return 'machine';
|
||||
if (routeId === '/api/telemetry' && method === 'POST') return 'machine';
|
||||
if (routeId === '/api/discord/interactions' && method === 'POST') return 'machine';
|
||||
if (routeId === '/api/moderation/runtime' && method === 'POST') return 'machine';
|
||||
if (routeId === '/api/leaderboard' && reading) return 'public';
|
||||
if ((routeId === '/login' || routeId === '/setup') && reading) return 'public';
|
||||
@@ -63,6 +64,7 @@ export function routePermission(routeId: string | null, method: string): RoutePe
|
||||
|
||||
if (
|
||||
within(routeId, '/api/users') ||
|
||||
within(routeId, '/api/integrations') ||
|
||||
within(routeId, '/settings') ||
|
||||
within(routeId, '/api/settings') ||
|
||||
within(routeId, '/api/server-keys') ||
|
||||
|
||||
@@ -26,6 +26,7 @@ import { migrateLeaderboardReports } from './leaderboard-reports.js';
|
||||
import { migratePlayerScoreTotal } from './player-score-total.js';
|
||||
import { migrateBank } from './bank.js';
|
||||
import { migrateAfk } from './afk.js';
|
||||
import { migrateDiscord } from '../discord/schema.js';
|
||||
import { migrateBankCredentials } from './bank-credentials.js';
|
||||
import { migrateTemperatures } from '../hosts/temperatures.js';
|
||||
import {
|
||||
@@ -210,7 +211,8 @@ export const migrations: readonly Migration[] = [
|
||||
name: 'moderation case lifecycle states',
|
||||
mode: 'foreign-keys-off',
|
||||
run: migrateModerationCaseStates
|
||||
}
|
||||
},
|
||||
{ version: 37, name: 'Discord boards and availability subscriptions', run: migrateDiscord }
|
||||
];
|
||||
|
||||
export const databaseVersion = migrations.length;
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { DiscordAdminState } from '../../discord.js';
|
||||
import { fleetIdentity } from '../../fleet-overview.js';
|
||||
import { listServers } from '../fleet/servers.js';
|
||||
import { discordCredentialStatus } from './config.js';
|
||||
import { publicFleet } from './projection.js';
|
||||
import { discordStatus, getDiscordSettings } from './store.js';
|
||||
|
||||
export function discordAdminState(): DiscordAdminState {
|
||||
const settings = getDiscordSettings();
|
||||
const published = publicFleet(settings);
|
||||
return {
|
||||
settings,
|
||||
credentials: discordCredentialStatus(),
|
||||
status: discordStatus(),
|
||||
servers: listServers().flatMap((server) => {
|
||||
const identity = fleetIdentity(server);
|
||||
if (identity.game !== 't5' && identity.game !== 't6') return [];
|
||||
return [
|
||||
{ name: server.name, game: identity.game, label: identity.title, map: identity.map }
|
||||
];
|
||||
}),
|
||||
preview: {
|
||||
t5: published.filter((server) => server.game === 't5'),
|
||||
t6: published.filter((server) => server.game === 't6')
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { leaderboardMetrics } from '../../leaderboard.js';
|
||||
import { zombieMaps } from '../../map-aliases.js';
|
||||
|
||||
const games = [
|
||||
{ name: 'Black Ops I', value: 't5' },
|
||||
{ name: 'Black Ops II', value: 't6' }
|
||||
];
|
||||
export const discordMaps = (['t5', 't6'] as const).flatMap((game) =>
|
||||
zombieMaps(game).map((map) => ({
|
||||
name: `${game === 't5' ? 'BO1' : 'BO2'} · ${map.friendly}`,
|
||||
value: [game, map.canonical, ...(game === 't6' ? [map.slug] : [])].join(':')
|
||||
}))
|
||||
);
|
||||
|
||||
const gameOption = { type: 3, name: 'game', description: 'Game', choices: games };
|
||||
const mapOption = { type: 3, name: 'map', description: 'Map and ruleset', choices: discordMaps };
|
||||
const pageOption = {
|
||||
type: 4,
|
||||
name: 'page',
|
||||
description: 'Page number',
|
||||
min_value: 1,
|
||||
max_value: 100001
|
||||
};
|
||||
|
||||
/** Guild commands only: no Gateway connection or privileged member/content intents. */
|
||||
export const discordCommands = [
|
||||
{
|
||||
name: 'servers',
|
||||
description: 'Browse Electric Cherry servers privately',
|
||||
type: 1,
|
||||
options: [
|
||||
gameOption,
|
||||
mapOption,
|
||||
{
|
||||
type: 3,
|
||||
name: 'status',
|
||||
description: 'Observed occupancy',
|
||||
choices: [
|
||||
{ name: 'All', value: 'all' },
|
||||
{ name: 'Empty', value: 'empty' },
|
||||
{ name: 'Space available', value: 'space' },
|
||||
{ name: 'Full', value: 'full' },
|
||||
{ name: 'In progress', value: 'in-progress' }
|
||||
]
|
||||
},
|
||||
pageOption
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'server',
|
||||
description: 'Show details of a published server',
|
||||
type: 1,
|
||||
options: [
|
||||
{
|
||||
type: 3,
|
||||
name: 'name',
|
||||
description: 'Published server name (or use a Details button)',
|
||||
required: true,
|
||||
max_length: 128
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'leaderboard',
|
||||
description: 'View Electric Cherry completed-game rankings',
|
||||
type: 1,
|
||||
options: [
|
||||
gameOption,
|
||||
mapOption,
|
||||
{
|
||||
type: 3,
|
||||
name: 'metric',
|
||||
description: 'Ranking metric',
|
||||
choices: leaderboardMetrics.map((metric) => ({
|
||||
name: metric.label,
|
||||
value: metric.key
|
||||
}))
|
||||
},
|
||||
{
|
||||
type: 4,
|
||||
name: 'squad',
|
||||
description: 'Number of players at match start',
|
||||
min_value: 1,
|
||||
max_value: 4
|
||||
},
|
||||
pageOption
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'watch',
|
||||
description: 'Receive one private map availability notification',
|
||||
type: 1,
|
||||
options: [
|
||||
{
|
||||
type: 1,
|
||||
name: 'add',
|
||||
description: 'Watch a map for up to 24 hours',
|
||||
options: [
|
||||
{ ...gameOption, required: true },
|
||||
{ ...mapOption, required: true },
|
||||
{
|
||||
type: 3,
|
||||
name: 'condition',
|
||||
description: 'Observed occupancy to watch for',
|
||||
required: true,
|
||||
choices: [
|
||||
{ name: 'An empty lobby', value: 'empty' },
|
||||
{ name: 'A lobby with space available', value: 'space' }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{ type: 1, name: 'list', description: 'List your active watches' },
|
||||
{
|
||||
type: 1,
|
||||
name: 'cancel',
|
||||
description: 'Cancel one of your watches',
|
||||
options: [
|
||||
{
|
||||
type: 3,
|
||||
name: 'id',
|
||||
description: 'Watch ID shown by /watch list',
|
||||
required: true,
|
||||
max_length: 100
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{ name: 'help', description: 'How to use the Electric Cherry companion', type: 1 }
|
||||
];
|
||||
@@ -0,0 +1,30 @@
|
||||
import { discordSnowflake } from '../../discord.js';
|
||||
|
||||
/** Credentials belong to the deployment environment and never to browser settings. */
|
||||
export function discordCredentials() {
|
||||
return {
|
||||
applicationId: (process.env.EC_DISCORD_APPLICATION_ID ?? '').trim(),
|
||||
publicKey: (process.env.EC_DISCORD_PUBLIC_KEY ?? '').trim(),
|
||||
botToken: (process.env.EC_DISCORD_BOT_TOKEN ?? '').trim()
|
||||
};
|
||||
}
|
||||
|
||||
export function discordCredentialStatus() {
|
||||
const credentials = discordCredentials();
|
||||
return {
|
||||
applicationId: credentials.applicationId,
|
||||
publicKeyConfigured: /^[a-fA-F0-9]{64}$/.test(credentials.publicKey),
|
||||
botTokenConfigured: !!credentials.botToken
|
||||
};
|
||||
}
|
||||
|
||||
export function requireDiscordCredentials(): ReturnType<typeof discordCredentials> {
|
||||
const credentials = discordCredentials();
|
||||
if (
|
||||
!discordSnowflake(credentials.applicationId) ||
|
||||
!/^[a-fA-F0-9]{64}$/.test(credentials.publicKey) ||
|
||||
!credentials.botToken
|
||||
)
|
||||
throw new Error('Configure the Discord application ID, public key and bot token first.');
|
||||
return credentials;
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
import {
|
||||
discordSnowflake,
|
||||
type DiscordGame,
|
||||
type DiscordMessage,
|
||||
type DiscordPublicServer
|
||||
} from '../../discord.js';
|
||||
import {
|
||||
leaderboardMetrics,
|
||||
type LeaderboardMetric,
|
||||
type LeaderboardOptions,
|
||||
type LeaderboardPlayerCount
|
||||
} from '../../leaderboard.js';
|
||||
import { leaderboardReport } from '../reports/index.js';
|
||||
import { discordCredentials } from './config.js';
|
||||
import { discordMaps } from './commands.js';
|
||||
import { discordServerKey, discordText, publicFleet } from './projection.js';
|
||||
import {
|
||||
cancelSubscription,
|
||||
getDiscordSettings,
|
||||
listSubscriptions,
|
||||
subscribeToMap
|
||||
} from './store.js';
|
||||
import {
|
||||
LEADERBOARD_PAGE_SIZE,
|
||||
privateMessage,
|
||||
renderBrowser,
|
||||
renderLeaderboard,
|
||||
renderServer,
|
||||
renderWatchChoice,
|
||||
renderWatchMaps,
|
||||
type BrowserFilter,
|
||||
type BrowserStatus
|
||||
} from './render.js';
|
||||
|
||||
interface DiscordOption {
|
||||
type?: number;
|
||||
name: string;
|
||||
value?: string | number;
|
||||
options?: DiscordOption[];
|
||||
}
|
||||
export interface DiscordInteraction {
|
||||
type: number;
|
||||
application_id?: string;
|
||||
guild_id?: string;
|
||||
member?: { user?: { id?: string } };
|
||||
data?: { name?: string; options?: DiscordOption[]; custom_id?: string; values?: string[] };
|
||||
}
|
||||
export interface DiscordInteractionResponse {
|
||||
type: 4;
|
||||
data: DiscordMessage;
|
||||
}
|
||||
class InputError extends Error {}
|
||||
const response = (data: DiscordMessage): DiscordInteractionResponse => ({ type: 4, data });
|
||||
export const privateDiscordError = (message: string) =>
|
||||
response(privateMessage(discordText(message, 400)));
|
||||
const statuses: BrowserStatus[] = ['all', 'empty', 'space', 'full', 'in-progress'];
|
||||
|
||||
function optionValues(options: DiscordOption[] | undefined): Map<string, string | number> {
|
||||
if (options !== undefined && (!Array.isArray(options) || options.length > 25))
|
||||
throw new InputError('Invalid command options.');
|
||||
const values = new Map<string, string | number>();
|
||||
for (const option of options ?? []) {
|
||||
if (
|
||||
!option ||
|
||||
typeof option.name !== 'string' ||
|
||||
values.has(option.name) ||
|
||||
!['string', 'number'].includes(typeof option.value)
|
||||
)
|
||||
throw new InputError('Invalid command options.');
|
||||
values.set(option.name, option.value!);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function stringOption(
|
||||
values: Map<string, string | number>,
|
||||
key: string,
|
||||
limit = 128
|
||||
): string | undefined {
|
||||
const value = values.get(key);
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value !== 'string' || !value.trim() || value.length > limit)
|
||||
throw new InputError(`Invalid ${key} option.`);
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function scope(values: Map<string, string | number>): { game?: DiscordGame; map?: string } {
|
||||
const game = stringOption(values, 'game');
|
||||
const map = stringOption(values, 'map');
|
||||
if (game && game !== 't5' && game !== 't6')
|
||||
throw new InputError('Choose Black Ops I or Black Ops II.');
|
||||
if (map && !discordMaps.some((entry) => entry.value === map))
|
||||
throw new InputError('Choose a map from the command options.');
|
||||
if (game && map && !map.startsWith(`${game}:`))
|
||||
throw new InputError('Choose a map from the selected game.');
|
||||
return { game: (game ?? map?.slice(0, 2)) as DiscordGame | undefined, map };
|
||||
}
|
||||
|
||||
function page(values: Map<string, string | number>): number {
|
||||
const value = values.get('page') ?? 1;
|
||||
if (!Number.isSafeInteger(value) || Number(value) < 1 || Number(value) > 100001)
|
||||
throw new InputError('Choose a page between 1 and 100001.');
|
||||
return Number(value) - 1;
|
||||
}
|
||||
|
||||
function watchMap(
|
||||
servers: DiscordPublicServer[],
|
||||
key: string,
|
||||
game?: DiscordGame
|
||||
): DiscordPublicServer {
|
||||
const found = servers.find(
|
||||
(server) =>
|
||||
(server.mapKey === key || discordServerKey(server.mapKey) === key) &&
|
||||
(!game || server.game === game)
|
||||
);
|
||||
if (!found)
|
||||
throw new InputError('That map is no longer published. Browse the current server list.');
|
||||
if (!/^[A-Za-z0-9_.:-]{1,100}$/.test(found.mapKey))
|
||||
throw new InputError('A map watch is unavailable for this reported map.');
|
||||
return found;
|
||||
}
|
||||
|
||||
async function leaderboard(values: Map<string, string | number>): Promise<DiscordMessage> {
|
||||
const settings = getDiscordSettings();
|
||||
if (!settings.leaderboardEnabled)
|
||||
return privateMessage('Leaderboard publication is not enabled for this Discord server.');
|
||||
const metric = stringOption(values, 'metric') ?? 'highestRound';
|
||||
if (!leaderboardMetrics.some((entry) => entry.key === metric))
|
||||
throw new InputError('Choose a ranking metric from the command options.');
|
||||
const squad = values.get('squad');
|
||||
if (
|
||||
squad !== undefined &&
|
||||
(!Number.isSafeInteger(squad) || Number(squad) < 1 || Number(squad) > 4)
|
||||
)
|
||||
throw new InputError('Squad size must be between 1 and 4.');
|
||||
const query: LeaderboardOptions = {
|
||||
...scope(values),
|
||||
metric: metric as LeaderboardMetric,
|
||||
players: squad as LeaderboardPlayerCount | undefined,
|
||||
limit: LEADERBOARD_PAGE_SIZE,
|
||||
offset: page(values) * LEADERBOARD_PAGE_SIZE
|
||||
};
|
||||
const report = await leaderboardReport(query);
|
||||
// Publishing may have been disabled while the report worker was running.
|
||||
const current = getDiscordSettings();
|
||||
if (!current.enabled || current.guildId !== settings.guildId || !current.leaderboardEnabled)
|
||||
return privateMessage('Leaderboard publication is no longer enabled.');
|
||||
return renderLeaderboard(report, query);
|
||||
}
|
||||
|
||||
function addWatch(
|
||||
servers: DiscordPublicServer[],
|
||||
key: string,
|
||||
mode: string | undefined,
|
||||
guildId: string,
|
||||
userId: string,
|
||||
game?: DiscordGame
|
||||
): DiscordMessage {
|
||||
if (mode !== 'empty' && mode !== 'space')
|
||||
throw new InputError('Choose an empty-lobby or space-available watch.');
|
||||
const server = watchMap(servers, key, game);
|
||||
let watch: { id: string; expiresAt: number };
|
||||
try {
|
||||
watch = subscribeToMap({ guildId, userId, game: server.game, mapKey: server.mapKey, mode });
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
(error.message.startsWith('You already have 10') ||
|
||||
error.message.startsWith('Map watches are currently full'))
|
||||
)
|
||||
throw new InputError(error.message);
|
||||
throw error;
|
||||
}
|
||||
return privateMessage(
|
||||
`Watching **${discordText(server.map)}** for ${mode === 'empty' ? 'an empty lobby' : 'space available'}.\nYou will receive one DM after stable observations. Enable DMs from this server to receive it. This does not guarantee match admission.\nExpires <t:${Math.floor(watch.expiresAt / 1000)}:R> · Cancel with /watch cancel\nWatch ID: ${discordText(watch.id)}`
|
||||
);
|
||||
}
|
||||
|
||||
function parseBrowserId(id: string): BrowserFilter {
|
||||
const match =
|
||||
/^ec:b:(all|t5|t6):(all|empty|space|full|in-progress):(\d{1,6}):([A-Za-z0-9_-]{0,70})(?::(?:prev|next))?$/.exec(
|
||||
id
|
||||
);
|
||||
if (!match) throw new InputError('This browser control is invalid. Run /servers again.');
|
||||
const map = Buffer.from(match[4], 'base64url').toString();
|
||||
if (map && !discordMaps.some((entry) => entry.value === map))
|
||||
throw new InputError('This map filter is unavailable. Run /servers again.');
|
||||
return {
|
||||
game: match[1] === 'all' ? undefined : (match[1] as DiscordGame),
|
||||
status: match[2] as BrowserStatus,
|
||||
page: Number(match[3]),
|
||||
map: map || undefined
|
||||
};
|
||||
}
|
||||
|
||||
/** All responses are private new messages; components never mutate the shared board. */
|
||||
export async function handleDiscordInteraction(
|
||||
interaction: DiscordInteraction
|
||||
): Promise<DiscordInteractionResponse> {
|
||||
const userId = interaction.member?.user?.id;
|
||||
if (
|
||||
!discordSnowflake(userId) ||
|
||||
!discordSnowflake(interaction.guild_id) ||
|
||||
interaction.application_id !== discordCredentials().applicationId
|
||||
)
|
||||
return privateDiscordError('This interaction is not available here.');
|
||||
const settings = getDiscordSettings();
|
||||
if (!settings.enabled || settings.guildId !== interaction.guild_id)
|
||||
return privateDiscordError('Electric Cherry is not enabled for this Discord server.');
|
||||
if (interaction.type !== 2 && interaction.type !== 3)
|
||||
return privateDiscordError('This interaction is not supported.');
|
||||
const data = interaction.data;
|
||||
if (!data || typeof data !== 'object')
|
||||
return privateDiscordError('This interaction is invalid.');
|
||||
try {
|
||||
if (interaction.type === 2 && data.name === 'leaderboard')
|
||||
return response(await leaderboard(optionValues(data.options)));
|
||||
if (interaction.type === 2 && data.name === 'help')
|
||||
return response(
|
||||
privateMessage(
|
||||
'## 🍒 Electric Cherry\n/servers — browse live servers privately\n/server — inspect a published server\n/leaderboard — completed-game rankings, when enabled\n/watch add — one DM when a map has an empty lobby or space\n/watch list · /watch cancel — manage your watches\n\nWatches expire after 24 hours. Observed spaces do not guarantee that a match accepts new players.'
|
||||
)
|
||||
);
|
||||
const servers = publicFleet(settings);
|
||||
if (interaction.type === 3) {
|
||||
const id = data.custom_id;
|
||||
if (typeof id !== 'string' || id.length > 100)
|
||||
throw new InputError('This control is invalid.');
|
||||
if (id.startsWith('ec:b:')) return response(renderBrowser(servers, parseBrowserId(id)));
|
||||
if (/^ec:d:[a-f0-9]{20}$/.test(id)) {
|
||||
const server = servers.find(
|
||||
(entry) => discordServerKey(entry.name) === id.slice(5)
|
||||
);
|
||||
if (!server) throw new InputError('This server is no longer published.');
|
||||
return response(renderServer(server));
|
||||
}
|
||||
if (/^ec:maps:(t5|t6)$/.test(id))
|
||||
return response(renderWatchMaps(servers, id.slice(8) as DiscordGame));
|
||||
if (
|
||||
id === 'ec:map' &&
|
||||
Array.isArray(data.values) &&
|
||||
data.values.length === 1 &&
|
||||
typeof data.values[0] === 'string'
|
||||
)
|
||||
return response(renderWatchChoice(watchMap(servers, data.values[0])));
|
||||
const watch = /^ec:w:(empty|space):([a-f0-9]{20})$/.exec(id);
|
||||
if (watch)
|
||||
return response(addWatch(servers, watch[2], watch[1], settings.guildId, userId));
|
||||
if (/^ec:leader:(t5|t6)$/.test(id))
|
||||
return response(await leaderboard(new Map([['game', id.slice(10)]])));
|
||||
throw new InputError(
|
||||
'This control is no longer available. Run /help for current commands.'
|
||||
);
|
||||
}
|
||||
if (data.name === 'watch') {
|
||||
if (!Array.isArray(data.options) || data.options.length !== 1)
|
||||
throw new InputError('Choose watch add, list, or cancel.');
|
||||
const command = data.options[0];
|
||||
const values = optionValues(command.options);
|
||||
if (command.name === 'add') {
|
||||
const selected = scope(values);
|
||||
if (!selected.game || !selected.map) throw new InputError('Choose a game and map.');
|
||||
return response(
|
||||
addWatch(
|
||||
servers,
|
||||
selected.map,
|
||||
stringOption(values, 'condition'),
|
||||
settings.guildId,
|
||||
userId,
|
||||
selected.game
|
||||
)
|
||||
);
|
||||
}
|
||||
if (command.name === 'cancel') {
|
||||
const id = stringOption(values, 'id', 100);
|
||||
if (!id) throw new InputError('Provide the watch ID shown by /watch list.');
|
||||
return response(
|
||||
privateMessage(
|
||||
cancelSubscription(id, settings.guildId, userId)
|
||||
? 'Watch cancelled.'
|
||||
: 'No active watch with that ID belongs to you.'
|
||||
)
|
||||
);
|
||||
}
|
||||
if (command.name === 'list') {
|
||||
const watches = listSubscriptions(userId, settings.guildId);
|
||||
return response(
|
||||
privateMessage(
|
||||
`## Your map watches\n${watches.length ? watches.map((watch) => `**${discordText(servers.find((server) => server.mapKey === watch.mapKey)?.map ?? discordMaps.find((map) => map.value === watch.mapKey)?.name ?? 'Unpublished map', 60)}** · ${watch.mode === 'empty' ? 'Empty lobby' : 'Space available'} · Expires <t:${Math.floor(watch.expiresAt / 1000)}:R>\nID: ${discordText(watch.id)}`).join('\n\n') : 'No active watches. Use /watch add to create one.'}`
|
||||
)
|
||||
);
|
||||
}
|
||||
throw new InputError('Choose watch add, list, or cancel.');
|
||||
}
|
||||
const values = optionValues(data.options);
|
||||
if (data.name === 'servers') {
|
||||
const status = stringOption(values, 'status') ?? 'all';
|
||||
if (!statuses.includes(status as BrowserStatus))
|
||||
throw new InputError('Choose an occupancy filter from the command options.');
|
||||
return response(
|
||||
renderBrowser(servers, {
|
||||
...scope(values),
|
||||
status: status as BrowserStatus,
|
||||
page: page(values)
|
||||
})
|
||||
);
|
||||
}
|
||||
if (data.name === 'server') {
|
||||
const name = stringOption(values, 'name');
|
||||
const server = servers.find((entry) => entry.name === name || entry.label === name);
|
||||
if (!server)
|
||||
throw new InputError(
|
||||
'That server is not published. Use /servers and click Details.'
|
||||
);
|
||||
return response(renderServer(server));
|
||||
}
|
||||
throw new InputError('This command is not supported. Run /help for available commands.');
|
||||
} catch (error) {
|
||||
if (error instanceof InputError) return privateDiscordError(error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { DiscordPublicServer, DiscordSettings } from '../../discord.js';
|
||||
import {
|
||||
currentRound,
|
||||
fleetIdentity,
|
||||
freshServer,
|
||||
type FleetServer
|
||||
} from '../../fleet-overview.js';
|
||||
import { currentCapacity } from '../../server-capacity.js';
|
||||
import { parseServerName, selectedZombieMap, zombieMaps } from '../../map-aliases.js';
|
||||
import { fleetSnapshot } from '../fleet/snapshot.js';
|
||||
|
||||
type SourceServer = FleetServer & {
|
||||
network?: { publicAddress: string | null; publicPort: number | null } | null;
|
||||
};
|
||||
|
||||
/** IDs stay short enough for Discord components, even for long registry names. */
|
||||
export function discordServerKey(name: string): string {
|
||||
return createHash('sha256').update(name).digest('hex').slice(0, 20);
|
||||
}
|
||||
|
||||
/** Rendering never interprets a game nickname as Markdown, a link, or a mention. */
|
||||
export function discordText(value: string, limit = 160): string {
|
||||
return (
|
||||
value
|
||||
.replace(/[\r\n\t]/g, ' ')
|
||||
// eslint-disable-next-line no-control-regex -- Strip untrusted game-name control characters.
|
||||
.replace(/[\u0000-\u001f\u007f\u202a-\u202e\u2066-\u2069]/g, '')
|
||||
.slice(0, limit)
|
||||
.replace(/@/g, '@\u200b')
|
||||
.replace(/</g, '‹')
|
||||
.replace(/>/g, '›')
|
||||
.replace(/([\\`*_{}[\]()~|])/g, '\\$1')
|
||||
);
|
||||
}
|
||||
|
||||
function mapIdentity(server: SourceServer, game: 't5' | 't6') {
|
||||
const parsed = parseServerName(server.name);
|
||||
const map = server.last_map ?? server.map ?? parsed?.mapCanonical ?? 'unknown';
|
||||
const slug = server.map === map ? (server.map_slug ?? parsed?.mapSlug) : undefined;
|
||||
const candidates = zombieMaps(game).filter((entry) => entry.canonical === map);
|
||||
const selected =
|
||||
game === 't6' && candidates.length > 1 && !slug
|
||||
? undefined
|
||||
: selectedZombieMap(game, map, slug);
|
||||
return {
|
||||
map: selected?.friendly ?? fleetIdentity(server).map ?? 'Unknown map',
|
||||
mapKey: [
|
||||
game,
|
||||
map,
|
||||
...(game === 't6' ? [selected?.slug ?? slug ?? 'unspecified'] : [])
|
||||
].join(':')
|
||||
};
|
||||
}
|
||||
|
||||
function publicConnection(server: SourceServer): string | null {
|
||||
const address = server.network?.publicAddress?.trim();
|
||||
const port = server.network?.publicPort;
|
||||
// Never fall back to agent, RCON, bind, or inferred host addresses.
|
||||
if (!address || !/^[A-Za-z0-9.:[\]-]{1,253}$/.test(address)) return null;
|
||||
if (!Number.isSafeInteger(port) || port! < 1 || port! > 65535) return null;
|
||||
const host = address.includes(':') && !address.startsWith('[') ? `[${address}]` : address;
|
||||
return `${host}:${port}`;
|
||||
}
|
||||
|
||||
/** Explicit field projection is the boundary between private fleet data and Discord. */
|
||||
export function projectFleet(
|
||||
servers: readonly SourceServer[],
|
||||
settings: DiscordSettings,
|
||||
now = Date.now()
|
||||
): DiscordPublicServer[] {
|
||||
const published = new Set(settings.publishedServers);
|
||||
return servers
|
||||
.flatMap((server): DiscordPublicServer[] => {
|
||||
if (!published.has(server.name)) return [];
|
||||
const identity = fleetIdentity(server);
|
||||
if (identity.game !== 't5' && identity.game !== 't6') return [];
|
||||
const game = identity.game;
|
||||
const fresh = freshServer(server, now);
|
||||
const players =
|
||||
fresh && Number.isSafeInteger(server.last_players) && server.last_players! >= 0
|
||||
? server.last_players
|
||||
: null;
|
||||
const capacity = players !== null ? currentCapacity(server, now) : null;
|
||||
const state: DiscordPublicServer['state'] =
|
||||
server.health === 'starting' || server.health === 'provisioning'
|
||||
? 'starting'
|
||||
: server.health === 'offline'
|
||||
? 'offline'
|
||||
: server.health === 'stale' || (server.health === 'online' && !fresh)
|
||||
? 'stale'
|
||||
: players === null
|
||||
? 'unknown'
|
||||
: players === 0
|
||||
? 'empty'
|
||||
: capacity !== null && players >= capacity
|
||||
? 'full'
|
||||
: 'in-progress';
|
||||
const observed = Date.parse(server.last_seen_at ?? '');
|
||||
return [
|
||||
{
|
||||
name: server.name,
|
||||
game,
|
||||
label: identity.title,
|
||||
...mapIdentity(server, game),
|
||||
state,
|
||||
players,
|
||||
capacity,
|
||||
round: players === null ? null : currentRound(server, server.telemetry, now),
|
||||
observedAt:
|
||||
Number.isFinite(observed) && observed <= now + 1000
|
||||
? new Date(observed).toISOString()
|
||||
: null,
|
||||
playerNames:
|
||||
settings.showPlayers && fresh && players !== null
|
||||
? (server.roster ?? []).slice(0, players).map((player) => player.name)
|
||||
: [],
|
||||
connection: settings.showAddresses ? publicConnection(server) : null
|
||||
}
|
||||
];
|
||||
})
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.game.localeCompare(b.game) ||
|
||||
a.label.localeCompare(b.label) ||
|
||||
a.name.localeCompare(b.name)
|
||||
);
|
||||
}
|
||||
|
||||
export function publicFleet(settings: DiscordSettings, now = Date.now()): DiscordPublicServer[] {
|
||||
return projectFleet(fleetSnapshot().servers, settings, now);
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import type { DiscordGame, DiscordMessage, DiscordPublicServer } from '../../discord.js';
|
||||
import type { LeaderboardOptions, LeaderboardResponse } from '../../leaderboard.js';
|
||||
import { leaderboardMetrics } from '../../leaderboard.js';
|
||||
import {
|
||||
efficiencyRequirement,
|
||||
formatEfficiencyValue,
|
||||
isEfficiencyMetric
|
||||
} from '../../leaderboard-efficiency.js';
|
||||
import { MIN_RECORDED_ROUND } from '../../match-history.js';
|
||||
import { gameTitle } from '../../ui/server-labels.js';
|
||||
import { discordServerKey, discordText } from './projection.js';
|
||||
|
||||
export const BOARD_PAGE_SIZE = 8;
|
||||
export const BROWSER_PAGE_SIZE = 5;
|
||||
export const LEADERBOARD_PAGE_SIZE = 10;
|
||||
const COMPONENTS_V2 = 1 << 15;
|
||||
export const EPHEMERAL = 1 << 6;
|
||||
type Component = Record<string, unknown>;
|
||||
export type BrowserStatus = 'all' | 'empty' | 'space' | 'full' | 'in-progress';
|
||||
export interface BrowserFilter {
|
||||
game?: DiscordGame;
|
||||
map?: string;
|
||||
status?: BrowserStatus;
|
||||
page?: number;
|
||||
}
|
||||
|
||||
const text = (content: string): Component => ({ type: 10, content });
|
||||
const button = (label: string, custom_id: string, disabled = false): Component => ({
|
||||
type: 2,
|
||||
style: 2,
|
||||
label,
|
||||
custom_id,
|
||||
disabled
|
||||
});
|
||||
const row = (...components: Component[]): Component => ({ type: 1, components });
|
||||
const container = (components: Component[]): Component => ({
|
||||
type: 17,
|
||||
accent_color: 0xd94868,
|
||||
components
|
||||
});
|
||||
|
||||
export function discordMessage(components: Component[], privateReply = true): DiscordMessage {
|
||||
return {
|
||||
flags: COMPONENTS_V2 | (privateReply ? EPHEMERAL : 0),
|
||||
components,
|
||||
allowed_mentions: { parse: [] }
|
||||
};
|
||||
}
|
||||
|
||||
export function privateMessage(message: string): DiscordMessage {
|
||||
return discordMessage([text(message)]);
|
||||
}
|
||||
|
||||
const states: Record<DiscordPublicServer['state'], string> = {
|
||||
empty: '🟢 Empty lobby',
|
||||
'in-progress': '🔵 In progress',
|
||||
full: '🟠 Full',
|
||||
starting: '🟡 Starting',
|
||||
offline: '⚫ Offline',
|
||||
stale: '🟡 Stale observation',
|
||||
unknown: '⚪ Status unknown'
|
||||
};
|
||||
|
||||
function summary(server: DiscordPublicServer): string {
|
||||
const count =
|
||||
server.players === null
|
||||
? 'Players unknown'
|
||||
: `${server.players}/${server.capacity ?? '?'} players`;
|
||||
return `${count}${server.round === null ? '' : ` · Round ${server.round}`}\n${states[server.state]}`;
|
||||
}
|
||||
|
||||
function observed(server: DiscordPublicServer): string {
|
||||
return server.observedAt
|
||||
? `Observed <t:${Math.floor(Date.parse(server.observedAt) / 1000)}:R>`
|
||||
: 'No observation available';
|
||||
}
|
||||
|
||||
function serverSection(server: DiscordPublicServer): Component {
|
||||
const map = server.label.startsWith(server.map) ? '' : `Map: ${discordText(server.map, 50)}\n`;
|
||||
return {
|
||||
type: 9,
|
||||
components: [
|
||||
text(
|
||||
`**${discordText(server.label, 90)}**\n${map}${summary(server)}\n-# ${observed(server)}`
|
||||
)
|
||||
],
|
||||
accessory: button('Details', `ec:d:${discordServerKey(server.name)}`)
|
||||
};
|
||||
}
|
||||
|
||||
/** Each page has <= 32 total components and comfortably fits Discord's 4,000 text limit. */
|
||||
export function renderBoard(
|
||||
game: DiscordGame,
|
||||
servers: readonly DiscordPublicServer[]
|
||||
): DiscordMessage[] {
|
||||
const selected = servers.filter((server) => server.game === game);
|
||||
const pages = Math.max(1, Math.ceil(selected.length / BOARD_PAGE_SIZE));
|
||||
return Array.from({ length: pages }, (_, page) => {
|
||||
const players = selected.reduce((sum, server) => sum + (server.players ?? 0), 0);
|
||||
const sections = selected
|
||||
.slice(page * BOARD_PAGE_SIZE, (page + 1) * BOARD_PAGE_SIZE)
|
||||
.map(serverSection);
|
||||
return discordMessage(
|
||||
[
|
||||
container([
|
||||
text(
|
||||
`## 🍒 Electric Cherry · ${gameTitle(game)}\n${selected.length} servers · ${players} observed players${pages > 1 ? ` · Page ${page + 1}/${pages}` : ''}`
|
||||
),
|
||||
...(sections.length
|
||||
? sections
|
||||
: [text('No servers are published for this game yet.')]),
|
||||
text(
|
||||
'-# Available spaces do not establish whether an in-progress match accepts players.'
|
||||
),
|
||||
row(
|
||||
button('Browse servers', browserId({ game })),
|
||||
button('Watch a map', `ec:maps:${game}`),
|
||||
button('Leaderboards', `ec:leader:${game}`)
|
||||
)
|
||||
])
|
||||
],
|
||||
false
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function browserId(filter: BrowserFilter): string {
|
||||
return `ec:b:${filter.game ?? 'all'}:${filter.status ?? 'all'}:${filter.page ?? 0}:${Buffer.from(filter.map ?? '').toString('base64url')}`;
|
||||
}
|
||||
|
||||
export function filterServers(
|
||||
servers: readonly DiscordPublicServer[],
|
||||
filter: BrowserFilter
|
||||
): DiscordPublicServer[] {
|
||||
return servers.filter(
|
||||
(server) =>
|
||||
(!filter.game || server.game === filter.game) &&
|
||||
(!filter.map || server.mapKey === filter.map) &&
|
||||
(!filter.status ||
|
||||
filter.status === 'all' ||
|
||||
(filter.status === 'space'
|
||||
? server.players !== null &&
|
||||
server.capacity !== null &&
|
||||
server.players < server.capacity
|
||||
: server.state === filter.status))
|
||||
);
|
||||
}
|
||||
|
||||
export function renderBrowser(
|
||||
servers: readonly DiscordPublicServer[],
|
||||
filter: BrowserFilter = {}
|
||||
): DiscordMessage {
|
||||
const selected = filterServers(servers, filter);
|
||||
const pages = Math.max(1, Math.ceil(selected.length / BROWSER_PAGE_SIZE));
|
||||
const page = Math.max(0, Math.min(filter.page ?? 0, pages - 1));
|
||||
const current = { ...filter, page };
|
||||
return discordMessage([
|
||||
container([
|
||||
text(
|
||||
`## Browse servers\n${filter.game ? gameTitle(filter.game) : 'All games'} · ${selected.length} matching servers · Page ${page + 1}/${pages}`
|
||||
),
|
||||
...selected
|
||||
.slice(page * BROWSER_PAGE_SIZE, (page + 1) * BROWSER_PAGE_SIZE)
|
||||
.map(serverSection),
|
||||
...(selected.length ? [] : [text('No servers match these filters.')]),
|
||||
row(
|
||||
button('All', browserId({ ...current, status: 'all', page: 0 })),
|
||||
button('Empty', browserId({ ...current, status: 'empty', page: 0 })),
|
||||
button('Space available', browserId({ ...current, status: 'space', page: 0 })),
|
||||
button('Full', browserId({ ...current, status: 'full', page: 0 }))
|
||||
),
|
||||
row(
|
||||
button(
|
||||
'Previous',
|
||||
`${browserId({ ...current, page: Math.max(0, page - 1) })}:prev`,
|
||||
page === 0
|
||||
),
|
||||
button(
|
||||
'Next',
|
||||
`${browserId({ ...current, page: page + 1 })}:next`,
|
||||
page + 1 >= pages
|
||||
)
|
||||
),
|
||||
text(
|
||||
'-# Your filters are private. Use /servers for game and map filters. Space available describes occupancy only.'
|
||||
)
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
export function renderServer(server: DiscordPublicServer): DiscordMessage {
|
||||
return discordMessage([
|
||||
container([
|
||||
text(
|
||||
`## ${discordText(server.label, 100)}\n${gameTitle(server.game)} · ${discordText(server.map, 100)}\n${summary(server)}\n${observed(server)}`
|
||||
),
|
||||
...(server.playerNames.length
|
||||
? [
|
||||
text(
|
||||
`**Players**\n${server.playerNames
|
||||
.slice(0, 16)
|
||||
.map((name) => discordText(name, 60))
|
||||
.join(' · ')}`
|
||||
)
|
||||
]
|
||||
: []),
|
||||
text(
|
||||
`**Connection**\n${server.connection ? `Public endpoint: ${discordText(server.connection, 270)}\n` : ''}${server.game === 't5' ? 'Open the Black Ops I Plutonium server browser and find this lobby.' : server.connection ? 'Open the Black Ops II Plutonium console and enter connect followed by the public endpoint above.' : 'Open the Black Ops II Plutonium server browser and find this lobby.'}\nMatch admission is not reported by Electric Cherry.`
|
||||
),
|
||||
row(
|
||||
button('Watch for empty', `ec:w:empty:${discordServerKey(server.mapKey)}`),
|
||||
button('Watch for space', `ec:w:space:${discordServerKey(server.mapKey)}`),
|
||||
button('Browse servers', browserId({ game: server.game }))
|
||||
)
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
export function renderWatchMaps(
|
||||
servers: readonly DiscordPublicServer[],
|
||||
game?: DiscordGame
|
||||
): DiscordMessage {
|
||||
const maps = [
|
||||
...new Map(
|
||||
servers
|
||||
.filter((server) => !game || server.game === game)
|
||||
.map((server) => [server.mapKey, server])
|
||||
).values()
|
||||
];
|
||||
if (!maps.length) return privateMessage('No published maps are available to watch.');
|
||||
return discordMessage([
|
||||
text(
|
||||
'## Watch a map\nChoose a map, then choose empty or space available. Watches send one DM, expire after 24 hours, and can be cancelled with /watch.'
|
||||
),
|
||||
row({
|
||||
type: 3,
|
||||
custom_id: 'ec:map',
|
||||
placeholder: 'Choose a published map',
|
||||
options: maps.slice(0, 25).map((server) => ({
|
||||
label: `${gameTitle(server.game, true)} · ${discordText(server.map, 40)}`.slice(
|
||||
0,
|
||||
100
|
||||
),
|
||||
value: discordServerKey(server.mapKey)
|
||||
}))
|
||||
})
|
||||
]);
|
||||
}
|
||||
|
||||
export function renderWatchChoice(server: DiscordPublicServer): DiscordMessage {
|
||||
return discordMessage([
|
||||
text(
|
||||
`## Watch ${discordText(server.map)}\nEmpty means zero observed players. Space means fewer players than the current observed capacity. Neither guarantees that a match accepts new players.\nOne DM · Expires in 24 hours`
|
||||
),
|
||||
row(
|
||||
button('Empty lobby', `ec:w:empty:${discordServerKey(server.mapKey)}`),
|
||||
button('Space available', `ec:w:space:${discordServerKey(server.mapKey)}`)
|
||||
)
|
||||
]);
|
||||
}
|
||||
|
||||
export function renderLeaderboard(
|
||||
report: LeaderboardResponse,
|
||||
query: LeaderboardOptions
|
||||
): DiscordMessage {
|
||||
const metric = report.metric;
|
||||
const label = leaderboardMetrics.find((item) => item.key === metric)!.label;
|
||||
const map = report.maps.find((item) => item.key === query.map)?.name;
|
||||
const items = report.entries.map((entry) => {
|
||||
const value = isEfficiencyMetric(metric)
|
||||
? formatEfficiencyValue(entry[metric], metric)
|
||||
: entry[metric].toLocaleString('en-US');
|
||||
return `**${entry.rank}.** ${discordText(entry.name, 60)} — **${value}**`;
|
||||
});
|
||||
return discordMessage([
|
||||
container([
|
||||
text(
|
||||
`## ${label}\n${query.game ? gameTitle(query.game) : 'All games'}${map ? ` · ${discordText(map)}` : ''}${query.players ? ` · ${query.players}-player starts` : ''}\n${items.length ? items.join('\n') : 'No qualifying records in this scope.'}`
|
||||
),
|
||||
text(
|
||||
`-# Completed matches with player records reaching round ${MIN_RECORDED_ROUND} or later.${isEfficiencyMetric(metric) ? ` ${efficiencyRequirement(metric)}.` : ''}\n-# Page ${Math.floor(report.offset / LEADERBOARD_PAGE_SIZE) + 1} · ${report.total} ranked players. Use /leaderboard to change filters or page.`
|
||||
)
|
||||
])
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
export function migrateDiscord(handle: Database.Database): void {
|
||||
handle.exec(`
|
||||
CREATE TABLE IF NOT EXISTS discord_configuration (
|
||||
id INTEGER PRIMARY KEY CHECK (id=1), value TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS discord_health (
|
||||
id INTEGER PRIMARY KEY CHECK (id=1), last_attempt_at INTEGER,
|
||||
last_success_at INTEGER, last_error TEXT
|
||||
);
|
||||
INSERT OR IGNORE INTO discord_health(id) VALUES(1);
|
||||
CREATE TABLE IF NOT EXISTS discord_boards (
|
||||
id TEXT PRIMARY KEY, game TEXT NOT NULL CHECK(game IN ('t5','t6')),
|
||||
guild_id TEXT NOT NULL, channel_id TEXT NOT NULL, page INTEGER NOT NULL, message_id TEXT,
|
||||
content_hash TEXT, verified_at INTEGER NOT NULL DEFAULT 0, generation INTEGER NOT NULL DEFAULT 0,
|
||||
UNIQUE(game,guild_id,channel_id,page)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS discord_subscriptions (
|
||||
id TEXT PRIMARY KEY, guild_id TEXT NOT NULL, user_id TEXT NOT NULL,
|
||||
game TEXT NOT NULL CHECK(game IN ('t5','t6')), map_key TEXT NOT NULL,
|
||||
mode TEXT NOT NULL CHECK(mode IN ('empty','space')),
|
||||
state TEXT NOT NULL CHECK(state IN ('active','queued','delivered','failed','cancelled','expired')),
|
||||
created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL,
|
||||
candidate TEXT, candidate_at INTEGER, error TEXT
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS discord_subscription_active
|
||||
ON discord_subscriptions(guild_id,user_id,game,map_key,mode)
|
||||
WHERE state IN ('active','queued');
|
||||
CREATE INDEX IF NOT EXISTS discord_subscription_expiry ON discord_subscriptions(expires_at);
|
||||
CREATE TABLE IF NOT EXISTS discord_outbox (
|
||||
id TEXT PRIMARY KEY, kind TEXT NOT NULL CHECK(kind IN ('board','delete','alert')),
|
||||
target_id TEXT NOT NULL UNIQUE, payload TEXT NOT NULL, content_hash TEXT,
|
||||
channel_id TEXT, attempts INTEGER NOT NULL DEFAULT 0,
|
||||
next_at INTEGER, created_at INTEGER NOT NULL, error TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS discord_outbox_due ON discord_outbox(next_at);
|
||||
CREATE TABLE IF NOT EXISTS discord_interactions (
|
||||
id TEXT PRIMARY KEY, expires_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS discord_interactions_expiry ON discord_interactions(expires_at);
|
||||
`);
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import type Database from 'better-sqlite3';
|
||||
import {
|
||||
defaultDiscordSettings,
|
||||
discordSnowflake,
|
||||
validateDiscordSettings,
|
||||
type DiscordDeliveryStatus,
|
||||
type DiscordGame,
|
||||
type DiscordMessage,
|
||||
type DiscordSettings
|
||||
} from '../../discord.js';
|
||||
import { db } from '../db/index.js';
|
||||
|
||||
export interface DiscordSubscription {
|
||||
id: string;
|
||||
guildId: string;
|
||||
userId: string;
|
||||
game: DiscordGame;
|
||||
mapKey: string;
|
||||
mode: 'empty' | 'space';
|
||||
state: 'active' | 'queued' | 'delivered' | 'failed' | 'cancelled' | 'expired';
|
||||
expiresAt: number;
|
||||
candidate: string | null;
|
||||
candidateAt: number | null;
|
||||
error: string | null;
|
||||
}
|
||||
export interface DiscordBoard {
|
||||
id: string;
|
||||
game: DiscordGame;
|
||||
guildId: string;
|
||||
channelId: string;
|
||||
page: number;
|
||||
messageId: string | null;
|
||||
contentHash: string | null;
|
||||
verifiedAt: number;
|
||||
generation: number;
|
||||
}
|
||||
export interface DiscordJob {
|
||||
id: string;
|
||||
kind: 'board' | 'delete' | 'alert';
|
||||
targetId: string;
|
||||
payload: DiscordMessage;
|
||||
contentHash: string | null;
|
||||
channelId: string | null;
|
||||
attempts: number;
|
||||
createdAt: number;
|
||||
}
|
||||
export interface MapSubscriptionInput {
|
||||
guildId: string;
|
||||
userId: string;
|
||||
game: DiscordGame;
|
||||
mapKey: string;
|
||||
mode: 'empty' | 'space';
|
||||
}
|
||||
const subscriptionColumns = `id,guild_id AS guildId,user_id AS userId,game,map_key AS mapKey,
|
||||
mode,state,expires_at AS expiresAt,candidate,candidate_at AS candidateAt,error`;
|
||||
const boardColumns = `id,game,guild_id AS guildId,channel_id AS channelId,page,message_id AS messageId,
|
||||
content_hash AS contentHash,verified_at AS verifiedAt,generation`;
|
||||
|
||||
/** Synchronous, bounded transactions; delivery never holds a database transaction open. */
|
||||
export class DiscordStore {
|
||||
constructor(
|
||||
readonly handle: Database.Database,
|
||||
readonly now: () => number = Date.now
|
||||
) {}
|
||||
|
||||
settings(): DiscordSettings {
|
||||
const row = this.handle
|
||||
.prepare('SELECT value FROM discord_configuration WHERE id=1')
|
||||
.get() as { value: string } | undefined;
|
||||
return row
|
||||
? validateDiscordSettings(JSON.parse(row.value))
|
||||
: structuredClone(defaultDiscordSettings);
|
||||
}
|
||||
|
||||
saveSettings(input: unknown): DiscordSettings {
|
||||
const settings = validateDiscordSettings(input);
|
||||
this.handle.transaction(() => {
|
||||
this.handle
|
||||
.prepare(
|
||||
'INSERT INTO discord_configuration(id,value) VALUES(1,?) ON CONFLICT(id) DO UPDATE SET value=excluded.value'
|
||||
)
|
||||
.run(JSON.stringify(settings));
|
||||
this.handle
|
||||
.prepare(
|
||||
"UPDATE discord_subscriptions SET state='cancelled' WHERE state IN ('active','queued') AND (?=0 OR guild_id!=?)"
|
||||
)
|
||||
.run(Number(settings.enabled), settings.guildId);
|
||||
this.handle
|
||||
.prepare(
|
||||
"DELETE FROM discord_outbox WHERE kind='alert' AND target_id IN (SELECT id FROM discord_subscriptions WHERE state!='queued')"
|
||||
)
|
||||
.run();
|
||||
this.retryFailed();
|
||||
})();
|
||||
return settings;
|
||||
}
|
||||
|
||||
status(): DiscordDeliveryStatus {
|
||||
const row = this.handle.prepare('SELECT * FROM discord_health WHERE id=1').get() as {
|
||||
last_attempt_at: number | null;
|
||||
last_success_at: number | null;
|
||||
last_error: string | null;
|
||||
};
|
||||
const iso = (value: number | null) =>
|
||||
value === null ? null : new Date(value).toISOString();
|
||||
return {
|
||||
lastAttemptAt: iso(row.last_attempt_at),
|
||||
lastSuccessAt: iso(row.last_success_at),
|
||||
lastError:
|
||||
row.last_error ??
|
||||
(
|
||||
this.handle
|
||||
.prepare(
|
||||
"SELECT error FROM discord_subscriptions WHERE state='failed' AND expires_at>? ORDER BY created_at DESC LIMIT 1"
|
||||
)
|
||||
.get(this.now()) as { error: string } | undefined
|
||||
)?.error ??
|
||||
null,
|
||||
boards: this.handle
|
||||
.prepare(
|
||||
'SELECT game,channel_id AS channelId,COUNT(message_id) AS messageCount FROM discord_boards GROUP BY game,channel_id'
|
||||
)
|
||||
.all() as DiscordDeliveryStatus['boards'],
|
||||
activeSubscriptions: this.count(
|
||||
"SELECT COUNT(*) AS n FROM discord_subscriptions WHERE state IN ('active','queued') AND expires_at>?",
|
||||
this.now()
|
||||
),
|
||||
pendingNotifications: this.count(
|
||||
"SELECT COUNT(*) AS n FROM discord_outbox WHERE kind='alert' AND next_at IS NOT NULL"
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
subscribe(input: MapSubscriptionInput): { id: string; expiresAt: number } {
|
||||
if (
|
||||
!discordSnowflake(input.userId) ||
|
||||
!discordSnowflake(input.guildId) ||
|
||||
!['t5', 't6'].includes(input.game) ||
|
||||
!['empty', 'space'].includes(input.mode) ||
|
||||
typeof input.mapKey !== 'string' ||
|
||||
!/^[a-zA-Z0-9_.:-]{1,100}$/.test(input.mapKey)
|
||||
)
|
||||
throw new Error('Invalid map watch.');
|
||||
const settings = this.settings();
|
||||
if (!settings.enabled || settings.guildId !== input.guildId)
|
||||
throw new Error('Discord map watches are not enabled for this server.');
|
||||
return this.handle.transaction(() => {
|
||||
this.expireSubscriptions();
|
||||
const existing = this.handle
|
||||
.prepare(
|
||||
"SELECT id,expires_at AS expiresAt FROM discord_subscriptions WHERE guild_id=? AND user_id=? AND game=? AND map_key=? AND mode=? AND state IN ('active','queued')"
|
||||
)
|
||||
.get(input.guildId, input.userId, input.game, input.mapKey, input.mode) as
|
||||
{ id: string; expiresAt: number } | undefined;
|
||||
if (existing) return existing;
|
||||
if (
|
||||
this.count(
|
||||
"SELECT COUNT(*) AS n FROM discord_subscriptions WHERE user_id=? AND state IN ('active','queued')",
|
||||
input.userId
|
||||
) >= 10
|
||||
)
|
||||
throw new Error(
|
||||
'You already have 10 active map watches. Cancel one before adding another.'
|
||||
);
|
||||
if (
|
||||
this.count(
|
||||
"SELECT COUNT(*) AS n FROM discord_subscriptions WHERE state IN ('active','queued')"
|
||||
) >= 1000
|
||||
)
|
||||
throw new Error('Map watches are currently full. Please try later.');
|
||||
const id = randomUUID();
|
||||
const expiresAt = this.now() + 24 * 60 * 60_000;
|
||||
this.handle
|
||||
.prepare(
|
||||
"INSERT INTO discord_subscriptions(id,guild_id,user_id,game,map_key,mode,state,created_at,expires_at) VALUES(?,?,?,?,?,?,'active',?,?)"
|
||||
)
|
||||
.run(
|
||||
id,
|
||||
input.guildId,
|
||||
input.userId,
|
||||
input.game,
|
||||
input.mapKey,
|
||||
input.mode,
|
||||
this.now(),
|
||||
expiresAt
|
||||
);
|
||||
return { id, expiresAt };
|
||||
})();
|
||||
}
|
||||
|
||||
subscriptions(userId?: string, guildId?: string): DiscordSubscription[] {
|
||||
this.expireSubscriptions();
|
||||
return this.handle
|
||||
.prepare(
|
||||
`SELECT ${subscriptionColumns} FROM discord_subscriptions WHERE state IN ('active','queued') ${userId ? 'AND user_id=? AND guild_id=?' : ''} ORDER BY created_at LIMIT 1000`
|
||||
)
|
||||
.all(...(userId ? [userId, guildId ?? ''] : [])) as DiscordSubscription[];
|
||||
}
|
||||
|
||||
subscription(id: string): DiscordSubscription | undefined {
|
||||
return this.handle
|
||||
.prepare(`SELECT ${subscriptionColumns} FROM discord_subscriptions WHERE id=?`)
|
||||
.get(id) as DiscordSubscription | undefined;
|
||||
}
|
||||
|
||||
cancel(id: string, guildId: string, userId: string): boolean {
|
||||
return this.handle.transaction(() => {
|
||||
const updated = this.handle
|
||||
.prepare(
|
||||
"UPDATE discord_subscriptions SET state='cancelled' WHERE id=? AND guild_id=? AND user_id=? AND state IN ('active','queued')"
|
||||
)
|
||||
.run(id, guildId, userId).changes;
|
||||
if (updated)
|
||||
this.handle
|
||||
.prepare("DELETE FROM discord_outbox WHERE kind='alert' AND target_id=?")
|
||||
.run(id);
|
||||
return updated > 0;
|
||||
})();
|
||||
}
|
||||
|
||||
expireSubscriptions(): void {
|
||||
this.handle
|
||||
.prepare(
|
||||
"UPDATE discord_subscriptions SET state='expired' WHERE state IN ('active','queued') AND expires_at<=?"
|
||||
)
|
||||
.run(this.now());
|
||||
this.handle
|
||||
.prepare(
|
||||
"DELETE FROM discord_outbox WHERE kind='alert' AND target_id IN (SELECT id FROM discord_subscriptions WHERE state NOT IN ('active','queued'))"
|
||||
)
|
||||
.run();
|
||||
this.handle
|
||||
.prepare(
|
||||
"DELETE FROM discord_subscriptions WHERE state NOT IN ('active','queued') AND (expires_at<? OR id IN (SELECT id FROM discord_subscriptions ORDER BY created_at DESC LIMIT -1 OFFSET 10000))"
|
||||
)
|
||||
.run(this.now() - 7 * 86400_000);
|
||||
}
|
||||
|
||||
candidate(id: string, candidate: string | null): void {
|
||||
this.handle
|
||||
.prepare(
|
||||
"UPDATE discord_subscriptions SET candidate=?,candidate_at=CASE WHEN candidate IS ? THEN candidate_at ELSE ? END WHERE id=? AND state='active'"
|
||||
)
|
||||
.run(candidate, candidate, candidate ? this.now() : null, id);
|
||||
}
|
||||
|
||||
queueAlert(subscription: DiscordSubscription, message: DiscordMessage): void {
|
||||
this.handle.transaction(() => {
|
||||
const current = this.subscription(subscription.id);
|
||||
if (!current || current.state !== 'active' || current.expiresAt <= this.now()) return;
|
||||
this.enqueue('alert', subscription.id, message, null);
|
||||
this.handle
|
||||
.prepare("UPDATE discord_subscriptions SET state='queued' WHERE id=?")
|
||||
.run(subscription.id);
|
||||
})();
|
||||
}
|
||||
|
||||
boards(): DiscordBoard[] {
|
||||
return this.handle
|
||||
.prepare(`SELECT ${boardColumns} FROM discord_boards ORDER BY game,channel_id,page`)
|
||||
.all() as DiscordBoard[];
|
||||
}
|
||||
|
||||
board(id: string): DiscordBoard | undefined {
|
||||
return this.handle
|
||||
.prepare(`SELECT ${boardColumns} FROM discord_boards WHERE id=?`)
|
||||
.get(id) as DiscordBoard | undefined;
|
||||
}
|
||||
|
||||
reconcileBoards(
|
||||
desired: {
|
||||
game: DiscordGame;
|
||||
guildId: string;
|
||||
channelId: string;
|
||||
messages: DiscordMessage[];
|
||||
}[]
|
||||
): void {
|
||||
this.handle.transaction(() => {
|
||||
const retained = new Set<string>();
|
||||
for (const group of desired) {
|
||||
for (const [page, message] of group.messages.entries()) {
|
||||
let board = this.boards().find(
|
||||
(item) =>
|
||||
item.game === group.game &&
|
||||
item.guildId === group.guildId &&
|
||||
item.channelId === group.channelId &&
|
||||
item.page === page
|
||||
);
|
||||
if (!board) {
|
||||
const id = randomUUID();
|
||||
this.handle
|
||||
.prepare(
|
||||
'INSERT INTO discord_boards(id,game,guild_id,channel_id,page) VALUES(?,?,?,?,?)'
|
||||
)
|
||||
.run(id, group.game, group.guildId, group.channelId, page);
|
||||
board = this.board(id)!;
|
||||
}
|
||||
retained.add(board.id);
|
||||
this.handle
|
||||
.prepare("DELETE FROM discord_outbox WHERE target_id=? AND kind='delete'")
|
||||
.run(board.id);
|
||||
const hash = createHash('sha256').update(JSON.stringify(message)).digest('hex');
|
||||
if (board.contentHash !== hash || !board.messageId)
|
||||
this.enqueue('board', board.id, message, hash);
|
||||
else
|
||||
this.handle
|
||||
.prepare(
|
||||
"DELETE FROM discord_outbox WHERE target_id=? AND kind='board'"
|
||||
)
|
||||
.run(board.id);
|
||||
}
|
||||
}
|
||||
for (const board of this.boards()) {
|
||||
if (!retained.has(board.id)) {
|
||||
if (board.messageId)
|
||||
this.enqueue('delete', board.id, { allowed_mentions: { parse: [] } }, null);
|
||||
else this.removeBoard(board.id);
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
private enqueue(
|
||||
kind: DiscordJob['kind'],
|
||||
targetId: string,
|
||||
payload: DiscordMessage,
|
||||
hash: string | null
|
||||
): void {
|
||||
const existing = this.handle
|
||||
.prepare('SELECT kind FROM discord_outbox WHERE target_id=?')
|
||||
.get(targetId) as { kind: string } | undefined;
|
||||
if (!existing && this.count('SELECT COUNT(*) AS n FROM discord_outbox') >= 500)
|
||||
throw new Error('Discord delivery queue is full.');
|
||||
this.handle
|
||||
.prepare(
|
||||
`INSERT INTO discord_outbox(id,kind,target_id,payload,content_hash,next_at,created_at) VALUES(?,?,?,?,?,?,?)
|
||||
ON CONFLICT(target_id) DO UPDATE SET kind=excluded.kind,payload=excluded.payload,content_hash=excluded.content_hash,
|
||||
attempts=CASE WHEN kind!=excluded.kind THEN 0 ELSE attempts END,
|
||||
next_at=CASE WHEN kind!=excluded.kind THEN excluded.next_at ELSE next_at END`
|
||||
)
|
||||
.run(
|
||||
randomUUID(),
|
||||
kind,
|
||||
targetId,
|
||||
JSON.stringify(payload),
|
||||
hash,
|
||||
this.now(),
|
||||
this.now()
|
||||
);
|
||||
}
|
||||
|
||||
jobs(limit = 10): DiscordJob[] {
|
||||
return (
|
||||
this.handle
|
||||
.prepare(
|
||||
`SELECT id,kind,target_id AS targetId,payload,content_hash AS contentHash,
|
||||
channel_id AS channelId,attempts,created_at AS createdAt FROM discord_outbox WHERE next_at<=? ORDER BY CASE kind WHEN 'delete' THEN 0 WHEN 'alert' THEN 1 ELSE 2 END,created_at LIMIT ?`
|
||||
)
|
||||
.all(this.now(), limit) as (Omit<DiscordJob, 'payload'> & { payload: string })[]
|
||||
).map((row) => ({ ...row, payload: JSON.parse(row.payload) }));
|
||||
}
|
||||
|
||||
cacheDm(jobId: string, channelId: string): void {
|
||||
this.handle
|
||||
.prepare('UPDATE discord_outbox SET channel_id=? WHERE id=?')
|
||||
.run(channelId, jobId);
|
||||
}
|
||||
|
||||
dropJob(id: string): void {
|
||||
this.handle.prepare('DELETE FROM discord_outbox WHERE id=?').run(id);
|
||||
}
|
||||
|
||||
resumeAlert(id: string): void {
|
||||
this.handle.transaction(() => {
|
||||
this.handle
|
||||
.prepare("DELETE FROM discord_outbox WHERE kind='alert' AND target_id=?")
|
||||
.run(id);
|
||||
this.handle
|
||||
.prepare(
|
||||
"UPDATE discord_subscriptions SET state='active',candidate=NULL,candidate_at=NULL WHERE id=? AND state='queued'"
|
||||
)
|
||||
.run(id);
|
||||
})();
|
||||
}
|
||||
|
||||
delivered(job: DiscordJob, messageId?: string): void {
|
||||
this.handle.transaction(() => {
|
||||
if (job.kind === 'board')
|
||||
this.handle
|
||||
.prepare(
|
||||
'UPDATE discord_boards SET message_id=?,content_hash=?,verified_at=? WHERE id=?'
|
||||
)
|
||||
.run(messageId, job.contentHash, this.now(), job.targetId);
|
||||
if (job.kind === 'delete')
|
||||
this.handle.prepare('DELETE FROM discord_boards WHERE id=?').run(job.targetId);
|
||||
if (job.kind === 'alert')
|
||||
this.handle
|
||||
.prepare(
|
||||
"UPDATE discord_subscriptions SET state='delivered',error=NULL WHERE id=? AND state='queued'"
|
||||
)
|
||||
.run(job.targetId);
|
||||
// A newer desired board can replace a payload while an HTTP call is in flight.
|
||||
this.handle
|
||||
.prepare('DELETE FROM discord_outbox WHERE id=? AND payload=?')
|
||||
.run(job.id, JSON.stringify(job.payload));
|
||||
this.handle
|
||||
.prepare(
|
||||
'UPDATE discord_health SET last_success_at=?,last_error=CASE WHEN EXISTS(SELECT 1 FROM discord_outbox WHERE error IS NOT NULL) THEN last_error ELSE NULL END WHERE id=1'
|
||||
)
|
||||
.run(this.now());
|
||||
})();
|
||||
}
|
||||
|
||||
removeBoard(id: string): void {
|
||||
this.handle.prepare('DELETE FROM discord_outbox WHERE target_id=?').run(id);
|
||||
this.handle.prepare('DELETE FROM discord_boards WHERE id=?').run(id);
|
||||
}
|
||||
|
||||
missingMessage(id: string): void {
|
||||
this.handle
|
||||
.prepare(
|
||||
'UPDATE discord_boards SET message_id=NULL,content_hash=NULL,verified_at=0,generation=generation+1 WHERE id=?'
|
||||
)
|
||||
.run(id);
|
||||
}
|
||||
|
||||
verified(id: string): void {
|
||||
this.handle
|
||||
.prepare('UPDATE discord_boards SET verified_at=? WHERE id=?')
|
||||
.run(this.now(), id);
|
||||
}
|
||||
|
||||
attempt(): void {
|
||||
this.handle
|
||||
.prepare('UPDATE discord_health SET last_attempt_at=? WHERE id=1')
|
||||
.run(this.now());
|
||||
}
|
||||
|
||||
error(message: string): void {
|
||||
this.handle
|
||||
.prepare('UPDATE discord_health SET last_error=? WHERE id=1')
|
||||
.run(message.slice(0, 300));
|
||||
}
|
||||
|
||||
failed(job: DiscordJob, error: string, retryAt: number | null): void {
|
||||
this.handle.transaction(() => {
|
||||
this.handle
|
||||
.prepare(
|
||||
'UPDATE discord_outbox SET attempts=attempts+1,next_at=?,error=? WHERE id=?'
|
||||
)
|
||||
.run(retryAt, error.slice(0, 300), job.id);
|
||||
if (retryAt === null && job.kind === 'alert') {
|
||||
this.handle
|
||||
.prepare("UPDATE discord_subscriptions SET state='failed',error=? WHERE id=?")
|
||||
.run(error.slice(0, 300), job.targetId);
|
||||
this.handle.prepare('DELETE FROM discord_outbox WHERE id=?').run(job.id);
|
||||
}
|
||||
this.error(error);
|
||||
})();
|
||||
}
|
||||
|
||||
retryFailed(): void {
|
||||
this.handle
|
||||
.prepare(
|
||||
'UPDATE discord_outbox SET next_at=?,attempts=0,error=NULL WHERE next_at IS NULL'
|
||||
)
|
||||
.run(this.now());
|
||||
this.handle.prepare('UPDATE discord_health SET last_error=NULL WHERE id=1').run();
|
||||
}
|
||||
|
||||
claimInteraction(id: string): boolean {
|
||||
if (!discordSnowflake(id)) throw new Error('Invalid Discord interaction ID.');
|
||||
return this.handle.transaction(() => {
|
||||
this.handle
|
||||
.prepare('DELETE FROM discord_interactions WHERE expires_at<=?')
|
||||
.run(this.now());
|
||||
if (this.handle.prepare('SELECT 1 FROM discord_interactions WHERE id=?').get(id))
|
||||
return false;
|
||||
if (this.count('SELECT COUNT(*) AS n FROM discord_interactions') >= 10000)
|
||||
throw new Error('Discord is processing too many interactions. Please try later.');
|
||||
this.handle
|
||||
.prepare('INSERT INTO discord_interactions(id,expires_at) VALUES(?,?)')
|
||||
.run(id, this.now() + 15 * 60_000);
|
||||
return true;
|
||||
})();
|
||||
}
|
||||
|
||||
private count(sql: string, ...args: (string | number)[]): number {
|
||||
return (this.handle.prepare(sql).get(...args) as { n: number }).n;
|
||||
}
|
||||
}
|
||||
|
||||
export const discordStore = () => new DiscordStore(db());
|
||||
export const getDiscordSettings = () => discordStore().settings();
|
||||
export const saveDiscordSettings = (input: unknown) => discordStore().saveSettings(input);
|
||||
export const discordStatus = () => discordStore().status();
|
||||
export const subscribeToMap = (input: MapSubscriptionInput) => discordStore().subscribe(input);
|
||||
export const listSubscriptions = (userId: string, guildId: string) =>
|
||||
discordStore().subscriptions(userId, guildId);
|
||||
export const cancelSubscription = (id: string, guildId: string, userId: string) =>
|
||||
discordStore().cancel(id, guildId, userId);
|
||||
export const claimDiscordInteraction = (id: string) => discordStore().claimInteraction(id);
|
||||
@@ -0,0 +1,132 @@
|
||||
/** Discord HTTP failures never include response bodies, which can contain private data. */
|
||||
export class DiscordDeliveryError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number,
|
||||
readonly retryAt: number | null = null,
|
||||
readonly code: number | null = null
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'DiscordDeliveryError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface DiscordTransport {
|
||||
request<T = Record<string, unknown>>(method: string, path: string, body?: unknown): Promise<T>;
|
||||
}
|
||||
|
||||
/** One serialized queue shares Discord's bucket/global cooldowns with every caller. */
|
||||
export class DiscordRest implements DiscordTransport {
|
||||
private tail: Promise<unknown> = Promise.resolve();
|
||||
private globalUntil = 0;
|
||||
private unauthorized = false;
|
||||
private routeBuckets = new Map<string, string>();
|
||||
private blockedUntil = new Map<string, number>();
|
||||
|
||||
constructor(
|
||||
private readonly token: string,
|
||||
private readonly fetcher: typeof fetch = fetch,
|
||||
private readonly now: () => number = Date.now
|
||||
) {}
|
||||
|
||||
request<T = Record<string, unknown>>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const pending = this.tail.then(() => this.send<T>(method, path, body));
|
||||
this.tail = pending.catch(() => undefined);
|
||||
return pending;
|
||||
}
|
||||
|
||||
private async send<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
// Callers supply resource paths, never arbitrary hosts or interaction tokens.
|
||||
if (!/^\/(?:channels|users|applications)\/[A-Za-z0-9_@/-]+$/.test(path))
|
||||
throw new Error('Invalid Discord API path.');
|
||||
if (this.unauthorized)
|
||||
throw new DiscordDeliveryError('Discord bot credentials were rejected.', 401);
|
||||
const segments = path.split('/');
|
||||
const major = `${segments[1]}/${segments[2]}`;
|
||||
const route = `${method}:${major}/${segments
|
||||
.slice(3)
|
||||
.map((part) => (/^\d+$/.test(part) ? ':id' : part))
|
||||
.join('/')}`;
|
||||
const key = this.routeBuckets.get(route) ?? route;
|
||||
const retryAt = Math.max(this.globalUntil, this.blockedUntil.get(key) ?? 0);
|
||||
if (retryAt > this.now())
|
||||
throw new DiscordDeliveryError(
|
||||
'Discord is waiting for a rate limit to reset.',
|
||||
429,
|
||||
retryAt
|
||||
);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await this.fetcher(`https://discord.com/api/v10${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bot ${this.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'ElectricCherry/1.0'
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
redirect: 'error'
|
||||
});
|
||||
} catch {
|
||||
throw new DiscordDeliveryError('Discord could not be reached.', 0, this.now() + 5_000);
|
||||
}
|
||||
const bucket = response.headers.get('X-RateLimit-Bucket');
|
||||
const bucketKey = bucket ? `${major}:${bucket}` : key;
|
||||
if (bucket) this.routeBuckets.set(route, bucketKey);
|
||||
const reset = seconds(response.headers.get('X-RateLimit-Reset-After'));
|
||||
if (response.headers.get('X-RateLimit-Remaining') === '0' && reset !== null)
|
||||
this.blockedUntil.set(bucketKey, this.now() + reset);
|
||||
let data: Record<string, unknown> = {};
|
||||
if (response.status !== 204) {
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch {
|
||||
if (response.ok)
|
||||
throw new DiscordDeliveryError(
|
||||
'Discord returned an invalid response.',
|
||||
502,
|
||||
this.now() + 5_000
|
||||
);
|
||||
}
|
||||
}
|
||||
if (response.status === 429) {
|
||||
const delay = Math.max(
|
||||
1000,
|
||||
seconds(data.retry_after) ??
|
||||
seconds(response.headers.get('Retry-After')) ??
|
||||
reset ??
|
||||
5_000
|
||||
);
|
||||
const until = this.now() + delay;
|
||||
if (data.global === true || response.headers.get('X-RateLimit-Global') === 'true')
|
||||
this.globalUntil = until;
|
||||
else this.blockedUntil.set(bucketKey, until);
|
||||
throw new DiscordDeliveryError('Discord rate limited this request.', 429, until);
|
||||
}
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) this.unauthorized = true;
|
||||
const message =
|
||||
response.status === 401
|
||||
? 'Discord bot credentials were rejected.'
|
||||
: response.status === 403
|
||||
? 'Discord denied permission or the recipient does not accept direct messages.'
|
||||
: response.status === 404
|
||||
? 'Discord channel or message no longer exists.'
|
||||
: `Discord request failed (HTTP ${response.status}).`;
|
||||
throw new DiscordDeliveryError(
|
||||
message,
|
||||
response.status,
|
||||
response.status >= 500 ? this.now() + 5_000 : null,
|
||||
typeof data.code === 'number' ? data.code : null
|
||||
);
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
}
|
||||
|
||||
function seconds(value: unknown): number | null {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? Math.ceil(parsed * 1000) : null;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { createPublicKey, verify } from 'node:crypto';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { boundedBody } from '../request-body.js';
|
||||
import { discordCredentials } from './config.js';
|
||||
import { discordSnowflake } from '../../discord.js';
|
||||
import type { DiscordMessage } from '../../discord.js';
|
||||
|
||||
export const DISCORD_INTERACTION_BODY_LIMIT = 64 * 1024;
|
||||
|
||||
/** Authenticate the exact request bytes before interpreting any interaction fields. */
|
||||
export async function verifiedDiscordBody(
|
||||
request: Request,
|
||||
now = Date.now()
|
||||
): Promise<Record<string, unknown>> {
|
||||
const signature = request.headers.get('x-signature-ed25519') ?? '';
|
||||
const timestamp = request.headers.get('x-signature-timestamp') ?? '';
|
||||
const { publicKey } = discordCredentials();
|
||||
if (
|
||||
!/^[a-fA-F0-9]{128}$/.test(signature) ||
|
||||
!/^[0-9]{10,12}$/.test(timestamp) ||
|
||||
Math.abs(now - Number(timestamp) * 1000) > 5 * 60_000 ||
|
||||
!/^[a-fA-F0-9]{64}$/.test(publicKey)
|
||||
)
|
||||
error(401, 'Invalid Discord signature.');
|
||||
const body = await boundedBody(request, DISCORD_INTERACTION_BODY_LIMIT);
|
||||
const key = createPublicKey({
|
||||
key: Buffer.concat([
|
||||
Buffer.from('302a300506032b6570032100', 'hex'),
|
||||
Buffer.from(publicKey, 'hex')
|
||||
]),
|
||||
format: 'der',
|
||||
type: 'spki'
|
||||
});
|
||||
if (
|
||||
!verify(
|
||||
null,
|
||||
Buffer.concat([Buffer.from(timestamp), body]),
|
||||
key,
|
||||
Buffer.from(signature, 'hex')
|
||||
)
|
||||
)
|
||||
error(401, 'Invalid Discord signature.');
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(body.toString('utf8'));
|
||||
} catch {
|
||||
error(400, 'Invalid Discord interaction.');
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
||||
error(400, 'Invalid Discord interaction.');
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function validInteractionEnvelope(body: Record<string, unknown>): boolean {
|
||||
return (
|
||||
discordSnowflake(body.id) &&
|
||||
body.application_id === discordCredentials().applicationId &&
|
||||
discordSnowflake(body.guild_id) &&
|
||||
typeof body.token === 'string' &&
|
||||
body.token.length >= 10 &&
|
||||
body.token.length <= 1000 &&
|
||||
!!body.member &&
|
||||
typeof body.member === 'object' &&
|
||||
discordSnowflake((body.member as { user?: { id?: unknown } }).user?.id)
|
||||
);
|
||||
}
|
||||
|
||||
/** Interaction webhooks use their short-lived token, never the deployment bot token. */
|
||||
export async function completeDiscordInteraction(
|
||||
applicationId: string,
|
||||
token: string,
|
||||
message: DiscordMessage,
|
||||
fetcher: typeof fetch = fetch,
|
||||
publicationAllowed: () => boolean = () => true
|
||||
): Promise<void> {
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
const response = await fetcher(
|
||||
`https://discord.com/api/v10/webhooks/${applicationId}/${encodeURIComponent(token)}/messages/@original`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...(publicationAllowed()
|
||||
? message
|
||||
: {
|
||||
flags: 32768 | 64,
|
||||
components: [
|
||||
{
|
||||
type: 10,
|
||||
content:
|
||||
'Publication settings changed. Please run your command again.'
|
||||
}
|
||||
]
|
||||
}),
|
||||
allowed_mentions: { parse: [] }
|
||||
}),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
redirect: 'error'
|
||||
}
|
||||
);
|
||||
if (response.ok) return;
|
||||
if (response.status === 429 && attempt < 2) {
|
||||
let delay = Number(response.headers.get('retry-after') ?? NaN);
|
||||
try {
|
||||
const result = (await response.json()) as { retry_after?: number };
|
||||
delay = Number(result.retry_after ?? delay);
|
||||
} catch {
|
||||
/* A valid retry header is sufficient. */
|
||||
}
|
||||
if (Number.isFinite(delay) && delay >= 0 && delay <= 30) {
|
||||
await new Promise((resolve) => setTimeout(resolve, Math.max(250, delay * 1000)));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Do not log a response body or URL: both can contain interaction credentials.
|
||||
throw new Error(`Discord interaction delivery failed (${response.status}).`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import type {
|
||||
DiscordGame,
|
||||
DiscordMessage,
|
||||
DiscordPublicServer,
|
||||
DiscordSettings
|
||||
} from '../../discord.js';
|
||||
import { requireDiscordCredentials } from './config.js';
|
||||
import { discordText, publicFleet } from './projection.js';
|
||||
import { renderBoard } from './render.js';
|
||||
import { DiscordStore, discordStore, type DiscordJob, type DiscordSubscription } from './store.js';
|
||||
import { DiscordDeliveryError, DiscordRest, type DiscordTransport } from './transport.js';
|
||||
|
||||
const TICK_MS = 30_000;
|
||||
const VERIFY_MS = 15 * 60_000;
|
||||
const FRESH_MS = 90_000;
|
||||
let client: { token: string; rest: DiscordRest } | undefined;
|
||||
|
||||
export function discordRest(): DiscordRest {
|
||||
const { botToken } = requireDiscordCredentials();
|
||||
if (!client || client.token !== botToken)
|
||||
client = { token: botToken, rest: new DiscordRest(botToken) };
|
||||
return client.rest;
|
||||
}
|
||||
|
||||
interface WorkerDependencies {
|
||||
store: DiscordStore;
|
||||
transport: () => DiscordTransport;
|
||||
fleet: (settings: DiscordSettings, now: number) => DiscordPublicServer[];
|
||||
render: (game: DiscordGame, servers: DiscordPublicServer[]) => DiscordMessage[];
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
/** A single independent reconciliation loop; it never issues game-server commands. */
|
||||
export class DiscordWorker {
|
||||
private running: Promise<void> | null = null;
|
||||
private readonly now: () => number;
|
||||
constructor(private readonly dependencies: WorkerDependencies) {
|
||||
this.now = dependencies.now ?? Date.now;
|
||||
}
|
||||
|
||||
tick(): Promise<void> {
|
||||
if (this.running) return this.running;
|
||||
this.running = this.run()
|
||||
.catch((error: unknown) => {
|
||||
// No raw fetch/config/SQL exceptions are persisted in operator-facing status.
|
||||
try {
|
||||
this.dependencies.store.error(
|
||||
error instanceof DiscordDeliveryError
|
||||
? error.message
|
||||
: 'Discord delivery could not complete. Check the integration configuration.'
|
||||
);
|
||||
} catch {
|
||||
console.error('[discord] Could not record delivery health.');
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
this.running = null;
|
||||
});
|
||||
return this.running;
|
||||
}
|
||||
|
||||
private async run(): Promise<void> {
|
||||
const { store, fleet, render } = this.dependencies;
|
||||
store.expireSubscriptions();
|
||||
const settings = store.settings();
|
||||
const servers = settings.enabled ? fleet(settings, this.now()) : [];
|
||||
store.reconcileBoards(
|
||||
settings.enabled
|
||||
? (['t5', 't6'] as const)
|
||||
.filter((game) => settings.channels[game])
|
||||
.map((game) => ({
|
||||
game,
|
||||
guildId: settings.guildId,
|
||||
channelId: settings.channels[game],
|
||||
messages: render(game, servers)
|
||||
}))
|
||||
: []
|
||||
);
|
||||
if (settings.enabled) {
|
||||
for (const watch of store.subscriptions()) {
|
||||
if (watch.state !== 'active') continue;
|
||||
if (watch.guildId !== settings.guildId) {
|
||||
store.cancel(watch.id, watch.guildId, watch.userId);
|
||||
continue;
|
||||
}
|
||||
const match = matchingServer(watch, servers, this.now());
|
||||
if (!match) {
|
||||
store.candidate(watch.id, null);
|
||||
continue;
|
||||
}
|
||||
// Persist the server identity, not each telemetry timestamp: two fresh
|
||||
// observations spanning a tick stabilize brief capacity fluctuations.
|
||||
if (watch.candidate?.split('|')[0] !== match.name) {
|
||||
store.candidate(watch.id, `${match.name}|${match.observedAt ?? ''}`);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
watch.candidateAt !== null &&
|
||||
this.now() - watch.candidateAt >= 15_000 &&
|
||||
watch.candidate !== `${match.name}|${match.observedAt ?? ''}`
|
||||
)
|
||||
store.queueAlert(watch, alertMessage(watch, match));
|
||||
}
|
||||
}
|
||||
const jobs = store.jobs(10);
|
||||
const toVerify = store
|
||||
.boards()
|
||||
.find(
|
||||
(board) =>
|
||||
board.messageId &&
|
||||
board.verifiedAt < this.now() - VERIFY_MS &&
|
||||
!jobs.some((job) => job.targetId === board.id)
|
||||
);
|
||||
if (!jobs.length && (!settings.enabled || !toVerify)) return;
|
||||
const rest = this.dependencies.transport();
|
||||
const channelGuilds = new Map<string, string>();
|
||||
const ensureChannel = async (channelId: string, guildId: string) => {
|
||||
if (!channelGuilds.has(channelId)) {
|
||||
const channel = await rest.request<{ guild_id?: string; type?: number }>(
|
||||
'GET',
|
||||
`/channels/${channelId}`
|
||||
);
|
||||
if (![0, 5].includes(channel.type ?? -1))
|
||||
throw new DiscordDeliveryError(
|
||||
'Choose a Discord text or announcement channel for the board.',
|
||||
403
|
||||
);
|
||||
channelGuilds.set(channelId, channel.guild_id ?? '');
|
||||
}
|
||||
if (channelGuilds.get(channelId) !== guildId)
|
||||
throw new DiscordDeliveryError(
|
||||
'The board channel does not belong to the configured Discord server.',
|
||||
403
|
||||
);
|
||||
};
|
||||
for (const job of jobs) {
|
||||
store.attempt();
|
||||
try {
|
||||
await this.deliver(job, rest, ensureChannel);
|
||||
} catch (error) {
|
||||
const failure =
|
||||
error instanceof DiscordDeliveryError
|
||||
? error
|
||||
: new DiscordDeliveryError(
|
||||
'Discord delivery could not complete.',
|
||||
0,
|
||||
this.now() + 5_000
|
||||
);
|
||||
const canRetry =
|
||||
failure.retryAt !== null && (failure.status === 429 || job.attempts < 7);
|
||||
const delay = Math.min(15 * 60_000, 5_000 * 2 ** Math.min(job.attempts, 8));
|
||||
store.failed(
|
||||
job,
|
||||
failure.message,
|
||||
canRetry
|
||||
? Math.max(
|
||||
failure.retryAt!,
|
||||
this.now() + (failure.status === 429 ? 0 : delay)
|
||||
)
|
||||
: null
|
||||
);
|
||||
if (failure.status === 401 || failure.status === 429) break;
|
||||
}
|
||||
}
|
||||
if (settings.enabled && toVerify) {
|
||||
const current = store.settings();
|
||||
if (
|
||||
!current.enabled ||
|
||||
current.guildId !== toVerify.guildId ||
|
||||
current.channels[toVerify.game] !== toVerify.channelId
|
||||
)
|
||||
return;
|
||||
store.attempt();
|
||||
try {
|
||||
await ensureChannel(toVerify.channelId, current.guildId);
|
||||
await rest.request(
|
||||
'GET',
|
||||
`/channels/${toVerify.channelId}/messages/${toVerify.messageId}`
|
||||
);
|
||||
store.verified(toVerify.id);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof DiscordDeliveryError &&
|
||||
error.status === 404 &&
|
||||
error.code === 10008
|
||||
)
|
||||
store.missingMessage(toVerify.id);
|
||||
else {
|
||||
store.verified(toVerify.id); // Check permissions again later, not every tick.
|
||||
store.error(
|
||||
error instanceof DiscordDeliveryError
|
||||
? error.message
|
||||
: 'Discord board verification failed.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async deliver(
|
||||
job: DiscordJob,
|
||||
rest: DiscordTransport,
|
||||
ensureChannel: (channelId: string, guildId: string) => Promise<void>
|
||||
): Promise<void> {
|
||||
const { store, fleet, render } = this.dependencies;
|
||||
const settings = store.settings();
|
||||
if (job.kind === 'alert') {
|
||||
const watch = store.subscription(job.targetId);
|
||||
if (
|
||||
!watch ||
|
||||
watch.state !== 'queued' ||
|
||||
watch.expiresAt <= this.now() ||
|
||||
!settings.enabled ||
|
||||
settings.guildId !== watch.guildId
|
||||
) {
|
||||
store.dropJob(job.id);
|
||||
return;
|
||||
}
|
||||
const match = matchingServer(watch, fleet(settings, this.now()), this.now());
|
||||
if (!match) {
|
||||
store.resumeAlert(watch.id);
|
||||
return;
|
||||
}
|
||||
let channelId = job.channelId;
|
||||
if (!channelId) {
|
||||
const channel = await rest.request<{ id: string }>('POST', '/users/@me/channels', {
|
||||
recipient_id: watch.userId
|
||||
});
|
||||
channelId = channel.id;
|
||||
if (!/^\d+$/.test(channelId))
|
||||
throw new DiscordDeliveryError(
|
||||
'Discord returned an invalid channel.',
|
||||
502,
|
||||
this.now() + 5_000
|
||||
);
|
||||
store.cacheDm(job.id, channelId);
|
||||
}
|
||||
// Recheck opt-in/config after the asynchronous channel lookup.
|
||||
const current = store.settings();
|
||||
const stillWatching = store.subscription(watch.id);
|
||||
if (
|
||||
!current.enabled ||
|
||||
current.guildId !== watch.guildId ||
|
||||
stillWatching?.state !== 'queued' ||
|
||||
stillWatching.expiresAt <= this.now()
|
||||
) {
|
||||
store.dropJob(job.id);
|
||||
return;
|
||||
}
|
||||
const fresh = matchingServer(watch, fleet(current, this.now()), this.now());
|
||||
if (!fresh) {
|
||||
store.resumeAlert(watch.id);
|
||||
return;
|
||||
}
|
||||
await rest.request('POST', `/channels/${channelId}/messages`, {
|
||||
...alertMessage(watch, fresh),
|
||||
nonce: nonce(job.id),
|
||||
enforce_nonce: true
|
||||
});
|
||||
store.delivered(job);
|
||||
return;
|
||||
}
|
||||
const board = store.board(job.targetId);
|
||||
if (!board) {
|
||||
store.dropJob(job.id);
|
||||
return;
|
||||
}
|
||||
if (job.kind === 'delete') {
|
||||
if (board.messageId) {
|
||||
try {
|
||||
await rest.request(
|
||||
'DELETE',
|
||||
`/channels/${board.channelId}/messages/${board.messageId}`
|
||||
);
|
||||
} catch (error) {
|
||||
if (!(error instanceof DiscordDeliveryError && error.status === 404))
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
store.delivered(job);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!settings.enabled ||
|
||||
settings.guildId !== board.guildId ||
|
||||
settings.channels[board.game] !== board.channelId
|
||||
) {
|
||||
store.dropJob(job.id);
|
||||
return;
|
||||
}
|
||||
await ensureChannel(board.channelId, settings.guildId);
|
||||
// Rebuild after awaits so pending jobs cannot publish withdrawn fields or servers.
|
||||
const current = store.settings();
|
||||
if (
|
||||
!current.enabled ||
|
||||
current.guildId !== settings.guildId ||
|
||||
current.channels[board.game] !== board.channelId
|
||||
) {
|
||||
store.dropJob(job.id);
|
||||
return;
|
||||
}
|
||||
const message = render(board.game, fleet(current, this.now()))[board.page];
|
||||
if (!message) {
|
||||
store.dropJob(job.id);
|
||||
return;
|
||||
}
|
||||
const updated = {
|
||||
...job,
|
||||
payload: message,
|
||||
contentHash: createHash('sha256').update(JSON.stringify(message)).digest('hex')
|
||||
};
|
||||
if (board.messageId) {
|
||||
try {
|
||||
await rest.request(
|
||||
'PATCH',
|
||||
`/channels/${board.channelId}/messages/${board.messageId}`,
|
||||
message
|
||||
);
|
||||
store.delivered(updated, board.messageId);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (!(
|
||||
error instanceof DiscordDeliveryError &&
|
||||
error.status === 404 &&
|
||||
error.code === 10008
|
||||
))
|
||||
throw error;
|
||||
store.missingMessage(board.id);
|
||||
}
|
||||
}
|
||||
const result = await rest.request<{ id: string }>(
|
||||
'POST',
|
||||
`/channels/${board.channelId}/messages`,
|
||||
{
|
||||
...message,
|
||||
nonce: nonce(`${board.id}:${store.board(board.id)?.generation ?? 0}`),
|
||||
enforce_nonce: true
|
||||
}
|
||||
);
|
||||
if (!/^\d+$/.test(result.id))
|
||||
throw new DiscordDeliveryError(
|
||||
'Discord returned an invalid message.',
|
||||
502,
|
||||
this.now() + 5_000
|
||||
);
|
||||
store.delivered(updated, result.id);
|
||||
}
|
||||
}
|
||||
|
||||
function matchingServer(
|
||||
watch: DiscordSubscription,
|
||||
servers: DiscordPublicServer[],
|
||||
now: number
|
||||
): DiscordPublicServer | undefined {
|
||||
return servers.find(
|
||||
(server) =>
|
||||
server.game === watch.game &&
|
||||
server.mapKey === watch.mapKey &&
|
||||
server.observedAt !== null &&
|
||||
now - Date.parse(server.observedAt) >= 0 &&
|
||||
now - Date.parse(server.observedAt) <= FRESH_MS &&
|
||||
['empty', 'in-progress'].includes(server.state) &&
|
||||
server.players !== null &&
|
||||
(watch.mode === 'empty'
|
||||
? server.players === 0
|
||||
: server.capacity !== null &&
|
||||
server.capacity > 0 &&
|
||||
server.players < server.capacity)
|
||||
);
|
||||
}
|
||||
|
||||
function alertMessage(watch: DiscordSubscription, server: DiscordPublicServer): DiscordMessage {
|
||||
const safe = (value: string) => discordText(value, 100);
|
||||
return {
|
||||
content: `🍒 **${safe(server.map)}** ${watch.mode === 'empty' ? 'has an empty lobby' : 'has an open player slot'}: **${safe(server.label)}** (${server.players}/${server.capacity ?? '?'}).\nThis describes occupancy; joining an in-progress game may still be restricted.\nYour one-time map watch is complete. Use /servers to browse or /watch to watch again.`,
|
||||
allowed_mentions: { parse: [] }
|
||||
};
|
||||
}
|
||||
|
||||
function nonce(id: string): string {
|
||||
return createHash('sha256').update(id).digest('hex').slice(0, 24);
|
||||
}
|
||||
|
||||
let worker: DiscordWorker | undefined;
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
export function tickDiscordWorker(): Promise<void> {
|
||||
worker ??= new DiscordWorker({
|
||||
store: discordStore(),
|
||||
transport: discordRest,
|
||||
fleet: publicFleet,
|
||||
render: renderBoard
|
||||
});
|
||||
return worker.tick();
|
||||
}
|
||||
export function startDiscordWorker(): void {
|
||||
if (timer) return;
|
||||
void tickDiscordWorker();
|
||||
timer = setInterval(() => {
|
||||
void tickDiscordWorker();
|
||||
}, TICK_MS);
|
||||
timer.unref();
|
||||
}
|
||||
export function stopDiscordWorker(): void {
|
||||
clearInterval(timer);
|
||||
timer = undefined;
|
||||
}
|
||||
export function resetDiscordClient(): void {
|
||||
client = undefined;
|
||||
}
|
||||
export function retryDiscordDelivery(): Promise<void> {
|
||||
resetDiscordClient();
|
||||
discordStore().retryFailed();
|
||||
return tickDiscordWorker();
|
||||
}
|
||||
Reference in New Issue
Block a user