From 38001cdeaa2a838a3ee3f427272d9b712d65395f Mon Sep 17 00:00:00 2001 From: Zetarcos <117005244+Zetarcos@users.noreply.github.com> Date: Wed, 22 Apr 2026 13:09:26 -0700 Subject: [PATCH] fix(discord): normalize ACP thread binding targets Normalize Discord ACP thread-binding channel targets at the REST/thread-create boundary while preserving current-conversation binding keys.\n\nThanks @Zetarcos. --- CHANGELOG.md | 1 + .../thread-bindings.discord-api.test.ts | 29 ++++++ .../monitor/thread-bindings.discord-api.ts | 23 ++++- .../monitor/thread-bindings.lifecycle.test.ts | 99 +++++++++++++++++++ .../src/monitor/thread-bindings.manager.ts | 16 ++- 5 files changed, 164 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8dbd9d4727..9fc58b37dd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Discord: normalize prefixed channel targets only at the thread-binding API boundary, so `sessions_spawn({ runtime: "acp", thread: true })` can create child threads from Discord channels without breaking current-channel ACP bindings. (#68034) Thanks @Zetarcos. - Discord: harden inbound thread metadata handling against partial Carbon channel getters, so non-command thread messages and queued jobs no longer crash when `name`, `parentId`, `parent`, or `ownerId` requires fetched raw data. - Discord: let `message` tool reactions resolve `user:` DM targets and preserve `channels.discord.guilds..channels..requireMention: false` during reply-stage activation fallback. Fixes #70165 and #69441. - Plugins/startup: pre-normalize and cache Jiti alias maps before creating plugin loaders, so module-scoped loader filenames do not reintroduce per-plugin alias-normalization startup cost. Fixes #70186. diff --git a/extensions/discord/src/monitor/thread-bindings.discord-api.test.ts b/extensions/discord/src/monitor/thread-bindings.discord-api.test.ts index bafb421e04c..20b0330818a 100644 --- a/extensions/discord/src/monitor/thread-bindings.discord-api.test.ts +++ b/extensions/discord/src/monitor/thread-bindings.discord-api.test.ts @@ -70,6 +70,35 @@ describe("resolveChannelIdForBinding", () => { expect(restGet).not.toHaveBeenCalled(); }); + it("normalizes prefixed explicit channelId without resolving route", async () => { + const resolved = await resolveChannelIdForBinding({ + accountId: "default", + threadId: "thread-1", + channelId: "channel:123456789012345678", + }); + + expect(resolved).toBe("123456789012345678"); + expect(createDiscordRestClient).not.toHaveBeenCalled(); + expect(restGet).not.toHaveBeenCalled(); + }); + + it("strips channel prefix before resolving route", async () => { + restGet.mockResolvedValueOnce({ + id: "123456789012345678", + type: ChannelType.GuildText, + }); + + const resolved = await resolveChannelIdForBinding({ + accountId: "default", + threadId: "channel:123456789012345678", + }); + + expect(resolved).toBe("123456789012345678"); + const route = JSON.stringify(restGet.mock.calls[0]?.[0] ?? null); + expect(route).toContain("123456789012345678"); + expect(route).not.toContain("channel:"); + }); + it("returns parent channel for thread channels", async () => { restGet.mockResolvedValueOnce({ id: "thread-1", diff --git a/extensions/discord/src/monitor/thread-bindings.discord-api.ts b/extensions/discord/src/monitor/thread-bindings.discord-api.ts index 9ac8e58ed1b..92bbf83b2d3 100644 --- a/extensions/discord/src/monitor/thread-bindings.discord-api.ts +++ b/extensions/discord/src/monitor/thread-bindings.discord-api.ts @@ -5,6 +5,7 @@ import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; import { createDiscordRestClient } from "../client.js"; import { sendMessageDiscord, sendWebhookMessageDiscord } from "../send.js"; import { createThreadDiscord } from "../send.messages.js"; +import { resolveDiscordChannelId } from "../target-parsing.js"; import { resolveThreadBindingPersonaFromRecord } from "./thread-bindings.persona.js"; import { BINDINGS_BY_THREAD_ID, @@ -50,6 +51,18 @@ function isThreadChannelType(type: unknown): boolean { ); } +function normalizeDiscordBindingChannelId(raw?: string | null): string | null { + const trimmed = normalizeOptionalString(raw) ?? ""; + if (!trimmed) { + return null; + } + try { + return resolveDiscordChannelId(trimmed); + } catch { + return null; + } +} + export function summarizeDiscordError(err: unknown): string { if (err instanceof Error) { return err.message; @@ -233,10 +246,14 @@ export async function resolveChannelIdForBinding(params: { threadId: string; channelId?: string; }): Promise { - const explicit = params.channelId?.trim(); + const explicit = normalizeDiscordBindingChannelId(params.channelId); if (explicit) { return explicit; } + const lookupThreadId = normalizeDiscordBindingChannelId(params.threadId); + if (!lookupThreadId) { + return null; + } try { const rest = createDiscordRestClient( { @@ -245,7 +262,7 @@ export async function resolveChannelIdForBinding(params: { }, params.cfg, ).rest; - const channel = (await rest.get(Routes.channel(params.threadId))) as { + const channel = (await rest.get(Routes.channel(lookupThreadId))) as { id?: string; type?: number; parent_id?: string; @@ -267,7 +284,7 @@ export async function resolveChannelIdForBinding(params: { return channelId || null; } catch (err) { logVerbose( - `discord thread binding channel resolve failed for ${params.threadId}: ${summarizeDiscordError(err)}`, + `discord thread binding channel resolve failed for ${lookupThreadId}: ${summarizeDiscordError(err)}`, ); return null; } diff --git a/extensions/discord/src/monitor/thread-bindings.lifecycle.test.ts b/extensions/discord/src/monitor/thread-bindings.lifecycle.test.ts index 695332578a8..a63c82d7158 100644 --- a/extensions/discord/src/monitor/thread-bindings.lifecycle.test.ts +++ b/extensions/discord/src/monitor/thread-bindings.lifecycle.test.ts @@ -984,6 +984,105 @@ describe("thread binding lifecycle", () => { expect(usedTokenNew).toBe(true); }); + it("normalizes prefixed parentConversationId before creating child thread bindings", async () => { + createThreadBindingManager({ + accountId: "default", + persist: false, + enableSweeper: false, + idleTimeoutMs: 24 * 60 * 60 * 1000, + maxAgeMs: 0, + }); + + hoisted.restGet.mockClear(); + hoisted.createThreadDiscord.mockClear(); + hoisted.createThreadDiscord.mockResolvedValueOnce({ id: "thread-created-parent-normalized" }); + + const bound = await getSessionBindingService().bind({ + targetSessionKey: "agent:codex:acp:test-parent-normalized", + targetKind: "session", + conversation: { + channel: "discord", + accountId: "default", + conversationId: "channel:1491611525914558668", + parentConversationId: "channel:1491611525914558667", + }, + placement: "child", + metadata: { + agentId: "codex", + label: "Codex ACP bind test", + threadName: "Codex ACP bind test", + }, + }); + + expect(bound).toMatchObject({ + conversation: { + channel: "discord", + accountId: "default", + conversationId: "thread-created-parent-normalized", + }, + }); + expect(hoisted.createThreadDiscord).toHaveBeenCalledWith( + "1491611525914558667", + expect.objectContaining({ autoArchiveMinutes: 60 }), + expect.objectContaining({ accountId: "default" }), + ); + expect(hoisted.restGet).not.toHaveBeenCalled(); + }); + + it("preserves prefixed current channel conversation ids as binding keys", async () => { + createThreadBindingManager({ + accountId: "default", + persist: false, + enableSweeper: false, + idleTimeoutMs: 24 * 60 * 60 * 1000, + maxAgeMs: 0, + }); + + hoisted.restGet.mockClear(); + hoisted.restPost.mockClear(); + + const service = getSessionBindingService(); + const bound = await service.bind({ + targetSessionKey: "agent:codex:acp:current-channel", + targetKind: "session", + conversation: { + channel: "discord", + accountId: "default", + conversationId: "channel:1491611525914558667", + }, + placement: "current", + metadata: { + agentId: "codex", + }, + }); + + expect(bound).toMatchObject({ + conversation: { + channel: "discord", + accountId: "default", + conversationId: "channel:1491611525914558667", + }, + }); + expect( + service.resolveByConversation({ + channel: "discord", + accountId: "default", + conversationId: "channel:1491611525914558667", + }), + ).toMatchObject({ + targetSessionKey: "agent:codex:acp:current-channel", + }); + expect( + service.resolveByConversation({ + channel: "discord", + accountId: "default", + conversationId: "1491611525914558667", + }), + ).toBeNull(); + expect(hoisted.restGet).not.toHaveBeenCalled(); + expect(hoisted.restPost).not.toHaveBeenCalled(); + }); + it("binds current Discord DMs as direct conversation bindings", async () => { createThreadBindingManager({ accountId: "default", diff --git a/extensions/discord/src/monitor/thread-bindings.manager.ts b/extensions/discord/src/monitor/thread-bindings.manager.ts index 2245b05fe1f..4628fb67a4f 100644 --- a/extensions/discord/src/monitor/thread-bindings.manager.ts +++ b/extensions/discord/src/monitor/thread-bindings.manager.ts @@ -15,6 +15,7 @@ import { import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; import { createDiscordRestClient } from "../client.js"; +import { resolveDiscordChannelId } from "../target-parsing.js"; import { createThreadForBinding, createWebhookForChannel, @@ -67,6 +68,18 @@ function registerManager(manager: ThreadBindingManager) { MANAGERS_BY_ACCOUNT_ID.set(manager.accountId, manager); } +function normalizeChildBindingParentChannelId(raw?: string | null): string | undefined { + const trimmed = normalizeOptionalString(raw) ?? ""; + if (!trimmed) { + return undefined; + } + try { + return resolveDiscordChannelId(trimmed); + } catch { + return undefined; + } +} + function unregisterManager(accountId: string, manager: ThreadBindingManager) { const existing = MANAGERS_BY_ACCOUNT_ID.get(accountId); if (existing === manager) { @@ -632,11 +645,12 @@ export function createThreadBindingManager( ? normalizeOptionalString(metadata.agentId) : undefined; let threadId: string | undefined; - let channelId = normalizeOptionalString(input.conversation.parentConversationId); + let channelId: string | undefined; let createThread = false; if (placement === "child") { createThread = true; + channelId = normalizeChildBindingParentChannelId(input.conversation.parentConversationId); if (!channelId && conversationId) { const cfg = resolveCurrentCfg(); channelId =