diff --git a/extensions/discord/src/monitor/agent-components.ts b/extensions/discord/src/monitor/agent-components.ts index 158446b7823..597e580ff40 100644 --- a/extensions/discord/src/monitor/agent-components.ts +++ b/extensions/discord/src/monitor/agent-components.ts @@ -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: { diff --git a/extensions/discord/src/monitor/monitor.test.ts b/extensions/discord/src/monitor/monitor.test.ts index adf6223fbb7..b55e4d47d3d 100644 --- a/extensions/discord/src/monitor/monitor.test.ts +++ b/extensions/discord/src/monitor/monitor.test.ts @@ -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", () => { diff --git a/src/hooks/message-hook-mappers.test.ts b/src/hooks/message-hook-mappers.test.ts index c365f463ade..686db1a4aa9 100644 --- a/src/hooks/message-hook-mappers.test.ts +++ b/src/hooks/message-hook-mappers.test.ts @@ -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 })); diff --git a/src/hooks/message-hook-mappers.ts b/src/hooks/message-hook-mappers.ts index 987c20ffb0a..695638d143f 100644 --- a/src/hooks/message-hook-mappers.ts +++ b/src/hooks/message-hook-mappers.ts @@ -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; } diff --git a/src/plugins/interactive.test.ts b/src/plugins/interactive.test.ts index 2c67652acc2..f794cde4037 100644 --- a/src/plugins/interactive.test.ts +++ b/src/plugins/interactive.test.ts @@ -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); + }); }); diff --git a/src/plugins/interactive.ts b/src/plugins/interactive.ts index 12dfe5ee9fe..f59e889bedf 100644 --- a/src/plugins/interactive.ts +++ b/src/plugins/interactive.ts @@ -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, diff --git a/src/plugins/runtime/runtime-telegram-typing.test.ts b/src/plugins/runtime/runtime-telegram-typing.test.ts index aa9f9fe2667..3394aa1cf50 100644 --- a/src/plugins/runtime/runtime-telegram-typing.test.ts +++ b/src/plugins/runtime/runtime-telegram-typing.test.ts @@ -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(); + }); }); diff --git a/src/plugins/runtime/runtime-telegram-typing.ts b/src/plugins/runtime/runtime-telegram-typing.ts index e03c2f33fe5..3a10d5f38d1 100644 --- a/src/plugins/runtime/runtime-telegram-typing.ts +++ b/src/plugins/runtime/runtime-telegram-typing.ts @@ -19,7 +19,10 @@ export async function createTelegramTypingLease(params: CreateTelegramTypingLeas refresh: () => Promise; 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 () => {