fix(plugins): tighten interactive callback handling

This commit is contained in:
Vincent Koc
2026-03-13 14:22:55 -07:00
parent 50df0bb00e
commit 0f4775148c
8 changed files with 132 additions and 3 deletions

View File

@@ -877,8 +877,9 @@ async function dispatchPluginDiscordInteractiveEvent(params: {
} catch {
// Interaction may have expired after the handler finished.
}
return "handled";
}
return "handled";
return "unmatched";
}
function resolveComponentCommandAuthorized(params: {

View File

@@ -598,6 +598,27 @@ describe("discord component interactions", () => {
);
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" })],
modals: [],
});
dispatchPluginInteractiveHandlerMock.mockResolvedValue({
matched: true,
handled: false,
duplicate: false,
});
const button = createDiscordComponentButton(createComponentContext());
const { interaction, reply } = createComponentButtonInteraction();
await button.run(interaction, { cid: "btn_1" } as ComponentData);
expect(dispatchPluginInteractiveHandlerMock).toHaveBeenCalledTimes(1);
expect(reply).toHaveBeenCalledWith({ content: "✓" });
expect(dispatchReplyMock).toHaveBeenCalledTimes(1);
});
});
describe("resolveDiscordOwnerAllowFrom", () => {

View File

@@ -4,6 +4,7 @@ import type { OpenClawConfig } from "../config/config.js";
import {
buildCanonicalSentMessageHookContext,
deriveInboundMessageHookContext,
toPluginInboundClaimContext,
toInternalMessagePreprocessedContext,
toInternalMessageReceivedContext,
toInternalMessageSentContext,
@@ -99,6 +100,29 @@ describe("message hook mappers", () => {
});
});
it("normalizes Discord channel targets for inbound claim contexts", () => {
const canonical = deriveInboundMessageHookContext(
makeInboundCtx({
Provider: "discord",
Surface: "discord",
OriginatingChannel: "discord",
To: "channel:123456789012345678",
OriginatingTo: "channel:123456789012345678",
GroupChannel: "general",
GroupSubject: "guild",
}),
);
expect(toPluginInboundClaimContext(canonical)).toEqual({
channelId: "discord",
accountId: "acc-1",
conversationId: "123456789012345678",
parentConversationId: undefined,
senderId: "sender-1",
messageId: "msg-1",
});
});
it("maps transcribed and preprocessed internal payloads", () => {
const cfg = {} as OpenClawConfig;
const canonical = deriveInboundMessageHookContext(makeInboundCtx({ Transcript: undefined }));

View File

@@ -153,6 +153,12 @@ function stripChannelPrefix(value: string | undefined, channelId: string): strin
if (!value) {
return undefined;
}
const genericPrefixes = ["channel:", "chat:", "user:"];
for (const prefix of genericPrefixes) {
if (value.startsWith(prefix)) {
return value.slice(prefix.length);
}
}
const prefix = `${channelId}:`;
return value.startsWith(prefix) ? value.slice(prefix.length) : value;
}

View File

@@ -146,4 +146,56 @@ describe("plugin interactive handlers", () => {
}),
);
});
it("does not consume dedupe keys when a handler throws", async () => {
const handler = vi
.fn(async () => ({ handled: true }))
.mockRejectedValueOnce(new Error("boom"))
.mockResolvedValueOnce({ handled: true });
expect(
registerPluginInteractiveHandler("codex-plugin", {
channel: "telegram",
namespace: "codex",
handler,
}),
).toEqual({ ok: true });
const baseParams = {
channel: "telegram" as const,
data: "codex:resume:thread-1",
callbackId: "cb-throw",
ctx: {
accountId: "default",
callbackId: "cb-throw",
conversationId: "-10099:topic:77",
parentConversationId: "-10099",
senderId: "user-1",
senderUsername: "ada",
threadId: 77,
isGroup: true,
isForum: true,
auth: { isAuthorizedSender: true },
callbackMessage: {
messageId: 55,
chatId: "-10099",
messageText: "Pick a thread",
},
},
respond: {
reply: vi.fn(async () => {}),
editMessage: vi.fn(async () => {}),
editButtons: vi.fn(async () => {}),
clearButtons: vi.fn(async () => {}),
deleteMessage: vi.fn(async () => {}),
},
};
await expect(dispatchPluginInteractiveHandler(baseParams)).rejects.toThrow("boom");
await expect(dispatchPluginInteractiveHandler(baseParams)).resolves.toEqual({
matched: true,
handled: true,
duplicate: false,
});
expect(handler).toHaveBeenCalledTimes(2);
});
});

View File

@@ -187,7 +187,7 @@ export async function dispatchPluginInteractiveHandler(params: {
const dedupeKey =
params.channel === "telegram" ? params.callbackId?.trim() : params.interactionId?.trim();
if (dedupeKey && callbackDedupe.check(dedupeKey)) {
if (dedupeKey && callbackDedupe.peek(dedupeKey)) {
return { matched: true, handled: true, duplicate: true };
}
@@ -253,6 +253,9 @@ export async function dispatchPluginInteractiveHandler(params: {
});
}
const resolved = await result;
if (dedupeKey) {
callbackDedupe.check(dedupeKey);
}
return {
matched: true,

View File

@@ -61,4 +61,23 @@ describe("createTelegramTypingLease", () => {
lease.stop();
});
it("falls back to the default interval for non-finite values", async () => {
vi.useFakeTimers();
const pulse = vi.fn(async () => undefined);
const lease = await createTelegramTypingLease({
to: "telegram:123",
intervalMs: Number.NaN,
pulse,
});
expect(pulse).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(3_999);
expect(pulse).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1);
expect(pulse).toHaveBeenCalledTimes(2);
lease.stop();
});
});

View File

@@ -19,7 +19,10 @@ export async function createTelegramTypingLease(params: CreateTelegramTypingLeas
refresh: () => Promise<void>;
stop: () => void;
}> {
const intervalMs = Math.max(1000, Math.floor(params.intervalMs ?? 4_000));
const intervalMs =
typeof params.intervalMs === "number" && Number.isFinite(params.intervalMs)
? Math.max(1_000, Math.floor(params.intervalMs))
: 4_000;
let stopped = false;
const refresh = async () => {