fix(discord): recover question feedback after failed acknowledgements (#128293)

This commit is contained in:
Peter Steinberger
2026-08-23 10:51:12 -07:00
committed by GitHub
parent bca206d794
commit 663a9d2f15
2 changed files with 116 additions and 15 deletions
@@ -1,6 +1,15 @@
// Discord question component feedback tests.
import { InteractionResponseType, MessageFlags } from "discord-api-types/v10";
import { describe, expect, it, vi } from "vitest";
import type { ButtonInteraction } from "../internal/discord.js";
import {
createInteraction as createDiscordInteraction,
type ButtonInteraction,
} from "../internal/discord.js";
import {
attachRestMock,
createInternalComponentInteractionPayload,
createInternalTestClient,
} from "../internal/test-builders.test-support.js";
import { createDiscordQuestionButton } from "./questions.js";
type InteractionHarness = {
@@ -91,4 +100,99 @@ describe("Discord question button", () => {
ephemeral: true,
});
});
it.each(
[
{
name: "submitted answer",
result: {
status: "answered" as const,
questionId: "target",
optionValue: "Production",
},
expectedText: "Answer submitted.",
},
{
name: "already answered question",
result: { status: "already-terminal" as const, reason: "already-terminal" as const },
expectedText: "This question was already answered.",
},
{
name: "question resolution failure",
error: new Error("question service unavailable"),
expectedText: "Could not submit this answer.",
},
].flatMap((outcome) => [
{ ...outcome, acknowledged: false },
{ ...outcome, acknowledged: true },
]),
)("delivers $name feedback when acknowledgement is $acknowledged", async (testCase) => {
const client = createInternalTestClient();
const post = vi.fn(async () => undefined);
if (!testCase.acknowledged) {
post.mockRejectedValueOnce(new Error("temporary connection reset"));
}
attachRestMock(client, { post });
const interaction = createDiscordInteraction(
client,
createInternalComponentInteractionPayload({
id: "interaction-1",
token: "interaction-token",
user: {
id: "user-1",
username: "Alice",
discriminator: "0",
avatar: null,
global_name: null,
},
}),
) as ButtonInteraction;
const resolveQuestion = vi.fn(async () => {
if (testCase.error) {
throw testCase.error;
}
return testCase.result;
});
const button = createDiscordQuestionButton({
cfg: {} as never,
accountId: "default",
authorizeQuestion: vi.fn(async () => true),
resolveQuestion: resolveQuestion as never,
});
await button.run(interaction, {
id: "ask_0123456789abcdef0123456789abcdef",
i: "1",
});
expect(resolveQuestion).toHaveBeenCalledOnce();
expect(post).toHaveBeenNthCalledWith(
1,
"/interactions/interaction-1/interaction-token/callback",
{
body: { type: InteractionResponseType.DeferredMessageUpdate },
},
);
if (testCase.acknowledged) {
expect(post).toHaveBeenNthCalledWith(
2,
"/webhooks/app1/interaction-token",
{ body: { content: testCase.expectedText, flags: MessageFlags.Ephemeral } },
undefined,
);
expect(interaction.responseState).toBe("deferred-update");
} else {
expect(post).toHaveBeenNthCalledWith(
2,
"/interactions/interaction-1/interaction-token/callback",
{
body: {
type: InteractionResponseType.ChannelMessageWithSource,
data: { content: testCase.expectedText, flags: MessageFlags.Ephemeral },
},
},
);
expect(interaction.responseState).toBe("replied");
}
});
});
+11 -14
View File
@@ -41,31 +41,28 @@ class QuestionButton extends Button {
try {
await interaction.acknowledge();
} catch {}
let result: Awaited<ReturnType<QuestionResolver>>;
let content: string;
try {
result = await this.ctx.resolveQuestion({
const result = await this.ctx.resolveQuestion({
cfg: this.ctx.cfg,
questionId: callback.questionId,
optionIndex: callback.optionIndex,
senderId: interaction.userId,
clientDisplayName: `Discord question (${this.ctx.accountId})`,
});
content =
result.status === "answered" ? "Answer submitted." : "This question was already answered.";
} catch {
try {
await interaction.followUp({ content: "Could not submit this answer.", ephemeral: true });
} catch {}
return;
content = "Could not submit this answer.";
}
try {
await interaction.followUp({
content:
result.status === "answered"
? "Answer submitted."
: "This question was already answered.",
ephemeral: true,
});
const feedback = { content, ephemeral: true };
// A rejected acknowledgement leaves the initial callback available, not the webhook.
await (interaction.responseState === "unacknowledged"
? interaction.reply(feedback)
: interaction.followUp(feedback));
} catch {
// Gateway state already committed; receipt delivery is best-effort.
// Gateway state may already be committed; receipt delivery is best-effort.
}
}
}