Skip to content

Commit 4785a07

Browse files
committed
feat(channels): add generic bot loop protection
1 parent d00e9eb commit 4785a07

53 files changed

Lines changed: 2025 additions & 63 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
bad30fbdd50ecdc6dd0e3dbbea0a1d7ed02a7e3e0cc30d7b1d4459832e4d1bd8 config-baseline.json
1+
e31e27f0cbfa980afa6eda98ee597049e5220d6e22ac89d3f1de095967a0b6c8 config-baseline.json
22
932ca6c43b47dc342b6c9999815e5f03c5ff46f6372034a4eb507c629a4e49b1 config-baseline.core.json
3-
ad1d3cb596115d66c21e93de95e229c14c585f0dd4799b4ae3cc29b84761adc6 config-baseline.channel.json
3+
2aa997d48549bd321a478485126a4bd5065ba47333a80e7eb07a0ef6ad75b0a6 config-baseline.channel.json
44
0dac8944a0d51ae96f97e3809907f8a04d08413434a1a1190240f7e13bb11c4d config-baseline.plugin.json
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
542dc30fe44a16119ee57f9fe48a5744beb7fc2cf425a5777b4c4b8b2ce883e1 plugin-sdk-api-baseline.json
2-
9f4fde0de9773af635862ea15ce1a3391ef15e3165ad43b2050b1c4b3113acf4 plugin-sdk-api-baseline.jsonl
1+
54198a37be7bbd7949aef79fb7b1b95550967e5a947cd34fc659f3cb648ffa0a plugin-sdk-api-baseline.json
2+
345e1f0786b83a3454de82b4434bf4aacaf755db9550366f445fa9a6ac98bf15 plugin-sdk-api-baseline.jsonl

extensions/discord/src/accounts.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,39 @@ describe("resolveDiscordAccount allowFrom precedence", () => {
130130
});
131131
});
132132

