From dbf139d14e8ac90613e49c08106dad9aa19a5eee Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 24 Jan 2026 11:09:20 +0000 Subject: [PATCH] test: cover explicit mention gating across channels --- src/auto-reply/reply/mentions.test.ts | 45 +++++++++++ ...ild-messages-mentionpatterns-match.test.ts | 80 +++++++++++++++++++ ...ends-tool-summaries-responseprefix.test.ts | 45 +++++++++++ ...patterns-match-without-botusername.test.ts | 41 ++++++++++ src/web/auto-reply/mentions.test.ts | 55 +++++++++++++ 5 files changed, 266 insertions(+) create mode 100644 src/auto-reply/reply/mentions.test.ts create mode 100644 src/web/auto-reply/mentions.test.ts diff --git a/src/auto-reply/reply/mentions.test.ts b/src/auto-reply/reply/mentions.test.ts new file mode 100644 index 000000000..7742b4f30 --- /dev/null +++ b/src/auto-reply/reply/mentions.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { matchesMentionWithExplicit } from "./mentions.js"; + +describe("matchesMentionWithExplicit", () => { + const mentionRegexes = [/\bclawd\b/i]; + + it("prefers explicit mentions when other mentions are present", () => { + const result = matchesMentionWithExplicit({ + text: "@clawd hello", + mentionRegexes, + explicit: { + hasAnyMention: true, + isExplicitlyMentioned: false, + canResolveExplicit: true, + }, + }); + expect(result).toBe(false); + }); + + it("returns true when explicitly mentioned even if regexes do not match", () => { + const result = matchesMentionWithExplicit({ + text: "<@123456>", + mentionRegexes: [], + explicit: { + hasAnyMention: true, + isExplicitlyMentioned: true, + canResolveExplicit: true, + }, + }); + expect(result).toBe(true); + }); + + it("falls back to regex matching when explicit mention cannot be resolved", () => { + const result = matchesMentionWithExplicit({ + text: "clawd please", + mentionRegexes, + explicit: { + hasAnyMention: true, + isExplicitlyMentioned: false, + canResolveExplicit: false, + }, + }); + expect(result).toBe(true); + }); +}); diff --git a/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.test.ts b/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.test.ts index d91c7b3d3..5bc3407f2 100644 --- a/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.test.ts +++ b/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.test.ts @@ -135,6 +135,86 @@ describe("discord tool result dispatch", () => { expect(sendMock).toHaveBeenCalledTimes(1); }, 20_000); + it("skips guild messages when another user is explicitly mentioned", async () => { + const { createDiscordMessageHandler } = await import("./monitor.js"); + const cfg = { + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: "/tmp/clawd", + }, + }, + session: { store: "/tmp/clawdbot-sessions.json" }, + channels: { + discord: { + dm: { enabled: true, policy: "open" }, + groupPolicy: "open", + guilds: { "*": { requireMention: true } }, + }, + }, + messages: { + responsePrefix: "PFX", + groupChat: { mentionPatterns: ["\\bclawd\\b"] }, + }, + } as ReturnType; + + const handler = createDiscordMessageHandler({ + cfg, + discordConfig: cfg.channels.discord, + accountId: "default", + token: "token", + runtime: { + log: vi.fn(), + error: vi.fn(), + exit: (code: number): never => { + throw new Error(`exit ${code}`); + }, + }, + botUserId: "bot-id", + guildHistories: new Map(), + historyLimit: 0, + mediaMaxBytes: 10_000, + textLimit: 2000, + replyToMode: "off", + dmEnabled: true, + groupDmEnabled: false, + guildEntries: { "*": { requireMention: true } }, + }); + + const client = { + fetchChannel: vi.fn().mockResolvedValue({ + type: ChannelType.GuildText, + name: "general", + }), + } as unknown as Client; + + await handler( + { + message: { + id: "m2", + content: "clawd: hello", + channelId: "c1", + timestamp: new Date().toISOString(), + type: MessageType.Default, + attachments: [], + embeds: [], + mentionedEveryone: false, + mentionedUsers: [{ id: "u2", bot: false, username: "Bea" }], + mentionedRoles: [], + author: { id: "u1", bot: false, username: "Ada" }, + }, + author: { id: "u1", bot: false, username: "Ada" }, + member: { nickname: "Ada" }, + guild: { id: "g1", name: "Guild" }, + guild_id: "g1", + }, + client, + ); + + expect(dispatchMock).not.toHaveBeenCalled(); + expect(sendMock).not.toHaveBeenCalled(); + }, 20_000); + it("accepts guild reply-to-bot messages as implicit mentions", async () => { const { createDiscordMessageHandler } = await import("./monitor.js"); const cfg = { diff --git a/src/slack/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts b/src/slack/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts index 7f5ffa03c..bcd797793 100644 --- a/src/slack/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts +++ b/src/slack/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts @@ -400,6 +400,51 @@ describe("monitorSlackProvider tool results", () => { expect(replyMock.mock.calls[0][0].WasMentioned).toBe(true); }); + it("skips channel messages when another user is explicitly mentioned", async () => { + slackTestState.config = { + messages: { + responsePrefix: "PFX", + groupChat: { mentionPatterns: ["\\bclawd\\b"] }, + }, + channels: { + slack: { + dm: { enabled: true, policy: "open", allowFrom: ["*"] }, + channels: { C1: { allow: true, requireMention: true } }, + }, + }, + }; + replyMock.mockResolvedValue({ text: "hi" }); + + const controller = new AbortController(); + const run = monitorSlackProvider({ + botToken: "bot-token", + appToken: "app-token", + abortSignal: controller.signal, + }); + + await waitForSlackEvent("message"); + const handler = getSlackHandlers()?.get("message"); + if (!handler) throw new Error("Slack message handler not registered"); + + await handler({ + event: { + type: "message", + user: "U1", + text: "clawd: hello <@U2>", + ts: "123", + channel: "C1", + channel_type: "channel", + }, + }); + + await flush(); + controller.abort(); + await run; + + expect(replyMock).not.toHaveBeenCalled(); + expect(sendMock).not.toHaveBeenCalled(); + }); + it("treats replies to bot threads as implicit mentions", async () => { slackTestState.config = { channels: { diff --git a/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts b/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts index 1a6afa519..66e60ecca 100644 --- a/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts +++ b/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts @@ -210,6 +210,47 @@ describe("createTelegramBot", () => { new RegExp(`^\\[Telegram Test Group id:7 (\\+\\d+[smhd] )?${timestampPattern}\\]`), ); }); + + it("skips group messages when another user is explicitly mentioned", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + + loadConfig.mockReturnValue({ + agents: { + defaults: { + envelopeTimezone: "utc", + }, + }, + identity: { name: "Bert" }, + messages: { groupChat: { mentionPatterns: ["\\bbert\\b"] } }, + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: true } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: 7, type: "group", title: "Test Group" }, + text: "bert: hello @alice", + entities: [{ type: "mention", offset: 12, length: 6 }], + date: 1736380800, + message_id: 3, + from: { id: 9, first_name: "Ada" }, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).not.toHaveBeenCalled(); + }); + it("keeps group envelope headers stable (sender identity is separate)", async () => { onSpy.mockReset(); const replySpy = replyModule.__replySpy as unknown as ReturnType; diff --git a/src/web/auto-reply/mentions.test.ts b/src/web/auto-reply/mentions.test.ts new file mode 100644 index 000000000..62d8613bc --- /dev/null +++ b/src/web/auto-reply/mentions.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import type { WebInboundMsg } from "./types.js"; +import { isBotMentionedFromTargets, resolveMentionTargets } from "./mentions.js"; + +const makeMsg = (overrides: Partial): WebInboundMsg => + ({ + id: "m1", + from: "120363401234567890@g.us", + conversationId: "120363401234567890@g.us", + to: "15551234567@s.whatsapp.net", + accountId: "default", + body: "", + chatType: "group", + chatId: "120363401234567890@g.us", + sendComposing: async () => {}, + reply: async () => {}, + sendMedia: async () => {}, + ...overrides, + }) as WebInboundMsg; + +describe("isBotMentionedFromTargets", () => { + const mentionCfg = { mentionRegexes: [/\bclawd\b/i] }; + + it("ignores regex matches when other mentions are present", () => { + const msg = makeMsg({ + body: "@Clawd please help", + mentionedJids: ["19998887777@s.whatsapp.net"], + selfE164: "+15551234567", + selfJid: "15551234567@s.whatsapp.net", + }); + const targets = resolveMentionTargets(msg); + expect(isBotMentionedFromTargets(msg, mentionCfg, targets)).toBe(false); + }); + + it("matches explicit self mentions", () => { + const msg = makeMsg({ + body: "hey", + mentionedJids: ["15551234567@s.whatsapp.net"], + selfE164: "+15551234567", + selfJid: "15551234567@s.whatsapp.net", + }); + const targets = resolveMentionTargets(msg); + expect(isBotMentionedFromTargets(msg, mentionCfg, targets)).toBe(true); + }); + + it("falls back to regex when no mentions are present", () => { + const msg = makeMsg({ + body: "clawd can you help?", + selfE164: "+15551234567", + selfJid: "15551234567@s.whatsapp.net", + }); + const targets = resolveMentionTargets(msg); + expect(isBotMentionedFromTargets(msg, mentionCfg, targets)).toBe(true); + }); +});