mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-24 08:21:39 +00:00
Discord: ack plugin interactions before dispatch
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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" })],
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"]>
|
||||
|
||||
Reference in New Issue
Block a user