Acknowledge Discord interactions before database work
CI / checks (push) Failing after 7m33s

This commit is contained in:
2026-10-18 18:40:46 -04:00
parent d715173719
commit 0f05be3a59
3 changed files with 127 additions and 18 deletions
+10
View File
@@ -139,6 +139,16 @@ visible.
- **Discord rejects the interaction endpoint:** verify HTTPS routing, the - **Discord rejects the interaction endpoint:** verify HTTPS routing, the
application's Public Key, and that the reverse proxy preserves signed application's Public Key, and that the reverse proxy preserves signed
requests. Discord must reach the endpoint without an interactive login. requests. Discord must reach the endpoint without an interactive login.
- **Buttons time out without a private loading message:** inspect the controller
logs while clicking once (`docker compose logs --since=5m electric-cherry`).
A `[discord] Deferring interaction` entry means the signed request reached the
handler and it prepared a private acknowledgement before database work. A
rejection entry identifies signature, body, or application-ID validation
failures. An application-ID mismatch also returns a private explanation;
`EC_DISCORD_APPLICATION_ID` must identify the same application as the Public
Key and bot token. If no entry appears, check the proxy and saved Interactions
Endpoint URL. A verified endpoint and working boards alone do not confirm
that button clicks are reaching the running controller.
- **Commands are missing:** save the correct Discord server ID, install the bot - **Commands are missing:** save the correct Discord server ID, install the bot
with `applications.commands`, then select **Register commands**. Register again with `applications.commands`, then select **Register commands**. Register again
after upgrading to a release that changes the command schema. after upgrading to a release that changes the command schema.
+40 -8
View File
@@ -10,6 +10,7 @@ import {
validInteractionEnvelope, validInteractionEnvelope,
verifiedDiscordBody verifiedDiscordBody
} from '$lib/server/discord/webhook.js'; } from '$lib/server/discord/webhook.js';
import { discordCredentials } from '$lib/server/discord/config.js';
const seen = new Map<string, number>(); const seen = new Map<string, number>();
const actors = new Map<string, number>(); const actors = new Map<string, number>();
@@ -23,11 +24,23 @@ export async function POST({ request }: { request: Request }) {
try { try {
const body = await verifiedDiscordBody(request); const body = await verifiedDiscordBody(request);
if (body.type === 1) return json({ type: 1 }); if (body.type === 1) return json({ type: 1 });
if (![2, 3].includes(body.type as number) || !validInteractionEnvelope(body)) if (![2, 3].includes(body.type as number) || !validInteractionEnvelope(body)) {
if (
[2, 3].includes(body.type as number) &&
body.application_id !== discordCredentials().applicationId
) {
console.error(
'[discord] Interaction application ID does not match EC_DISCORD_APPLICATION_ID.'
);
return json(
privateReply(
'Electric Cherry’s Discord application ID is misconfigured. Ask an administrator to check EC_DISCORD_APPLICATION_ID.'
)
);
}
console.warn('[discord] Rejected an invalid interaction envelope.');
return json({ error: 'Invalid Discord interaction.' }, { status: 400 }); 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(); const now = Date.now();
for (const [key, at] of seen) if (now - at > 5 * 60_000) seen.delete(key); 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); for (const [key, at] of actors) if (now - at > 60_000) actors.delete(key);
@@ -36,15 +49,27 @@ export async function POST({ request }: { request: Request }) {
if (seen.has(id)) return json({ type: 5, data: { flags: 64 } }); if (seen.has(id)) return json({ type: 5, data: { flags: 64 } });
if (pending >= 32 || seen.size >= 10_000 || now - (actors.get(actor) ?? 0) < 1000) if (pending >= 32 || seen.size >= 10_000 || now - (actors.get(actor) ?? 0) < 1000)
return json(privateReply('Please wait a moment before trying again.')); return json(privateReply('Please wait a moment before trying again.'));
if (!claimDiscordInteraction(id)) return json({ type: 5, data: { flags: 64 } });
seen.set(id, now); seen.set(id, now);
actors.set(actor, now); actors.set(actor, now);
pending++; pending++;
// Acknowledge immediately; report queries and Discord delivery must not consume // Do not read or write SQLite before returning the acknowledgement. In particular,
// the three-second response window. Durable watches are saved by the handler. // acquiring the replay-claim write lock can outlast Discord's three-second deadline.
console.info('[discord] Deferring interaction.', { id, type: body.type });
setImmediate(() => { setImmediate(() => {
void (async () => { void (async () => {
try { try {
if (!claimDiscordInteraction(id)) return;
const settings = getDiscordSettings();
if (!settings.enabled || body.guild_id !== settings.guildId) {
await completeDiscordInteraction(
body.application_id as string,
body.token as string,
privateMessage(
'Electric Cherry is not enabled for this Discord server.'
)
);
return;
}
const reply = await handleDiscordInteraction( const reply = await handleDiscordInteraction(
body as unknown as DiscordInteraction body as unknown as DiscordInteraction
); );
@@ -56,6 +81,7 @@ export async function POST({ request }: { request: Request }) {
() => JSON.stringify(getDiscordSettings()) === JSON.stringify(settings) () => JSON.stringify(getDiscordSettings()) === JSON.stringify(settings)
); );
} catch { } catch {
console.error('[discord] Deferred interaction failed.', { id });
try { try {
await completeDiscordInteraction( await completeDiscordInteraction(
body.application_id as string, body.application_id as string,
@@ -74,8 +100,14 @@ export async function POST({ request }: { request: Request }) {
}); });
return json({ type: 5, data: { flags: 64 } }); return json({ type: 5, data: { flags: 64 } });
} catch (cause) { } catch (cause) {
if (isHttpError(cause)) if (isHttpError(cause)) {
console.warn('[discord] Interaction request rejected.', {
status: cause.status,
reason: cause.body.message
});
return json({ error: cause.body.message }, { status: cause.status }); return json({ error: cause.body.message }, { status: cause.status });
}
console.error('[discord] Interaction request failed before acknowledgement.');
return json({ error: 'Could not process Discord interaction.' }, { status: 500 }); return json({ error: 'Could not process Discord interaction.' }, { status: 500 });
} }
} }
+77 -10
View File
@@ -14,9 +14,11 @@ const guildId = '223456789012345678';
const userId = '323456789012345678'; const userId = '323456789012345678';
const settings = vi.hoisted(() => ({ enabled: true, guildId: '223456789012345678' })); const settings = vi.hoisted(() => ({ enabled: true, guildId: '223456789012345678' }));
const handle = vi.hoisted(() => vi.fn()); const handle = vi.hoisted(() => vi.fn());
const readSettings = vi.hoisted(() => vi.fn());
const claimInteraction = vi.hoisted(() => vi.fn());
vi.mock('../../src/lib/server/discord/store.js', () => ({ vi.mock('../../src/lib/server/discord/store.js', () => ({
getDiscordSettings: () => settings, getDiscordSettings: readSettings,
claimDiscordInteraction: () => true claimDiscordInteraction: claimInteraction
})); }));
vi.mock('../../src/lib/server/discord/interactions.js', () => ({ vi.mock('../../src/lib/server/discord/interactions.js', () => ({
handleDiscordInteraction: handle handleDiscordInteraction: handle
@@ -54,6 +56,11 @@ beforeEach(() => {
); );
vi.stubEnv('EC_DISCORD_APPLICATION_ID', applicationId); vi.stubEnv('EC_DISCORD_APPLICATION_ID', applicationId);
settings.enabled = true; settings.enabled = true;
readSettings.mockReset().mockImplementation(() => settings);
claimInteraction.mockReset().mockReturnValue(true);
vi.spyOn(console, 'info').mockImplementation(() => {});
vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.spyOn(console, 'error').mockImplementation(() => {});
handle.mockReset().mockResolvedValue({ handle.mockReset().mockResolvedValue({
type: 4, type: 4,
data: { flags: 64, content: 'Private results', allowed_mentions: { parse: [] } } data: { flags: 64, content: 'Private results', allowed_mentions: { parse: [] } }
@@ -63,6 +70,7 @@ afterEach(() => {
vi.useRealTimers(); vi.useRealTimers();
vi.unstubAllEnvs(); vi.unstubAllEnvs();
vi.unstubAllGlobals(); vi.unstubAllGlobals();
vi.restoreAllMocks();
}); });
describe('Discord signed webhook boundary', () => { describe('Discord signed webhook boundary', () => {
@@ -114,19 +122,76 @@ describe('Discord signed webhook boundary', () => {
).toBe(false); ).toBe(false);
}); });
it('does not dispatch disabled or foreign-guild interactions', async () => { it.each(['disabled', 'foreign-guild'])(
'acknowledges %s interactions before checking configuration without dispatching them',
async (mode) => {
const fetcher = vi.fn().mockResolvedValue(new Response('{}'));
vi.stubGlobal('fetch', fetcher);
const { POST } = await import('../../src/routes/api/discord/interactions/+server.js'); const { POST } = await import('../../src/routes/api/discord/interactions/+server.js');
const foreign = await POST({ settings.enabled = mode !== 'disabled';
request: signedRequest({ ...interaction(), guild_id: userId }) const result = await POST({
request: signedRequest({
...interaction(),
guild_id: mode === 'foreign-guild' ? userId : guildId
})
}); });
expect((await foreign.json()).data.flags).toBe(64); expect(await result.json()).toEqual({ type: 5, data: { flags: 64 } });
settings.enabled = false; expect(readSettings).not.toHaveBeenCalled();
expect( expect(claimInteraction).not.toHaveBeenCalled();
(await (await POST({ request: signedRequest(interaction()) })).json()).data.flags await new Promise((resolve) => setImmediate(resolve));
).toBe(64); expect(fetcher).toHaveBeenCalledOnce();
expect(fetcher.mock.calls[0][1].body).toContain('not enabled');
expect(handle).not.toHaveBeenCalled();
}
);
it('explains a signed application ID mismatch instead of making the button time out', async () => {
const { POST } = await import('../../src/routes/api/discord/interactions/+server.js');
const result = await POST({
request: signedRequest({ ...interaction(), application_id: guildId })
});
expect(result.status).toBe(200);
expect(await result.json()).toMatchObject({
type: 4,
data: { flags: 64, content: expect.stringContaining('EC_DISCORD_APPLICATION_ID') }
});
expect(claimInteraction).not.toHaveBeenCalled();
expect(handle).not.toHaveBeenCalled(); expect(handle).not.toHaveBeenCalled();
}); });
it.each(['ec:d:01234567890123456789', 'ec:b:t5:all:0:', 'ec:maps:t5', 'ec:leader:t5'])(
'acknowledges %s before any database work, even when the replay write fails',
async (customId) => {
const fetcher = vi.fn().mockResolvedValue(new Response('{}'));
vi.stubGlobal('fetch', fetcher);
claimInteraction.mockImplementation(() => {
throw new Error('private database failure');
});
const { POST } = await import('../../src/routes/api/discord/interactions/+server.js');
const result = await POST({
request: signedRequest({
...interaction(),
type: 3,
data: { custom_id: customId, component_type: 2 }
})
});
expect(result.status).toBe(200);
expect(await result.json()).toEqual({ type: 5, data: { flags: 64 } });
expect(claimInteraction).not.toHaveBeenCalled();
expect(readSettings).not.toHaveBeenCalled();
await new Promise((resolve) => setImmediate(resolve));
expect(claimInteraction).toHaveBeenCalledOnce();
expect(handle).not.toHaveBeenCalled();
expect(fetcher.mock.calls[0][1].body).toContain('could not complete');
expect(JSON.stringify(vi.mocked(console.error).mock.calls)).not.toContain(
'private database failure'
);
expect(JSON.stringify(vi.mocked(console.error).mock.calls)).not.toContain(
'private-interaction-token'
);
}
);
it('defers privately, dispatches once per interaction and delivers without bot credentials', async () => { it('defers privately, dispatches once per interaction and delivers without bot credentials', async () => {
const fetcher = vi.fn().mockResolvedValue(new Response('{}')); const fetcher = vi.fn().mockResolvedValue(new Response('{}'));
vi.stubGlobal('fetch', fetcher); vi.stubGlobal('fetch', fetcher);
@@ -139,6 +204,8 @@ describe('Discord signed webhook boundary', () => {
pending pending
); );
expect(handle).not.toHaveBeenCalled(); expect(handle).not.toHaveBeenCalled();
expect(readSettings).not.toHaveBeenCalled();
expect(claimInteraction).not.toHaveBeenCalled();
await new Promise((resolve) => setImmediate(resolve)); await new Promise((resolve) => setImmediate(resolve));
expect(handle).toHaveBeenCalledTimes(1); expect(handle).toHaveBeenCalledTimes(1);
expect(fetcher).toHaveBeenCalledOnce(); expect(fetcher).toHaveBeenCalledOnce();