From eb4e96573a1e6dc07ee68d3b29bff46b08c3f673 Mon Sep 17 00:00:00 2001 From: huntharo Date: Sat, 14 Mar 2026 22:24:21 -0400 Subject: [PATCH] Discord: route bound DMs to plugins --- .../reply/dispatch-from-config.test.ts | 67 +++++++++++++++++++ src/hooks/message-hook-mappers.test.ts | 26 ++++++- src/hooks/message-hook-mappers.ts | 27 ++++++++ 3 files changed, 119 insertions(+), 1 deletion(-) diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index ddbbbf5eb78..94c978a3904 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -2110,6 +2110,73 @@ describe("dispatchReplyFromConfig", () => { expect(replyResolver).not.toHaveBeenCalled(); }); + it("routes plugin-owned Discord DM bindings to the owning plugin before generic inbound claim broadcast", async () => { + setNoAbort(); + hookMocks.runner.hasHooks.mockImplementation( + ((hookName?: string) => + hookName === "inbound_claim" || hookName === "message_received") as () => boolean, + ); + sessionBindingMocks.resolveByConversation.mockReturnValue({ + bindingId: "binding-dm-1", + targetSessionKey: "plugin-binding:codex:dm123", + targetKind: "session", + conversation: { + channel: "discord", + accountId: "default", + conversationId: "user:1177378744822943744", + }, + status: "active", + boundAt: 1710000000000, + metadata: { + pluginBindingOwner: "plugin", + pluginId: "openclaw-codex-app-server", + pluginRoot: "/Users/huntharo/github/openclaw-app-server", + }, + } satisfies SessionBindingRecord); + const cfg = emptyConfig; + const dispatcher = createDispatcher(); + const ctx = buildTestCtx({ + Provider: "discord", + Surface: "discord", + OriginatingChannel: "discord", + From: "discord:1177378744822943744", + OriginatingTo: "channel:1480574946919846079", + To: "channel:1480574946919846079", + AccountId: "default", + SenderId: "user-9", + SenderUsername: "ada", + CommandAuthorized: true, + WasMentioned: false, + CommandBody: "who are you", + RawBody: "who are you", + Body: "who are you", + MessageSid: "msg-claim-plugin-dm-1", + SessionKey: "agent:main:discord:user:1177378744822943744", + }); + const replyResolver = vi.fn(async () => ({ text: "should not run" }) satisfies ReplyPayload); + + const result = await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); + + expect(result).toEqual({ queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } }); + expect(sessionBindingMocks.touch).toHaveBeenCalledWith("binding-dm-1"); + expect(hookMocks.runner.runInboundClaimForPlugin).toHaveBeenCalledWith( + "openclaw-codex-app-server", + expect.objectContaining({ + channel: "discord", + accountId: "default", + conversationId: "user:1177378744822943744", + content: "who are you", + }), + expect.objectContaining({ + channelId: "discord", + accountId: "default", + conversationId: "user:1177378744822943744", + }), + ); + expect(hookMocks.runner.runInboundClaim).not.toHaveBeenCalled(); + expect(replyResolver).not.toHaveBeenCalled(); + }); + it("marks diagnostics skipped for duplicate inbound messages", async () => { setNoAbort(); const cfg = { diagnostics: { enabled: true } } as OpenClawConfig; diff --git a/src/hooks/message-hook-mappers.test.ts b/src/hooks/message-hook-mappers.test.ts index 686db1a4aa9..53660054a15 100644 --- a/src/hooks/message-hook-mappers.test.ts +++ b/src/hooks/message-hook-mappers.test.ts @@ -116,7 +116,31 @@ describe("message hook mappers", () => { expect(toPluginInboundClaimContext(canonical)).toEqual({ channelId: "discord", accountId: "acc-1", - conversationId: "123456789012345678", + conversationId: "channel:123456789012345678", + parentConversationId: undefined, + senderId: "sender-1", + messageId: "msg-1", + }); + }); + + it("normalizes Discord DM targets for inbound claim contexts", () => { + const canonical = deriveInboundMessageHookContext( + makeInboundCtx({ + Provider: "discord", + Surface: "discord", + OriginatingChannel: "discord", + From: "discord:1177378744822943744", + To: "channel:1480574946919846079", + OriginatingTo: "channel:1480574946919846079", + GroupChannel: undefined, + GroupSubject: undefined, + }), + ); + + expect(toPluginInboundClaimContext(canonical)).toEqual({ + channelId: "discord", + accountId: "acc-1", + conversationId: "user:1177378744822943744", parentConversationId: undefined, senderId: "sender-1", messageId: "msg-1", diff --git a/src/hooks/message-hook-mappers.ts b/src/hooks/message-hook-mappers.ts index 695638d143f..968a4d50719 100644 --- a/src/hooks/message-hook-mappers.ts +++ b/src/hooks/message-hook-mappers.ts @@ -179,6 +179,33 @@ function deriveParentConversationId( } function deriveConversationId(canonical: CanonicalInboundMessageHookContext): string | undefined { + if (canonical.channelId === "discord") { + const rawTarget = canonical.to ?? canonical.originatingTo ?? canonical.conversationId; + const rawSender = canonical.from; + const senderUserId = rawSender?.startsWith("discord:user:") + ? rawSender.slice("discord:user:".length) + : rawSender?.startsWith("discord:") + ? rawSender.slice("discord:".length) + : undefined; + if (!canonical.isGroup && senderUserId) { + return `user:${senderUserId}`; + } + if (!rawTarget) { + return undefined; + } + if (rawTarget.startsWith("discord:channel:")) { + return `channel:${rawTarget.slice("discord:channel:".length)}`; + } + if (rawTarget.startsWith("discord:user:")) { + return `user:${rawTarget.slice("discord:user:".length)}`; + } + if (rawTarget.startsWith("discord:")) { + return `user:${rawTarget.slice("discord:".length)}`; + } + if (rawTarget.startsWith("channel:") || rawTarget.startsWith("user:")) { + return rawTarget; + } + } const baseConversationId = stripChannelPrefix( canonical.to ?? canonical.originatingTo ?? canonical.conversationId, canonical.channelId,