diff --git a/CHANGELOG.md b/CHANGELOG.md index 7901b18b501..cd4ad486c79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Changes +- Discord/allowBots mention gating: add `allowBots: "mentions"` to only accept bot-authored messages that mention the bot. Thanks @thewilloftheshadow. - Docs/Web search: remove outdated Brave free-tier wording and replace prescriptive AI ToS guidance with neutral compliance language in Brave setup docs. (#26860) Thanks @HenryLoenwind. - Tools/Diffs guidance loading: move diffs usage guidance from unconditional prompt-hook injection to the plugin companion skill path, reducing unrelated-turn prompt noise while keeping diffs tool behavior unchanged. (#32630) thanks @sircrumpet. - Agents/tool-result truncation: preserve important tail diagnostics by using head+tail truncation for oversized tool results while keeping configurable truncation options. (#20076) thanks @jlwestsr. diff --git a/docs/channels/discord.md b/docs/channels/discord.md index 6dd15a686c6..e11ca7dd651 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -1082,6 +1082,7 @@ openclaw logs --follow By default bot-authored messages are ignored. If you set `channels.discord.allowBots=true`, use strict mention and allowlist rules to avoid loop behavior. + Prefer `channels.discord.allowBots="mentions"` to only accept bot messages that mention the bot. diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 9257c37b604..2daafd801e8 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -306,7 +306,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat - Optional `channels.discord.defaultAccount` overrides default account selection when it matches a configured account id. - Use `user:` (DM) or `channel:` (guild channel) for delivery targets; bare numeric IDs are rejected. - Guild slugs are lowercase with spaces replaced by `-`; channel keys use the slugged name (no `#`). Prefer guild IDs. -- Bot-authored messages are ignored by default. `allowBots: true` enables them (own messages still filtered). +- Bot-authored messages are ignored by default. `allowBots: true` enables them; use `allowBots: "mentions"` to only accept bot messages that mention the bot (own messages still filtered). - `channels.discord.guilds..ignoreOtherMentions` (and channel overrides) drops messages that mention another user or role but not the bot (excluding @everyone/@here). - `maxLinesPerMessage` (default 17) splits tall messages even when under 2000 chars. - `channels.discord.threadBindings` controls Discord thread-bound routing: diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 3b3f5cecbc4..a6a49fae033 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -1323,6 +1323,8 @@ export const FIELD_HELP: Record = { "Allow Discord to write config in response to channel events/commands (default: true).", "channels.discord.token": "Discord bot token used for gateway and REST API authentication for this provider account. Keep this secret out of committed config and rotate immediately after any leak.", + "channels.discord.allowBots": + 'Allow bot-authored messages to trigger Discord replies (default: false). Set "mentions" to only accept bot messages that mention the bot.', "channels.discord.proxy": "Proxy URL for Discord gateway + API requests (app-id lookup and allowlist resolution). Set per account via channels.discord.accounts..proxy.", "channels.whatsapp.configWrites": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index cb7df9ce718..35ad9db80f9 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -740,6 +740,7 @@ export const FIELD_LABELS: Record = { "channels.slack.commands.native": "Slack Native Commands", "channels.slack.commands.nativeSkills": "Slack Native Skill Commands", "channels.slack.allowBots": "Slack Allow Bot Messages", + "channels.discord.allowBots": "Discord Allow Bot Messages", "channels.discord.token": "Discord Bot Token", "channels.slack.botToken": "Slack Bot Token", "channels.slack.appToken": "Slack App Token", diff --git a/src/config/types.discord.ts b/src/config/types.discord.ts index cda5d6c6a75..2102e31128c 100644 --- a/src/config/types.discord.ts +++ b/src/config/types.discord.ts @@ -221,8 +221,8 @@ export type DiscordAccountConfig = { token?: string; /** HTTP(S) proxy URL for Discord gateway WebSocket connections. */ proxy?: string; - /** Allow bot-authored messages to trigger replies (default: false). */ - allowBots?: boolean; + /** Allow bot-authored messages to trigger replies (default: false). Set "mentions" to gate on mentions. */ + allowBots?: boolean | "mentions"; /** * Break-glass override: allow mutable identity matching (names/tags/slugs) in allowlists. * Default behavior is ID-only matching. diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 4b3426c6f8a..14d836e113f 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -412,7 +412,7 @@ export const DiscordAccountSchema = z configWrites: z.boolean().optional(), token: SecretInputSchema.optional().register(sensitive), proxy: z.string().optional(), - allowBots: z.boolean().optional(), + allowBots: z.union([z.boolean(), z.literal("mentions")]).optional(), dangerouslyAllowNameMatching: z.boolean().optional(), groupPolicy: GroupPolicySchema.optional().default("allowlist"), historyLimit: z.number().int().min(0).optional(), diff --git a/src/discord/monitor/message-handler.preflight.test.ts b/src/discord/monitor/message-handler.preflight.test.ts index 9ab05320055..9a2fb11eebf 100644 --- a/src/discord/monitor/message-handler.preflight.test.ts +++ b/src/discord/monitor/message-handler.preflight.test.ts @@ -354,6 +354,148 @@ describe("preflightDiscordMessage", () => { expect(result?.shouldRequireMention).toBe(false); }); + it("drops bot messages without mention when allowBots=mentions", async () => { + const channelId = "channel-bot-mentions-off"; + const guildId = "guild-bot-mentions-off"; + const client = { + fetchChannel: async (id: string) => { + if (id === channelId) { + return { + id: channelId, + type: ChannelType.GuildText, + name: "general", + }; + } + return null; + }, + } as unknown as import("@buape/carbon").Client; + const message = { + id: "m-bot-mentions-off", + content: "relay chatter", + timestamp: new Date().toISOString(), + channelId, + attachments: [], + mentionedUsers: [], + mentionedRoles: [], + mentionedEveryone: false, + author: { + id: "relay-bot-1", + bot: true, + username: "Relay", + }, + } as unknown as import("@buape/carbon").Message; + + const result = await preflightDiscordMessage({ + cfg: { + session: { + mainKey: "main", + scope: "per-sender", + }, + } as import("../../config/config.js").OpenClawConfig, + discordConfig: { + allowBots: "mentions", + } as NonNullable["discord"], + accountId: "default", + token: "token", + runtime: {} as import("../../runtime.js").RuntimeEnv, + botUserId: "openclaw-bot", + guildHistories: new Map(), + historyLimit: 0, + mediaMaxBytes: 1_000_000, + textLimit: 2_000, + replyToMode: "all", + dmEnabled: true, + groupDmEnabled: true, + ackReactionScope: "direct", + groupPolicy: "open", + threadBindings: createNoopThreadBindingManager("default"), + data: { + channel_id: channelId, + guild_id: guildId, + guild: { + id: guildId, + name: "Guild One", + }, + author: message.author, + message, + } as unknown as import("./listeners.js").DiscordMessageEvent, + client, + }); + + expect(result).toBeNull(); + }); + + it("allows bot messages with explicit mention when allowBots=mentions", async () => { + const channelId = "channel-bot-mentions-on"; + const guildId = "guild-bot-mentions-on"; + const client = { + fetchChannel: async (id: string) => { + if (id === channelId) { + return { + id: channelId, + type: ChannelType.GuildText, + name: "general", + }; + } + return null; + }, + } as unknown as import("@buape/carbon").Client; + const message = { + id: "m-bot-mentions-on", + content: "hi <@openclaw-bot>", + timestamp: new Date().toISOString(), + channelId, + attachments: [], + mentionedUsers: [{ id: "openclaw-bot" }], + mentionedRoles: [], + mentionedEveryone: false, + author: { + id: "relay-bot-1", + bot: true, + username: "Relay", + }, + } as unknown as import("@buape/carbon").Message; + + const result = await preflightDiscordMessage({ + cfg: { + session: { + mainKey: "main", + scope: "per-sender", + }, + } as import("../../config/config.js").OpenClawConfig, + discordConfig: { + allowBots: "mentions", + } as NonNullable["discord"], + accountId: "default", + token: "token", + runtime: {} as import("../../runtime.js").RuntimeEnv, + botUserId: "openclaw-bot", + guildHistories: new Map(), + historyLimit: 0, + mediaMaxBytes: 1_000_000, + textLimit: 2_000, + replyToMode: "all", + dmEnabled: true, + groupDmEnabled: true, + ackReactionScope: "direct", + groupPolicy: "open", + threadBindings: createNoopThreadBindingManager("default"), + data: { + channel_id: channelId, + guild_id: guildId, + guild: { + id: guildId, + name: "Guild One", + }, + author: message.author, + message, + } as unknown as import("./listeners.js").DiscordMessageEvent, + client, + }); + + expect(result).not.toBeNull(); + }); + it("drops guild messages that mention another user when ignoreOtherMentions=true", async () => { const channelId = "channel-other-mention-1"; const guildId = "guild-other-mention-1"; diff --git a/src/discord/monitor/message-handler.preflight.ts b/src/discord/monitor/message-handler.preflight.ts index da1b14050c5..7339caf0604 100644 --- a/src/discord/monitor/message-handler.preflight.ts +++ b/src/discord/monitor/message-handler.preflight.ts @@ -139,7 +139,9 @@ export async function preflightDiscordMessage( return null; } - const allowBots = params.discordConfig?.allowBots ?? false; + const allowBotsSetting = params.discordConfig?.allowBots; + const allowBotsMode = + allowBotsSetting === "mentions" ? "mentions" : allowBotsSetting === true ? "all" : "off"; if (params.botUserId && author.id === params.botUserId) { // Always ignore own messages to prevent self-reply loops return null; @@ -166,7 +168,7 @@ export async function preflightDiscordMessage( }); if (author.bot) { - if (!allowBots && !sender.isPluralKit) { + if (allowBotsMode === "off" && !sender.isPluralKit) { logVerbose("discord: drop bot message (allowBots=false)"); return null; } @@ -656,6 +658,15 @@ export async function preflightDiscordMessage( } } + if (author.bot && !sender.isPluralKit && allowBotsMode === "mentions") { + const botMentioned = isDirectMessage || wasMentioned || implicitMention; + if (!botMentioned) { + logDebug(`[discord-preflight] drop: bot message missing mention (allowBots=mentions)`); + logVerbose("discord: drop bot message (allowBots=mentions, missing mention)"); + return null; + } + } + const ignoreOtherMentions = channelConfig?.ignoreOtherMentions ?? guildInfo?.ignoreOtherMentions ?? false; if (