133+
describe("resolveDiscordAccount botLoopProtection precedence", () => {
134+
it("merges account overrides over Discord channel defaults field-by-field", () => {
135+
const resolved = resolveDiscordAccount({
136+
cfg: {
137+
channels: {
138+
discord: {
139+
botLoopProtection: {
140+
maxEventsPerWindow: 4,
141+
windowSeconds: 60,
142+
cooldownSeconds: 30,
143+
},
144+
accounts: {
145+
work: {
146+
token: "token-work",
147+
botLoopProtection: {
148+
windowSeconds: 10,
149+
},
150+
},
151+
},
152+
},
153+
},
154+
},
155+
accountId: "work",
156+
});
157+
158+
expect(resolved.config.botLoopProtection).toEqual({
159+
maxEventsPerWindow: 4,
160+
windowSeconds: 10,
161+
cooldownSeconds: 30,
162+
});
163+
});
164+
});
165+
133166
describe("resolveDiscordMaxLinesPerMessage", () => {
134167
it("falls back to merged root discord maxLinesPerMessage when runtime config omits it", () => {
135168
const resolved = resolveDiscordMaxLinesPerMessage({

extensions/discord/src/accounts.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,15 @@ export function mergeDiscordAccountConfig(
4242
cfg: OpenClawConfig,
4343
accountId: string,
4444
): DiscordAccountConfig {
45-
return resolveMergedAccountConfig<DiscordAccountConfig>({
45+
const merged = resolveMergedAccountConfig<DiscordAccountConfig>({
4646
channelConfig: cfg.channels?.discord as DiscordAccountConfig | undefined,
4747
accounts: cfg.channels?.discord?.accounts as
4848
| Record<string, Partial<DiscordAccountConfig>>
4949
| undefined,
5050
accountId,
51+
nestedObjectKeys: ["botLoopProtection"],
5152
});
53+
return merged;
5254
}
5355

5456
export function resolveDiscordAccountAllowFrom(params: {

extensions/discord/src/config-ui-hints.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,26 @@ export const discordChannelConfigUiHints = {
310310
label: "Discord Allow Bot Messages",
311311
help: 'Allow bot-authored messages to trigger Discord replies (default: false). Set "mentions" to only accept bot messages that mention the bot.',
312312
},
313+
botLoopProtection: {
314+
label: "Discord Bot Loop Protection",
315+
help: "Sliding-window guard for bot-to-bot Discord loops. Default is enabled whenever allowBots lets bot-authored messages reach dispatch.",
316+
},
317+
"botLoopProtection.enabled": {
318+
label: "Discord Bot Loop Protection Enabled",
319+
help: 'Enable the bot-pair loop guard. Defaults to true when allowBots is true or "mentions", and false when bot messages are ignored.',
320+
},
321+
"botLoopProtection.maxEventsPerWindow": {
322+
label: "Discord Bot Pair Events Per Window",
323+
help: "Maximum messages a single Discord bot pair may exchange in the configured window before suppression starts. Default: 20.",
324+
},
325+
"botLoopProtection.windowSeconds": {
326+
label: "Discord Bot Loop Window Seconds",
327+
help: "Sliding window length in seconds for Discord bot-pair loop budgets. Default: 60.",
328+
},
329+
"botLoopProtection.cooldownSeconds": {
330+
label: "Discord Bot Loop Cooldown Seconds",
331+
help: "Seconds to suppress a Discord bot pair after it exceeds the loop budget. Default: 60.",
332+
},
313333
mentionAliases: {
314334
label: "Discord Mention Aliases",
315335
help: "Map outbound @handle text to stable Discord user IDs before sending. Set per account via channels.discord.accounts.<id>.mentionAliases.",

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,12 +66,13 @@ export function createDiscordMessage(params: {
6666
attachments?: Array<Record<string, unknown>>;
6767
webhookId?: string;
6868
type?: import("../internal/discord.js").MessageType;
69+
timestamp?: string;
6970
}): import("../internal/discord.js").Message {
7071
return {
7172
id: params.id,
7273
type: params.type,
7374
content: params.content,
74-
timestamp: new Date().toISOString(),
75+
timestamp: params.timestamp ?? new Date().toISOString(),
7576
channelId: params.channelId,
7677
webhookId: params.webhookId,
7778
attachments: params.attachments ?? [],

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

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -634,6 +634,149 @@ describe("preflightDiscordMessage", () => {
634634
).toBe("default");
635635
});
636636

637+
it("passes bot-loop protection facts for accepted bot-authored Discord messages (#58789)", async () => {
638+
const channelId = "channel-bot-loop";
639+
const guildId = "guild-bot-loop";
640+
const senderBotId = "relay-bot-1";
641+
const messageTimestamp = "2026-05-13T05:00:00.000Z";
642+
643+
const message = createDiscordMessage({
644+
id: "m-loop-1",
645+
channelId,
646+
content: "chatter <@openclaw-bot>",
647+
mentionedUsers: [{ id: "openclaw-bot" }],
648+
author: { id: senderBotId, bot: true, username: "Relay" },
649+
timestamp: messageTimestamp,
650+
});
651+
const result = await preflightDiscordMessage(
652+
createPreflightArgs({
653+
cfg: DEFAULT_PREFLIGHT_CFG,
654+
discordConfig: {
655+
allowBots: true,
656+
botLoopProtection: {
657+
enabled: true,
658+
maxEventsPerWindow: 3,
659+
cooldownSeconds: 60,
660+
},
661+
} as DiscordConfig,
662+
data: createGuildEvent({
663+
channelId,
664+
guildId,
665+
author: message.author,
666+
message,
667+
}),
668+
client: createGuildTextClient(channelId),
669+
}),
670+
);
671+
672+
expect(expectPreflightResult(result).botLoopProtection).toEqual({
673+
scopeId: "default",
674+
conversationId: channelId,
675+
senderId: senderBotId,
676+
receiverId: "openclaw-bot",
677+
config: {
678+
enabled: true,
679+
maxEventsPerWindow: 3,
680+
cooldownSeconds: 60,
681+
},
682+
defaultsConfig: undefined,
683+
defaultEnabled: true,
684+
nowMs: Date.parse(messageTimestamp),
685+
});
686+
});
687+
688+
it("passes generic channel defaults for Discord bot loop budgets", async () => {
689+
const channelId = "channel-bot-loop-defaults";
690+
const guildId = "guild-bot-loop-defaults";
691+
const discordConfig = { allowBots: true } as DiscordConfig;
692+
const message = createDiscordMessage({
693+
id: "m-loop-default-1",
694+
channelId,
695+
content: "relay <@openclaw-bot>",
696+
mentionedUsers: [{ id: "openclaw-bot" }],
697+
author: { id: "relay-bot-defaults", bot: true, username: "Relay" },
698+
});
699+
const result = await runGuildPreflight({
700+
channelId,
701+
guildId,
702+
message,
703+
discordConfig,
704+
cfg: {
705+
...DEFAULT_PREFLIGHT_CFG,
706+
channels: {
707+
defaults: {
708+
botLoopProtection: {
709+
maxEventsPerWindow: 1,
710+
cooldownSeconds: 60,
711+
},
712+
},
713+
},
714+
},
715+
});
716+
717+
expect(expectPreflightResult(result).botLoopProtection?.defaultsConfig).toEqual({
718+
maxEventsPerWindow: 1,
719+
cooldownSeconds: 60,
720+
});
721+
});
722+
723+
it("does not prepare loop-guard facts for bot messages that later preflight gates drop (#58789)", async () => {
724+
const channelId = "channel-bot-loop-dropped";
725+
const guildId = "guild-bot-loop-dropped";
726+
const senderBotId = "relay-bot-dropped";
727+
const discordConfig = {
728+
allowBots: true,
729+
botLoopProtection: {
730+
enabled: true,
731+
maxEventsPerWindow: 1,
732+
cooldownSeconds: 60,
733+
},
734+
} as DiscordConfig;
735+
const guildEntries = {
736+
[guildId]: {
737+
requireMention: false,
738+
ignoreOtherMentions: true,
739+
},
740+
};
741+
742+
for (const messageId of ["m-dropped-1", "m-dropped-2"]) {
743+
const message = createDiscordMessage({
744+
id: messageId,
745+
channelId,
746+
content: `cc <@999> ${messageId}`,
747+
mentionedUsers: [{ id: "999" }],
748+
author: { id: senderBotId, bot: true, username: "Relay" },
749+
});
750+
751+
expect(
752+
await runGuildPreflight({
753+
channelId,
754+
guildId,
755+
message,
756+
discordConfig,
757+
guildEntries,
758+
}),
759+
).toBeNull();
760+
}
761+
762+
const validMessage = createDiscordMessage({
763+
id: "m-valid-after-dropped",
764+
channelId,
765+
content: "legitimate bot relay",
766+
author: { id: senderBotId, bot: true, username: "Relay" },
767+
});
768+
769+
expect(
770+
await runGuildPreflight({
771+
channelId,
772+
guildId,
773+
message: validMessage,
774+
discordConfig,
775+
guildEntries,
776+
}),
777+
).not.toBeNull();
778+
});
779+
637780
it("keeps bound-thread regular bot messages flowing when allowBots=true", async () => {
638781
const threadBinding = createThreadBinding({
639782
targetKind: "session",

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

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import {
2121
} from "./allow-list.js";
2222
import { resolveDiscordChannelInfoSafe, resolveDiscordChannelNameSafe } from "./channel-access.js";
2323
import { resolveDiscordTextCommandAccess } from "./dm-command-auth.js";
24-
import { resolveDiscordSystemLocation } from "./format.js";
24+
import { resolveDiscordSystemLocation, resolveTimestampMs } from "./format.js";
2525
import { resolveDiscordDmPreflightAccess } from "./message-handler.dm-preflight.js";
2626
import { hydrateDiscordMessageIfNeeded } from "./message-handler.hydration.js";
2727
import { resolveDiscordPreflightChannelAccess } from "./message-handler.preflight-channel-access.js";
@@ -554,7 +554,6 @@ export async function preflightDiscordMessage(
554554
return null;
555555
}
556556
}
557-
558557
const ignoreOtherMentions =
559558
channelConfig?.ignoreOtherMentions ?? guildInfo?.ignoreOtherMentions ?? false;
560559
if (
@@ -613,6 +612,24 @@ export async function preflightDiscordMessage(
613612
}
614613
}
615614

615+
const botLoopProtection =
616+
author.bot &&
617+
!sender.isPluralKit &&
618+
allowBotsMode !== "off" &&
619+
params.botUserId &&
620+
author.id !== params.botUserId
621+
? {
622+
scopeId: params.accountId,
623+
conversationId: messageChannelId,
624+
senderId: author.id,
625+
receiverId: params.botUserId,
626+
config: params.discordConfig?.botLoopProtection,
627+
defaultsConfig: params.cfg.channels?.defaults?.botLoopProtection,
628+
defaultEnabled: true,
629+
nowMs: resolveTimestampMs(message.timestamp),
630+
}
631+
: undefined;
632+
616633
logDebug(
617634
`[discord-preflight] success: route=${effectiveRoute.agentId} sessionKey=${effectiveRoute.sessionKey}`,
618635
);
@@ -662,5 +679,6 @@ export async function preflightDiscordMessage(
662679
effectiveWasMentioned,
663680
canDetectMention,
664681
historyEntry,
682+
botLoopProtection,
665683
});
666684
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { OpenClawConfig, ReplyToMode } from "openclaw/plugin-sdk/config-contracts";
22
import type { SessionBindingRecord } from "openclaw/plugin-sdk/conversation-runtime";
3+
import type { ChannelBotLoopProtectionFacts } from "openclaw/plugin-sdk/inbound-reply-dispatch";
34
import type { HistoryEntry } from "openclaw/plugin-sdk/reply-history";
45
import type { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
56
import type { ChannelType, Client, User } from "../internal/discord.js";
@@ -92,6 +93,7 @@ export type DiscordMessagePreflightContext = DiscordMessagePreflightSharedFields
9293
historyEntry?: HistoryEntry;
9394
threadBindings: DiscordThreadBindingLookup;
9495
discordRestFetch?: typeof fetch;
96+
botLoopProtection?: ChannelBotLoopProtectionFacts;
9597
};
9698

9799
export type DiscordMessagePreflightParams = DiscordMessagePreflightSharedFields & {

extensions/discord/src/monitor/message-handler.process.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
import { DEFAULT_EMOJIS, DEFAULT_TIMING } from "openclaw/plugin-sdk/channel-feedback";
2+
import {
3+
recordChannelBotPairLoopAndCheckSuppression,
4+
type ChannelBotLoopProtectionFacts,
5+
} from "openclaw/plugin-sdk/inbound-reply-dispatch";
26
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-dispatch-runtime";
37
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
48
import type { DiscordMessagePreflightContext } from "./message-handler.preflight.js";
@@ -597,6 +601,55 @@ function expectSinglePreviewEdit() {
597601
}
598602

599603
describe("processDiscordMessage ack reactions", () => {
604+
it("drops bot-loop-suppressed messages before Discord side effects", async () => {
605+
const botLoopProtection: ChannelBotLoopProtectionFacts = {
606+
scopeId: "discord-process-side-effect-test",
607+
conversationId: "c-loop-side-effects",
608+
senderId: "bot-a",
609+
receiverId: "bot-b",
610+
config: {
611+
maxEventsPerWindow: 1,
612+
windowSeconds: 60,
613+
cooldownSeconds: 60,
614+
},
615+
defaultEnabled: true,
616+
nowMs: 10_000,
617+
};
618+
expect(recordChannelBotPairLoopAndCheckSuppression(botLoopProtection)).toEqual({
619+
suppressed: false,
620+
});
621+
const observer = { onReplyPlanResolved: vi.fn() };
622+
const ctx = await createAutomaticSourceDeliveryContext({
623+
messageChannelId: botLoopProtection.conversationId,
624+
message: {
625+
id: "m-loop-side-effects",
626+
channelId: botLoopProtection.conversationId,
627+
timestamp: new Date().toISOString(),
628+
attachments: [
629+
{
630+
id: "att-loop",
631+
url: "https://cdn.discordapp.test/loop.png",
632+
contentType: "image/png",
633+
filename: "loop.png",
634+
size: 16,
635+
},
636+
],
637+
},
638+
botLoopProtection: {
639+
...botLoopProtection,
640+
nowMs: 10_001,
641+
},
642+
});
643+
644+
await processDiscordMessage(ctx, observer);
645+
646+
expect(observer.onReplyPlanResolved).not.toHaveBeenCalled();
647+
expect(createDiscordRestClientSpy).not.toHaveBeenCalled();
648+
expect(sendMocks.reactMessageDiscord).not.toHaveBeenCalled();
649+
expect(recordInboundSession).not.toHaveBeenCalled();
650+
expect(dispatchInboundMessage).not.toHaveBeenCalled();
651+
});
652+
600653
it("skips ack reactions for group-mentions when mentions are not required", async () => {
601654
const ctx = await createBaseContext({
602655
shouldRequireMention: false,

0 commit comments

Comments
 (0)