Discord: ack plugin interactions before dispatch

This commit is contained in:
huntharo
2026-03-21 09:09:18 -04:00
parent 262868358f
commit 37ff484bbf
4 changed files with 132 additions and 12 deletions

View File

@@ -121,10 +121,34 @@ async function dispatchPluginDiscordInteractiveEvent(params: {
? `channel:${params.interactionCtx.channelId}`
: `user:${params.interactionCtx.userId}`;
let responded = false;
let acknowledged = false;
const updateOriginalMessage = async (input: {
text?: string;
components?: TopLevelComponents[];
}) => {
const payload = {
...(input.text !== undefined ? { content: input.text } : {}),
...(input.components !== undefined ? { components: input.components } : {}),
};
if (acknowledged) {
// Carbon edits @original on reply() after acknowledge(), which preserves
// plugin edit/clear flows without consuming a second interaction callback.
await params.interaction.reply(payload);
return;
}
if (!("update" in params.interaction) || typeof params.interaction.update !== "function") {
throw new Error("Discord interaction cannot update the source message");
}
await params.interaction.update(payload);
};
const respond: PluginInteractiveDiscordHandlerContext["respond"] = {
acknowledge: async () => {
responded = true;
if (responded) {
return;
}
await params.interaction.acknowledge();
acknowledged = true;
responded = true;
},
reply: async ({ text, ephemeral = true }: { text: string; ephemeral?: boolean }) => {
responded = true;
@@ -141,23 +165,17 @@ async function dispatchPluginDiscordInteractiveEvent(params: {
});
},
editMessage: async (input) => {
if (!("update" in params.interaction) || typeof params.interaction.update !== "function") {
throw new Error("Discord interaction cannot update the source message");
}
const { text, components } = input;
responded = true;
await params.interaction.update({
...(text !== undefined ? { content: text } : {}),
...(components !== undefined ? { components: components as TopLevelComponents[] } : {}),
await updateOriginalMessage({
text,
components: components as TopLevelComponents[] | undefined,
});
},
clearComponents: async (input?: { text?: string }) => {
if (!("update" in params.interaction) || typeof params.interaction.update !== "function") {
throw new Error("Discord interaction cannot clear components on the source message");
}
responded = true;
await params.interaction.update({
...(input?.text !== undefined ? { content: input.text } : {}),
await updateOriginalMessage({
text: input?.text,
components: [],
});
},
@@ -224,6 +242,13 @@ async function dispatchPluginDiscordInteractiveEvent(params: {
},
},
respond,
onMatched: async () => {
try {
await respond.acknowledge();
} catch {
// Interaction may have expired before the plugin handler ran.
}
},
});
if (!dispatched.matched) {
return "unmatched";

View File

@@ -885,6 +885,43 @@ describe("discord component interactions", () => {
expect(dispatchReplyMock).not.toHaveBeenCalled();
});
it("lets plugin Discord interactions clear components after acknowledging", async () => {
registerDiscordComponentEntries({
entries: [createButtonEntry({ callbackData: "codex:approve" })],
modals: [],
});
dispatchPluginInteractiveHandlerMock.mockImplementation(async (params: any) => {
await params.respond.acknowledge();
await params.respond.clearComponents({ text: "Handled" });
return {
matched: true,
handled: true,
duplicate: false,
};
});
const button = createDiscordComponentButton(createComponentContext());
const acknowledge = vi.fn().mockResolvedValue(undefined);
const reply = vi.fn().mockResolvedValue(undefined);
const update = vi.fn().mockResolvedValue(undefined);
const interaction = {
...(createComponentButtonInteraction().interaction as any),
acknowledge,
reply,
update,
} as ButtonInteraction;
await button.run(interaction, { cid: "btn_1" } as ComponentData);
expect(acknowledge).toHaveBeenCalledTimes(1);
expect(reply).toHaveBeenCalledWith({
content: "Handled",
components: [],
});
expect(update).not.toHaveBeenCalled();
expect(dispatchReplyMock).not.toHaveBeenCalled();
});
it("falls through to built-in Discord component routing when a plugin declines handling", async () => {
registerDiscordComponentEntries({
entries: [createButtonEntry({ callbackData: "codex:approve" })],

View File

@@ -313,6 +313,58 @@ describe("plugin interactive handlers", () => {
});
});
it("acknowledges matched Discord interactions before awaiting plugin handlers", async () => {
const callOrder: string[] = [];
const handler = vi.fn(async () => {
callOrder.push("handler");
expect(callOrder).toEqual(["ack", "handler"]);
return { handled: true };
});
expect(
registerPluginInteractiveHandler("codex-plugin", {
channel: "discord",
namespace: "codex",
handler,
}),
).toEqual({ ok: true });
await expect(
dispatchPluginInteractiveHandler({
channel: "discord",
data: "codex:approve:thread-1",
interactionId: "ix-ack-1",
ctx: {
accountId: "default",
interactionId: "ix-ack-1",
conversationId: "channel-1",
parentConversationId: "parent-1",
guildId: "guild-1",
senderId: "user-1",
senderUsername: "ada",
auth: { isAuthorizedSender: true },
interaction: {
kind: "button",
messageId: "message-1",
},
},
respond: {
acknowledge: vi.fn(async () => {}),
reply: vi.fn(async () => {}),
followUp: vi.fn(async () => {}),
editMessage: vi.fn(async () => {}),
clearComponents: vi.fn(async () => {}),
},
onMatched: async () => {
callOrder.push("ack");
},
}),
).resolves.toEqual({
matched: true,
handled: true,
duplicate: false,
});
});
it("routes Slack interactions by namespace and dedupes interaction ids", async () => {
const handler = vi.fn(async () => ({ handled: true }));
expect(

View File

@@ -168,6 +168,7 @@ export async function dispatchPluginInteractiveHandler(params: {
clearButtons: () => Promise<void>;
deleteMessage: () => Promise<void>;
};
onMatched?: () => Promise<void> | void;
}): Promise<InteractiveDispatchResult>;
export async function dispatchPluginInteractiveHandler(params: {
channel: "discord";
@@ -175,6 +176,7 @@ export async function dispatchPluginInteractiveHandler(params: {
interactionId: string;
ctx: DiscordInteractiveDispatchContext;
respond: PluginInteractiveDiscordHandlerContext["respond"];
onMatched?: () => Promise<void> | void;
}): Promise<InteractiveDispatchResult>;
export async function dispatchPluginInteractiveHandler(params: {
channel: "slack";
@@ -182,6 +184,7 @@ export async function dispatchPluginInteractiveHandler(params: {
interactionId: string;
ctx: SlackInteractiveDispatchContext;
respond: PluginInteractiveSlackHandlerContext["respond"];
onMatched?: () => Promise<void> | void;
}): Promise<InteractiveDispatchResult>;
export async function dispatchPluginInteractiveHandler(params: {
channel: "telegram" | "discord" | "slack";
@@ -205,6 +208,7 @@ export async function dispatchPluginInteractiveHandler(params: {
}
| PluginInteractiveDiscordHandlerContext["respond"]
| PluginInteractiveSlackHandlerContext["respond"];
onMatched?: () => Promise<void> | void;
}): Promise<InteractiveDispatchResult> {
const match = resolveNamespaceMatch(params.channel, params.data);
if (!match) {
@@ -217,6 +221,8 @@ export async function dispatchPluginInteractiveHandler(params: {
return { matched: true, handled: true, duplicate: true };
}
await params.onMatched?.();
let result:
| ReturnType<PluginInteractiveTelegramHandlerRegistration["handler"]>
| ReturnType<PluginInteractiveDiscordHandlerRegistration["handler"]>