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:
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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) {