diff --git a/CHANGELOG.md b/CHANGELOG.md index c8c9f2884a7..6638c5ec4e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Discord: inherit thread parent bindings when routing Discord messages. (#3892) Thanks @aerolalit. - Docs: update MiniMax OAuth setup commands; Extensions: use OpenClaw plugin SDK for MiniMax OAuth. (#5402) Thanks @Maosghoul. - Discord: resolve PluralKit proxied senders for allowlists and labels. (#5838) Thanks @thewilloftheshadow. - Telegram: restore draft streaming partials. (#5543) Thanks @obviyus. diff --git a/README.md b/README.md index d375461ecb2..205707e4052 100644 --- a/README.md +++ b/README.md @@ -491,40 +491,40 @@ Thanks to all clawtributors:

steipete cpojer plum-dawg bohdanpodvirnyi iHildy jaydenfyi joaohlisboa mneves75 MatthieuBizien MaudeBot - Glucksberg rahthakor vrknetha radek-paclt vignesh07 joshp123 Tobias Bischoff czekaj mukhtharcm sebslight - maxsumrall xadenryan rodrigouroz Mariano Belinky tyler6204 juanpablodlc hsrvc magimetal zerone0x meaningfool - patelhiren NicholasSpisak jonisjongithub abhisekbasu1 jamesgroat claude JustYannicc SocialNerd42069 Hyaxia dantelex - daveonkels google-labs-jules[bot] lc0rp adam91holt mousberg hougangdev gumadeiras shakkernerd mteam88 hirefrank - joeynyc orlyjamie Eng. Juan Combetto dbhurley TSavo julianengel bradleypriest benithors rohannagpal elliotsecops - timolins benostein f-trycua nachx639 pvoo sreekaransrinath gupsammy cristip73 stefangalescu nachoiacovino - Vasanth Rao Naik Sabavat petter-b thewilloftheshadow scald andranik-sahakyan davidguttman sleontenko denysvitali sircrumpet peschee - rafaelreis-r nonggialiang dominicnunez lploc94 ratulsarna lutr0 kiranjd danielz1z AdeboyeDN Alg0rix - papago2355 emanuelst evanotero KristijanJovanovski CashWilliams jlowin rdev rhuanssauro osolmaz joshrad-dev - adityashaw2 sheeek ryancontent jasonsschin obviyus artuskg Takhoffman onutc pauloportella HirokiKobayashi-R - ThanhNguyxn yuting0624 neooriginal manuelhettich minghinmatthewlam manikv12 myfunc travisirby buddyh connorshea - kyleok mcinteerj dependabot[bot] amitbiswal007 John-Rood timkrase uos-status gerardward2007 roshanasingh4 tosh-hamburg - azade-c dlauer JonUleis shivamraut101 bjesuiter cheeeee robbyczgw-cla YuriNachos badlogic conroywhitney - Josh Phillips pookNast Whoaa512 chriseidhof ngutman ysqander Yurii Chukhlib aj47 kennyklee superman32432432 - grp06 Hisleren antons austinm911 blacksmith-sh[bot] damoahdominic dan-dr HeimdallStrategy imfing jalehman - jarvis-medmatic kkarimi mahmoudashraf93 pkrmf RandyVentures robhparker Ryan Lisse dougvk erikpr1994 fal3 - Ghost jonasjancarik Keith the Silly Goose L36 Server Marc mitschabaude-bot mkbehr neist sibbl chrisrodz - Friederike Seiler gabriel-trigo iamadig Jonathan D. Rhyne (DJ-D) Joshua Mitchell Kit koala73 manmal ogulcancelik pasogott - petradonka rubyrunsstuff siddhantjain spiceoogway suminhthanh svkozak VACInc wes-davis zats 24601 - ameno- Chris Taylor dguido Django Navarro evalexpr henrino3 humanwritten larlyssa Lukavyi odysseus0 - oswalpalash pcty-nextgen-service-account pi0 rmorse Roopak Nijhara Syhids Ubuntu Aaron Konyer aaronveklabs andreabadesso - Andrii cash-echo-bot Clawd ClawdFx EnzeD erik-agens Evizero fcatuhe itsjaydesu ivancasco - ivanrvpereira Jarvis jayhickey jeffersonwarrior jeffersonwarrior jverdi longmaba MarvinCui mjrussell odnxe - optimikelabs p6l-richard philipp-spiess Pocket Clawd robaxelsen Sash Catanzarite Suksham-sharma T5-AndyML tewatia travisp - VAC william arzt zknicker 0oAstro abhaymundhara aduk059 alejandro maza Alex-Alaniz alexstyl andrewting19 - anpoirier araa47 arthyn Asleep123 Ayush Ojha Ayush10 bguidolim bolismauro championswimmer chenyuan99 - Chloe-VP Clawdbot Maintainers conhecendoia dasilva333 David-Marsh-Photo Developer Dimitrios Ploutarchos Drake Thomsen dylanneve1 Felix Krause - foeken frankekn ganghyun kim grrowl gtsifrikas HazAT hrdwdmrbl hugobarauna Jamie Openshaw Jane - Jarvis Deploy Jefferson Nunn jogi47 kentaro Kevin Lin kira-ariaki kitze Kiwitwitter levifig Lloyd - longjos loukotal louzhixian martinpucik Matt mini mertcicekci0 Miles mitsuhiko mrdbstn MSch - Mustafa Tag Eldeen mylukin nathanbosse ndraiman nexty5870 Noctivoro ppamment prathamdby ptn1411 reeltimeapps - RLTCmpe Rolf Fredheim Rony Kelner Samrat Jha senoldogann Seredeep sergical shiv19 shiyuanhai siraht - snopoke techboss testingabc321 The Admiral thesash Vibe Kanban voidserf Vultr-Clawd Admin Wimmie wolfred - wstock YangHuang2280 yazinsai yevhen YiWang24 ymat19 Zach Knickerbocker zackerthescar 0xJonHoldsCrypto aaronn - Alphonse-arianee atalovesyou Azade carlulsoe ddyo Erik latitudeki5223 Manuel Maly Mourad Boustani odrobnik - pcty-nextgen-ios-builder Quentin Randy Torres rhjoh ronak-guliani William Stock + Glucksberg rahthakor vrknetha radek-paclt vignesh07 joshp123 Tobias Bischoff sebslight czekaj mukhtharcm + maxsumrall xadenryan Mariano Belinky rodrigouroz tyler6204 juanpablodlc conroywhitney hsrvc magimetal zerone0x + meaningfool patelhiren NicholasSpisak jonisjongithub abhisekbasu1 jamesgroat claude JustYannicc Hyaxia dantelex + SocialNerd42069 daveonkels google-labs-jules[bot] lc0rp mousberg adam91holt hougangdev gumadeiras shakkernerd mteam88 + hirefrank joeynyc orlyjamie dbhurley Eng. Juan Combetto TSavo julianengel bradleypriest benithors rohannagpal + timolins f-trycua benostein elliotsecops nachx639 pvoo sreekaransrinath gupsammy cristip73 stefangalescu + nachoiacovino Vasanth Rao Naik Sabavat petter-b thewilloftheshadow scald andranik-sahakyan davidguttman sleontenko denysvitali sircrumpet + peschee nonggialiang rafaelreis-r dominicnunez lploc94 ratulsarna lutr0 sfo2001 kiranjd danielz1z + AdeboyeDN Alg0rix Takhoffman papago2355 emanuelst evanotero KristijanJovanovski jlowin rdev rhuanssauro + joshrad-dev osolmaz adityashaw2 CashWilliams sheeek obviyus ryancontent jasonsschin artuskg onutc + pauloportella HirokiKobayashi-R ThanhNguyxn yuting0624 neooriginal manuelhettich minghinmatthewlam manikv12 myfunc travisirby + buddyh connorshea kyleok mcinteerj dependabot[bot] amitbiswal007 John-Rood timkrase uos-status gerardward2007 + roshanasingh4 tosh-hamburg azade-c dlauer JonUleis shivamraut101 bjesuiter cheeeee robbyczgw-cla YuriNachos + badlogic Josh Phillips pookNast Whoaa512 chriseidhof ngutman ysqander Yurii Chukhlib aj47 kennyklee + superman32432432 grp06 Hisleren antons austinm911 blacksmith-sh[bot] damoahdominic dan-dr HeimdallStrategy imfing + jalehman jarvis-medmatic kkarimi mahmoudashraf93 pkrmf RandyVentures robhparker Ryan Lisse dougvk erikpr1994 + fal3 Ghost jonasjancarik Keith the Silly Goose L36 Server Marc mitschabaude-bot mkbehr neist sibbl + abhijeet117 chrisrodz Friederike Seiler gabriel-trigo iamadig Jonathan D. Rhyne (DJ-D) Joshua Mitchell Kit koala73 manmal + ogulcancelik pasogott petradonka rubyrunsstuff siddhantjain spiceoogway suminhthanh svkozak VACInc wes-davis + zats 24601 ameno- Chris Taylor dguido Django Navarro evalexpr henrino3 humanwritten larlyssa + Lukavyi odysseus0 oswalpalash pcty-nextgen-service-account pi0 rmorse Roopak Nijhara Syhids Ubuntu Aaron Konyer + aaronveklabs andreabadesso Andrii cash-echo-bot Clawd ClawdFx EnzeD erik-agens Evizero fcatuhe + itsjaydesu ivancasco ivanrvpereira Jarvis jayhickey jeffersonwarrior jeffersonwarrior jverdi longmaba MarvinCui + mitsuhiko mjrussell odnxe optimikelabs p6l-richard philipp-spiess Pocket Clawd robaxelsen Sash Catanzarite Suksham-sharma + T5-AndyML tewatia travisp VAC william arzt zknicker 0oAstro abhaymundhara aduk059 aldoeliacim + alejandro maza Alex-Alaniz alexstyl andrewting19 anpoirier araa47 arthyn Asleep123 Ayush Ojha Ayush10 + bguidolim bolismauro championswimmer chenyuan99 Chloe-VP Clawdbot Maintainers conhecendoia dasilva333 David-Marsh-Photo Developer + Dimitrios Ploutarchos Drake Thomsen dylanneve1 Felix Krause foeken frankekn ganghyun kim grrowl gtsifrikas HazAT + hrdwdmrbl hugobarauna Jamie Openshaw Jane Jarvis Deploy Jefferson Nunn jogi47 kentaro Kevin Lin kira-ariaki + kitze Kiwitwitter levifig Lloyd longjos loukotal louzhixian martinpucik Matt mini mertcicekci0 + Miles mrdbstn MSch Mustafa Tag Eldeen mylukin nathanbosse ndraiman nexty5870 Noctivoro ppamment + prathamdby ptn1411 reeltimeapps RLTCmpe Rolf Fredheim Rony Kelner Samrat Jha senoldogann Seredeep sergical + shiv19 shiyuanhai siraht snopoke techboss testingabc321 The Admiral thesash Vibe Kanban voidserf + Vultr-Clawd Admin Wimmie wolfred wstock YangHuang2280 yazinsai yevhen YiWang24 ymat19 Zach Knickerbocker + zackerthescar 0xJonHoldsCrypto aaronn Alphonse-arianee atalovesyou Azade carlulsoe ddyo Erik latitudeki5223 + Manuel Maly Mourad Boustani odrobnik pcty-nextgen-ios-builder Quentin Randy Torres rhjoh ronak-guliani William Stock

diff --git a/src/discord/monitor/listeners.ts b/src/discord/monitor/listeners.ts index a7cd9ce3332..e6f85be9e39 100644 --- a/src/discord/monitor/listeners.ts +++ b/src/discord/monitor/listeners.ts @@ -277,6 +277,7 @@ async function handleDiscordReactionEvent(params: { accountId: params.accountId, guildId: data.guild_id ?? undefined, peer: { kind: "channel", id: data.channel_id }, + parentPeer: parentId ? { kind: "channel", id: parentId } : undefined, }); enqueueSystemEvent(text, { sessionKey: route.sessionKey, diff --git a/src/discord/monitor/message-handler.preflight.ts b/src/discord/monitor/message-handler.preflight.ts index f909c4ece1a..726a07decd7 100644 --- a/src/discord/monitor/message-handler.preflight.ts +++ b/src/discord/monitor/message-handler.preflight.ts @@ -192,6 +192,32 @@ export async function preflightDiscordMessage( accountId: params.accountId, direction: "inbound", }); + + // Resolve thread parent early for binding inheritance + const channelName = + channelInfo?.name ?? + ((isGuildMessage || isGroupDm) && message.channel && "name" in message.channel + ? message.channel.name + : undefined); + const earlyThreadChannel = resolveDiscordThreadChannel({ + isGuildMessage, + message, + channelInfo, + }); + let earlyThreadParentId: string | undefined; + let earlyThreadParentName: string | undefined; + let earlyThreadParentType: ChannelType | undefined; + if (earlyThreadChannel) { + const parentInfo = await resolveDiscordThreadParentInfo({ + client: params.client, + threadChannel: earlyThreadChannel, + channelInfo, + }); + earlyThreadParentId = parentInfo.id; + earlyThreadParentName = parentInfo.name; + earlyThreadParentType = parentInfo.type; + } + const route = resolveAgentRoute({ cfg: params.cfg, channel: "discord", @@ -201,6 +227,8 @@ export async function preflightDiscordMessage( kind: isDirectMessage ? "dm" : isGroupDm ? "group" : "channel", id: isDirectMessage ? author.id : message.channelId, }, + // Pass parent peer for thread binding inheritance + parentPeer: earlyThreadParentId ? { kind: "channel", id: earlyThreadParentId } : undefined, }); const mentionRegexes = buildMentionRegexes(params.cfg, route.agentId); const explicitlyMentioned = Boolean( @@ -262,29 +290,11 @@ export async function preflightDiscordMessage( return null; } - const channelName = - channelInfo?.name ?? - ((isGuildMessage || isGroupDm) && message.channel && "name" in message.channel - ? message.channel.name - : undefined); - const threadChannel = resolveDiscordThreadChannel({ - isGuildMessage, - message, - channelInfo, - }); - let threadParentId: string | undefined; - let threadParentName: string | undefined; - let threadParentType: ChannelType | undefined; - if (threadChannel) { - const parentInfo = await resolveDiscordThreadParentInfo({ - client: params.client, - threadChannel, - channelInfo, - }); - threadParentId = parentInfo.id; - threadParentName = parentInfo.name; - threadParentType = parentInfo.type; - } + // Reuse early thread resolution from above (for binding inheritance) + const threadChannel = earlyThreadChannel; + const threadParentId = earlyThreadParentId; + const threadParentName = earlyThreadParentName; + const threadParentType = earlyThreadParentType; const threadName = threadChannel?.name; const configChannelName = threadParentName ?? channelName; const configChannelSlug = configChannelName ? normalizeDiscordSlug(configChannelName) : ""; diff --git a/src/discord/monitor/native-command.ts b/src/discord/monitor/native-command.ts index f8b2a4f353f..59a07b255f5 100644 --- a/src/discord/monitor/native-command.ts +++ b/src/discord/monitor/native-command.ts @@ -736,6 +736,7 @@ async function dispatchDiscordCommandInteraction(params: { kind: isDirectMessage ? "dm" : isGroupDm ? "group" : "channel", id: isDirectMessage ? user.id : channelId, }, + parentPeer: threadParentId ? { kind: "channel", id: threadParentId } : undefined, }); const conversationLabel = isDirectMessage ? (user.globalName ?? user.username) : channelId; const ctxPayload = finalizeInboundContext({ diff --git a/src/routing/resolve-route.test.ts b/src/routing/resolve-route.test.ts index cd38a496ba1..3e484ac727a 100644 --- a/src/routing/resolve-route.test.ts +++ b/src/routing/resolve-route.test.ts @@ -252,3 +252,160 @@ test("dmScope=per-account-channel-peer uses default accountId when not provided" }); expect(route.sessionKey).toBe("agent:main:telegram:default:dm:7550356539"); }); + +describe("parentPeer binding inheritance (thread support)", () => { + test("thread inherits binding from parent channel when no direct match", () => { + const cfg: MoltbotConfig = { + bindings: [ + { + agentId: "adecco", + match: { + channel: "discord", + peer: { kind: "channel", id: "parent-channel-123" }, + }, + }, + ], + }; + const route = resolveAgentRoute({ + cfg, + channel: "discord", + peer: { kind: "channel", id: "thread-456" }, + parentPeer: { kind: "channel", id: "parent-channel-123" }, + }); + expect(route.agentId).toBe("adecco"); + expect(route.matchedBy).toBe("binding.peer.parent"); + }); + + test("direct peer binding wins over parent peer binding", () => { + const cfg: MoltbotConfig = { + bindings: [ + { + agentId: "thread-agent", + match: { + channel: "discord", + peer: { kind: "channel", id: "thread-456" }, + }, + }, + { + agentId: "parent-agent", + match: { + channel: "discord", + peer: { kind: "channel", id: "parent-channel-123" }, + }, + }, + ], + }; + const route = resolveAgentRoute({ + cfg, + channel: "discord", + peer: { kind: "channel", id: "thread-456" }, + parentPeer: { kind: "channel", id: "parent-channel-123" }, + }); + expect(route.agentId).toBe("thread-agent"); + expect(route.matchedBy).toBe("binding.peer"); + }); + + test("parent peer binding wins over guild binding", () => { + const cfg: MoltbotConfig = { + bindings: [ + { + agentId: "parent-agent", + match: { + channel: "discord", + peer: { kind: "channel", id: "parent-channel-123" }, + }, + }, + { + agentId: "guild-agent", + match: { + channel: "discord", + guildId: "guild-789", + }, + }, + ], + }; + const route = resolveAgentRoute({ + cfg, + channel: "discord", + peer: { kind: "channel", id: "thread-456" }, + parentPeer: { kind: "channel", id: "parent-channel-123" }, + guildId: "guild-789", + }); + expect(route.agentId).toBe("parent-agent"); + expect(route.matchedBy).toBe("binding.peer.parent"); + }); + + test("falls back to guild binding when no parent peer match", () => { + const cfg: MoltbotConfig = { + bindings: [ + { + agentId: "other-parent-agent", + match: { + channel: "discord", + peer: { kind: "channel", id: "other-parent-999" }, + }, + }, + { + agentId: "guild-agent", + match: { + channel: "discord", + guildId: "guild-789", + }, + }, + ], + }; + const route = resolveAgentRoute({ + cfg, + channel: "discord", + peer: { kind: "channel", id: "thread-456" }, + parentPeer: { kind: "channel", id: "parent-channel-123" }, + guildId: "guild-789", + }); + expect(route.agentId).toBe("guild-agent"); + expect(route.matchedBy).toBe("binding.guild"); + }); + + test("parentPeer with empty id is ignored", () => { + const cfg: MoltbotConfig = { + bindings: [ + { + agentId: "parent-agent", + match: { + channel: "discord", + peer: { kind: "channel", id: "parent-channel-123" }, + }, + }, + ], + }; + const route = resolveAgentRoute({ + cfg, + channel: "discord", + peer: { kind: "channel", id: "thread-456" }, + parentPeer: { kind: "channel", id: "" }, + }); + expect(route.agentId).toBe("main"); + expect(route.matchedBy).toBe("default"); + }); + + test("null parentPeer is handled gracefully", () => { + const cfg: MoltbotConfig = { + bindings: [ + { + agentId: "parent-agent", + match: { + channel: "discord", + peer: { kind: "channel", id: "parent-channel-123" }, + }, + }, + ], + }; + const route = resolveAgentRoute({ + cfg, + channel: "discord", + peer: { kind: "channel", id: "thread-456" }, + parentPeer: null, + }); + expect(route.agentId).toBe("main"); + expect(route.matchedBy).toBe("default"); + }); +}); diff --git a/src/routing/resolve-route.ts b/src/routing/resolve-route.ts index 0dca0e18883..bfa187e573c 100644 --- a/src/routing/resolve-route.ts +++ b/src/routing/resolve-route.ts @@ -22,6 +22,8 @@ export type ResolveAgentRouteInput = { channel: string; accountId?: string | null; peer?: RoutePeer | null; + /** Parent peer for threads — used for binding inheritance when peer doesn't match directly. */ + parentPeer?: RoutePeer | null; guildId?: string | null; teamId?: string | null; }; @@ -37,6 +39,7 @@ export type ResolvedAgentRoute = { /** Match description for debugging/logging. */ matchedBy: | "binding.peer" + | "binding.peer.parent" | "binding.guild" | "binding.team" | "binding.account" @@ -212,6 +215,15 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR } } + // Thread parent inheritance: if peer (thread) didn't match, check parent peer binding + const parentPeer = input.parentPeer + ? { kind: input.parentPeer.kind, id: normalizeId(input.parentPeer.id) } + : null; + if (parentPeer && parentPeer.id) { + const parentPeerMatch = bindings.find((b) => matchesPeer(b.match, parentPeer)); + if (parentPeerMatch) return choose(parentPeerMatch.agentId, "binding.peer.parent"); + } + if (guildId) { const guildMatch = bindings.find((b) => matchesGuild(b.match, guildId)); if (guildMatch) {