Skip to content

Commit

Permalink
refactor(slash): use REST API calls for deferred responses
Browse files Browse the repository at this point in the history
This should ensure that no interaction responses are edited before they
have been sent. Previously, this race condition has plagued the bot.
  • Loading branch information
BastiDood committed Dec 15, 2024
1 parent 0920822 commit c5e0f37
Show file tree
Hide file tree
Showing 6 changed files with 110 additions and 34 deletions.
48 changes: 48 additions & 0 deletions src/lib/server/api/discord.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
22 changes: 8 additions & 14 deletions src/routes/webhook/discord/interaction/+server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ async function handleInteraction(
logger: Logger, // TODO: Fine-grained database-level performance logs.
timestamp: Date,
interaction: DeserializedInteraction,
): Promise<InteractionResponse> {
): Promise<InteractionResponse | null> {
// eslint-disable-next-line default-case
switch (interaction.type) {
case InteractionType.Ping:
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
22 changes: 17 additions & 5 deletions src/routes/webhook/discord/interaction/confess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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[],
Expand All @@ -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);
Expand Down
13 changes: 8 additions & 5 deletions src/routes/webhook/discord/interaction/reply-submit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -231,7 +233,8 @@ export async function handleReplySubmit(
logger,
timestamp,
appId,
token,
interactionId,
interactionToken,
permissions,
channelId,
parentMessageId,
Expand Down
20 changes: 16 additions & 4 deletions src/routes/webhook/discord/interaction/resend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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[],
Expand All @@ -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);
Expand Down
19 changes: 13 additions & 6 deletions src/routes/webhook/discord/interaction/util.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { setTimeout } from 'node:timers/promises';
import { strictEqual } from 'node:assert/strict';

import type { Logger } from 'pino';
Expand All @@ -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<string>,
) {
// 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');
}
Expand Down

0 comments on commit c5e0f37

Please sign in to comment.