diff --git a/src/lib/server/api/discord.ts b/src/lib/server/api/discord.ts index 48e78ff..aa024a4 100644 --- a/src/lib/server/api/discord.ts +++ b/src/lib/server/api/discord.ts @@ -6,6 +6,7 @@ import type { Logger } from 'pino'; import { AllowedMentionType } from '$lib/server/models/discord/allowed-mentions'; import { EmbedType } from '$lib/server/models/discord/embed'; import type { InteractionResponse } from '$lib/server/models/discord/interaction-response'; +import { InteractionResponseType } from '$lib/server/models/discord/interaction-response/base'; import { MessageComponentButtonStyle } from '$lib/server/models/discord/message/component/button/base'; import { MessageComponentType } from '$lib/server/models/discord/message/component/base'; import { MessageFlags } from '$lib/server/models/discord/message/base'; @@ -239,6 +240,53 @@ export async function logResentConfessionViaHttp( ); } +async function createInteractionResponse( + logger: Logger, + interactionId: Snowflake, + interactionToken: string, + data: InteractionResponse, + botToken: string, +) { + const body = JSON.stringify(data, (_, value) => (typeof value === 'bigint' ? value.toString() : value)); + + const start = performance.now(); + const response = await fetch(`${DISCORD_API_BASE_URL}/interactions/${interactionId}/${interactionToken}/callback`, { + body, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bot ${botToken}`, + }, + }); + const createInteractionResponseTimeMillis = performance.now() - start; + const child = logger.child({ createInteractionResponseTimeMillis }); + + if (response.status === 204) { + child.info('interaction response created'); + return null; + } + + const json = await response.json(); + const { code, message } = parse(DiscordError, json); + child.error({ statusCode: response.status }, message); + return code; +} + +export async function deferResponse( + logger: Logger, + interactionId: Snowflake, + interactionToken: string, + botToken = DISCORD_BOT_TOKEN, +) { + return await createInteractionResponse( + logger, + interactionId, + interactionToken, + { type: InteractionResponseType.DeferredChannelMessageWithSource, data: { flags: MessageFlags.Ephemeral } }, + botToken, + ); +} + export async function editOriginalInteractionResponse( logger: Logger, appId: Snowflake, diff --git a/src/routes/webhook/discord/interaction/+server.ts b/src/routes/webhook/discord/interaction/+server.ts index 6acfbc4..ecae567 100644 --- a/src/routes/webhook/discord/interaction/+server.ts +++ b/src/routes/webhook/discord/interaction/+server.ts @@ -36,7 +36,7 @@ async function handleInteraction( logger: Logger, // TODO: Fine-grained database-level performance logs. timestamp: Date, interaction: DeserializedInteraction, -): Promise { +): Promise { // eslint-disable-next-line default-case switch (interaction.type) { case InteractionType.Ping: @@ -56,15 +56,13 @@ async function handleInteraction( logger, timestamp, interaction.application_id, + interaction.id, interaction.token, interaction.channel_id, interaction.member.user.id, interaction.data.options, ); - return { - type: InteractionResponseType.DeferredChannelMessageWithSource, - data: { flags: MessageFlags.Ephemeral }, - }; + return null; case 'help': return { type: InteractionResponseType.ChannelMessageWithSource, @@ -111,15 +109,13 @@ async function handleInteraction( logger, timestamp, interaction.application_id, + interaction.id, interaction.token, interaction.channel_id, interaction.member.user.id, interaction.data.options, ); - return { - type: InteractionResponseType.DeferredChannelMessageWithSource, - data: { flags: MessageFlags.Ephemeral }, - }; + return null; case 'info': return { type: InteractionResponseType.ChannelMessageWithSource, @@ -178,16 +174,14 @@ async function handleInteraction( logger, timestamp, interaction.application_id, + interaction.id, interaction.token, interaction.channel_id, interaction.member.user.id, interaction.member.permissions, interaction.data.components, ); - return { - type: InteractionResponseType.DeferredChannelMessageWithSource, - data: { flags: MessageFlags.Ephemeral }, - }; + return null; default: fail(`unexpected modal submit ${interaction.data.custom_id}`); break; @@ -224,7 +218,7 @@ export async function POST({ locals: { ctx }, request }) { const interactionTimeMillis = performance.now() - start; logger.info({ interactionTimeMillis }, 'interaction processed'); - return json(response); + return response === null ? new Response(null, { status: 202 }) : json(response); } error(401); diff --git a/src/routes/webhook/discord/interaction/confess.ts b/src/routes/webhook/discord/interaction/confess.ts index 8ab6440..4e166fe 100644 --- a/src/routes/webhook/discord/interaction/confess.ts +++ b/src/routes/webhook/discord/interaction/confess.ts @@ -56,7 +56,8 @@ async function submitConfession( logger: Logger, timestamp: Date, appId: Snowflake, - token: string, + interactionId: Snowflake, + interactionToken: string, confessionChannelId: Snowflake, authorId: Snowflake, description: string, @@ -99,7 +100,7 @@ async function submitConfession( logger.info({ internalId, confessionId }, 'confession pending approval submitted'); // Promise is ignored so that it runs in the background - void doDeferredResponse(logger, appId, token, async () => { + void doDeferredResponse(logger, appId, interactionId, interactionToken, async () => { const discordErrorCode = await logPendingConfessionViaHttp( logger, timestamp, @@ -148,7 +149,7 @@ async function submitConfession( logger.info({ internalId, confessionId }, 'confession submitted'); // Promise is ignored so that it runs in the background - void doDeferredResponse(logger, appId, token, async () => { + void doDeferredResponse(logger, appId, interactionId, interactionToken, async () => { const message = await dispatchConfessionViaHttp( logger, timestamp, @@ -203,7 +204,8 @@ export async function handleConfess( logger: Logger, timestamp: Date, appId: Snowflake, - token: string, + interactionId: Snowflake, + interactionToken: string, channelId: Snowflake, authorId: Snowflake, [option, ...options]: InteractionApplicationCommandChatInputOption[], @@ -212,7 +214,17 @@ export async function handleConfess( strictEqual(option?.type, InteractionApplicationCommandChatInputOptionType.String); strictEqual(option.name, 'content'); try { - await submitConfession(db, logger, timestamp, appId, token, channelId, authorId, option.value); + await submitConfession( + db, + logger, + timestamp, + appId, + interactionId, + interactionToken, + channelId, + authorId, + option.value, + ); } catch (err) { if (err instanceof ConfessError) { logger.error(err, err.message); diff --git a/src/routes/webhook/discord/interaction/reply-submit.ts b/src/routes/webhook/discord/interaction/reply-submit.ts index df0254a..28f606f 100644 --- a/src/routes/webhook/discord/interaction/reply-submit.ts +++ b/src/routes/webhook/discord/interaction/reply-submit.ts @@ -58,7 +58,8 @@ async function submitReply( logger: Logger, timestamp: Date, appId: Snowflake, - token: string, + interactionId: Snowflake, + interactionToken: string, permissions: bigint, confessionChannelId: Snowflake, parentMessageId: Snowflake, @@ -105,7 +106,7 @@ async function submitReply( logger.info({ internalId, confessionId }, 'reply pending approval submitted'); // Promise is ignored so that it runs in the background - void doDeferredResponse(logger, appId, token, async () => { + void doDeferredResponse(logger, appId, interactionId, interactionToken, async () => { const discordErrorCode = await logPendingConfessionViaHttp( logger, timestamp, @@ -154,7 +155,7 @@ async function submitReply( logger.info({ internalId, confessionId }, 'reply submitted'); // Promise is ignored so that it runs in the background - void doDeferredResponse(logger, appId, token, async () => { + void doDeferredResponse(logger, appId, interactionId, interactionToken, async () => { const message = await dispatchConfessionViaHttp( logger, timestamp, @@ -208,7 +209,8 @@ export async function handleReplySubmit( logger: Logger, timestamp: Date, appId: Snowflake, - token: string, + interactionId: Snowflake, + interactionToken: string, channelId: Snowflake, authorId: Snowflake, permissions: bigint, @@ -231,7 +233,8 @@ export async function handleReplySubmit( logger, timestamp, appId, - token, + interactionId, + interactionToken, permissions, channelId, parentMessageId, diff --git a/src/routes/webhook/discord/interaction/resend.ts b/src/routes/webhook/discord/interaction/resend.ts index 6c93ea5..8b03d84 100644 --- a/src/routes/webhook/discord/interaction/resend.ts +++ b/src/routes/webhook/discord/interaction/resend.ts @@ -54,7 +54,8 @@ async function resendConfession( logger: Logger, timestamp: Date, appId: Snowflake, - token: string, + interactionId: Snowflake, + interactionToken: string, confessionChannelId: Snowflake, confessionId: bigint, moderatorId: Snowflake, @@ -88,7 +89,7 @@ async function resendConfession( logger.info('confession resend has been submitted'); // Promise is ignored so that it runs in the background - void doDeferredResponse(logger, appId, token, async () => { + void doDeferredResponse(logger, appId, interactionId, interactionToken, async () => { const message = await dispatchConfessionViaHttp( logger, createdAt, @@ -145,7 +146,8 @@ export async function handleResend( logger: Logger, timestamp: Date, appId: Snowflake, - token: string, + interactionId: Snowflake, + interactionToken: string, channelId: Snowflake, moderatorId: Snowflake, [option, ...options]: InteractionApplicationCommandChatInputOption[], @@ -156,7 +158,17 @@ export async function handleResend( const confessionId = BigInt(option.value); try { - await resendConfession(db, logger, timestamp, appId, token, channelId, confessionId, moderatorId); + await resendConfession( + db, + logger, + timestamp, + appId, + interactionId, + interactionToken, + channelId, + confessionId, + moderatorId, + ); } catch (err) { if (err instanceof ResendError) { logger.error(err, err.message); diff --git a/src/routes/webhook/discord/interaction/util.ts b/src/routes/webhook/discord/interaction/util.ts index 2370c57..f568dc3 100644 --- a/src/routes/webhook/discord/interaction/util.ts +++ b/src/routes/webhook/discord/interaction/util.ts @@ -1,4 +1,3 @@ -import { setTimeout } from 'node:timers/promises'; import { strictEqual } from 'node:assert/strict'; import type { Logger } from 'pino'; @@ -7,18 +6,26 @@ import type { InteractionApplicationCommandChatInputOption } from '$lib/server/m import { InteractionApplicationCommandChatInputOptionType } from '$lib/server/models/discord/interaction/application-command/chat-input/option/base'; import type { Snowflake } from '$lib/server/models/discord/snowflake'; -import { editOriginalInteractionResponse } from '$lib/server/api/discord'; +import { deferResponse, editOriginalInteractionResponse } from '$lib/server/api/discord'; +import { UnexpectedDiscordErrorCode } from './errors'; export async function doDeferredResponse( logger: Logger, appId: Snowflake, - token: string, + interactionId: Snowflake, + interactionToken: string, callback: () => Promise, ) { - // HACK: Waiting for our server to respond to Discord. - await setTimeout(2000); const start = performance.now(); - await editOriginalInteractionResponse(logger, appId, token, { content: await callback() }); + { + const result = await deferResponse(logger, interactionId, interactionToken); + if (typeof result === 'number') throw new UnexpectedDiscordErrorCode(result); + } + { + const content = await callback(); + const result = await editOriginalInteractionResponse(logger, appId, interactionToken, { content }); + if (typeof result === 'number') throw new UnexpectedDiscordErrorCode(result); + } const deferredResponseTimeMillis = performance.now() - start; logger.info({ deferredResponseTimeMillis }, 'deferred response complete'); }