From d813e14950b4d79ff4c88aeecef89b5c936c51e2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 6 Jan 2026 01:38:36 +0100 Subject: [PATCH] chore: update mention gating docs and tests --- docs/configuration.md | 3 ++- docs/discord.md | 1 + docs/group-messages.md | 4 +++- docs/groups.md | 1 + docs/imessage.md | 2 +- docs/slack.md | 2 +- docs/telegram.md | 4 ++-- docs/troubleshooting.md | 4 ++-- src/auto-reply/reply/mentions.test.ts | 30 +++++++++++++++++++++++ src/imessage/monitor.test.ts | 30 +++++++++++++++++++++++ src/telegram/bot.test.ts | 34 +++++++++++++++++++++++++++ 11 files changed, 107 insertions(+), 8 deletions(-) create mode 100644 src/auto-reply/reply/mentions.test.ts diff --git a/docs/configuration.md b/docs/configuration.md index a7d53405a..1959ee0ad 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -110,7 +110,7 @@ Optional agent identity used for defaults and UX. This is written by the macOS o If set, CLAWDBOT derives defaults (only when you haven’t set them explicitly): - `messages.responsePrefix` from `identity.emoji` -- `routing.groupChat.mentionPatterns` from `identity.name` (so “@Samantha” works in groups) +- `routing.groupChat.mentionPatterns` from `identity.name` (so “@Samantha” works in groups across Telegram/Slack/Discord/iMessage/WhatsApp) ```json5 { @@ -183,6 +183,7 @@ Group messages default to **require mention** (either metadata mention or regex **Mention types:** - **Metadata mentions**: Native platform @-mentions (e.g., WhatsApp tap-to-mention). Ignored in WhatsApp self-chat mode (see `whatsapp.allowFrom`). - **Text patterns**: Regex patterns defined in `mentionPatterns`. Always checked regardless of self-chat mode. +- Mention gating is enforced only when mention detection is possible (native mentions or at least one `mentionPattern`). ```json5 { diff --git a/docs/discord.md b/docs/discord.md index 7c68ef72b..a96bfff0b 100644 --- a/docs/discord.md +++ b/docs/discord.md @@ -123,6 +123,7 @@ Example “single server, only allow me, only allow #help”: Notes: - `requireMention: true` means the bot only replies when mentioned (recommended for shared channels). +- `routing.groupChat.mentionPatterns` also count as mentions for guild messages. - If `channels` is present, any channel not listed is denied by default. ### 6) Verify it works diff --git a/docs/group-messages.md b/docs/group-messages.md index 00563d39a..0c6701cd1 100644 --- a/docs/group-messages.md +++ b/docs/group-messages.md @@ -1,5 +1,5 @@ --- -summary: "Behavior and config for WhatsApp group message handling" +summary: "Behavior and config for WhatsApp group message handling (mentionPatterns are shared across surfaces)" read_when: - Changing group message rules or mentions --- @@ -7,6 +7,8 @@ read_when: Goal: let Clawd sit in WhatsApp groups, wake up only when pinged, and keep that thread separate from the personal DM session. +Note: `routing.groupChat.mentionPatterns` is now used by Telegram/Discord/Slack/iMessage as well; this doc focuses on WhatsApp-specific behavior. + ## What’s implemented (2025-12-03) - Activation modes: `mention` (default) or `always`. `mention` requires a ping (real WhatsApp @-mentions via `mentionedJids`, regex patterns, or the bot’s E.164 anywhere in the text). `always` wakes the agent on every message but it should reply only when it can add meaningful value; otherwise it returns the silent token `NO_REPLY`. Defaults can be set in config (`whatsapp.groups`) and overridden per group via `/activation`. - Group allowlist bypass: we still enforce `whatsapp.allowFrom` on the participant at inbox ingest, but group JIDs themselves no longer block replies. diff --git a/docs/groups.md b/docs/groups.md index c80c02a4a..48a562ed4 100644 --- a/docs/groups.md +++ b/docs/groups.md @@ -51,6 +51,7 @@ Group messages require a mention unless overridden per group. Defaults live per Notes: - `mentionPatterns` are case-insensitive regexes. - Surfaces that provide explicit mentions still pass; patterns are a fallback. +- Mention gating is only enforced when mention detection is possible (native mentions or `mentionPatterns` are configured). - Discord defaults live in `discord.guilds."*"` (overridable per guild/channel). ## Activation (owner-only) diff --git a/docs/imessage.md b/docs/imessage.md index 54d391f87..f602f6de7 100644 --- a/docs/imessage.md +++ b/docs/imessage.md @@ -55,7 +55,7 @@ imsg chats --limit 20 ## Group chat behavior - Group messages set `ChatType=group`, `GroupSubject`, and `GroupMembers`. -- Group activation respects `imessage.groups."*".requireMention` and `routing.groupChat.mentionPatterns`. +- Group activation respects `imessage.groups."*".requireMention` and `routing.groupChat.mentionPatterns` (patterns are required to detect mentions on iMessage). - Replies go back to the same `chat_id` (group or direct). ## Troubleshooting diff --git a/docs/slack.md b/docs/slack.md index a42b7cd53..40169515e 100644 --- a/docs/slack.md +++ b/docs/slack.md @@ -158,6 +158,6 @@ Slack tool actions can be gated with `slack.actions.*`: | emojiList | enabled | Custom emoji list | ## Notes -- Mention gating is controlled via `slack.channels` (set `requireMention` to `true`). +- Mention gating is controlled via `slack.channels` (set `requireMention` to `true`); `routing.groupChat.mentionPatterns` also count as mentions. - Reaction notifications follow `slack.reactionNotifications` (use `reactionAllowlist` with mode `allowlist`). - Attachments are downloaded to the media store when permitted and under the size limit. diff --git a/docs/telegram.md b/docs/telegram.md index d6f868314..058a5c36e 100644 --- a/docs/telegram.md +++ b/docs/telegram.md @@ -35,7 +35,7 @@ Status: ready for bot-mode use with grammY (long-polling by default; webhook sup ## Planned implementation details - Library: grammY is the only client for send + gateway (fetch fallback removed); grammY throttler is enabled by default to stay under Bot API limits. -- Inbound normalization: maps Bot API updates to `MsgContext` with `Surface: "telegram"`, `ChatType: direct|group`, `SenderName`, `MediaPath`/`MediaType` when attachments arrive, `Timestamp`, and reply-to metadata (`ReplyToId`, `ReplyToBody`, `ReplyToSender`) when the user replies; reply context is appended to `Body` as a `[Replying to ...]` block (includes `id:` when available); groups require @bot mention by default (override per chat in config). +- Inbound normalization: maps Bot API updates to `MsgContext` with `Surface: "telegram"`, `ChatType: direct|group`, `SenderName`, `MediaPath`/`MediaType` when attachments arrive, `Timestamp`, and reply-to metadata (`ReplyToId`, `ReplyToBody`, `ReplyToSender`) when the user replies; reply context is appended to `Body` as a `[Replying to ...]` block (includes `id:` when available); groups require @bot mention or a `routing.groupChat.mentionPatterns` match by default (override per chat in config). - Outbound: text and media (photo/video/audio/document) with optional caption; chunked to limits. Typing cue sent best-effort. - Config: `TELEGRAM_BOT_TOKEN` env or `telegram.botToken` required; `telegram.groups`, `telegram.allowFrom`, `telegram.mediaMaxMb`, `telegram.replyToMode`, `telegram.proxy`, `telegram.webhookSecret`, `telegram.webhookUrl`, `telegram.webhookPath` supported. - Mention gating precedence (most specific wins): `telegram.groups..requireMention` → `telegram.groups."*".requireMention` → default `true`. @@ -65,7 +65,7 @@ Example config: ## Group etiquette - Keep privacy mode off if you expect the bot to read all messages; with privacy on, it only sees commands/mentions. - Make the bot an admin if you need it to send in restricted groups or channels. -- Mention the bot (`@yourbot`) or use commands to trigger; per-group overrides live in `telegram.groups` if you want always-on behavior. +- Mention the bot (`@yourbot`) or use a `routing.groupChat.mentionPatterns` trigger; per-group overrides live in `telegram.groups` if you want always-on behavior. ## Reply tags To request a threaded reply, the model can include one tag in its output: diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 21e80c5ec..ec56836eb 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -29,8 +29,8 @@ cat ~/.clawdbot/clawdbot.json | jq '.whatsapp.allowFrom' **Check 2:** For group chats, is mention required? ```bash -# The message must match mentionPatterns or explicit mentions; defaults live in whatsapp.groups -cat ~/.clawdbot/clawdbot.json | jq '.routing.groupChat, .whatsapp.groups' +# The message must match mentionPatterns or explicit mentions; defaults live in provider groups/guilds. +cat ~/.clawdbot/clawdbot.json | jq '.routing.groupChat, .whatsapp.groups, .telegram.groups, .imessage.groups, .discord.guilds' ``` **Check 3:** Check the logs diff --git a/src/auto-reply/reply/mentions.test.ts b/src/auto-reply/reply/mentions.test.ts new file mode 100644 index 000000000..34639c5aa --- /dev/null +++ b/src/auto-reply/reply/mentions.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; + +import { + buildMentionRegexes, + matchesMentionPatterns, + normalizeMentionText, +} from "./mentions.js"; + +describe("mention helpers", () => { + it("builds regexes and skips invalid patterns", () => { + const regexes = buildMentionRegexes({ + routing: { + groupChat: { mentionPatterns: ["\\bclawd\\b", "(invalid"] }, + }, + }); + expect(regexes).toHaveLength(1); + expect(regexes[0]?.test("clawd")).toBe(true); + }); + + it("normalizes zero-width characters", () => { + expect(normalizeMentionText("cl\u200bawd")).toBe("clawd"); + }); + + it("matches patterns case-insensitively", () => { + const regexes = buildMentionRegexes({ + routing: { groupChat: { mentionPatterns: ["\\bclawd\\b"] } }, + }); + expect(matchesMentionPatterns("CLAWD: hi", regexes)).toBe(true); + }); +}); diff --git a/src/imessage/monitor.test.ts b/src/imessage/monitor.test.ts index 6b9dac986..16810333c 100644 --- a/src/imessage/monitor.test.ts +++ b/src/imessage/monitor.test.ts @@ -139,6 +139,36 @@ describe("monitorIMessageProvider", () => { expect(replyMock).toHaveBeenCalled(); }); + it("allows group messages when requireMention is true but no mentionPatterns exist", async () => { + config = { + ...config, + routing: { groupChat: { mentionPatterns: [] }, allowFrom: [] }, + imessage: { groups: { "*": { requireMention: true } } }, + }; + const run = monitorIMessageProvider(); + await waitForSubscribe(); + + notificationHandler?.({ + method: "message", + params: { + message: { + id: 12, + chat_id: 777, + sender: "+15550001111", + is_from_me: false, + text: "hello group", + is_group: true, + }, + }, + }); + + await flush(); + closeResolve?.(); + await run; + + expect(replyMock).toHaveBeenCalled(); + }); + it("prefixes tool and final replies with responsePrefix", async () => { config = { ...config, diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index 96fd5b85c..bee9bd560 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -193,6 +193,40 @@ describe("createTelegramBot", () => { expect(replySpy).not.toHaveBeenCalled(); }); + it("allows group messages when requireMention is enabled but mentions cannot be detected", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + + loadConfig.mockReturnValue({ + routing: { groupChat: { mentionPatterns: [] } }, + 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: 3, + from: { id: 9, first_name: "Ada" }, + }, + me: {}, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expect(payload.WasMentioned).toBe(false); + }); + it("includes reply-to context when a Telegram reply is received", async () => { onSpy.mockReset(); sendMessageSpy.mockReset();