chore: update mention gating docs and tests

This commit is contained in:
Peter Steinberger
2026-01-06 01:38:36 +01:00
parent 811ec8b78b
commit d813e14950
11 changed files with 107 additions and 8 deletions

View File

@@ -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 havent 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
{

View File

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

View File

@@ -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.
## Whats implemented (2025-12-03)
- Activation modes: `mention` (default) or `always`. `mention` requires a ping (real WhatsApp @-mentions via `mentionedJids`, regex patterns, or the bots 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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);
});
});

View File

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

View File

@@ -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<string, unknown>,
) => Promise<void>;
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();