feat: move group mention gating to provider groups

This commit is contained in:
Peter Steinberger
2026-01-02 22:23:00 +01:00
parent e93102b276
commit 5cf1a9535e
27 changed files with 613 additions and 50 deletions

View File

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

Binary file not shown.

View File

@@ -125,11 +125,13 @@ Example:
heartbeat: { every: "0m" }
},
whatsapp: {
allowFrom: ["+15555550123"]
allowFrom: ["+15555550123"],
groups: {
"*": { requireMention: true }
}
},
routing: {
groupChat: {
requireMention: true,
mentionPatterns: ["@clawd", "clawd"]
}
},

View File

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

View File

@@ -18,7 +18,7 @@ Updated: 2025-12-07
- **Proxy:** optional `telegram.proxy` uses `undici.ProxyAgent` through grammYs `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

View File

@@ -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.
## Whats implemented (2025-12-03)
- Activation modes: `mention` (default) or `always`. `mention` requires a ping (real WhatsApp @-mentions via `mentionedJids`, regex patterns, or the bots E.164 anywhere in the text). `always` wakes the agent on every message but it should reply only when it can add meaningful value; otherwise it returns the silent token `NO_REPLY`. 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 bots E.164 anywhere in the text). `always` wakes the agent on every message but it should reply only when it can add meaningful value; otherwise it returns the silent token `NO_REPLY`. Defaults can be set in config (`whatsapp.groups`) and overridden per group via `/activation`.
- Group allowlist bypass: we still enforce `whatsapp.allowFrom` on the participant at inbox ingest, but group JIDs themselves no longer block replies.
- 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,

View File

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

View File

@@ -22,7 +22,7 @@ Short guide to verify the WhatsApp Web / Baileys stack without guessing.
## When something fails
- `logged out` or status 409515 → 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.

View File

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

View File

@@ -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"] } }
}
```

View File

@@ -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"]
}
}

View File

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

View File

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

View File

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

Binary file not shown.

View File

@@ -285,9 +285,10 @@ describe("trigger handling", () => {
},
whatsapp: {
allowFrom: ["*"],
groups: { "*": { requireMention: false } },
},
routing: {
groupChat: { requireMention: false },
groupChat: {},
},
session: { store: join(home, "sessions.json") },
},

View File

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

View File

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

View File

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

View File

@@ -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[] {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"],
},
},

View File

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