feat: move group mention gating to provider groups
This commit is contained in:
@@ -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.
|
||||
|
||||
BIN
docs/.DS_Store
vendored
Normal file
BIN
docs/.DS_Store
vendored
Normal file
Binary file not shown.
@@ -125,11 +125,13 @@ Example:
|
||||
heartbeat: { every: "0m" }
|
||||
},
|
||||
whatsapp: {
|
||||
allowFrom: ["+15555550123"]
|
||||
allowFrom: ["+15555550123"],
|
||||
groups: {
|
||||
"*": { requireMention: true }
|
||||
}
|
||||
},
|
||||
routing: {
|
||||
groupChat: {
|
||||
requireMention: true,
|
||||
mentionPatterns: ["@clawd", "clawd"]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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.<chatId>.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:<id>` targets. Use `imsg chats --limit 20` to list chats.
|
||||
- Group mention gating lives in `imessage.groups` (default at `imessage.groups."*"`).
|
||||
|
||||
### `agent.workspace`
|
||||
|
||||
|
||||
@@ -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:<chatId>`; 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
|
||||
|
||||
@@ -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:<jid>` 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,
|
||||
|
||||
@@ -17,13 +17,30 @@ Clawdis treats group chats consistently across surfaces: WhatsApp, Telegram, Dis
|
||||
- `#room` is reserved for rooms/channels; group chats use `g-<slug>` (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
|
||||
}
|
||||
|
||||
@@ -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 <ms>` to override the 10s default.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"] } }
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:<chatId>` 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:<chatId>` 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.<chatId>.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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
BIN
src/.DS_Store
vendored
Normal file
BIN
src/.DS_Store
vendored
Normal file
Binary file not shown.
@@ -285,9 +285,10 @@ describe("trigger handling", () => {
|
||||
},
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
groups: { "*": { requireMention: false } },
|
||||
},
|
||||
routing: {
|
||||
groupChat: { requireMention: false },
|
||||
groupChat: {},
|
||||
},
|
||||
session: { store: join(home, "sessions.json") },
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<string | number>;
|
||||
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<string, unknown>).groupChat &&
|
||||
typeof (routing as Record<string, unknown>).groupChat === "object"
|
||||
? ((routing as Record<string, unknown>)
|
||||
.groupChat as Record<string, unknown>)
|
||||
: 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<string, unknown>)
|
||||
: {};
|
||||
const groups =
|
||||
section.groups && typeof section.groups === "object"
|
||||
? (section.groups as Record<string, unknown>)
|
||||
: {};
|
||||
const defaultKey = "*";
|
||||
const entry =
|
||||
groups[defaultKey] && typeof groups[defaultKey] === "object"
|
||||
? (groups[defaultKey] as Record<string, unknown>)
|
||||
: {};
|
||||
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<string, unknown>).groupChat;
|
||||
}
|
||||
if (Object.keys(routing as Record<string, unknown>).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<string, unknown>).requireMention;
|
||||
if (requireMention === undefined) return;
|
||||
|
||||
const groups =
|
||||
(telegram as Record<string, unknown>).groups &&
|
||||
typeof (telegram as Record<string, unknown>).groups === "object"
|
||||
? ((telegram as Record<string, unknown>)
|
||||
.groups as Record<string, unknown>)
|
||||
: {};
|
||||
const defaultKey = "*";
|
||||
const entry =
|
||||
groups[defaultKey] && typeof groups[defaultKey] === "object"
|
||||
? (groups[defaultKey] as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
if (entry.requireMention === undefined) {
|
||||
entry.requireMention = requireMention;
|
||||
groups[defaultKey] = entry;
|
||||
(telegram as Record<string, unknown>).groups = groups;
|
||||
changes.push(
|
||||
'Moved telegram.requireMention → telegram.groups."*".requireMention.',
|
||||
);
|
||||
} else {
|
||||
changes.push(
|
||||
'Removed telegram.requireMention (telegram.groups."*" already set).',
|
||||
);
|
||||
}
|
||||
|
||||
delete (telegram as Record<string, unknown>).requireMention;
|
||||
if (Object.keys(telegram as Record<string, unknown>).length === 0) {
|
||||
delete raw.telegram;
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function findLegacyConfigIssues(raw: unknown): LegacyConfigIssue[] {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -79,10 +79,22 @@ function resolveMentionRegexes(cfg: ReturnType<typeof loadConfig>): RegExp[] {
|
||||
);
|
||||
}
|
||||
|
||||
function resolveRequireMention(opts: MonitorIMessageOpts): boolean {
|
||||
const cfg = loadConfig();
|
||||
function resolveGroupRequireMention(
|
||||
cfg: ReturnType<typeof loadConfig>,
|
||||
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;
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
) => Promise<void>;
|
||||
|
||||
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<string, unknown>,
|
||||
) => Promise<void>;
|
||||
|
||||
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<string, unknown>,
|
||||
) => Promise<void>;
|
||||
|
||||
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<string, unknown>,
|
||||
) => Promise<void>;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<void>)
|
||||
| undefined;
|
||||
const listenerFactory = async (opts: {
|
||||
onMessage: (
|
||||
msg: import("./inbound.js").WebInboundMessage,
|
||||
) => Promise<void>;
|
||||
}) => {
|
||||
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"],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user