From 5cf1a9535eec1fef304518725c39debf1576800f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 22:23:00 +0100 Subject: [PATCH] feat: move group mention gating to provider groups --- CHANGELOG.md | 2 + docs/.DS_Store | Bin 0 -> 8196 bytes docs/clawd.md | 6 +- docs/configuration.md | 31 ++++- docs/grammy.md | 2 +- docs/group-messages.md | 7 +- docs/groups.md | 21 +++- docs/health.md | 2 +- docs/imessage.md | 2 +- docs/index.md | 7 +- docs/security.md | 6 +- docs/telegram.md | 16 ++- docs/troubleshooting.md | 4 +- docs/whatsapp.md | 1 + src/.DS_Store | Bin 0 -> 10244 bytes src/auto-reply/reply.triggers.test.ts | 3 +- src/auto-reply/reply.ts | 47 +++++++- src/auto-reply/status.ts | 3 +- src/config/config.test.ts | 58 ++++++++++ src/config/config.ts | 161 +++++++++++++++++++++++++- src/gateway/server.test.ts | 7 +- src/imessage/monitor.test.ts | 33 +++++- src/imessage/monitor.ts | 20 +++- src/telegram/bot.test.ts | 129 ++++++++++++++++++++- src/telegram/bot.ts | 32 +++-- src/web/auto-reply.test.ts | 51 +++++++- src/web/auto-reply.ts | 12 +- 27 files changed, 613 insertions(+), 50 deletions(-) create mode 100644 docs/.DS_Store create mode 100644 src/.DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e3e04d63..df916c37a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,12 +15,14 @@ - Discord: remove legacy `discord.allowFrom`, `discord.guildAllowFrom`, and `discord.requireMention`; use `discord.dm` + `discord.guilds`. - Providers: Discord/Telegram no longer auto-start from env tokens alone; add `discord: { enabled: true }` / `telegram: { enabled: true }` to your config when using `DISCORD_BOT_TOKEN` / `TELEGRAM_BOT_TOKEN`. - Config: remove `routing.allowFrom`; use `whatsapp.allowFrom` instead (run `clawdis doctor` to migrate). +- Config: remove `routing.groupChat.requireMention` + `telegram.requireMention`; use `whatsapp.groups`, `imessage.groups`, and `telegram.groups` defaults instead (run `clawdis doctor` to migrate). ### Features - Talk mode: continuous speech conversations (macOS/iOS/Android) with ElevenLabs TTS, reply directives, and optional interrupt-on-speech. - UI: add optional `ui.seamColor` accent to tint the Talk Mode side bubble (macOS/iOS/Android). - Nix mode: opt-in declarative config + read-only settings UI when `CLAWDIS_NIX_MODE=1` (thanks @joshp123 for the persistence — earned my trust; I'll merge these going forward). - Agent runtime: accept legacy `Z_AI_API_KEY` for Z.AI provider auth (maps to `ZAI_API_KEY`). +- Groups: add per-group mention gating defaults/overrides for Telegram/WhatsApp/iMessage via `*.groups` with `"*"` defaults. - Discord: add user-installed slash command handling with per-user sessions and auto-registration (#94) — thanks @thewilloftheshadow. - Discord: add DM enable/allowlist plus guild channel/user/guild allowlists with id/name matching. - Signal: add `signal-cli` JSON-RPC support for send/receive via the Signal provider. diff --git a/docs/.DS_Store b/docs/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..365518619dd565fed8241e6c996d86c77e4723e3 GIT binary patch literal 8196 zcmeHML2DC16nXzaYQBO<6q)Ie1ZwO==agZb({-_OhTSPon=o#H%Mk zJ&A(;iQ>(JXYua$=1ns*+1=trEBL0&yfE*Z{l5KnvXhw-k=fWC_lVYssEf++>}fO& zh4Wl%<%I9Chz8?{O4_9znoqWSgBn^p)B$xs9Z(0<0d?SCZ~$vImu}0tFIjEX0d?TN zbb#Lv7L{Y>Vu;}X2O`6axdJym5M`bY;_}a%NOi||f z=A_)@+>?uywR>Os&A9Pz;ih+bgTQHVDO!c5do-pQbls&fpDF~z8Xs9KA!1F}pZ@5- z+IO02sGz7F)u87j^vvxdH7rw z*}z-RrhHe0ufTne()&Kh9iPj@eCI#ES$!?@phG?z`0Ck&PgajEX5bdxW=^ZahEpN&IQJ zUOXp`MyC3PAhxJjzbW&vk|+zfuK(p6v-P>m$4VkHDEdRd*`SR&@K+u9 E1#FTMI{*Lx literal 0 HcmV?d00001 diff --git a/docs/clawd.md b/docs/clawd.md index 1c43847d0..b00aedac7 100644 --- a/docs/clawd.md +++ b/docs/clawd.md @@ -125,11 +125,13 @@ Example: heartbeat: { every: "0m" } }, whatsapp: { - allowFrom: ["+15555550123"] + allowFrom: ["+15555550123"], + groups: { + "*": { requireMention: true } + } }, routing: { groupChat: { - requireMention: true, mentionPatterns: ["@clawd", "clawd"] } }, diff --git a/docs/configuration.md b/docs/configuration.md index 7d7f30964..014b0915a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -10,7 +10,7 @@ CLAWDIS reads an optional **JSON5** config from `~/.clawdis/clawdis.json` (comme If the file is missing, CLAWDIS uses safe-ish defaults (embedded Pi agent + per-sender sessions + workspace `~/clawd`). You usually only need a config to: - restrict who can trigger the bot (`whatsapp.allowFrom`, `telegram.allowFrom`, etc.) -- tune group mention behavior (`routing.groupChat`) +- tune group mention behavior (`whatsapp.groups`, `telegram.groups`, `imessage.groups`, `discord.guilds`) - customize message prefixes (`messages`) - set the agent’s workspace (`agent.workspace`) - tune the embedded agent (`agent`) and session behavior (`session`) @@ -86,9 +86,24 @@ Allowlist of E.164 phone numbers that may trigger WhatsApp auto-replies. } ``` +### `whatsapp.groups` + +Per-group mention gating for WhatsApp groups. Default group config lives at `whatsapp.groups."*"`. + +```json5 +{ + whatsapp: { + groups: { + "*": { requireMention: true }, + "123@g.us": { requireMention: false } // group JID + } + } +} +``` + ### `routing.groupChat` -Group messages default to **require mention** (either metadata mention or regex patterns). Applies to WhatsApp, Telegram, Discord, and iMessage group chats. +Group mention patterns + history handling shared across surfaces (WhatsApp/iMessage/Telegram/Discord). ```json5 { @@ -100,6 +115,7 @@ Group messages default to **require mention** (either metadata mention or regex } } ``` +Mention gating defaults live per provider (`whatsapp.groups`, `telegram.groups`, `imessage.groups`, `discord.guilds`). ### `routing.queue` @@ -153,7 +169,10 @@ Set `telegram.enabled: false` to disable automatic startup. telegram: { enabled: true, botToken: "your-bot-token", - requireMention: true, + groups: { + "*": { requireMention: true }, + "123456789": { requireMention: false } // group chat id + }, allowFrom: ["123456789"], mediaMaxMb: 5, proxy: "socks5://localhost:9050", @@ -163,6 +182,7 @@ Set `telegram.enabled: false` to disable automatic startup. } } ``` +Mention gating precedence (most specific wins): `telegram.groups..requireMention` → `telegram.groups."*".requireMention` → default `true`. ### `discord` (bot transport) @@ -217,6 +237,10 @@ Clawdis spawns `imsg rpc` (JSON-RPC over stdio). No daemon or port required. cliPath: "imsg", dbPath: "~/Library/Messages/chat.db", allowFrom: ["+15555550123", "user@example.com", "chat_id:123"], + groups: { + "*": { requireMention: true }, + "123": { requireMention: false } // chat_id for the group + }, includeAttachments: false, mediaMaxMb: 16, service: "auto", @@ -229,6 +253,7 @@ Notes: - Requires Full Disk Access to the Messages DB. - The first send will prompt for Messages automation permission. - Prefer `chat_id:` targets. Use `imsg chats --limit 20` to list chats. +- Group mention gating lives in `imessage.groups` (default at `imessage.groups."*"`). ### `agent.workspace` diff --git a/docs/grammy.md b/docs/grammy.md index cfd593622..fb212f3ec 100644 --- a/docs/grammy.md +++ b/docs/grammy.md @@ -18,7 +18,7 @@ Updated: 2025-12-07 - **Proxy:** optional `telegram.proxy` uses `undici.ProxyAgent` through grammY’s `client.baseFetch`. - **Webhook support:** `webhook-set.ts` wraps `setWebhook/deleteWebhook`; `webhook.ts` hosts the callback with health + graceful shutdown. Gateway enables webhook mode when `telegram.webhookUrl` is set (otherwise it long-polls). - **Sessions:** direct chats map to `main`; groups map to `telegram:group:`; replies route back to the same surface. -- **Config knobs:** `telegram.botToken`, `requireMention`, `allowFrom`, `mediaMaxMb`, `proxy`, `webhookSecret`, `webhookUrl`. +- **Config knobs:** `telegram.botToken`, `telegram.groups`, `telegram.allowFrom`, `telegram.mediaMaxMb`, `telegram.proxy`, `telegram.webhookSecret`, `telegram.webhookUrl`. - **Tests:** grammy mocks cover DM + group mention gating and outbound send; more media/webhook fixtures still welcome. Open questions diff --git a/docs/group-messages.md b/docs/group-messages.md index e8fb355d0..943bac3bf 100644 --- a/docs/group-messages.md +++ b/docs/group-messages.md @@ -8,7 +8,7 @@ read_when: Goal: let Clawd sit in WhatsApp groups, wake up only when pinged, and keep that thread separate from the personal DM session. ## 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`. Activation is controlled per group (command or UI), not via config. +- 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. - Per-group sessions: session keys look like `whatsapp:group:` so commands such as `/verbose on` or `/think:high` are scoped to that group; personal DM state is untouched. Heartbeats are skipped for group threads. - Context injection: last N (default 50) group messages are prefixed under `[Chat messages since your last reply - for context]`, with the triggering line under `[Current message - respond to this]`. @@ -21,6 +21,11 @@ Add a `groupChat` block to `~/.clawdis/clawdis.json` so display-name pings work ```json5 { + "whatsapp": { + "groups": { + "*": { "requireMention": true } + } + }, "routing": { "groupChat": { "historyLimit": 50, diff --git a/docs/groups.md b/docs/groups.md index b24b27e39..5c5fe3983 100644 --- a/docs/groups.md +++ b/docs/groups.md @@ -17,13 +17,30 @@ Clawdis treats group chats consistently across surfaces: WhatsApp, Telegram, Dis - `#room` is reserved for rooms/channels; group chats use `g-` (lowercase, spaces -> `-`, keep `#@+._-`). ## Mention gating (default) -Group messages require a mention unless overridden per group. +Group messages require a mention unless overridden per group. Defaults live per subsystem under `*.groups."*"`. ```json5 { + whatsapp: { + groups: { + "*": { requireMention: true }, + "123@g.us": { requireMention: false } + } + }, + telegram: { + groups: { + "*": { requireMention: true }, + "123456789": { requireMention: false } + } + }, + imessage: { + groups: { + "*": { requireMention: true }, + "123": { requireMention: false } + } + }, routing: { groupChat: { - requireMention: true, mentionPatterns: ["@clawd", "clawdbot", "\\+15555550123"], historyLimit: 50 } diff --git a/docs/health.md b/docs/health.md index 316ac6fab..f8ebc6b24 100644 --- a/docs/health.md +++ b/docs/health.md @@ -22,7 +22,7 @@ Short guide to verify the WhatsApp Web / Baileys stack without guessing. ## When something fails - `logged out` or status 409–515 → relink with `clawdis logout` then `clawdis login`. - Gateway unreachable → start it: `clawdis gateway --port 18789` (use `--force` if the port is busy). -- No inbound messages → confirm linked phone is online and the sender is allowed (`whatsapp.allowFrom`); for group chats, ensure mention rules match (`routing.groupChat`). +- No inbound messages → confirm linked phone is online and the sender is allowed (`whatsapp.allowFrom`); for group chats, ensure mention rules match (`routing.groupChat.mentionPatterns` and `whatsapp.groups`). ## Dedicated "health" command `clawdis health --json` asks the running Gateway for its health snapshot (no direct Baileys socket from the CLI). It reports linked creds, auth age, Baileys connect result/status code, session-store summary, and a probe duration. It exits non-zero if the Gateway is unreachable or the probe fails/timeouts. Use `--timeout ` to override the 10s default. diff --git a/docs/imessage.md b/docs/imessage.md index ff0cf9e23..ab907677d 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 `routing.groupChat.requireMention` and `mentionPatterns`. +- Group activation respects `imessage.groups."*".requireMention` and `routing.groupChat.mentionPatterns`. - Replies go back to the same `chat_id` (group or direct). ## Troubleshooting diff --git a/docs/index.md b/docs/index.md index dd9316fb5..81749e3bb 100644 --- a/docs/index.md +++ b/docs/index.md @@ -106,8 +106,11 @@ Example: ```json5 { - whatsapp: { allowFrom: ["+15555550123"] }, - routing: { groupChat: { requireMention: true, mentionPatterns: ["@clawd"] } } + whatsapp: { + allowFrom: ["+15555550123"], + groups: { "*": { requireMention: true } } + }, + routing: { groupChat: { mentionPatterns: ["@clawd"] } } } ``` diff --git a/docs/security.md b/docs/security.md index 32d76adae..81b961666 100644 --- a/docs/security.md +++ b/docs/security.md @@ -54,9 +54,13 @@ Only allow specific phone numbers to trigger your AI. Never use `["*"]` in produ ```json { + "whatsapp": { + "groups": { + "*": { "requireMention": true } + } + }, "routing": { "groupChat": { - "requireMention": true, "mentionPatterns": ["@clawd", "@mybot"] } } diff --git a/docs/telegram.md b/docs/telegram.md index 797783f72..fd6c3f018 100644 --- a/docs/telegram.md +++ b/docs/telegram.md @@ -7,7 +7,7 @@ read_when: Updated: 2025-12-07 -Status: ready for bot-mode use with grammY (long-polling by default; webhook supported when configured). Text + media send, mention-gated group replies, and optional proxy support are implemented. +Status: ready for bot-mode use with grammY (long-polling by default; webhook supported when configured). Text + media send, mention-gated group replies with per-group overrides, and optional proxy support are implemented. ## Goals - Let you talk to Clawdis via a Telegram bot in DMs and groups. @@ -24,7 +24,7 @@ Status: ready for bot-mode use with grammY (long-polling by default; webhook sup - The webhook listener currently binds to `0.0.0.0:8787` and serves `POST /telegram-webhook` by default. - If you need a different public port/host, set `telegram.webhookUrl` to the externally reachable URL and use a reverse proxy to forward to `:8787`. 4) Direct chats: user sends the first message; all subsequent turns land in the shared `main` session (default, no extra config). -5) Groups: add the bot, disable privacy mode (or make it admin) so it can read messages; group threads stay on `telegram:group:` and require mention/command to trigger replies. +5) Groups: add the bot, disable privacy mode (or make it admin) so it can read messages; group threads stay on `telegram:group:` and require mention/command by default (override via `telegram.groups`). 6) Optional allowlist: use `telegram.allowFrom` for direct chats by chat id (`123456789` or `telegram:123456789`). ## Capabilities & limits (Bot API) @@ -35,9 +35,10 @@ 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; groups require @bot mention by default. +- 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; groups require @bot mention 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.requireMention`, `telegram.allowFrom`, `telegram.mediaMaxMb`, `telegram.proxy`, `telegram.webhookSecret`, `telegram.webhookUrl`, `telegram.webhookPath` supported. +- Config: `TELEGRAM_BOT_TOKEN` env or `telegram.botToken` required; `telegram.groups`, `telegram.allowFrom`, `telegram.mediaMaxMb`, `telegram.proxy`, `telegram.webhookSecret`, `telegram.webhookUrl`, `telegram.webhookPath` supported. + - Mention gating precedence (most specific wins): `telegram.groups..requireMention` → `telegram.groups."*".requireMention` → default `true`. Example config: ```json5 @@ -45,7 +46,10 @@ Example config: telegram: { enabled: true, botToken: "123:abc", - requireMention: true, + groups: { + "*": { requireMention: true }, + "123456789": { requireMention: false } // group chat id + }, allowFrom: ["123456789"], // direct chat ids allowed (or "*") mediaMaxMb: 5, proxy: "socks5://localhost:9050", @@ -60,7 +64,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; we’ll honor `group.requireMention` by default to avoid noise. +- Mention the bot (`@yourbot`) or use commands to trigger; per-group overrides live in `telegram.groups` if you want always-on behavior. ## Roadmap - ✅ Design and defaults (this doc) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 6588c21c9..3a7d17b61 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -29,8 +29,8 @@ cat ~/.clawdis/clawdis.json | jq '.whatsapp.allowFrom' **Check 2:** For group chats, is mention required? ```bash -# The message must contain a pattern from mentionPatterns -cat ~/.clawdis/clawdis.json | jq '.routing.groupChat' +# The message must match mentionPatterns or explicit mentions; defaults live in whatsapp.groups +cat ~/.clawdis/clawdis.json | jq '.routing.groupChat, .whatsapp.groups' ``` **Check 3:** Check the logs diff --git a/docs/whatsapp.md b/docs/whatsapp.md index d4e965b1e..4f04fdf15 100644 --- a/docs/whatsapp.md +++ b/docs/whatsapp.md @@ -113,6 +113,7 @@ WhatsApp requires a real mobile number for verification. VoIP and virtual number ## Config quick map - `whatsapp.allowFrom` (DM allowlist). +- `whatsapp.groups` (group mention gating defaults/overrides) - `routing.groupChat.mentionPatterns` - `routing.groupChat.historyLimit` - `messages.messagePrefix` (inbound prefix) diff --git a/src/.DS_Store b/src/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..9f69440380adc641750056756bf6606f0e5852ab GIT binary patch literal 10244 zcmeI1yK59d9LIlikKCCkXetZQ1qB5aOlM)S31?-aq|hQo?sDfPa(8>lC4lgG^)afvv1J3m85Lfvr@8S;t7sZlBS^bdK}dPr?=SO=^F)&c8)b-+6C zH#mU5+5E_M$!M?E0qcNupzQ#k9~_)Kx+*qh6jKK-VhMnl!fkQjd#nRQj-#ThVpB$u ziazD)fvHrcmKaQ?qut?hL|4V8j53{=Oedx)GqpltqB`;%At&a_Xs^}*>p<867q>bn zPh~1mEwp}TQO||8~X`t%VL~P=i2=}cjk)ZitMxM`Fv|>P0qWVGeZUNc{D{0P>vadiDPqNh0gYC zY5Se)k}c4WP8F{*c#=msPZtfrxf)I4+JJY%U~AC0!IfP+AFtH;27a`9A4tBKes#{Z z)dp|&Bj-(mw?ZQZUt)Q{Z_#;nU7MMIA$e4uUO(Jw)Jq-U^|GkvQ59d@rDAZ8s&yzz zqce0b{9GC5p}usQ)pQ0I8{}Laf~!U+X@ts#8#KBv*sk-Gp0_yh-`Mo1RWrQ9Yp089Fb_ zu8fP2%744#RR&MGiDHz-bKp;7jPiyPZ0WGss&nl9+UC&mbvL3FM*XBK>+=!H$7)~~ zb?Z%0zul_Wzc}~o{tfvo(qe>*e7%0O!l+N;dGP!a-j9*M?@w3nsrsr_umAAu%%<0V z{kFn?2GI(mzWk>ygBhQX!y!;Kf*x#p3Wk6}=a_&0$|l29NY(gW{Ql zBSk*b2boewz!agU|CEx2L-sch(44Py&K5f9vFIsel)~PpN7FOnW)#`f^ZZ+y9-*F%8 zVI8mzSO=^F)&c9l`W;A0sl)gG@gHv7=l|>P&?>D1)`5S{0hY@3=lXyxmtkT41a3Jv z2XXR)>zXo(6kNE3-xu5Q_vv=AV%Pt6{r}&s|9=B6dDt}o literal 0 HcmV?d00001 diff --git a/src/auto-reply/reply.triggers.test.ts b/src/auto-reply/reply.triggers.test.ts index 8b8242208..2fea53288 100644 --- a/src/auto-reply/reply.triggers.test.ts +++ b/src/auto-reply/reply.triggers.test.ts @@ -285,9 +285,10 @@ describe("trigger handling", () => { }, whatsapp: { allowFrom: ["*"], + groups: { "*": { requireMention: false } }, }, routing: { - groupChat: { requireMention: false }, + groupChat: {}, }, session: { store: join(home, "sessions.json") }, }, diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index 1b85bb096..91d4a0ac6 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -512,8 +512,48 @@ export async function getReplyFromConfig( sessionCtx.Body = queueCleaned; sessionCtx.BodyStripped = queueCleaned; + const resolveGroupRequireMention = () => { + const surface = + groupResolution?.surface ?? ctx.Surface?.trim().toLowerCase(); + const groupId = groupResolution?.id ?? ctx.From?.replace(/^group:/, ""); + if (surface === "telegram") { + if (groupId) { + const groupConfig = cfg.telegram?.groups?.[groupId]; + if (typeof groupConfig?.requireMention === "boolean") { + return groupConfig.requireMention; + } + } + const groupDefault = cfg.telegram?.groups?.["*"]?.requireMention; + if (typeof groupDefault === "boolean") return groupDefault; + return true; + } + if (surface === "whatsapp") { + if (groupId) { + const groupConfig = cfg.whatsapp?.groups?.[groupId]; + if (typeof groupConfig?.requireMention === "boolean") { + return groupConfig.requireMention; + } + } + const groupDefault = cfg.whatsapp?.groups?.["*"]?.requireMention; + if (typeof groupDefault === "boolean") return groupDefault; + return true; + } + if (surface === "imessage") { + if (groupId) { + const groupConfig = cfg.imessage?.groups?.[groupId]; + if (typeof groupConfig?.requireMention === "boolean") { + return groupConfig.requireMention; + } + } + const groupDefault = cfg.imessage?.groups?.["*"]?.requireMention; + if (typeof groupDefault === "boolean") return groupDefault; + return true; + } + return true; + }; + const defaultGroupActivation = () => { - const requireMention = cfg.routing?.groupChat?.requireMention; + const requireMention = resolveGroupRequireMention(); return requireMention === false ? "always" : "mention"; }; @@ -954,6 +994,10 @@ export async function getReplyFromConfig( const webLinked = await webAuthExists(); const webAuthAgeMs = getWebAuthAgeMs(); const heartbeatSeconds = resolveHeartbeatSeconds(cfg, undefined); + const groupActivation = isGroup + ? normalizeGroupActivation(sessionEntry?.groupActivation) ?? + defaultGroupActivation() + : undefined; const statusText = buildStatusMessage({ agent: { model, @@ -966,6 +1010,7 @@ export async function getReplyFromConfig( sessionKey, sessionScope, storePath, + groupActivation, resolvedThink: resolvedThinkLevel, resolvedVerbose: resolvedVerboseLevel, webLinked, diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index 4024841ef..1ee3fc251 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -30,6 +30,7 @@ type StatusArgs = { sessionKey?: string; sessionScope?: SessionScope; storePath?: string; + groupActivation?: "mention" | "always"; resolvedThink?: ThinkLevel; resolvedVerbose?: VerboseLevel; now?: number; @@ -198,7 +199,7 @@ export function buildStatusMessage(args: StatusArgs): string { Boolean(args.sessionKey?.includes(":channel:")) || Boolean(args.sessionKey?.startsWith("group:")); const groupActivationLine = isGroupSession - ? `Group activation: ${entry?.groupActivation ?? "mention"}` + ? `Group activation: ${args.groupActivation ?? entry?.groupActivation ?? "mention"}` : undefined; const contextLine = `Context: ${formatTokens( diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 6ababcbe4..288999b2f 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -502,6 +502,18 @@ describe("legacy config detection", () => { } }); + it("rejects routing.groupChat.requireMention", async () => { + vi.resetModules(); + const { validateConfigObject } = await import("./config.js"); + const res = validateConfigObject({ + routing: { groupChat: { requireMention: false } }, + }); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.issues[0]?.path).toBe("routing.groupChat.requireMention"); + } + }); + it("migrates routing.allowFrom to whatsapp.allowFrom", async () => { vi.resetModules(); const { migrateLegacyConfig } = await import("./config.js"); @@ -515,6 +527,52 @@ describe("legacy config detection", () => { expect(res.config?.routing?.allowFrom).toBeUndefined(); }); + it("migrates routing.groupChat.requireMention to whatsapp/telegram/imessage groups", async () => { + vi.resetModules(); + const { migrateLegacyConfig } = await import("./config.js"); + const res = migrateLegacyConfig({ + routing: { groupChat: { requireMention: false } }, + }); + expect(res.changes).toContain( + 'Moved routing.groupChat.requireMention → whatsapp.groups."*".requireMention.', + ); + expect(res.changes).toContain( + 'Moved routing.groupChat.requireMention → telegram.groups."*".requireMention.', + ); + expect(res.changes).toContain( + 'Moved routing.groupChat.requireMention → imessage.groups."*".requireMention.', + ); + expect(res.config?.whatsapp?.groups?.["*"]?.requireMention).toBe(false); + expect(res.config?.telegram?.groups?.["*"]?.requireMention).toBe(false); + expect(res.config?.imessage?.groups?.["*"]?.requireMention).toBe(false); + expect(res.config?.routing?.groupChat?.requireMention).toBeUndefined(); + }); + + it("rejects telegram.requireMention", async () => { + vi.resetModules(); + const { validateConfigObject } = await import("./config.js"); + const res = validateConfigObject({ + telegram: { requireMention: true }, + }); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.issues[0]?.path).toBe("telegram.requireMention"); + } + }); + + it("migrates telegram.requireMention to telegram.groups.*.requireMention", async () => { + vi.resetModules(); + const { migrateLegacyConfig } = await import("./config.js"); + const res = migrateLegacyConfig({ + telegram: { requireMention: false }, + }); + expect(res.changes).toContain( + 'Moved telegram.requireMention → telegram.groups."*".requireMention.', + ); + expect(res.config?.telegram?.groups?.["*"]?.requireMention).toBe(false); + expect(res.config?.telegram?.requireMention).toBeUndefined(); + }); + it("surfaces legacy issues in snapshot", async () => { await withTempHome(async (home) => { const configPath = path.join(home, ".clawdis", "clawdis.json"); diff --git a/src/config/config.ts b/src/config/config.ts index 21e6aecfd..1a04a2348 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -61,6 +61,12 @@ export type WebConfig = { export type WhatsAppConfig = { /** Optional allowlist for WhatsApp direct chats (E.164). */ allowFrom?: string[]; + groups?: Record< + string, + { + requireMention?: boolean; + } + >; }; export type BrowserConfig = { @@ -160,7 +166,12 @@ export type TelegramConfig = { botToken?: string; /** Path to file containing bot token (for secret managers like agenix) */ tokenFile?: string; - requireMention?: boolean; + groups?: Record< + string, + { + requireMention?: boolean; + } + >; allowFrom?: Array; mediaMaxMb?: number; proxy?: string; @@ -257,6 +268,12 @@ export type IMessageConfig = { includeAttachments?: boolean; /** Max outbound media size in MB. */ mediaMaxMb?: number; + groups?: Record< + string, + { + requireMention?: boolean; + } + >; }; export type QueueMode = "queue" | "interrupt"; @@ -271,7 +288,6 @@ export type QueueModeBySurface = { }; export type GroupChatConfig = { - requireMention?: boolean; mentionPatterns?: string[]; historyLimit?: number; }; @@ -628,7 +644,6 @@ const ModelsConfigSchema = z const GroupChatSchema = z .object({ - requireMention: z.boolean().optional(), mentionPatterns: z.array(z.string()).optional(), historyLimit: z.number().int().positive().optional(), }) @@ -930,6 +945,16 @@ const ClawdisSchema = z.object({ whatsapp: z .object({ allowFrom: z.array(z.string()).optional(), + groups: z + .record( + z.string(), + z + .object({ + requireMention: z.boolean().optional(), + }) + .optional(), + ) + .optional(), }) .optional(), telegram: z @@ -937,7 +962,16 @@ const ClawdisSchema = z.object({ enabled: z.boolean().optional(), botToken: z.string().optional(), tokenFile: z.string().optional(), - requireMention: z.boolean().optional(), + groups: z + .record( + z.string(), + z + .object({ + requireMention: z.boolean().optional(), + }) + .optional(), + ) + .optional(), allowFrom: z.array(z.union([z.string(), z.number()])).optional(), mediaMaxMb: z.number().positive().optional(), proxy: z.string().optional(), @@ -1025,6 +1059,16 @@ const ClawdisSchema = z.object({ allowFrom: z.array(z.union([z.string(), z.number()])).optional(), includeAttachments: z.boolean().optional(), mediaMaxMb: z.number().positive().optional(), + groups: z + .record( + z.string(), + z + .object({ + requireMention: z.boolean().optional(), + }) + .optional(), + ) + .optional(), }) .optional(), bridge: z @@ -1183,6 +1227,16 @@ const LEGACY_CONFIG_RULES: LegacyConfigRule[] = [ message: "routing.allowFrom was removed; use whatsapp.allowFrom instead (run `clawdis doctor` to migrate).", }, + { + path: ["routing", "groupChat", "requireMention"], + message: + 'routing.groupChat.requireMention was removed; use whatsapp/telegram/imessage groups defaults (e.g. whatsapp.groups."*".requireMention) instead (run `clawdis doctor` to migrate).', + }, + { + path: ["telegram", "requireMention"], + message: + "telegram.requireMention was removed; use telegram.groups.\"*\".requireMention instead (run `clawdis doctor` to migrate).", + }, ]; const LEGACY_CONFIG_MIGRATIONS: LegacyConfigMigration[] = [ @@ -1216,6 +1270,105 @@ const LEGACY_CONFIG_MIGRATIONS: LegacyConfigMigration[] = [ raw.whatsapp = whatsapp; }, }, + { + id: "routing.groupChat.requireMention->groups.*.requireMention", + describe: + "Move routing.groupChat.requireMention to whatsapp/telegram/imessage groups", + apply: (raw, changes) => { + const routing = raw.routing; + if (!routing || typeof routing !== "object") return; + const groupChat = + (routing as Record).groupChat && + typeof (routing as Record).groupChat === "object" + ? ((routing as Record) + .groupChat as Record) + : null; + if (!groupChat) return; + const requireMention = groupChat.requireMention; + if (requireMention === undefined) return; + + const applyTo = (key: "whatsapp" | "telegram" | "imessage") => { + const section = + raw[key] && typeof raw[key] === "object" + ? (raw[key] as Record) + : {}; + const groups = + section.groups && typeof section.groups === "object" + ? (section.groups as Record) + : {}; + const defaultKey = "*"; + const entry = + groups[defaultKey] && typeof groups[defaultKey] === "object" + ? (groups[defaultKey] as Record) + : {}; + if (entry.requireMention === undefined) { + entry.requireMention = requireMention; + groups[defaultKey] = entry; + section.groups = groups; + raw[key] = section; + changes.push( + `Moved routing.groupChat.requireMention → ${key}.groups."*".requireMention.`, + ); + } else { + changes.push( + `Removed routing.groupChat.requireMention (${key}.groups."*" already set).`, + ); + } + }; + + applyTo("whatsapp"); + applyTo("telegram"); + applyTo("imessage"); + + delete groupChat.requireMention; + if (Object.keys(groupChat).length === 0) { + delete (routing as Record).groupChat; + } + if (Object.keys(routing as Record).length === 0) { + delete raw.routing; + } + }, + }, + { + id: "telegram.requireMention->telegram.groups.*.requireMention", + describe: "Move telegram.requireMention to telegram.groups.*.requireMention", + apply: (raw, changes) => { + const telegram = raw.telegram; + if (!telegram || typeof telegram !== "object") return; + const requireMention = (telegram as Record).requireMention; + if (requireMention === undefined) return; + + const groups = + (telegram as Record).groups && + typeof (telegram as Record).groups === "object" + ? ((telegram as Record) + .groups as Record) + : {}; + const defaultKey = "*"; + const entry = + groups[defaultKey] && typeof groups[defaultKey] === "object" + ? (groups[defaultKey] as Record) + : {}; + + if (entry.requireMention === undefined) { + entry.requireMention = requireMention; + groups[defaultKey] = entry; + (telegram as Record).groups = groups; + changes.push( + 'Moved telegram.requireMention → telegram.groups."*".requireMention.', + ); + } else { + changes.push( + 'Removed telegram.requireMention (telegram.groups."*" already set).', + ); + } + + delete (telegram as Record).requireMention; + if (Object.keys(telegram as Record).length === 0) { + delete raw.telegram; + } + }, + }, ]; function findLegacyConfigIssues(raw: unknown): LegacyConfigIssue[] { diff --git a/src/gateway/server.test.ts b/src/gateway/server.test.ts index f76fb9b48..903f63932 100644 --- a/src/gateway/server.test.ts +++ b/src/gateway/server.test.ts @@ -2353,7 +2353,10 @@ describe("gateway server", () => { const prevToken = process.env.TELEGRAM_BOT_TOKEN; delete process.env.TELEGRAM_BOT_TOKEN; await writeConfigFile({ - telegram: { botToken: "123:abc", requireMention: false }, + telegram: { + botToken: "123:abc", + groups: { "*": { requireMention: false } }, + }, }); const { server, ws } = await startServerWithClient(); @@ -2370,7 +2373,7 @@ describe("gateway server", () => { const snap = await readConfigFileSnapshot(); expect(snap.valid).toBe(true); expect(snap.config?.telegram?.botToken).toBeUndefined(); - expect(snap.config?.telegram?.requireMention).toBe(false); + expect(snap.config?.telegram?.groups?.["*"]?.requireMention).toBe(false); ws.close(); await server.close(); diff --git a/src/imessage/monitor.test.ts b/src/imessage/monitor.test.ts index dcc5f8ed2..84840a197 100644 --- a/src/imessage/monitor.test.ts +++ b/src/imessage/monitor.test.ts @@ -59,10 +59,10 @@ async function waitForSubscribe() { beforeEach(() => { config = { - imessage: {}, + imessage: { groups: { "*": { requireMention: true } } }, session: { mainKey: "main" }, routing: { - groupChat: { mentionPatterns: ["@clawd"], requireMention: true }, + groupChat: { mentionPatterns: ["@clawd"] }, allowFrom: [], }, }; @@ -106,6 +106,35 @@ describe("monitorIMessageProvider", () => { expect(sendMock).not.toHaveBeenCalled(); }); + it("allows group messages when imessage groups default disables mention gating", async () => { + config = { + ...config, + imessage: { groups: { "*": { requireMention: false } } }, + }; + const run = monitorIMessageProvider(); + await waitForSubscribe(); + + notificationHandler?.({ + method: "message", + params: { + message: { + id: 11, + chat_id: 123, + sender: "+15550001111", + is_from_me: false, + text: "hello group", + is_group: true, + }, + }, + }); + + await flush(); + closeResolve?.(); + await run; + + expect(replyMock).toHaveBeenCalled(); + }); + it("delivers group replies when mentioned", async () => { replyMock.mockResolvedValueOnce({ text: "yo" }); const run = monitorIMessageProvider(); diff --git a/src/imessage/monitor.ts b/src/imessage/monitor.ts index bbc0fcb36..cc02800b4 100644 --- a/src/imessage/monitor.ts +++ b/src/imessage/monitor.ts @@ -79,10 +79,22 @@ function resolveMentionRegexes(cfg: ReturnType): RegExp[] { ); } -function resolveRequireMention(opts: MonitorIMessageOpts): boolean { - const cfg = loadConfig(); +function resolveGroupRequireMention( + cfg: ReturnType, + opts: MonitorIMessageOpts, + chatId?: number | null, +): boolean { if (typeof opts.requireMention === "boolean") return opts.requireMention; - return cfg.routing?.groupChat?.requireMention ?? true; + const groupId = chatId != null ? String(chatId) : undefined; + if (groupId) { + const groupConfig = cfg.imessage?.groups?.[groupId]; + if (typeof groupConfig?.requireMention === "boolean") { + return groupConfig.requireMention; + } + } + const groupDefault = cfg.imessage?.groups?.["*"]?.requireMention; + if (typeof groupDefault === "boolean") return groupDefault; + return true; } function isMentioned(text: string, regexes: RegExp[]): boolean { @@ -133,7 +145,6 @@ export async function monitorIMessageProvider( const cfg = loadConfig(); const allowFrom = resolveAllowFrom(opts); const mentionRegexes = resolveMentionRegexes(cfg); - const requireMention = resolveRequireMention(opts); const includeAttachments = opts.includeAttachments ?? cfg.imessage?.includeAttachments ?? false; const mediaMaxBytes = @@ -170,6 +181,7 @@ export async function monitorIMessageProvider( const messageText = (message.text ?? "").trim(); const mentioned = isGroup ? isMentioned(messageText, mentionRegexes) : true; + const requireMention = resolveGroupRequireMention(cfg, opts, chatId); if (isGroup && requireMention && !mentioned) { logVerbose(`imessage: skipping group message (no mention)`); return; diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index 56a046bc3..19df8842d 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -1,7 +1,12 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import * as replyModule from "../auto-reply/reply.js"; import { createTelegramBot } from "./bot.js"; +const loadConfig = vi.fn(() => ({})); +vi.mock("../config/config.js", () => ({ + loadConfig, +})); + const useSpy = vi.fn(); const onSpy = vi.fn(); const stopSpy = vi.fn(); @@ -44,6 +49,10 @@ vi.mock("../auto-reply/reply.js", () => { }); describe("createTelegramBot", () => { + beforeEach(() => { + loadConfig.mockReturnValue({}); + }); + it("installs grammY throttler", () => { createTelegramBot({ token: "tok" }); expect(throttlerSpy).toHaveBeenCalledTimes(1); @@ -177,4 +186,122 @@ describe("createTelegramBot", () => { expect(call[2]?.reply_to_message_id).toBeUndefined(); } }); + + it("skips group messages without mention when requireMention is enabled", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + telegram: { groups: { "*": { requireMention: true } } }, + }); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: 123, type: "group", title: "Dev Chat" }, + text: "hello", + date: 1736380800, + }, + me: { username: "clawdis_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).not.toHaveBeenCalled(); + }); + + it("allows per-group requireMention override", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + telegram: { + groups: { + "*": { requireMention: true }, + "123": { requireMention: false }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: 123, type: "group", title: "Dev Chat" }, + text: "hello", + date: 1736380800, + }, + me: { username: "clawdis_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("honors groups default when no explicit group override exists", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + telegram: { + groups: { "*": { requireMention: false } }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: 456, type: "group", title: "Ops" }, + text: "hello", + date: 1736380800, + }, + me: { username: "clawdis_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("does not block group messages when bot username is unknown", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + telegram: { groups: { "*": { requireMention: true } } }, + }); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: 789, type: "group", title: "No Me" }, + text: "hello", + date: 1736380800, + }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index 295f09095..341401c14 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -58,12 +58,21 @@ export function createTelegramBot(opts: TelegramBotOptions) { bot.api.config.use(apiThrottler()); const cfg = loadConfig(); - const requireMention = - opts.requireMention ?? cfg.telegram?.requireMention ?? true; const allowFrom = opts.allowFrom ?? cfg.telegram?.allowFrom; const mediaMaxBytes = (opts.mediaMaxMb ?? cfg.telegram?.mediaMaxMb ?? 5) * 1024 * 1024; const logger = getChildLogger({ module: "telegram-auto-reply" }); + const resolveGroupRequireMention = (chatId: string | number) => { + const groupId = String(chatId); + const groupConfig = cfg.telegram?.groups?.[groupId]; + if (typeof groupConfig?.requireMention === "boolean") { + return groupConfig.requireMention; + } + const groupDefault = cfg.telegram?.groups?.["*"]?.requireMention; + if (typeof groupDefault === "boolean") return groupDefault; + if (typeof opts.requireMention === "boolean") return opts.requireMention; + return true; + }; bot.on("message", async (ctx) => { try { @@ -101,14 +110,16 @@ export function createTelegramBot(opts: TelegramBotOptions) { } const botUsername = ctx.me?.username?.toLowerCase(); - if ( - isGroup && - requireMention && - botUsername && - !hasBotMention(msg, botUsername) - ) { - logger.info({ chatId, reason: "no-mention" }, "skipping group message"); - return; + const wasMentioned = + Boolean(botUsername) && hasBotMention(msg, botUsername); + if (isGroup && resolveGroupRequireMention(chatId) && botUsername) { + if (!wasMentioned) { + logger.info( + { chatId, reason: "no-mention" }, + "skipping group message", + ); + return; + } } const media = await resolveMedia( @@ -150,6 +161,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { ReplyToBody: replyTarget?.body, ReplyToSender: replyTarget?.sender, Timestamp: msg.date ? msg.date * 1000 : undefined, + WasMentioned: isGroup && botUsername ? wasMentioned : undefined, MediaPath: media?.path, MediaType: media?.contentType, MediaUrl: media?.path, diff --git a/src/web/auto-reply.test.ts b/src/web/auto-reply.test.ts index 4cc2df39b..bb61f81c3 100644 --- a/src/web/auto-reply.test.ts +++ b/src/web/auto-reply.test.ts @@ -1005,6 +1005,55 @@ describe("web auto-reply", () => { expect(payload.Body).toContain("[from: Bob (+222)]"); }); + it("allows group messages when whatsapp groups default disables mention gating", async () => { + const sendMedia = vi.fn(); + const reply = vi.fn().mockResolvedValue(undefined); + const sendComposing = vi.fn(); + const resolver = vi.fn().mockResolvedValue({ text: "ok" }); + + setLoadConfigMock(() => ({ + whatsapp: { + allowFrom: ["*"], + groups: { "*": { requireMention: false } }, + }, + routing: { groupChat: { mentionPatterns: ["@clawd"] } }, + })); + + let capturedOnMessage: + | ((msg: import("./inbound.js").WebInboundMessage) => Promise) + | undefined; + const listenerFactory = async (opts: { + onMessage: ( + msg: import("./inbound.js").WebInboundMessage, + ) => Promise; + }) => { + capturedOnMessage = opts.onMessage; + return { close: vi.fn() }; + }; + + await monitorWebProvider(false, listenerFactory, false, resolver); + expect(capturedOnMessage).toBeDefined(); + + await capturedOnMessage?.({ + body: "hello group", + from: "123@g.us", + conversationId: "123@g.us", + chatId: "123@g.us", + chatType: "group", + to: "+2", + id: "g-default-off", + senderE164: "+111", + senderName: "Alice", + selfE164: "+999", + sendComposing, + reply, + sendMedia, + }); + + expect(resolver).toHaveBeenCalledTimes(1); + resetLoadConfigMock(); + }); + it("supports always-on group activation with silent token and preserves history", async () => { const sendMedia = vi.fn(); const reply = vi.fn().mockResolvedValue(undefined); @@ -1100,10 +1149,10 @@ describe("web auto-reply", () => { whatsapp: { // Self-chat heuristic: allowFrom includes selfE164. allowFrom: ["+999"], + groups: { "*": { requireMention: true } }, }, routing: { groupChat: { - requireMention: true, mentionPatterns: ["\\bclawd\\b"], }, }, diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index 628254100..37f6e2df2 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -812,13 +812,23 @@ export async function monitorWebProvider( .join(", "); }; + const resolveGroupRequireMentionFor = (conversationId: string) => { + const groupConfig = cfg.whatsapp?.groups?.[conversationId]; + if (typeof groupConfig?.requireMention === "boolean") { + return groupConfig.requireMention; + } + const groupDefault = cfg.whatsapp?.groups?.["*"]?.requireMention; + if (typeof groupDefault === "boolean") return groupDefault; + return true; + }; + const resolveGroupActivationFor = (conversationId: string) => { const key = conversationId.startsWith("group:") ? conversationId : `whatsapp:group:${conversationId}`; const store = loadSessionStore(sessionStorePath); const entry = store[key]; - const requireMention = cfg.routing?.groupChat?.requireMention; + const requireMention = resolveGroupRequireMentionFor(conversationId); const defaultActivation = requireMention === false ? "always" : "mention"; return ( normalizeGroupActivation(entry?.groupActivation) ?? defaultActivation