feat(discord): add allowBots mention gating

This commit is contained in:
Shadow
2026-03-03 12:47:25 -06:00
parent b0bcea03db
commit 65816657c2
9 changed files with 164 additions and 6 deletions

View File

@@ -1323,6 +1323,8 @@ export const FIELD_HELP: Record<string, string> = {
"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.<id>.proxy.",
"channels.whatsapp.configWrites":

View File

@@ -740,6 +740,7 @@ export const FIELD_LABELS: Record<string, string> = {
"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",

View File

@@ -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.

View File

@@ -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(),

View File

@@ -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<import("../../config/config.js").OpenClawConfig["channels"]>["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<import("../../config/config.js").OpenClawConfig["channels"]>["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";

View File

@@ -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 (