Skip to content

Commit 540d2dd

Browse files
author
User
committed
fix(discord): ignore bound-thread bot system messages in preflight
1 parent 5b44d80 commit 540d2dd

File tree

2 files changed

+206
-5
lines changed

2 files changed

+206
-5
lines changed

src/discord/monitor/message-handler.preflight.test.ts

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,180 @@ describe("resolvePreflightMentionRequirement", () => {
5858
});
5959

6060
describe("preflightDiscordMessage", () => {
61+
it("drops bound-thread bot system messages to prevent ACP self-loop", async () => {
62+
const threadBinding = createThreadBinding({
63+
targetKind: "acp",
64+
targetSessionKey: "agent:main:acp:discord-thread-1",
65+
});
66+
const threadId = "thread-system-1";
67+
const parentId = "channel-parent-1";
68+
const client = {
69+
fetchChannel: async (channelId: string) => {
70+
if (channelId === threadId) {
71+
return {
72+
id: threadId,
73+
type: ChannelType.PublicThread,
74+
name: "focus",
75+
parentId,
76+
ownerId: "owner-1",
77+
};
78+
}
79+
if (channelId === parentId) {
80+
return {
81+
id: parentId,
82+
type: ChannelType.GuildText,
83+
name: "general",
84+
};
85+
}
86+
return null;
87+
},
88+
} as unknown as import("@buape/carbon").Client;
89+
const message = {
90+
id: "m-system-1",
91+
content:
92+
"⚙️ codex-acp session active (auto-unfocus in 24h). Messages here go directly to this session.",
93+
timestamp: new Date().toISOString(),
94+
channelId: threadId,
95+
attachments: [],
96+
mentionedUsers: [],
97+
mentionedRoles: [],
98+
mentionedEveryone: false,
99+
author: {
100+
id: "relay-bot-1",
101+
bot: true,
102+
username: "OpenClaw",
103+
},
104+
} as unknown as import("@buape/carbon").Message;
105+
106+
const result = await preflightDiscordMessage({
107+
cfg: {
108+
session: {
109+
mainKey: "main",
110+
scope: "per-sender",
111+
},
112+
} as import("../../config/config.js").OpenClawConfig,
113+
discordConfig: {
114+
allowBots: true,
115+
} as NonNullable<import("../../config/config.js").OpenClawConfig["channels"]>["discord"],
116+
accountId: "default",
117+
token: "token",
118+
runtime: {} as import("../../runtime.js").RuntimeEnv,
119+
botUserId: "openclaw-bot",
120+
guildHistories: new Map(),
121+
historyLimit: 0,
122+
mediaMaxBytes: 1_000_000,
123+
textLimit: 2_000,
124+
replyToMode: "all",
125+
dmEnabled: true,
126+
groupDmEnabled: true,
127+
ackReactionScope: "direct",
128+
groupPolicy: "open",
129+
threadBindings: {
130+
getByThreadId: (id: string) => (id === threadId ? threadBinding : undefined),
131+
} as import("./thread-bindings.js").ThreadBindingManager,
132+
data: {
133+
channel_id: threadId,
134+
guild_id: "guild-1",
135+
guild: {
136+
id: "guild-1",
137+
name: "Guild One",
138+
},
139+
author: message.author,
140+
message,
141+
} as unknown as import("./listeners.js").DiscordMessageEvent,
142+
client,
143+
});
144+
145+
expect(result).toBeNull();
146+
});
147+
148+
it("keeps bound-thread regular bot messages flowing when allowBots=true", async () => {
149+
const threadBinding = createThreadBinding({
150+
targetKind: "acp",
151+
targetSessionKey: "agent:main:acp:discord-thread-1",
152+
});
153+
const threadId = "thread-bot-regular-1";
154+
const parentId = "channel-parent-regular-1";
155+
const client = {
156+
fetchChannel: async (channelId: string) => {
157+
if (channelId === threadId) {
158+
return {
159+
id: threadId,
160+
type: ChannelType.PublicThread,
161+
name: "focus",
162+
parentId,
163+
ownerId: "owner-1",
164+
};
165+
}
166+
if (channelId === parentId) {
167+
return {
168+
id: parentId,
169+
type: ChannelType.GuildText,
170+
name: "general",
171+
};
172+
}
173+
return null;
174+
},
175+
} as unknown as import("@buape/carbon").Client;
176+
const message = {
177+
id: "m-bot-regular-1",
178+
content: "here is tool output chunk",
179+
timestamp: new Date().toISOString(),
180+
channelId: threadId,
181+
attachments: [],
182+
mentionedUsers: [],
183+
mentionedRoles: [],
184+
mentionedEveryone: false,
185+
author: {
186+
id: "relay-bot-1",
187+
bot: true,
188+
username: "Relay",
189+
},
190+
} as unknown as import("@buape/carbon").Message;
191+
192+
const result = await preflightDiscordMessage({
193+
cfg: {
194+
session: {
195+
mainKey: "main",
196+
scope: "per-sender",
197+
},
198+
} as import("../../config/config.js").OpenClawConfig,
199+
discordConfig: {
200+
allowBots: true,
201+
} as NonNullable<import("../../config/config.js").OpenClawConfig["channels"]>["discord"],
202+
accountId: "default",
203+
token: "token",
204+
runtime: {} as import("../../runtime.js").RuntimeEnv,
205+
botUserId: "openclaw-bot",
206+
guildHistories: new Map(),
207+
historyLimit: 0,
208+
mediaMaxBytes: 1_000_000,
209+
textLimit: 2_000,
210+
replyToMode: "all",
211+
dmEnabled: true,
212+
groupDmEnabled: true,
213+
ackReactionScope: "direct",
214+
groupPolicy: "open",
215+
threadBindings: {
216+
getByThreadId: (id: string) => (id === threadId ? threadBinding : undefined),
217+
} as import("./thread-bindings.js").ThreadBindingManager,
218+
data: {
219+
channel_id: threadId,
220+
guild_id: "guild-1",
221+
guild: {
222+
id: "guild-1",
223+
name: "Guild One",
224+
},
225+
author: message.author,
226+
message,
227+
} as unknown as import("./listeners.js").DiscordMessageEvent,
228+
client,
229+
});
230+
231+
expect(result).not.toBeNull();
232+
expect(result?.boundSessionKey).toBe(threadBinding.targetSessionKey);
233+
});
234+
61235
it("bypasses mention gating in bound threads for allowed bot senders", async () => {
62236
const threadBinding = createThreadBinding();
63237
const threadId = "thread-bot-focus";

src/discord/monitor/message-handler.preflight.ts

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
import { ChannelType, MessageType, type User } from "@buape/carbon";
2+
import type {
3+
DiscordMessagePreflightContext,
4+
DiscordMessagePreflightParams,
5+
} from "./message-handler.preflight.types.js";
26
import { hasControlCommand } from "../../auto-reply/command-detection.js";
37
import { shouldHandleTextCommands } from "../../auto-reply/commands-registry.js";
48
import {
@@ -45,10 +49,6 @@ import {
4549
resolveDiscordSystemLocation,
4650
resolveTimestampMs,
4751
} from "./format.js";
48-
import type {
49-
DiscordMessagePreflightContext,
50-
DiscordMessagePreflightParams,
51-
} from "./message-handler.preflight.types.js";
5252
import {
5353
resolveDiscordChannelInfo,
5454
resolveDiscordMessageChannelId,
@@ -67,6 +67,23 @@ export type {
6767
DiscordMessagePreflightParams,
6868
} from "./message-handler.preflight.types.js";
6969

70+
const DISCORD_BOUND_THREAD_SYSTEM_PREFIXES = ["⚙️", "🤖"];
71+
72+
function isBoundThreadBotSystemMessage(params: {
73+
isBoundThreadSession: boolean;
74+
isBotAuthor: boolean;
75+
text?: string;
76+
}): boolean {
77+
if (!params.isBoundThreadSession || !params.isBotAuthor) {
78+
return false;
79+
}
80+
const text = params.text?.trim();
81+
if (!text) {
82+
return false;
83+
}
84+
return DISCORD_BOUND_THREAD_SYSTEM_PREFIXES.some((prefix) => text.startsWith(prefix));
85+
}
86+
7087
export function resolvePreflightMentionRequirement(params: {
7188
shouldRequireMention: boolean;
7289
isBoundThreadSession: boolean;
@@ -317,6 +334,17 @@ export async function preflightDiscordMessage(
317334
agentId: boundAgentId ?? route.agentId,
318335
}
319336
: route;
337+
const isBoundThreadSession = Boolean(boundSessionKey && earlyThreadChannel);
338+
if (
339+
isBoundThreadBotSystemMessage({
340+
isBoundThreadSession,
341+
isBotAuthor: Boolean(author.bot),
342+
text: messageText,
343+
})
344+
) {
345+
logVerbose(`discord: drop bound-thread bot system message ${message.id}`);
346+
return null;
347+
}
320348
const mentionRegexes = buildMentionRegexes(params.cfg, effectiveRoute.agentId);
321349
const explicitlyMentioned = Boolean(
322350
botId && message.mentionedUsers?.some((user: User) => user.id === botId),
@@ -480,7 +508,6 @@ export async function preflightDiscordMessage(
480508
channelConfig,
481509
guildInfo,
482510
});
483-
const isBoundThreadSession = Boolean(boundSessionKey && threadChannel);
484511
const shouldRequireMention = resolvePreflightMentionRequirement({
485512
shouldRequireMention: shouldRequireMentionByConfig,
486513
isBoundThreadSession,

0 commit comments

Comments
 (0)