From 71939523a02fe07bd88aa0aa9868aeee446b9380 Mon Sep 17 00:00:00 2001 From: Shadow Date: Fri, 13 Feb 2026 13:04:19 -0600 Subject: [PATCH] fix: normalize Discord autoThread reply target (#8302) (thanks @gavinbmoore) --- CHANGELOG.md | 4 +++- src/discord/monitor/threading.test.ts | 25 +++++++++++++++++++++++++ src/discord/monitor/threading.ts | 12 ++++-------- 3 files changed, 32 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 156b137b36..a7cdf28d1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Discord: route autoThread replies to existing threads instead of the root channel. (#8302) Thanks @gavinbmoore, @thewilloftheshadow. - Agents/Image tool: cap image-analysis completion `maxTokens` by model capability (`min(4096, model.maxTokens)`) to avoid over-limit provider failures while still preventing truncation. (#11770) Thanks @detecti1. - TUI/Streaming: preserve richer streamed assistant text when final payload drops pre-tool-call text blocks, while keeping non-empty final payload authoritative for plain-text updates. (#15452) Thanks @TsekaLuk. - Inbound/Web UI: preserve literal `\n` sequences when normalizing inbound text so Windows paths like `C:\\Work\\nxxx\\README.md` are not corrupted. (#11547) Thanks @mcaxtr. @@ -380,8 +381,9 @@ Docs: https://docs.openclaw.ai - Security: require validated shared-secret auth before skipping device identity on gateway connect. - Security: guard skill installer downloads with SSRF checks (block private/localhost URLs). - Security: harden Windows exec allowlist; block cmd.exe bypass via single &. Thanks @simecek. -- fix(voice-call): harden inbound allowlist; reject anonymous callers; require Telnyx publicKey for allowlist; token-gate Twilio media streams; cap webhook body size (thanks @simecek) +- Discord: route autoThread replies to existing threads instead of the root channel. (#8302) Thanks @gavinbmoore, @thewilloftheshadow. - Media understanding: apply SSRF guardrails to provider fetches; allow private baseUrl overrides explicitly. +- fix(voice-call): harden inbound allowlist; reject anonymous callers; require Telnyx publicKey for allowlist; token-gate Twilio media streams; cap webhook body size (thanks @simecek) - fix(webchat): respect user scroll position during streaming and refresh (#7226) (thanks @marcomarandiz) - Telegram: recover from grammY long-poll timed out errors. (#7466) Thanks @macmimi23. - Agents: repair malformed tool calls and session transcripts. (#7473) Thanks @justinhuangcode. diff --git a/src/discord/monitor/threading.test.ts b/src/discord/monitor/threading.test.ts index 0d8a4bb0da..530d9730e2 100644 --- a/src/discord/monitor/threading.test.ts +++ b/src/discord/monitor/threading.test.ts @@ -210,6 +210,31 @@ describe("resolveDiscordAutoThreadReplyPlan", () => { ); }); + it("routes replies to an existing thread channel", async () => { + const client = { rest: { post: async () => ({ id: "thread" }) } } as unknown as Client; + const plan = await resolveDiscordAutoThreadReplyPlan({ + client, + message: { + id: "m1", + channelId: "parent", + } as unknown as import("./listeners.js").DiscordMessageEvent["message"], + isGuildMessage: true, + channelConfig: { + autoThread: true, + } as unknown as import("./allow-list.js").DiscordChannelConfigResolved, + threadChannel: { id: "thread" }, + baseText: "hello", + combinedBody: "hello", + replyToMode: "all", + agentId: "agent", + channel: "discord", + }); + expect(plan.deliverTarget).toBe("channel:thread"); + expect(plan.replyTarget).toBe("channel:thread"); + expect(plan.replyReference.use()).toBe("m1"); + expect(plan.autoThreadContext).toBeNull(); + }); + it("does nothing when autoThread is disabled", async () => { const client = { rest: { post: async () => ({ id: "thread" }) } } as unknown as Client; const plan = await resolveDiscordAutoThreadReplyPlan({ diff --git a/src/discord/monitor/threading.ts b/src/discord/monitor/threading.ts index 045ae190a1..41c4ab5e0d 100644 --- a/src/discord/monitor/threading.ts +++ b/src/discord/monitor/threading.ts @@ -294,7 +294,9 @@ export async function resolveDiscordAutoThreadReplyPlan(params: { agentId: string; channel: string; }): Promise { - const originalReplyTarget = `channel:${params.message.channelId}`; + // Prefer the resolved thread channel ID when available so replies stay in-thread. + const targetChannelId = params.threadChannel?.id ?? params.message.channelId; + const originalReplyTarget = `channel:${targetChannelId}`; const createdThreadId = await maybeCreateDiscordAutoThread({ client: params.client, message: params.message, @@ -391,17 +393,11 @@ export function resolveDiscordReplyDeliveryPlan(params: { let deliverTarget = originalReplyTarget; let replyTarget = originalReplyTarget; - // When a new thread was created, route to the new thread + // When a new thread was created, route to the new thread. if (params.createdThreadId) { deliverTarget = `channel:${params.createdThreadId}`; replyTarget = deliverTarget; } - // When in an existing thread (not newly created), ensure we route to the thread - // This fixes #8278: autoThread replies sometimes going to root channel - else if (params.threadChannel?.id) { - deliverTarget = `channel:${params.threadChannel.id}`; - replyTarget = deliverTarget; - } const allowReference = deliverTarget === originalReplyTarget; const replyReference = createReplyReferencePlanner({ replyToMode: allowReference ? params.replyToMode : "off",