From 811ec8b78b76fbf547f77a3814a621fcd2730211 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 6 Jan 2026 01:32:17 +0100 Subject: [PATCH] fix: unify mention gating across providers --- CHANGELOG.md | 1 + src/auto-reply/reply/mentions.ts | 29 +++++++++++ src/discord/monitor.tool-result.test.ts | 55 ++++++++++++++++++++ src/discord/monitor.ts | 16 ++++-- src/imessage/monitor.ts | 41 ++++++--------- src/slack/monitor.tool-result.test.ts | 45 +++++++++++++++++ src/slack/monitor.ts | 10 +++- src/telegram/bot.test.ts | 67 +++++++++++++++++++++++++ src/telegram/bot.ts | 14 ++++-- src/web/auto-reply.ts | 26 +++------- 10 files changed, 253 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d24ddf77..7d3572d31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ - WhatsApp: set sender E.164 for direct chats so owner commands work in DMs. - Slack: keep auto-replies in the original thread when responding to thread messages. Thanks @scald for PR #251. - Control UI: avoid Slack config ReferenceError by reading slack config snapshots. Thanks @sreekaransrinath for PR #249. +- Telegram: honor routing.groupChat.mentionPatterns for group mention gating. Thanks @regenrek for PR #242. ### Maintenance - Deps: bump pi-* stack, Slack SDK, discord-api-types, file-type, zod, and Biome. diff --git a/src/auto-reply/reply/mentions.ts b/src/auto-reply/reply/mentions.ts index c85914fd5..d9edcfa0f 100644 --- a/src/auto-reply/reply/mentions.ts +++ b/src/auto-reply/reply/mentions.ts @@ -1,6 +1,35 @@ import type { ClawdbotConfig } from "../../config/config.js"; import type { MsgContext } from "../templating.js"; +export function buildMentionRegexes(cfg: ClawdbotConfig | undefined): RegExp[] { + const patterns = cfg?.routing?.groupChat?.mentionPatterns ?? []; + return patterns + .map((pattern) => { + try { + return new RegExp(pattern, "i"); + } catch { + return null; + } + }) + .filter((value): value is RegExp => Boolean(value)); +} + +export function normalizeMentionText(text: string): string { + return (text ?? "") + .replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u206f]/g, "") + .toLowerCase(); +} + +export function matchesMentionPatterns( + text: string, + mentionRegexes: RegExp[], +): boolean { + if (mentionRegexes.length === 0) return false; + const cleaned = normalizeMentionText(text ?? ""); + if (!cleaned) return false; + return mentionRegexes.some((re) => re.test(cleaned)); +} + export function stripStructuralPrefixes(text: string): string { // Ignore wrapper labels, timestamps, and sender prefixes so directive-only // detection still works in group batches that include history/context. diff --git a/src/discord/monitor.tool-result.test.ts b/src/discord/monitor.tool-result.test.ts index 867c592c3..b9314b5a2 100644 --- a/src/discord/monitor.tool-result.test.ts +++ b/src/discord/monitor.tool-result.test.ts @@ -147,4 +147,59 @@ describe("monitorDiscordProvider tool results", () => { expect(sendMock.mock.calls[0][1]).toBe("PFX tool update"); expect(sendMock.mock.calls[1][1]).toBe("PFX final reply"); }); + + it("accepts guild messages when mentionPatterns match", async () => { + config = { + messages: { responsePrefix: "PFX" }, + discord: { + dm: { enabled: true }, + guilds: { "*": { requireMention: true } }, + }, + routing: { + allowFrom: [], + groupChat: { mentionPatterns: ["\\bclawd\\b"] }, + }, + }; + replyMock.mockResolvedValue({ text: "hi" }); + + const controller = new AbortController(); + const run = monitorDiscordProvider({ + token: "token", + abortSignal: controller.signal, + }); + + const discord = await import("discord.js"); + const client = await waitForClient(); + if (!client) throw new Error("Discord client not created"); + + client.emit(discord.Events.MessageCreate, { + id: "m2", + content: "clawd: hello", + author: { id: "u1", bot: false, username: "Ada", tag: "Ada#1" }, + member: { displayName: "Ada" }, + channelId: "c1", + channel: { + type: discord.ChannelType.GuildText, + name: "general", + isSendable: () => false, + }, + guild: { id: "g1", name: "Guild" }, + mentions: { + has: () => false, + everyone: false, + users: { size: 0 }, + roles: { size: 0 }, + }, + attachments: { first: () => undefined }, + type: discord.MessageType.Default, + createdTimestamp: Date.now(), + }); + + await flush(); + controller.abort(); + await run; + + expect(replyMock).toHaveBeenCalledTimes(1); + expect(replyMock.mock.calls[0][0].WasMentioned).toBe(true); + }); }); diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts index 35bebbab5..4a8ecebf6 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -18,6 +18,10 @@ import { import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js"; import { hasControlCommand } from "../auto-reply/command-detection.js"; import { formatAgentEnvelope } from "../auto-reply/envelope.js"; +import { + buildMentionRegexes, + matchesMentionPatterns, +} from "../auto-reply/reply/mentions.js"; import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js"; import { getReplyFromConfig } from "../auto-reply/reply.js"; import type { ReplyPayload } from "../auto-reply/types.js"; @@ -140,6 +144,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const mediaMaxBytes = (opts.mediaMaxMb ?? cfg.discord?.mediaMaxMb ?? 8) * 1024 * 1024; const textLimit = resolveTextChunkLimit(cfg, "discord"); + const mentionRegexes = buildMentionRegexes(cfg); const historyLimit = Math.max( 0, opts.historyLimit ?? cfg.discord?.historyLimit ?? 20, @@ -202,13 +207,15 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { return; } const botId = client.user?.id; - const wasMentioned = - !isDirectMessage && Boolean(botId && message.mentions.has(botId)); const forwardedSnapshot = resolveForwardedSnapshot(message); const forwardedText = forwardedSnapshot ? resolveDiscordSnapshotText(forwardedSnapshot.snapshot) : ""; const baseText = resolveDiscordMessageText(message, forwardedText); + const wasMentioned = + !isDirectMessage && + (Boolean(botId && message.mentions.has(botId)) || + matchesMentionPatterns(baseText, mentionRegexes)); if (shouldLogVerbose()) { logVerbose( `discord: inbound id=${message.id} guild=${message.guild?.id ?? "dm"} channel=${message.channelId} mention=${wasMentioned ? "yes" : "no"} type=${isDirectMessage ? "dm" : isGroupDm ? "group-dm" : "guild"} content=${baseText ? "yes" : "no"}`, @@ -309,8 +316,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { !hasAnyMention && commandAuthorized && hasControlCommand(baseText); - if (isGuildMessage && resolvedRequireMention) { - if (botId && !wasMentioned && !shouldBypassMention) { + const canDetectMention = Boolean(botId) || mentionRegexes.length > 0; + if (isGuildMessage && resolvedRequireMention && canDetectMention) { + if (!wasMentioned && !shouldBypassMention) { logVerbose( `discord: drop guild message (mention required, botId=${botId})`, ); diff --git a/src/imessage/monitor.ts b/src/imessage/monitor.ts index 300d27ed2..30f12e7ee 100644 --- a/src/imessage/monitor.ts +++ b/src/imessage/monitor.ts @@ -1,6 +1,10 @@ import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js"; import { hasControlCommand } from "../auto-reply/command-detection.js"; import { formatAgentEnvelope } from "../auto-reply/envelope.js"; +import { + buildMentionRegexes, + matchesMentionPatterns, +} from "../auto-reply/reply/mentions.js"; import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js"; import { getReplyFromConfig } from "../auto-reply/reply.js"; import type { ReplyPayload } from "../auto-reply/types.js"; @@ -67,20 +71,6 @@ function resolveAllowFrom(opts: MonitorIMessageOpts): string[] { return raw.map((entry) => String(entry).trim()).filter(Boolean); } -function resolveMentionRegexes(cfg: ReturnType): RegExp[] { - return ( - cfg.routing?.groupChat?.mentionPatterns - ?.map((pattern) => { - try { - return new RegExp(pattern, "i"); - } catch { - return null; - } - }) - .filter((val): val is RegExp => Boolean(val)) ?? [] - ); -} - function resolveGroupRequireMention( cfg: ReturnType, opts: MonitorIMessageOpts, @@ -99,14 +89,6 @@ function resolveGroupRequireMention( return true; } -function isMentioned(text: string, regexes: RegExp[]): boolean { - if (!text) return false; - const cleaned = text - .replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u206f]/g, "") - .toLowerCase(); - return regexes.some((re) => re.test(cleaned)); -} - async function deliverReplies(params: { replies: ReplyPayload[]; target: string; @@ -148,7 +130,7 @@ export async function monitorIMessageProvider( const cfg = loadConfig(); const textLimit = resolveTextChunkLimit(cfg, "imessage"); const allowFrom = resolveAllowFrom(opts); - const mentionRegexes = resolveMentionRegexes(cfg); + const mentionRegexes = buildMentionRegexes(cfg); const includeAttachments = opts.includeAttachments ?? cfg.imessage?.includeAttachments ?? false; const mediaMaxBytes = @@ -183,15 +165,24 @@ export async function monitorIMessageProvider( } const messageText = (message.text ?? "").trim(); - const mentioned = isGroup ? isMentioned(messageText, mentionRegexes) : true; + const mentioned = isGroup + ? matchesMentionPatterns(messageText, mentionRegexes) + : true; const requireMention = resolveGroupRequireMention(cfg, opts, chatId); + const canDetectMention = mentionRegexes.length > 0; const shouldBypassMention = isGroup && requireMention && !mentioned && commandAuthorized && hasControlCommand(messageText); - if (isGroup && requireMention && !mentioned && !shouldBypassMention) { + if ( + isGroup && + requireMention && + canDetectMention && + !mentioned && + !shouldBypassMention + ) { logVerbose(`imessage: skipping group message (no mention)`); return; } diff --git a/src/slack/monitor.tool-result.test.ts b/src/slack/monitor.tool-result.test.ts index 99ec296dc..4a19ca8fc 100644 --- a/src/slack/monitor.tool-result.test.ts +++ b/src/slack/monitor.tool-result.test.ts @@ -123,6 +123,51 @@ describe("monitorSlackProvider tool results", () => { expect(sendMock.mock.calls[1][1]).toBe("PFX final reply"); }); + it("accepts channel messages when mentionPatterns match", async () => { + config = { + messages: { responsePrefix: "PFX" }, + slack: { + dm: { enabled: true }, + groupDm: { enabled: false }, + channels: { C1: { allow: true, requireMention: true } }, + }, + routing: { + allowFrom: [], + groupChat: { mentionPatterns: ["\\bclawd\\b"] }, + }, + }; + replyMock.mockResolvedValue({ text: "hi" }); + + const controller = new AbortController(); + const run = monitorSlackProvider({ + botToken: "bot-token", + appToken: "app-token", + abortSignal: controller.signal, + }); + + await waitForEvent("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", + ts: "123", + channel: "C1", + channel_type: "channel", + }, + }); + + await flush(); + controller.abort(); + await run; + + expect(replyMock).toHaveBeenCalledTimes(1); + expect(replyMock.mock.calls[0][0].WasMentioned).toBe(true); + }); + it("threads replies when incoming message is in a thread", async () => { replyMock.mockResolvedValue({ text: "thread reply" }); diff --git a/src/slack/monitor.ts b/src/slack/monitor.ts index f3929881f..e8509f774 100644 --- a/src/slack/monitor.ts +++ b/src/slack/monitor.ts @@ -6,6 +6,10 @@ import bolt from "@slack/bolt"; import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js"; import { hasControlCommand } from "../auto-reply/command-detection.js"; import { formatAgentEnvelope } from "../auto-reply/envelope.js"; +import { + buildMentionRegexes, + matchesMentionPatterns, +} from "../auto-reply/reply/mentions.js"; import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js"; import { getReplyFromConfig } from "../auto-reply/reply.js"; import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; @@ -379,6 +383,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { opts.slashCommand ?? cfg.slack?.slashCommand, ); const textLimit = resolveTextChunkLimit(cfg, "slack"); + const mentionRegexes = buildMentionRegexes(cfg); const mediaMaxBytes = (opts.mediaMaxMb ?? cfg.slack?.mediaMaxMb ?? 20) * 1024 * 1024; @@ -581,7 +586,8 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { const wasMentioned = opts.wasMentioned ?? (!isDirectMessage && - Boolean(botUserId && message.text?.includes(`<@${botUserId}>`))); + (Boolean(botUserId && message.text?.includes(`<@${botUserId}>`)) || + matchesMentionPatterns(message.text ?? "", mentionRegexes))); const sender = await resolveUserName(message.user); const senderName = sender?.name ?? message.user; const allowList = normalizeAllowListLower(allowFrom); @@ -600,9 +606,11 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { !hasAnyMention && commandAuthorized && hasControlCommand(message.text ?? ""); + const canDetectMention = Boolean(botUserId) || mentionRegexes.length > 0; if ( isRoom && channelConfig?.requireMention && + canDetectMention && !wasMentioned && !shouldBypassMention ) { diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index 594958389..96fd5b85c 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -126,6 +126,73 @@ describe("createTelegramBot", () => { expect(sendChatActionSpy).toHaveBeenCalledWith(42, "typing"); }); + it("accepts group messages when mentionPatterns match (without @botUsername)", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + + loadConfig.mockReturnValue({ + identity: { name: "Bert" }, + routing: { groupChat: { mentionPatterns: ["\\bbert\\b"] } }, + telegram: { groups: { "*": { requireMention: true } } }, + }); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: 7, type: "group", title: "Test Group" }, + text: "bert: introduce yourself", + date: 1736380800, + message_id: 1, + from: { id: 9, first_name: "Ada" }, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expect(payload.WasMentioned).toBe(true); + }); + + it("skips group messages when requireMention is enabled and no mention matches", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + + loadConfig.mockReturnValue({ + routing: { groupChat: { mentionPatterns: ["\\bbert\\b"] } }, + telegram: { groups: { "*": { requireMention: true } } }, + }); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: 7, type: "group", title: "Test Group" }, + text: "hello everyone", + date: 1736380800, + message_id: 2, + from: { id: 9, first_name: "Ada" }, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).not.toHaveBeenCalled(); + }); + it("includes reply-to context when a Telegram reply is received", async () => { onSpy.mockReset(); sendMessageSpy.mockReset(); diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index ff7722c17..f8022dc98 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -7,6 +7,10 @@ import { Bot, InputFile, webhookCallback } from "grammy"; import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js"; import { hasControlCommand } from "../auto-reply/command-detection.js"; import { formatAgentEnvelope } from "../auto-reply/envelope.js"; +import { + buildMentionRegexes, + matchesMentionPatterns, +} from "../auto-reply/reply/mentions.js"; import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js"; import { getReplyFromConfig } from "../auto-reply/reply.js"; import type { ReplyPayload } from "../auto-reply/types.js"; @@ -67,6 +71,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { const mediaMaxBytes = (opts.mediaMaxMb ?? cfg.telegram?.mediaMaxMb ?? 5) * 1024 * 1024; const logger = getChildLogger({ module: "telegram-auto-reply" }); + const mentionRegexes = buildMentionRegexes(cfg); const resolveGroupRequireMention = (chatId: string | number) => { const groupId = String(chatId); const groupConfig = cfg.telegram?.groups?.[groupId]; @@ -132,7 +137,8 @@ export function createTelegramBot(opts: TelegramBotOptions) { entry.toLowerCase() === `@${senderUsername.toLowerCase()}`, )); const wasMentioned = - Boolean(botUsername) && hasBotMention(msg, botUsername); + (Boolean(botUsername) && hasBotMention(msg, botUsername)) || + matchesMentionPatterns(msg.text ?? msg.caption ?? "", mentionRegexes); const hasAnyMention = (msg.entities ?? msg.caption_entities ?? []).some( (ent) => ent.type === "mention", ); @@ -143,7 +149,9 @@ export function createTelegramBot(opts: TelegramBotOptions) { !hasAnyMention && commandAuthorized && hasControlCommand(msg.text ?? msg.caption ?? ""); - if (isGroup && resolveGroupRequireMention(chatId) && botUsername) { + const canDetectMention = + Boolean(botUsername) || mentionRegexes.length > 0; + if (isGroup && resolveGroupRequireMention(chatId) && canDetectMention) { if (!wasMentioned && !shouldBypassMention) { logger.info( { chatId, reason: "no-mention" }, @@ -196,7 +204,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { ReplyToBody: replyTarget?.body, ReplyToSender: replyTarget?.sender, Timestamp: msg.date ? msg.date * 1000 : undefined, - WasMentioned: isGroup && botUsername ? wasMentioned : undefined, + WasMentioned: isGroup ? wasMentioned : undefined, MediaPath: media?.path, MediaType: media?.contentType, MediaUrl: media?.path, diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index 089723ed6..c46afcf66 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -9,6 +9,10 @@ import { HEARTBEAT_PROMPT, stripHeartbeatToken, } from "../auto-reply/heartbeat.js"; +import { + buildMentionRegexes, + normalizeMentionText, +} from "../auto-reply/reply/mentions.js"; import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js"; import { getReplyFromConfig } from "../auto-reply/reply.js"; import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; @@ -147,17 +151,7 @@ type MentionConfig = { }; function buildMentionConfig(cfg: ReturnType): MentionConfig { - const gc = cfg.routing?.groupChat; - const mentionRegexes = - gc?.mentionPatterns - ?.map((p) => { - try { - return new RegExp(p, "i"); - } catch { - return null; - } - }) - .filter((r): r is RegExp => Boolean(r)) ?? []; + const mentionRegexes = buildMentionRegexes(cfg); return { mentionRegexes, allowFrom: cfg.whatsapp?.allowFrom }; } @@ -166,10 +160,8 @@ function isBotMentioned( mentionCfg: MentionConfig, ): boolean { const clean = (text: string) => - text - // Remove zero-width and directionality markers WhatsApp injects around display names - .replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u206f]/g, "") - .toLowerCase(); + // Remove zero-width and directionality markers WhatsApp injects around display names + normalizeMentionText(text); const isSelfChat = isSelfChatMode(msg.selfE164, mentionCfg.allowFrom); @@ -212,9 +204,7 @@ function debugMention( const details = { from: msg.from, body: msg.body, - bodyClean: msg.body - .replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u206f]/g, "") - .toLowerCase(), + bodyClean: normalizeMentionText(msg.body), mentionedJids: msg.mentionedJids ?? null, selfJid: msg.selfJid ?? null, selfE164: msg.selfE164 ?? null,