From 38394ab3a87f726540f95cb3147295d49efcd9d7 Mon Sep 17 00:00:00 2001 From: huntharo Date: Sat, 14 Mar 2026 21:38:07 -0400 Subject: [PATCH] Discord: support direct plugin conversation binds --- .../monitor/thread-bindings.discord-api.ts | 2 +- .../monitor/thread-bindings.lifecycle.test.ts | 52 +++++++++++++++++++ .../src/monitor/thread-bindings.manager.ts | 18 ++++++- 3 files changed, 69 insertions(+), 3 deletions(-) diff --git a/extensions/discord/src/monitor/thread-bindings.discord-api.ts b/extensions/discord/src/monitor/thread-bindings.discord-api.ts index 38360b27728..134eda0f109 100644 --- a/extensions/discord/src/monitor/thread-bindings.discord-api.ts +++ b/extensions/discord/src/monitor/thread-bindings.discord-api.ts @@ -17,7 +17,7 @@ import { } from "./thread-bindings.types.js"; function buildThreadTarget(threadId: string): string { - return `channel:${threadId}`; + return /^(channel:|user:)/i.test(threadId) ? threadId : `channel:${threadId}`; } export function isThreadArchived(raw: unknown): boolean { diff --git a/extensions/discord/src/monitor/thread-bindings.lifecycle.test.ts b/extensions/discord/src/monitor/thread-bindings.lifecycle.test.ts index 013952e7c71..3efd8c52584 100644 --- a/extensions/discord/src/monitor/thread-bindings.lifecycle.test.ts +++ b/extensions/discord/src/monitor/thread-bindings.lifecycle.test.ts @@ -7,6 +7,7 @@ import { setRuntimeConfigSnapshot, type OpenClawConfig, } from "../../../../src/config/config.js"; +import { getSessionBindingService } from "../../../../src/infra/outbound/session-binding-service.js"; const hoisted = vi.hoisted(() => { const sendMessageDiscord = vi.fn(async (_to: string, _text: string, _opts?: unknown) => ({})); @@ -788,6 +789,57 @@ describe("thread binding lifecycle", () => { expect(usedTokenNew).toBe(true); }); + it("binds current Discord DMs as direct conversation bindings", async () => { + createThreadBindingManager({ + accountId: "default", + persist: false, + enableSweeper: false, + idleTimeoutMs: 24 * 60 * 60 * 1000, + maxAgeMs: 0, + }); + + hoisted.restGet.mockClear(); + hoisted.restPost.mockClear(); + + const bound = await getSessionBindingService().bind({ + targetSessionKey: "plugin-binding:openclaw-codex-app-server:dm", + targetKind: "session", + conversation: { + channel: "discord", + accountId: "default", + conversationId: "user:1177378744822943744", + }, + placement: "current", + metadata: { + pluginBindingOwner: "plugin", + pluginId: "openclaw-codex-app-server", + pluginRoot: "/Users/huntharo/github/openclaw-app-server", + }, + }); + + expect(bound).toMatchObject({ + conversation: { + channel: "discord", + accountId: "default", + conversationId: "user:1177378744822943744", + parentConversationId: "user:1177378744822943744", + }, + }); + expect( + getSessionBindingService().resolveByConversation({ + channel: "discord", + accountId: "default", + conversationId: "user:1177378744822943744", + }), + ).toMatchObject({ + conversation: { + conversationId: "user:1177378744822943744", + }, + }); + expect(hoisted.restGet).not.toHaveBeenCalled(); + expect(hoisted.restPost).not.toHaveBeenCalled(); + }); + it("keeps overlapping thread ids isolated per account", async () => { const a = createThreadBindingManager({ accountId: "a", diff --git a/extensions/discord/src/monitor/thread-bindings.manager.ts b/extensions/discord/src/monitor/thread-bindings.manager.ts index f2b544af36e..efa599cadc2 100644 --- a/extensions/discord/src/monitor/thread-bindings.manager.ts +++ b/extensions/discord/src/monitor/thread-bindings.manager.ts @@ -117,6 +117,11 @@ function toThreadBindingTargetKind(raw: BindingTargetKind): "subagent" | "acp" { return raw === "subagent" ? "subagent" : "acp"; } +function isDirectConversationBindingId(value?: string | null): boolean { + const trimmed = value?.trim(); + return Boolean(trimmed && /^(user:|channel:)/i.test(trimmed)); +} + function toSessionBindingRecord( record: ThreadBindingRecord, defaults: { idleTimeoutMs: number; maxAgeMs: number }, @@ -265,6 +270,8 @@ export function createThreadBindingManager( const cfg = resolveCurrentCfg(); let threadId = normalizeThreadId(bindParams.threadId); let channelId = bindParams.channelId?.trim() || ""; + const directConversationBinding = + isDirectConversationBindingId(threadId) || isDirectConversationBindingId(channelId); if (!threadId && bindParams.createThread) { if (!channelId) { @@ -288,6 +295,10 @@ export function createThreadBindingManager( return null; } + if (!channelId && directConversationBinding) { + channelId = threadId; + } + if (!channelId) { channelId = (await resolveChannelIdForBinding({ @@ -310,12 +321,12 @@ export function createThreadBindingManager( const targetKind = normalizeTargetKind(bindParams.targetKind, targetSessionKey); let webhookId = bindParams.webhookId?.trim() || ""; let webhookToken = bindParams.webhookToken?.trim() || ""; - if (!webhookId || !webhookToken) { + if (!directConversationBinding && (!webhookId || !webhookToken)) { const cachedWebhook = findReusableWebhook({ accountId, channelId }); webhookId = cachedWebhook.webhookId ?? ""; webhookToken = cachedWebhook.webhookToken ?? ""; } - if (!webhookId || !webhookToken) { + if (!directConversationBinding && (!webhookId || !webhookToken)) { const createdWebhook = await createWebhookForChannel({ cfg, accountId, @@ -513,6 +524,9 @@ export function createThreadBindingManager( }); continue; } + if (isDirectConversationBindingId(binding.threadId)) { + continue; + } try { const channel = await rest.get(Routes.channel(binding.threadId)); if (!channel || typeof channel !== "object") {