diff --git a/docs/configuration.md b/docs/configuration.md index 64766931f..d5a3a909c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -186,8 +186,9 @@ Metadata written by CLI wizards (`onboard`, `configure`, `doctor`, `update`). ### `whatsapp.allowFrom` -Allowlist of E.164 phone numbers that may trigger WhatsApp auto-replies (DMs only). +Allowlist of E.164 phone numbers that may trigger WhatsApp auto-replies (**DMs only**). If empty, the default allowlist is your own WhatsApp number (self-chat mode). +For groups, use `whatsapp.groupPolicy` + `whatsapp.groupAllowFrom`. ```json5 { @@ -237,6 +238,51 @@ To respond **only** to specific text triggers (ignoring native @-mentions): } ``` +### Group policy (per provider) + +Use `*.groupPolicy` to control whether group/room messages are accepted at all: + +```json5 +{ + whatsapp: { + groupPolicy: "allowlist", + groupAllowFrom: ["+15551234567"] + }, + telegram: { + groupPolicy: "allowlist", + groupAllowFrom: ["tg:123456789", "@alice"] + }, + signal: { + groupPolicy: "allowlist", + groupAllowFrom: ["+15551234567"] + }, + imessage: { + groupPolicy: "allowlist", + groupAllowFrom: ["chat_id:123"] + }, + discord: { + groupPolicy: "allowlist", + guilds: { + "GUILD_ID": { + channels: { help: { allow: true } } + } + } + }, + slack: { + groupPolicy: "allowlist", + channels: { "#general": { allow: true } } + } +} +``` + +Notes: +- `"open"` (default): groups bypass allowlists; mention-gating still applies. +- `"disabled"`: block all group/room messages. +- `"allowlist"`: only allow groups/rooms that match the configured allowlist. +- WhatsApp/Telegram/Signal/iMessage use `groupAllowFrom` (fallback: explicit `allowFrom`). +- Discord/Slack use channel allowlists (`discord.guilds.*.channels`, `slack.channels`). +- Group DMs (Discord/Slack) are still controlled by `dm.groupEnabled` + `dm.groupChannels`. + ### `routing.queue` Controls how inbound messages behave when an agent run is already active. diff --git a/docs/discord.md b/docs/discord.md index db76e325e..80e9a3f36 100644 --- a/docs/discord.md +++ b/docs/discord.md @@ -155,6 +155,7 @@ Notes: discord: { enabled: true, token: "abc.123", + groupPolicy: "open", mediaMaxMb: 8, actions: { reactions: true, @@ -210,6 +211,7 @@ Ack reactions are controlled globally via `messages.ackReaction` + - `dm.allowFrom`: DM allowlist (user ids or names). Omit or set to `["*"]` to allow any DM sender. - `dm.groupEnabled`: enable group DMs (default `false`). - `dm.groupChannels`: optional allowlist for group DM channel ids or slugs. +- `groupPolicy`: controls guild channel handling (`open|disabled|allowlist`); `allowlist` requires channel allowlists. - `guilds`: per-guild rules keyed by guild id (preferred) or slug. - `guilds."*"`: default per-guild settings applied when no explicit entry exists. - `guilds..slug`: optional friendly slug used for display names. diff --git a/docs/group-messages.md b/docs/group-messages.md index 254439124..85cfe4305 100644 --- a/docs/group-messages.md +++ b/docs/group-messages.md @@ -11,7 +11,7 @@ Note: `routing.groupChat.mentionPatterns` is now used by Telegram/Discord/Slack/ ## What’s implemented (2025-12-03) - Activation modes: `mention` (default) or `always`. `mention` requires a ping (real WhatsApp @-mentions via `mentionedJids`, regex patterns, or the bot’s E.164 anywhere in the text). `always` wakes the agent on every message but it should reply only when it can add meaningful value; otherwise it returns the silent token `NO_REPLY`. Defaults can be set in config (`whatsapp.groups`) and overridden per group via `/activation`. When `whatsapp.groups` is set, it also acts as a group allowlist (include `"*"` to allow all). -- Group allowlist: `whatsapp.groups` gates which group JIDs are allowed; `whatsapp.allowFrom` still gates participants for direct chats. +- Group policy: `whatsapp.groupPolicy` controls whether group messages are accepted (`open|disabled|allowlist`). `allowlist` uses `whatsapp.groupAllowFrom` (fallback: explicit `whatsapp.allowFrom`). - 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]`. - Sender surfacing: every group batch now ends with `[from: Sender Name (+E164)]` so Pi knows who is speaking. diff --git a/docs/groups.md b/docs/groups.md index cd9a9f13b..a4fd17dfd 100644 --- a/docs/groups.md +++ b/docs/groups.md @@ -1,11 +1,11 @@ --- -summary: "Group chat behavior across surfaces (WhatsApp/Telegram/Discord/iMessage)" +summary: "Group chat behavior across surfaces (WhatsApp/Telegram/Discord/Slack/Signal/iMessage)" read_when: - Changing group chat behavior or mention gating --- # Groups -Clawdbot treats group chats consistently across surfaces: WhatsApp, Telegram, Discord, iMessage. +Clawdbot treats group chats consistently across surfaces: WhatsApp, Telegram, Discord, Slack, Signal, iMessage. ## Session keys - Group sessions use `surface:group:` session keys (rooms/channels use `surface:channel:`). @@ -16,32 +16,53 @@ Clawdbot treats group chats consistently across surfaces: WhatsApp, Telegram, Di - UI labels use `displayName` when available, formatted as `surface:`. - `#room` is reserved for rooms/channels; group chats use `g-` (lowercase, spaces -> `-`, keep `#@+._-`). -## Group policy (WhatsApp & Telegram) -Both WhatsApp and Telegram support a `groupPolicy` config to control how group messages are handled: +## Group policy +Control how group/room messages are handled per provider: ```json5 { whatsapp: { - allowFrom: ["+15551234567"], - groupPolicy: "disabled" // "open" | "disabled" | "allowlist" + groupPolicy: "disabled", // "open" | "disabled" | "allowlist" + groupAllowFrom: ["+15551234567"] }, telegram: { - allowFrom: ["123456789", "@username"], - groupPolicy: "disabled" // "open" | "disabled" | "allowlist" + groupPolicy: "disabled", + groupAllowFrom: ["123456789", "@username"] + }, + signal: { + groupPolicy: "disabled", + groupAllowFrom: ["+15551234567"] + }, + imessage: { + groupPolicy: "disabled", + groupAllowFrom: ["chat_id:123"] + }, + discord: { + groupPolicy: "allowlist", + guilds: { + "GUILD_ID": { channels: { help: { allow: true } } } + } + }, + slack: { + groupPolicy: "allowlist", + channels: { "#general": { allow: true } } } } ``` | Policy | Behavior | |--------|----------| -| `"open"` | Default. Groups bypass `allowFrom`, only mention-gating applies. | +| `"open"` | Default. Groups bypass allowlists; mention-gating still applies. | | `"disabled"` | Block all group messages entirely. | -| `"allowlist"` | Only allow group messages from senders listed in `allowFrom`. | +| `"allowlist"` | Only allow groups/rooms that match the configured allowlist. | Notes: -- `allowFrom` filters DMs by default. With `groupPolicy: "allowlist"`, it also filters group message senders. - `groupPolicy` is separate from mention-gating (which requires @mentions). -- For Telegram `allowlist`, the sender can be matched by user ID (e.g., `"123456789"`, `"telegram:123456789"`, or `"tg:123456789"`; prefixes are case-insensitive) or username (e.g., `"@alice"` or `"alice"`). +- WhatsApp/Telegram/Signal/iMessage: use `groupAllowFrom` (fallback: explicit `allowFrom`). +- Discord: allowlist uses `discord.guilds..channels`. +- Slack: allowlist uses `slack.channels`. +- Group DMs are controlled separately (`discord.dm.*`, `slack.dm.*`). +- Telegram allowlist can match user IDs (`"123456789"`, `"telegram:123456789"`, `"tg:123456789"`) or usernames (`"@alice"` or `"alice"`); prefixes are case-insensitive. ## Mention gating (default) Group messages require a mention unless overridden per group. Defaults live per subsystem under `*.groups."*"`. diff --git a/docs/imessage.md b/docs/imessage.md index 611858f6f..526144cf0 100644 --- a/docs/imessage.md +++ b/docs/imessage.md @@ -27,6 +27,8 @@ Status: external CLI integration. No daemon. cliPath: "imsg", dbPath: "~/Library/Messages/chat.db", allowFrom: ["+15555550123", "user@example.com", "chat_id:123"], + groupPolicy: "open", + groupAllowFrom: ["chat_id:123"], includeAttachments: false, mediaMaxMb: 16, service: "auto", @@ -37,6 +39,8 @@ Status: external CLI integration. No daemon. Notes: - `allowFrom` accepts handles (phone/email) or `chat_id:` entries. +- `groupPolicy` controls group handling (`open|disabled|allowlist`). +- `groupAllowFrom` accepts the same entries as `allowFrom`. - `service` defaults to `auto` (use `imessage` or `sms` to pin). - `region` is only used for SMS targeting. diff --git a/docs/signal.md b/docs/signal.md index 7dd4843ac..845ce4223 100644 --- a/docs/signal.md +++ b/docs/signal.md @@ -50,8 +50,12 @@ You can still run Clawdbot on your own Signal account if your goal is “respond httpHost: "127.0.0.1", httpPort: 8080, - // Who is allowed to talk to the bot - allowFrom: ["+15557654321"] // your personal number (or "*") + // Who is allowed to talk to the bot (DMs) + allowFrom: ["+15557654321"], // your personal number (or "*") + + // Group policy + allowlist + groupPolicy: "open", + groupAllowFrom: ["+15557654321"] } } ``` diff --git a/docs/slack.md b/docs/slack.md index c8154266d..97f0f2c0a 100644 --- a/docs/slack.md +++ b/docs/slack.md @@ -145,6 +145,7 @@ Slack uses Socket Mode only (no HTTP webhook server). Provide both tokens: "enabled": true, "botToken": "xoxb-...", "appToken": "xapp-...", + "groupPolicy": "open", "dm": { "enabled": true, "allowFrom": ["U123", "U456", "*"], @@ -188,6 +189,10 @@ Ack reactions are controlled globally via `messages.ackReaction` + - Channels map to `slack:channel:` sessions. - Slash commands use `slack:slash:` sessions. +## Group policy +- `slack.groupPolicy` controls channel handling (`open|disabled|allowlist`). +- `allowlist` requires channels to be listed in `slack.channels`. + ## Delivery targets Use these with cron/CLI sends: - `user:` for DMs diff --git a/docs/telegram.md b/docs/telegram.md index 67c5ccac4..df53c8e5e 100644 --- a/docs/telegram.md +++ b/docs/telegram.md @@ -25,7 +25,9 @@ Status: ready for bot-mode use with grammY (long-polling by default; webhook sup - 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:`. When `telegram.groups` is set, it becomes a group allowlist (use `"*"` to allow all). Mention/command gating defaults come from `telegram.groups`. -6) Optional allowlist: use `telegram.allowFrom` for direct chats by chat id (`123456789`, `telegram:123456789`, or `tg:123456789`; prefixes are case-insensitive). +6) Optional allowlist: + - Direct chats: `telegram.allowFrom` by chat id (`123456789`, `telegram:123456789`, or `tg:123456789`; prefixes are case-insensitive). + - Groups: set `telegram.groupPolicy = "allowlist"` and list senders in `telegram.groupAllowFrom` (fallback: explicit `telegram.allowFrom`). ## Capabilities & limits (Bot API) - Sees only messages sent after it’s added to a chat; no pre-history access. @@ -37,7 +39,7 @@ Status: ready for bot-mode use with grammY (long-polling by default; webhook sup - Library: grammY is the only client for send + gateway (fetch fallback removed); grammY throttler is enabled by default to stay under Bot API limits. - Inbound normalization: maps Bot API updates to `MsgContext` with `Surface: "telegram"`, `ChatType: direct|group`, `SenderName`, `MediaPath`/`MediaType` when attachments arrive, `Timestamp`, and reply-to metadata (`ReplyToId`, `ReplyToBody`, `ReplyToSender`) when the user replies; reply context is appended to `Body` as a `[Replying to ...]` block (includes `id:` when available); groups require @bot mention or a `routing.groupChat.mentionPatterns` match by default (override per chat in config). - Outbound: text and media (photo/video/audio/document) with optional caption; chunked to limits. Typing cue sent best-effort. -- Config: `TELEGRAM_BOT_TOKEN` env or `telegram.botToken` required; `telegram.groups` (group allowlist + mention defaults), `telegram.allowFrom`, `telegram.mediaMaxMb`, `telegram.replyToMode`, `telegram.proxy`, `telegram.webhookSecret`, `telegram.webhookUrl`, `telegram.webhookPath` supported. +- Config: `TELEGRAM_BOT_TOKEN` env or `telegram.botToken` required; `telegram.groups` (group allowlist + mention defaults), `telegram.allowFrom`, `telegram.groupAllowFrom`, `telegram.groupPolicy`, `telegram.mediaMaxMb`, `telegram.replyToMode`, `telegram.proxy`, `telegram.webhookSecret`, `telegram.webhookUrl`, `telegram.webhookPath` supported. - Ack reactions are controlled globally via `messages.ackReaction` + `messages.ackReactionScope`. - Mention gating precedence (most specific wins): `telegram.groups..requireMention` → `telegram.groups."*".requireMention` → default `true`. @@ -53,6 +55,8 @@ Example config: "123456789": { requireMention: false } // group chat id }, allowFrom: ["123456789"], // direct chat ids allowed (or "*") + groupPolicy: "allowlist", + groupAllowFrom: ["tg:123456789", "@alice"], mediaMaxMb: 5, proxy: "socks5://localhost:9050", webhookSecret: "mysecret", diff --git a/docs/whatsapp.md b/docs/whatsapp.md index 454e83a21..2ee5d4bf6 100644 --- a/docs/whatsapp.md +++ b/docs/whatsapp.md @@ -49,6 +49,8 @@ WhatsApp requires a real mobile number for verification. VoIP and virtual number - Direct chats use E.164; groups use group JID. - **Allowlist**: `whatsapp.allowFrom` enforced for direct chats only. - If `whatsapp.allowFrom` is empty, default allowlist = self number (self-chat mode). +- **Group policy**: `whatsapp.groupPolicy` controls group handling (`open|disabled|allowlist`). + - `allowlist` uses `whatsapp.groupAllowFrom` (fallback: explicit `whatsapp.allowFrom`). - **Self-chat mode**: avoids auto read receipts and ignores mention JIDs. - Read receipts sent for non-self-chat DMs. @@ -69,6 +71,7 @@ WhatsApp requires a real mobile number for verification. VoIP and virtual number ## Groups - Groups map to `whatsapp:group:` sessions. +- Group policy: `whatsapp.groupPolicy = open|disabled|allowlist` (default `open`). - Activation modes: - `mention` (default): requires @mention or regex match. - `always`: always triggers. @@ -118,6 +121,8 @@ WhatsApp requires a real mobile number for verification. VoIP and virtual number ## Config quick map - `whatsapp.allowFrom` (DM allowlist). +- `whatsapp.groupAllowFrom` (group sender allowlist). +- `whatsapp.groupPolicy` (group policy). - `whatsapp.groups` (group allowlist + mention gating defaults; use `"*"` to allow all) - `routing.groupChat.mentionPatterns` - `routing.groupChat.historyLimit` diff --git a/src/config/types.ts b/src/config/types.ts index 514f108f3..34a48c225 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -1,6 +1,7 @@ export type ReplyMode = "text" | "command"; export type SessionScope = "per-sender" | "global"; export type ReplyToMode = "off" | "first" | "all"; +export type GroupPolicy = "open" | "disabled" | "allowlist"; export type SessionSendPolicyAction = "allow" | "deny"; export type SessionSendPolicyMatch = { @@ -78,13 +79,15 @@ export type AgentElevatedAllowFromConfig = { export type WhatsAppConfig = { /** Optional allowlist for WhatsApp direct chats (E.164). */ allowFrom?: string[]; + /** Optional allowlist for WhatsApp group senders (E.164). */ + groupAllowFrom?: string[]; /** * Controls how group messages are handled: * - "open" (default): groups bypass allowFrom, only mention-gating applies * - "disabled": block all group messages entirely - * - "allowlist": only allow group messages from senders in allowFrom + * - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom */ - groupPolicy?: "open" | "disabled" | "allowlist"; + groupPolicy?: GroupPolicy; /** Outbound text chunk size (chars). Default: 4000. */ textChunkLimit?: number; groups?: Record< @@ -214,13 +217,15 @@ export type TelegramConfig = { } >; allowFrom?: Array; + /** Optional allowlist for Telegram group senders (user ids or usernames). */ + groupAllowFrom?: Array; /** * Controls how group messages are handled: * - "open" (default): groups bypass allowFrom, only mention-gating applies * - "disabled": block all group messages entirely - * - "allowlist": only allow group messages from senders in allowFrom + * - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom */ - groupPolicy?: "open" | "disabled" | "allowlist"; + groupPolicy?: GroupPolicy; /** Outbound text chunk size (chars). Default: 4000. */ textChunkLimit?: number; mediaMaxMb?: number; @@ -296,6 +301,13 @@ export type DiscordConfig = { /** If false, do not start the Discord provider. Default: true. */ enabled?: boolean; token?: string; + /** + * Controls how guild channel messages are handled: + * - "open" (default): guild channels bypass allowlists; mention-gating applies + * - "disabled": block all guild channel messages + * - "allowlist": only allow channels present in discord.guilds.*.channels + */ + groupPolicy?: GroupPolicy; /** Outbound text chunk size (chars). Default: 2000. */ textChunkLimit?: number; mediaMaxMb?: number; @@ -355,6 +367,13 @@ export type SlackConfig = { enabled?: boolean; botToken?: string; appToken?: string; + /** + * Controls how channel messages are handled: + * - "open" (default): channels bypass allowlists; mention-gating applies + * - "disabled": block all channel messages + * - "allowlist": only allow channels present in slack.channels + */ + groupPolicy?: GroupPolicy; textChunkLimit?: number; mediaMaxMb?: number; /** Reaction notification mode (off|own|all|allowlist). Default: own. */ @@ -387,6 +406,15 @@ export type SignalConfig = { ignoreStories?: boolean; sendReadReceipts?: boolean; allowFrom?: Array; + /** Optional allowlist for Signal group senders (E.164). */ + groupAllowFrom?: Array; + /** + * Controls how group messages are handled: + * - "open" (default): groups bypass allowFrom, no extra gating + * - "disabled": block all group messages + * - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom + */ + groupPolicy?: GroupPolicy; /** Outbound text chunk size (chars). Default: 4000. */ textChunkLimit?: number; mediaMaxMb?: number; @@ -405,6 +433,15 @@ export type IMessageConfig = { region?: string; /** Optional allowlist for inbound handles or chat_id targets. */ allowFrom?: Array; + /** Optional allowlist for group senders or chat_id targets. */ + groupAllowFrom?: Array; + /** + * Controls how group messages are handled: + * - "open" (default): groups bypass allowFrom; mention-gating applies + * - "disabled": block all group messages entirely + * - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom + */ + groupPolicy?: GroupPolicy; /** Include attachments + reactions in watch payloads. */ includeAttachments?: boolean; /** Max outbound media size in MB. */ diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 4d50c041e..6bc472be6 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -598,7 +598,8 @@ export const ClawdbotSchema = z.object({ whatsapp: z .object({ allowFrom: z.array(z.string()).optional(), - groupPolicy: GroupPolicySchema.default("open").optional(), + groupAllowFrom: z.array(z.string()).optional(), + groupPolicy: GroupPolicySchema.optional().default("open"), textChunkLimit: z.number().int().positive().optional(), groups: z .record( @@ -629,7 +630,8 @@ export const ClawdbotSchema = z.object({ ) .optional(), allowFrom: z.array(z.union([z.string(), z.number()])).optional(), - groupPolicy: GroupPolicySchema.default("open").optional(), + groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(), + groupPolicy: GroupPolicySchema.optional().default("open"), textChunkLimit: z.number().int().positive().optional(), mediaMaxMb: z.number().positive().optional(), proxy: z.string().optional(), @@ -642,6 +644,7 @@ export const ClawdbotSchema = z.object({ .object({ enabled: z.boolean().optional(), token: z.string().optional(), + groupPolicy: GroupPolicySchema.optional().default("open"), textChunkLimit: z.number().int().positive().optional(), slashCommand: z .object({ @@ -714,6 +717,7 @@ export const ClawdbotSchema = z.object({ enabled: z.boolean().optional(), botToken: z.string().optional(), appToken: z.string().optional(), + groupPolicy: GroupPolicySchema.optional().default("open"), textChunkLimit: z.number().int().positive().optional(), mediaMaxMb: z.number().positive().optional(), reactionNotifications: z @@ -777,6 +781,8 @@ export const ClawdbotSchema = z.object({ ignoreStories: z.boolean().optional(), sendReadReceipts: z.boolean().optional(), allowFrom: z.array(z.union([z.string(), z.number()])).optional(), + groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(), + groupPolicy: GroupPolicySchema.optional().default("open"), textChunkLimit: z.number().int().positive().optional(), mediaMaxMb: z.number().positive().optional(), }) @@ -791,6 +797,8 @@ export const ClawdbotSchema = z.object({ .optional(), region: z.string().optional(), allowFrom: z.array(z.union([z.string(), z.number()])).optional(), + groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(), + groupPolicy: GroupPolicySchema.optional().default("open"), includeAttachments: z.boolean().optional(), mediaMaxMb: z.number().positive().optional(), textChunkLimit: z.number().int().positive().optional(), diff --git a/src/discord/monitor.test.ts b/src/discord/monitor.test.ts index b9f6bb6ac..99dd7e4c0 100644 --- a/src/discord/monitor.test.ts +++ b/src/discord/monitor.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { allowListMatches, type DiscordGuildEntryResolved, + isDiscordGroupAllowedByPolicy, normalizeDiscordAllowList, normalizeDiscordSlug, resolveDiscordChannelConfig, @@ -132,6 +133,58 @@ describe("discord guild/channel resolution", () => { }); }); +describe("discord groupPolicy gating", () => { + it("allows when policy is open", () => { + expect( + isDiscordGroupAllowedByPolicy({ + groupPolicy: "open", + channelAllowlistConfigured: false, + channelAllowed: false, + }), + ).toBe(true); + }); + + it("blocks when policy is disabled", () => { + expect( + isDiscordGroupAllowedByPolicy({ + groupPolicy: "disabled", + channelAllowlistConfigured: true, + channelAllowed: true, + }), + ).toBe(false); + }); + + it("blocks allowlist when no channel allowlist configured", () => { + expect( + isDiscordGroupAllowedByPolicy({ + groupPolicy: "allowlist", + channelAllowlistConfigured: false, + channelAllowed: true, + }), + ).toBe(false); + }); + + it("allows allowlist when channel is allowed", () => { + expect( + isDiscordGroupAllowedByPolicy({ + groupPolicy: "allowlist", + channelAllowlistConfigured: true, + channelAllowed: true, + }), + ).toBe(true); + }); + + it("blocks allowlist when channel is not allowed", () => { + expect( + isDiscordGroupAllowedByPolicy({ + groupPolicy: "allowlist", + channelAllowlistConfigured: true, + channelAllowed: false, + }), + ).toBe(false); + }); +}); + describe("discord group DM gating", () => { it("allows all when no allowlist", () => { expect( diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts index dc30b0a1b..15253ba03 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -141,6 +141,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const dmConfig = cfg.discord?.dm; const guildEntries = cfg.discord?.guilds; + const groupPolicy = cfg.discord?.groupPolicy ?? "open"; const allowFrom = dmConfig?.allowFrom; const mediaMaxBytes = (opts.mediaMaxMb ?? cfg.discord?.mediaMaxMb ?? 8) * 1024 * 1024; @@ -159,7 +160,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { if (shouldLogVerbose()) { logVerbose( - `discord: config dm=${dmEnabled ? "on" : "off"} allowFrom=${summarizeAllowList(allowFrom)} groupDm=${groupDmEnabled ? "on" : "off"} groupDmChannels=${summarizeAllowList(groupDmChannels)} guilds=${summarizeGuilds(guildEntries)} historyLimit=${historyLimit} mediaMaxMb=${Math.round(mediaMaxBytes / (1024 * 1024))}`, + `discord: config dm=${dmEnabled ? "on" : "off"} allowFrom=${summarizeAllowList(allowFrom)} groupDm=${groupDmEnabled ? "on" : "off"} groupDmChannels=${summarizeAllowList(groupDmChannels)} groupPolicy=${groupPolicy} guilds=${summarizeGuilds(guildEntries)} historyLimit=${historyLimit} mediaMaxMb=${Math.round(mediaMaxBytes / (1024 * 1024))}`, ); } @@ -279,6 +280,32 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { }); if (isGroupDm && !groupDmAllowed) return; + const channelAllowlistConfigured = + Boolean(guildInfo?.channels) && + Object.keys(guildInfo?.channels ?? {}).length > 0; + const channelAllowed = channelConfig?.allowed !== false; + if ( + isGuildMessage && + !isDiscordGroupAllowedByPolicy({ + groupPolicy, + channelAllowlistConfigured, + channelAllowed, + }) + ) { + if (groupPolicy === "disabled") { + logVerbose("discord: drop guild message (groupPolicy: disabled)"); + } else if (!channelAllowlistConfigured) { + logVerbose( + "discord: drop guild message (groupPolicy: allowlist, no channel allowlist)", + ); + } else { + logVerbose( + `Blocked discord channel ${message.channelId} not in guild channel allowlist (groupPolicy: allowlist)`, + ); + } + return; + } + if (isGuildMessage && channelConfig?.allowed === false) { logVerbose( `Blocked discord channel ${message.channelId} not in guild channel allowlist`, @@ -1169,6 +1196,18 @@ export function resolveDiscordChannelConfig(params: { return { allowed: true }; } +export function isDiscordGroupAllowedByPolicy(params: { + groupPolicy: "open" | "disabled" | "allowlist"; + channelAllowlistConfigured: boolean; + channelAllowed: boolean; +}): boolean { + const { groupPolicy, channelAllowlistConfigured, channelAllowed } = params; + if (groupPolicy === "disabled") return false; + if (groupPolicy === "open") return true; + if (!channelAllowlistConfigured) return false; + return channelAllowed; +} + export function resolveGroupDmAllow(params: { channels: Array | undefined; channelId: string; diff --git a/src/imessage/monitor.test.ts b/src/imessage/monitor.test.ts index e50765150..92fbf2a18 100644 --- a/src/imessage/monitor.test.ts +++ b/src/imessage/monitor.test.ts @@ -266,10 +266,13 @@ describe("monitorIMessageProvider", () => { ); }); - it("honors allowFrom entries", async () => { + it("honors group allowlist when groupPolicy is allowlist", async () => { config = { ...config, - imessage: { allowFrom: ["chat_id:101"] }, + imessage: { + groupPolicy: "allowlist", + groupAllowFrom: ["chat_id:101"], + }, }; const run = monitorIMessageProvider(); await waitForSubscribe(); @@ -295,6 +298,35 @@ describe("monitorIMessageProvider", () => { expect(replyMock).not.toHaveBeenCalled(); }); + it("blocks group messages when groupPolicy is disabled", async () => { + config = { + ...config, + imessage: { groupPolicy: "disabled" }, + }; + const run = monitorIMessageProvider(); + await waitForSubscribe(); + + notificationHandler?.({ + method: "message", + params: { + message: { + id: 10, + chat_id: 303, + sender: "+15550003333", + is_from_me: false, + text: "@clawd hi", + is_group: true, + }, + }, + }); + + await flush(); + closeResolve?.(); + await run; + + expect(replyMock).not.toHaveBeenCalled(); + }); + it("updates last route with chat_id for direct messages", async () => { replyMock.mockResolvedValueOnce({ text: "ok" }); const run = monitorIMessageProvider(); diff --git a/src/imessage/monitor.ts b/src/imessage/monitor.ts index 5b289ec8b..e719796a6 100644 --- a/src/imessage/monitor.ts +++ b/src/imessage/monitor.ts @@ -52,6 +52,7 @@ export type MonitorIMessageOpts = { cliPath?: string; dbPath?: string; allowFrom?: Array; + groupAllowFrom?: Array; includeAttachments?: boolean; mediaMaxMb?: number; requireMention?: boolean; @@ -75,6 +76,17 @@ function resolveAllowFrom(opts: MonitorIMessageOpts): string[] { return raw.map((entry) => String(entry).trim()).filter(Boolean); } +function resolveGroupAllowFrom(opts: MonitorIMessageOpts): string[] { + const cfg = loadConfig(); + const raw = + opts.groupAllowFrom ?? + cfg.imessage?.groupAllowFrom ?? + (cfg.imessage?.allowFrom && cfg.imessage.allowFrom.length > 0 + ? cfg.imessage.allowFrom + : []); + return raw.map((entry) => String(entry).trim()).filter(Boolean); +} + async function deliverReplies(params: { replies: ReplyPayload[]; target: string; @@ -116,6 +128,8 @@ export async function monitorIMessageProvider( const cfg = loadConfig(); const textLimit = resolveTextChunkLimit(cfg, "imessage"); const allowFrom = resolveAllowFrom(opts); + const groupAllowFrom = resolveGroupAllowFrom(opts); + const groupPolicy = cfg.imessage?.groupPolicy ?? "open"; const mentionRegexes = buildMentionRegexes(cfg); const includeAttachments = opts.includeAttachments ?? cfg.imessage?.includeAttachments ?? false; @@ -140,12 +154,37 @@ export async function monitorIMessageProvider( const groupId = isGroup ? String(chatId) : undefined; if (isGroup) { - const groupPolicy = resolveProviderGroupPolicy({ + if (groupPolicy === "disabled") { + logVerbose("Blocked iMessage group message (groupPolicy: disabled)"); + return; + } + if (groupPolicy === "allowlist") { + if (groupAllowFrom.length === 0) { + logVerbose( + "Blocked iMessage group message (groupPolicy: allowlist, no groupAllowFrom)", + ); + return; + } + const allowed = isAllowedIMessageSender({ + allowFrom: groupAllowFrom, + sender, + chatId: chatId ?? undefined, + chatGuid, + chatIdentifier, + }); + if (!allowed) { + logVerbose( + `Blocked iMessage sender ${sender} (not in groupAllowFrom)`, + ); + return; + } + } + const groupListPolicy = resolveProviderGroupPolicy({ cfg, surface: "imessage", groupId, }); - if (groupPolicy.allowlistEnabled && !groupPolicy.allowed) { + if (groupListPolicy.allowlistEnabled && !groupListPolicy.allowed) { logVerbose( `imessage: skipping group message (${groupId ?? "unknown"}) not in allowlist`, ); @@ -153,14 +192,14 @@ export async function monitorIMessageProvider( } } - const commandAuthorized = isAllowedIMessageSender({ + const dmAuthorized = isAllowedIMessageSender({ allowFrom, sender, chatId: chatId ?? undefined, chatGuid, chatIdentifier, }); - if (!commandAuthorized) { + if (!isGroup && !dmAuthorized) { logVerbose(`Blocked iMessage sender ${sender} (not in allowFrom)`); return; } @@ -177,6 +216,17 @@ export async function monitorIMessageProvider( overrideOrder: "before-config", }); const canDetectMention = mentionRegexes.length > 0; + const commandAuthorized = isGroup + ? groupAllowFrom.length > 0 + ? isAllowedIMessageSender({ + allowFrom: groupAllowFrom, + sender, + chatId: chatId ?? undefined, + chatGuid, + chatIdentifier, + }) + : true + : dmAuthorized; const shouldBypassMention = isGroup && requireMention && diff --git a/src/signal/monitor.test.ts b/src/signal/monitor.test.ts new file mode 100644 index 000000000..e99907922 --- /dev/null +++ b/src/signal/monitor.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; + +import { isSignalGroupAllowed } from "./monitor.js"; + +describe("signal groupPolicy gating", () => { + it("allows when policy is open", () => { + expect( + isSignalGroupAllowed({ + groupPolicy: "open", + allowFrom: [], + sender: "+15550001111", + }), + ).toBe(true); + }); + + it("blocks when policy is disabled", () => { + expect( + isSignalGroupAllowed({ + groupPolicy: "disabled", + allowFrom: ["+15550001111"], + sender: "+15550001111", + }), + ).toBe(false); + }); + + it("blocks allowlist when empty", () => { + expect( + isSignalGroupAllowed({ + groupPolicy: "allowlist", + allowFrom: [], + sender: "+15550001111", + }), + ).toBe(false); + }); + + it("allows allowlist when sender matches", () => { + expect( + isSignalGroupAllowed({ + groupPolicy: "allowlist", + allowFrom: ["+15550001111"], + sender: "+15550001111", + }), + ).toBe(true); + }); + + it("allows allowlist wildcard", () => { + expect( + isSignalGroupAllowed({ + groupPolicy: "allowlist", + allowFrom: ["*"], + sender: "+15550002222", + }), + ).toBe(true); + }); +}); diff --git a/src/signal/monitor.ts b/src/signal/monitor.ts index 3bbe0afbd..0c3215153 100644 --- a/src/signal/monitor.ts +++ b/src/signal/monitor.ts @@ -55,6 +55,7 @@ export type MonitorSignalOpts = { ignoreStories?: boolean; sendReadReceipts?: boolean; allowFrom?: Array; + groupAllowFrom?: Array; mediaMaxMb?: number; }; @@ -97,6 +98,17 @@ function resolveAllowFrom(opts: MonitorSignalOpts): string[] { return raw.map((entry) => String(entry).trim()).filter(Boolean); } +function resolveGroupAllowFrom(opts: MonitorSignalOpts): string[] { + const cfg = loadConfig(); + const raw = + opts.groupAllowFrom ?? + cfg.signal?.groupAllowFrom ?? + (cfg.signal?.allowFrom && cfg.signal.allowFrom.length > 0 + ? cfg.signal.allowFrom + : []); + return raw.map((entry) => String(entry).trim()).filter(Boolean); +} + function isAllowedSender(sender: string, allowFrom: string[]): boolean { if (allowFrom.length === 0) return true; if (allowFrom.includes("*")) return true; @@ -107,6 +119,18 @@ function isAllowedSender(sender: string, allowFrom: string[]): boolean { return normalizedAllow.includes(normalizedSender); } +export function isSignalGroupAllowed(params: { + groupPolicy: "open" | "disabled" | "allowlist"; + allowFrom: string[]; + sender: string; +}): boolean { + const { groupPolicy, allowFrom, sender } = params; + if (groupPolicy === "disabled") return false; + if (groupPolicy === "open") return true; + if (allowFrom.length === 0) return false; + return isAllowedSender(sender, allowFrom); +} + async function waitForSignalDaemonReady(params: { baseUrl: string; abortSignal?: AbortSignal; @@ -222,6 +246,8 @@ export async function monitorSignalProvider( const baseUrl = resolveBaseUrl(opts); const account = resolveAccount(opts); const allowFrom = resolveAllowFrom(opts); + const groupAllowFrom = resolveGroupAllowFrom(opts); + const groupPolicy = cfg.signal?.groupPolicy ?? "open"; const mediaMaxBytes = (opts.mediaMaxMb ?? cfg.signal?.mediaMaxMb ?? 8) * 1024 * 1024; const ignoreAttachments = @@ -288,15 +314,37 @@ export async function monitorSignalProvider( if (account && normalizeE164(sender) === normalizeE164(account)) { return; } - const commandAuthorized = isAllowedSender(sender, allowFrom); - if (!commandAuthorized) { - logVerbose(`Blocked signal sender ${sender} (not in allowFrom)`); - return; - } - const groupId = dataMessage.groupInfo?.groupId ?? undefined; const groupName = dataMessage.groupInfo?.groupName ?? undefined; const isGroup = Boolean(groupId); + if (isGroup && groupPolicy === "disabled") { + logVerbose("Blocked signal group message (groupPolicy: disabled)"); + return; + } + if (isGroup && groupPolicy === "allowlist") { + if (groupAllowFrom.length === 0) { + logVerbose( + "Blocked signal group message (groupPolicy: allowlist, no groupAllowFrom)", + ); + return; + } + if (!isAllowedSender(sender, groupAllowFrom)) { + logVerbose( + `Blocked signal group sender ${sender} (not in groupAllowFrom)`, + ); + return; + } + } + + const commandAuthorized = isGroup + ? groupAllowFrom.length > 0 + ? isAllowedSender(sender, groupAllowFrom) + : true + : isAllowedSender(sender, allowFrom); + if (!isGroup && !commandAuthorized) { + logVerbose(`Blocked signal sender ${sender} (not in allowFrom)`); + return; + } const messageText = (dataMessage.message ?? "").trim(); let mediaPath: string | undefined; diff --git a/src/slack/monitor.test.ts b/src/slack/monitor.test.ts new file mode 100644 index 000000000..baa5a7397 --- /dev/null +++ b/src/slack/monitor.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; + +import { isSlackRoomAllowedByPolicy } from "./monitor.js"; + +describe("slack groupPolicy gating", () => { + it("allows when policy is open", () => { + expect( + isSlackRoomAllowedByPolicy({ + groupPolicy: "open", + channelAllowlistConfigured: false, + channelAllowed: false, + }), + ).toBe(true); + }); + + it("blocks when policy is disabled", () => { + expect( + isSlackRoomAllowedByPolicy({ + groupPolicy: "disabled", + channelAllowlistConfigured: true, + channelAllowed: true, + }), + ).toBe(false); + }); + + it("blocks allowlist when no channel allowlist configured", () => { + expect( + isSlackRoomAllowedByPolicy({ + groupPolicy: "allowlist", + channelAllowlistConfigured: false, + channelAllowed: true, + }), + ).toBe(false); + }); + + it("allows allowlist when channel is allowed", () => { + expect( + isSlackRoomAllowedByPolicy({ + groupPolicy: "allowlist", + channelAllowlistConfigured: true, + channelAllowed: true, + }), + ).toBe(true); + }); + + it("blocks allowlist when channel is not allowed", () => { + expect( + isSlackRoomAllowedByPolicy({ + groupPolicy: "allowlist", + channelAllowlistConfigured: true, + channelAllowed: false, + }), + ).toBe(false); + }); +}); diff --git a/src/slack/monitor.ts b/src/slack/monitor.ts index fb8f11cbd..30e59612a 100644 --- a/src/slack/monitor.ts +++ b/src/slack/monitor.ts @@ -379,6 +379,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { const groupDmChannels = normalizeAllowList(dmConfig?.groupChannels); const channelsConfig = cfg.slack?.channels; const dmEnabled = dmConfig?.enabled ?? true; + const groupPolicy = cfg.slack?.groupPolicy ?? "open"; const reactionMode = cfg.slack?.reactionNotifications ?? "own"; const reactionAllowlist = cfg.slack?.reactionAllowlist ?? []; const slashCommand = resolveSlackSlashCommandConfig( @@ -517,7 +518,19 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { channelName: params.channelName, channels: channelsConfig, }); - if (channelConfig?.allowed === false) return false; + const channelAllowed = channelConfig?.allowed !== false; + const channelAllowlistConfigured = + Boolean(channelsConfig) && Object.keys(channelsConfig ?? {}).length > 0; + if ( + !isSlackRoomAllowedByPolicy({ + groupPolicy, + channelAllowlistConfigured, + channelAllowed, + }) + ) { + return false; + } + if (!channelAllowed) return false; } return true; @@ -1440,6 +1453,18 @@ type SlackRespondFn = (payload: { response_type?: "ephemeral" | "in_channel"; }) => Promise; +export function isSlackRoomAllowedByPolicy(params: { + groupPolicy: "open" | "disabled" | "allowlist"; + channelAllowlistConfigured: boolean; + channelAllowed: boolean; +}): boolean { + const { groupPolicy, channelAllowlistConfigured, channelAllowed } = params; + if (groupPolicy === "disabled") return false; + if (groupPolicy === "open") return true; + if (!channelAllowlistConfigured) return false; + return channelAllowed; +} + async function deliverSlackSlashReplies(params: { replies: ReplyPayload[]; respond: SlackRespondFn; diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index f698d6caa..b635d3a28 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -1133,4 +1133,69 @@ describe("createTelegramBot", () => { // Should call reply because sender ID matches after stripping tg: prefix expect(replySpy).toHaveBeenCalled(); }); + + it("blocks group messages when groupPolicy allowlist has no groupAllowFrom", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + telegram: { + groupPolicy: "allowlist", + groups: { "*": { requireMention: false } }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 123456789, username: "testuser" }, + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).not.toHaveBeenCalled(); + }); + + it("allows control commands with TG-prefixed groupAllowFrom entries", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + telegram: { + groupPolicy: "allowlist", + groupAllowFrom: [" TG:123456789 "], + groups: { "*": { requireMention: true } }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 123456789, username: "testuser" }, + text: "/status", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index 6fe351080..79b14da48 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -86,6 +86,7 @@ export type TelegramBotOptions = { runtime?: RuntimeEnv; requireMention?: boolean; allowFrom?: Array; + groupAllowFrom?: Array; mediaMaxMb?: number; replyToMode?: ReplyToMode; proxyFetch?: typeof fetch; @@ -111,14 +112,46 @@ export function createTelegramBot(opts: TelegramBotOptions) { const cfg = loadConfig(); const textLimit = resolveTextChunkLimit(cfg, "telegram"); const allowFrom = opts.allowFrom ?? cfg.telegram?.allowFrom; - const normalizedAllowFrom = (allowFrom ?? []) - .map((value) => String(value).trim()) - .filter(Boolean) - .map((value) => value.replace(/^(telegram|tg):/i, "")); - const normalizedAllowFromLower = normalizedAllowFrom.map((value) => - value.toLowerCase(), - ); - const hasAllowFromWildcard = normalizedAllowFrom.includes("*"); + const groupAllowFrom = + opts.groupAllowFrom ?? + cfg.telegram?.groupAllowFrom ?? + (cfg.telegram?.allowFrom && cfg.telegram.allowFrom.length > 0 + ? cfg.telegram.allowFrom + : undefined) ?? + (opts.allowFrom && opts.allowFrom.length > 0 ? opts.allowFrom : undefined); + const normalizeAllowFrom = (list?: Array) => { + const entries = (list ?? []) + .map((value) => String(value).trim()) + .filter(Boolean); + const hasWildcard = entries.includes("*"); + const normalized = entries + .filter((value) => value !== "*") + .map((value) => value.replace(/^(telegram|tg):/i, "")); + const normalizedLower = normalized.map((value) => value.toLowerCase()); + return { + entries: normalized, + entriesLower: normalizedLower, + hasWildcard, + hasEntries: entries.length > 0, + }; + }; + const isSenderAllowed = (params: { + allow: ReturnType; + senderId?: string; + senderUsername?: string; + }) => { + const { allow, senderId, senderUsername } = params; + if (!allow.hasEntries) return true; + if (allow.hasWildcard) return true; + if (senderId && allow.entries.includes(senderId)) return true; + const username = senderUsername?.toLowerCase(); + if (!username) return false; + return allow.entriesLower.some( + (entry) => entry === username || entry === `@${username}`, + ); + }; + const dmAllow = normalizeAllowFrom(allowFrom); + const groupAllow = normalizeAllowFrom(groupAllowFrom); const replyToMode = opts.replyToMode ?? cfg.telegram?.replyToMode ?? "off"; const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions"; @@ -160,11 +193,9 @@ export function createTelegramBot(opts: TelegramBotOptions) { }; // allowFrom for direct chats - if (!isGroup && normalizedAllowFrom.length > 0) { + if (!isGroup && dmAllow.hasEntries) { const candidate = String(chatId); - const permitted = - hasAllowFromWildcard || normalizedAllowFrom.includes(candidate); - if (!permitted) { + if (!isSenderAllowed({ allow: dmAllow, senderId: candidate })) { logVerbose( `Blocked unauthorized telegram sender ${candidate} (not in allowFrom)`, ); @@ -173,20 +204,13 @@ export function createTelegramBot(opts: TelegramBotOptions) { } const botUsername = primaryCtx.me?.username?.toLowerCase(); - const allowFromList = normalizedAllowFrom; const senderId = msg.from?.id ? String(msg.from.id) : ""; const senderUsername = msg.from?.username ?? ""; - const senderUsernameLower = senderUsername.toLowerCase(); - const commandAuthorized = - allowFromList.length === 0 || - hasAllowFromWildcard || - (senderId && allowFromList.includes(senderId)) || - (senderUsername && - normalizedAllowFromLower.some( - (entry) => - entry === senderUsernameLower || - entry === `@${senderUsernameLower}`, - )); + const commandAuthorized = isSenderAllowed({ + allow: isGroup ? groupAllow : dmAllow, + senderId, + senderUsername, + }); const wasMentioned = (Boolean(botUsername) && hasBotMention(msg, botUsername)) || matchesMentionPatterns(msg.text ?? msg.caption ?? "", mentionRegexes); @@ -388,7 +412,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { // Group policy filtering: controls how group messages are handled // - "open" (default): groups bypass allowFrom, only mention-gating applies // - "disabled": block all group messages entirely - // - "allowlist": only allow group messages from senders in allowFrom + // - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom const groupPolicy = cfg.telegram?.groupPolicy ?? "open"; if (groupPolicy === "disabled") { logVerbose(`Blocked telegram group message (groupPolicy: disabled)`); @@ -403,18 +427,20 @@ export function createTelegramBot(opts: TelegramBotOptions) { ); return; } - const senderIdAllowed = normalizedAllowFrom.includes( - String(senderId), - ); - // Also check username if available (with or without @ prefix) - const senderUsername = msg.from?.username?.toLowerCase(); - const usernameAllowed = - senderUsername != null && - normalizedAllowFromLower.some( - (value) => - value === senderUsername || value === `@${senderUsername}`, + if (!groupAllow.hasEntries) { + logVerbose( + "Blocked telegram group message (groupPolicy: allowlist, no groupAllowFrom)", ); - if (!hasAllowFromWildcard && !senderIdAllowed && !usernameAllowed) { + return; + } + const senderUsername = msg.from?.username ?? ""; + if ( + !isSenderAllowed({ + allow: groupAllow, + senderId: String(senderId), + senderUsername, + }) + ) { logVerbose( `Blocked telegram group message from ${senderId} (groupPolicy: allowlist)`, ); diff --git a/src/web/inbound.ts b/src/web/inbound.ts index d6aff8390..ef46b4b36 100644 --- a/src/web/inbound.ts +++ b/src/web/inbound.ts @@ -178,18 +178,30 @@ export async function monitorWebInbox(options: { configuredAllowFrom && configuredAllowFrom.length > 0 ? configuredAllowFrom : defaultAllowFrom; + const groupAllowFrom = + cfg.whatsapp?.groupAllowFrom ?? + (configuredAllowFrom && configuredAllowFrom.length > 0 + ? configuredAllowFrom + : undefined); const isSamePhone = from === selfE164; const isSelfChat = isSelfChatMode(selfE164, configuredAllowFrom); - // Pre-compute normalized allowlist for filtering (used by both group and DM checks) - const hasWildcard = allowFrom?.includes("*") ?? false; + // Pre-compute normalized allowlists for filtering + const dmHasWildcard = allowFrom?.includes("*") ?? false; const normalizedAllowFrom = - allowFrom && allowFrom.length > 0 ? allowFrom.map(normalizeE164) : []; + allowFrom && allowFrom.length > 0 + ? allowFrom.filter((entry) => entry !== "*").map(normalizeE164) + : []; + const groupHasWildcard = groupAllowFrom?.includes("*") ?? false; + const normalizedGroupAllowFrom = + groupAllowFrom && groupAllowFrom.length > 0 + ? groupAllowFrom.filter((entry) => entry !== "*").map(normalizeE164) + : []; // Group policy filtering: controls how group messages are handled // - "open" (default): groups bypass allowFrom, only mention-gating applies // - "disabled": block all group messages entirely - // - "allowlist": only allow group messages from senders in allowFrom + // - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom const groupPolicy = cfg.whatsapp?.groupPolicy ?? "open"; if (group && groupPolicy === "disabled") { logVerbose(`Blocked group message (groupPolicy: disabled)`); @@ -198,9 +210,15 @@ export async function monitorWebInbox(options: { if (group && groupPolicy === "allowlist") { // For allowlist mode, the sender (participant) must be in allowFrom // If we can't resolve the sender E164, block the message for safety + if (!groupAllowFrom || groupAllowFrom.length === 0) { + logVerbose( + "Blocked group message (groupPolicy: allowlist, no groupAllowFrom)", + ); + continue; + } const senderAllowed = - hasWildcard || - (senderE164 != null && normalizedAllowFrom.includes(senderE164)); + groupHasWildcard || + (senderE164 != null && normalizedGroupAllowFrom.includes(senderE164)); if (!senderAllowed) { logVerbose( `Blocked group message from ${senderE164 ?? "unknown sender"} (groupPolicy: allowlist)`, @@ -214,7 +232,7 @@ export async function monitorWebInbox(options: { !group && Array.isArray(allowFrom) && allowFrom.length > 0; if (!isSamePhone && allowlistEnabled) { const candidate = from; - if (!hasWildcard && !normalizedAllowFrom.includes(candidate)) { + if (!dmHasWildcard && !normalizedAllowFrom.includes(candidate)) { logVerbose( `Blocked unauthorized sender ${candidate} (not in allowFrom list)`, ); diff --git a/src/web/monitor-inbox.test.ts b/src/web/monitor-inbox.test.ts index 02af9d057..2f23c3c52 100644 --- a/src/web/monitor-inbox.test.ts +++ b/src/web/monitor-inbox.test.ts @@ -711,10 +711,10 @@ describe("web monitor inbox", () => { await listener.close(); }); - it("blocks group messages from senders not in allowFrom when groupPolicy is 'allowlist'", async () => { + it("blocks group messages from senders not in groupAllowFrom when groupPolicy is 'allowlist'", async () => { mockLoadConfig.mockReturnValue({ whatsapp: { - allowFrom: ["+1234"], // Does not include +999 + groupAllowFrom: ["+1234"], // Does not include +999 groupPolicy: "allowlist", }, messages: { @@ -746,16 +746,16 @@ describe("web monitor inbox", () => { sock.ev.emit("messages.upsert", upsert); await new Promise((resolve) => setImmediate(resolve)); - // Should NOT call onMessage because sender +999 not in allowFrom + // Should NOT call onMessage because sender +999 not in groupAllowFrom expect(onMessage).not.toHaveBeenCalled(); await listener.close(); }); - it("allows group messages from senders in allowFrom when groupPolicy is 'allowlist'", async () => { + it("allows group messages from senders in groupAllowFrom when groupPolicy is 'allowlist'", async () => { mockLoadConfig.mockReturnValue({ whatsapp: { - allowFrom: ["+15551234567"], // Includes the sender + groupAllowFrom: ["+15551234567"], // Includes the sender groupPolicy: "allowlist", }, messages: { @@ -787,7 +787,7 @@ describe("web monitor inbox", () => { sock.ev.emit("messages.upsert", upsert); await new Promise((resolve) => setImmediate(resolve)); - // Should call onMessage because sender is in allowFrom + // Should call onMessage because sender is in groupAllowFrom expect(onMessage).toHaveBeenCalledTimes(1); const payload = onMessage.mock.calls[0][0]; expect(payload.chatType).toBe("group"); @@ -799,7 +799,7 @@ describe("web monitor inbox", () => { it("allows all group senders with wildcard in groupPolicy allowlist", async () => { mockLoadConfig.mockReturnValue({ whatsapp: { - allowFrom: ["*"], // Wildcard allows everyone + groupAllowFrom: ["*"], // Wildcard allows everyone groupPolicy: "allowlist", }, messages: { @@ -839,6 +839,45 @@ describe("web monitor inbox", () => { await listener.close(); }); + it("blocks group messages when groupPolicy allowlist has no groupAllowFrom", async () => { + mockLoadConfig.mockReturnValue({ + whatsapp: { + groupPolicy: "allowlist", + }, + messages: { + messagePrefix: undefined, + responsePrefix: undefined, + timestampPrefix: false, + }, + }); + + const onMessage = vi.fn(); + const listener = await monitorWebInbox({ verbose: false, onMessage }); + const sock = await createWaSocket(); + + const upsert = { + type: "notify", + messages: [ + { + key: { + id: "grp-allowlist-empty", + fromMe: false, + remoteJid: "11111@g.us", + participant: "999@s.whatsapp.net", + }, + message: { conversation: "blocked by empty allowlist" }, + }, + ], + }; + + sock.ev.emit("messages.upsert", upsert); + await new Promise((resolve) => setImmediate(resolve)); + + expect(onMessage).not.toHaveBeenCalled(); + + await listener.close(); + }); + it("allows messages from senders in allowFrom list", async () => { mockLoadConfig.mockReturnValue({ whatsapp: {