diff --git a/CHANGELOG.md b/CHANGELOG.md index 05f83549d..bb970eca1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ ### Fixes - Typing indicators: stop typing once the reply dispatcher drains to prevent stuck typing across Discord/Telegram/WhatsApp. +- WhatsApp/Telegram: add groupPolicy handling for group messages and normalize allowFrom matching (tg/telegram prefixes). Thanks @mneves75. - Auto-reply: add configurable ack reactions for inbound messages (default ๐Ÿ‘€ or `identity.emoji`) with scope controls. Thanks @obviyus for PR #178. - Onboarding: resolve CLI entrypoint when running via `npx` so gateway daemon install works without a build step. - Onboarding: when OpenAI Codex OAuth is used, default to `openai-codex/gpt-5.2` and warn if the selected model lacks auth. diff --git a/docs/groups.md b/docs/groups.md index 7a605863c..cd9a9f13b 100644 --- a/docs/groups.md +++ b/docs/groups.md @@ -16,6 +16,33 @@ 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: + +```json5 +{ + whatsapp: { + allowFrom: ["+15551234567"], + groupPolicy: "disabled" // "open" | "disabled" | "allowlist" + }, + telegram: { + allowFrom: ["123456789", "@username"], + groupPolicy: "disabled" // "open" | "disabled" | "allowlist" + } +} +``` + +| Policy | Behavior | +|--------|----------| +| `"open"` | Default. Groups bypass `allowFrom`, only mention-gating applies. | +| `"disabled"` | Block all group messages entirely. | +| `"allowlist"` | Only allow group messages from senders listed in `allowFrom`. | + +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"`). + ## Mention gating (default) Group messages require a mention unless overridden per group. Defaults live per subsystem under `*.groups."*"`. diff --git a/docs/plans/group-policy-hardening.md b/docs/plans/group-policy-hardening.md new file mode 100644 index 000000000..684ff7f77 --- /dev/null +++ b/docs/plans/group-policy-hardening.md @@ -0,0 +1,121 @@ +# Engineering Execution Spec: groupPolicy Hardening (Telegram Allowlist Parity) + +**Date**: 2026-01-05 +**Status**: Complete +**PR**: #216 (feat/whatsapp-group-policy) + +--- + +## Executive Summary + +Follow-up hardening work ensures Telegram allowlists behave consistently across inbound group/DM filtering and outbound send normalization. The focus is on prefix parity (`telegram:` / `tg:`), case-insensitive matching for prefixes, and resilience to accidental whitespace in config entries. Documentation and tests were updated to reflect and lock in this behavior. + +--- + +## Findings Analysis + +### [MED] F1: Telegram Allowlist Prefix Handling Is Case-Sensitive and Excludes `tg:` + +**Location**: `src/telegram/bot.ts` + +**Problem**: Inbound allowlist normalization only stripped a lowercase `telegram:` prefix. This rejected `TG:123` / `Telegram:123` and did not accept the `tg:` shorthand even though outbound send normalization already accepts `tg:` and case-insensitive prefixes. + +**Impact**: +- DMs and group allowlists fail when users copy/paste prefixed IDs from logs or existing send format. +- Behavior is inconsistent between inbound filtering and outbound send normalization. + +**Fix**: Normalize allowlist entries by trimming whitespace and stripping `telegram:` / `tg:` prefixes case-insensitively at pre-compute time. + +--- + +### [LOW] F2: Allowlist Entries Are Not Trimmed + +**Location**: `src/telegram/bot.ts` + +**Problem**: Allowlist entries are not trimmed; accidental whitespace causes mismatches. + +**Fix**: Trim and drop empty entries while normalizing allowlist inputs. + +--- + +## Implementation Phases + +### Phase 1: Normalize Telegram Allowlist Inputs + +**File**: `src/telegram/bot.ts` + +**Changes**: +1. Trim allowlist entries and drop empty values. +2. Strip `telegram:` / `tg:` prefixes case-insensitively. +3. Simplify DM allowlist check to rely on normalized values. + +--- + +### Phase 2: Add Coverage for Prefix + Whitespace + +**File**: `src/telegram/bot.test.ts` + +**Add Tests**: +- DM allowlist accepts `TG:` prefix with surrounding whitespace. +- Group allowlist accepts `TG:` prefix case-insensitively. + +--- + +### Phase 3: Documentation Updates + +**Files**: +- `docs/groups.md` +- `docs/telegram.md` + +**Changes**: +- Document `tg:` alias and case-insensitive prefixes for Telegram allowlists. + +--- + +### Phase 4: Verification + +1. Run targeted Telegram tests (`pnpm test -- src/telegram/bot.test.ts`). +2. If time allows, run full suite (`pnpm test`). + +--- + +## Files Modified + +| File | Change Type | Description | +|------|-------------|-------------| +| `src/telegram/bot.ts` | Fix | Trim allowlist values; strip `telegram:` / `tg:` prefixes case-insensitively | +| `src/telegram/bot.test.ts` | Test | Add DM + group allowlist coverage for `TG:` prefix + whitespace | +| `docs/groups.md` | Docs | Mention `tg:` alias + case-insensitive prefixes | +| `docs/telegram.md` | Docs | Mention `tg:` alias + case-insensitive prefixes | + +--- + +## Success Criteria + +- [x] Telegram allowlist accepts `telegram:` / `tg:` prefixes case-insensitively. +- [x] Telegram allowlist tolerates whitespace in config entries. +- [x] DM and group allowlist tests cover prefixed cases. +- [x] Docs updated to reflect allowlist formats. +- [x] Targeted tests pass. +- [x] Full test suite passes. + +--- + +## Risk Assessment + +| Risk | Severity | Mitigation | +|------|----------|------------| +| Behavior change for malformed entries | Low | Normalization is additive and trims only whitespace | +| Test fragility | Low | Isolated unit tests; no external dependencies | +| Doc drift | Low | Updated docs alongside code | + +--- + +## Estimated Complexity + +- **Phase 1**: Low (normalization helpers) +- **Phase 2**: Low (2 new tests) +- **Phase 3**: Low (doc edits) +- **Phase 4**: Low (verification) + +**Total**: ~20 minutes diff --git a/docs/telegram.md b/docs/telegram.md index 7d0271e96..67c5ccac4 100644 --- a/docs/telegram.md +++ b/docs/telegram.md @@ -25,7 +25,7 @@ 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` or `telegram:123456789`). +6) Optional allowlist: use `telegram.allowFrom` for direct chats by chat id (`123456789`, `telegram:123456789`, or `tg:123456789`; prefixes are case-insensitive). ## Capabilities & limits (Bot API) - Sees only messages sent after itโ€™s added to a chat; no pre-history access. diff --git a/src/config/types.ts b/src/config/types.ts index e1aa82da0..514f108f3 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -78,6 +78,13 @@ export type AgentElevatedAllowFromConfig = { export type WhatsAppConfig = { /** Optional allowlist for WhatsApp direct chats (E.164). */ allowFrom?: 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 + */ + groupPolicy?: "open" | "disabled" | "allowlist"; /** Outbound text chunk size (chars). Default: 4000. */ textChunkLimit?: number; groups?: Record< @@ -207,6 +214,13 @@ export type TelegramConfig = { } >; allowFrom?: 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 + */ + groupPolicy?: "open" | "disabled" | "allowlist"; /** Outbound text chunk size (chars). Default: 4000. */ textChunkLimit?: number; mediaMaxMb?: number; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 7c6116153..4d50c041e 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -81,6 +81,12 @@ const ReplyToModeSchema = z.union([ z.literal("all"), ]); +// GroupPolicySchema: controls how group messages are handled +// Used with .default("open").optional() pattern: +// - .optional() allows field omission in input config +// - .default("open") ensures runtime always resolves to "open" if not provided +const GroupPolicySchema = z.enum(["open", "disabled", "allowlist"]); + const QueueModeBySurfaceSchema = z .object({ whatsapp: QueueModeSchema.optional(), @@ -592,6 +598,7 @@ export const ClawdbotSchema = z.object({ whatsapp: z .object({ allowFrom: z.array(z.string()).optional(), + groupPolicy: GroupPolicySchema.default("open").optional(), textChunkLimit: z.number().int().positive().optional(), groups: z .record( @@ -622,6 +629,7 @@ export const ClawdbotSchema = z.object({ ) .optional(), allowFrom: z.array(z.union([z.string(), z.number()])).optional(), + groupPolicy: GroupPolicySchema.default("open").optional(), textChunkLimit: z.number().int().positive().optional(), mediaMaxMb: z.number().positive().optional(), proxy: z.string().optional(), diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index c3c971fdd..f698d6caa 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -643,4 +643,494 @@ describe("createTelegramBot", () => { }); expect(sendPhotoSpy).not.toHaveBeenCalled(); }); + + // groupPolicy tests + it("blocks all group messages when groupPolicy is 'disabled'", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + telegram: { + groupPolicy: "disabled", + allowFrom: ["123456789"], + }, + }); + + 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: "@clawdbot_bot hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + // Should NOT call getReplyFromConfig because groupPolicy is disabled + expect(replySpy).not.toHaveBeenCalled(); + }); + + it("blocks group messages from senders not in allowFrom when groupPolicy is 'allowlist'", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + telegram: { + groupPolicy: "allowlist", + allowFrom: ["123456789"], // Does not include sender 999999 + }, + }); + + 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: 999999, username: "notallowed" }, // Not in allowFrom + text: "@clawdbot_bot hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).not.toHaveBeenCalled(); + }); + + it("allows group messages from senders in allowFrom (by ID) when groupPolicy is 'allowlist'", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + telegram: { + groupPolicy: "allowlist", + allowFrom: ["123456789"], + groups: { "*": { requireMention: false } }, // Skip mention check + }, + }); + + 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" }, // In allowFrom + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("allows group messages from senders in allowFrom (by username) when groupPolicy is 'allowlist'", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + telegram: { + groupPolicy: "allowlist", + allowFrom: ["@testuser"], // By username + 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: 12345, username: "testuser" }, // Username matches @testuser + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("allows group messages from telegram:-prefixed allowFrom entries when groupPolicy is 'allowlist'", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + telegram: { + groupPolicy: "allowlist", + allowFrom: ["telegram:77112533"], + 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: 77112533, username: "mneves" }, + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("allows group messages from tg:-prefixed allowFrom entries case-insensitively when groupPolicy is 'allowlist'", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + telegram: { + groupPolicy: "allowlist", + allowFrom: ["TG:77112533"], + 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: 77112533, username: "mneves" }, + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("allows all group messages when groupPolicy is 'open' (default)", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + telegram: { + // groupPolicy not set, should default to "open" + 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: 999999, username: "random" }, // Random sender + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("matches usernames case-insensitively when groupPolicy is 'allowlist'", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + telegram: { + groupPolicy: "allowlist", + allowFrom: ["@TestUser"], // Uppercase in config + 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: 12345, username: "testuser" }, // Lowercase in message + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("allows direct messages regardless of groupPolicy", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + telegram: { + groupPolicy: "disabled", // Even with disabled, DMs should work + allowFrom: ["123456789"], + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: 123456789, type: "private" }, // Direct message + from: { id: 123456789, username: "testuser" }, + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("allows direct messages with tg/Telegram-prefixed allowFrom entries", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + telegram: { + allowFrom: [" TG:123456789 "], + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: 123456789, type: "private" }, // Direct message + from: { id: 123456789, username: "testuser" }, + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("allows direct messages with telegram:-prefixed allowFrom entries", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + telegram: { + allowFrom: ["telegram:123456789"], + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: 123456789, type: "private" }, + from: { id: 123456789, username: "testuser" }, + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("allows group messages with wildcard in allowFrom when groupPolicy is 'allowlist'", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + telegram: { + groupPolicy: "allowlist", + allowFrom: ["*"], // Wildcard allows everyone + 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: 999999, username: "random" }, // Random sender, but wildcard allows + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("blocks group messages with no sender ID when groupPolicy is 'allowlist'", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + telegram: { + groupPolicy: "allowlist", + allowFrom: ["123456789"], + }, + }); + + 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" }, + // No `from` field (e.g., channel post or anonymous admin) + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).not.toHaveBeenCalled(); + }); + + it("matches telegram:-prefixed allowFrom entries in group allowlist", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + telegram: { + groupPolicy: "allowlist", + allowFrom: ["telegram:123456789"], // Prefixed format + 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" }, // Matches after stripping prefix + text: "hello from prefixed user", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + // Should call reply because sender ID matches after stripping telegram: prefix + expect(replySpy).toHaveBeenCalled(); + }); + + it("matches tg:-prefixed allowFrom entries case-insensitively in group allowlist", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + telegram: { + groupPolicy: "allowlist", + allowFrom: ["TG:123456789"], // Prefixed format (case-insensitive) + 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" }, // Matches after stripping tg: prefix + text: "hello from prefixed user", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + // Should call reply because sender ID matches after stripping tg: prefix + expect(replySpy).toHaveBeenCalled(); + }); }); diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index c9ead4cd4..aaf179d42 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -86,6 +86,14 @@ 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 replyToMode = opts.replyToMode ?? cfg.telegram?.replyToMode ?? "off"; const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions"; @@ -127,14 +135,10 @@ export function createTelegramBot(opts: TelegramBotOptions) { }; // allowFrom for direct chats - if (!isGroup && Array.isArray(allowFrom) && allowFrom.length > 0) { + if (!isGroup && normalizedAllowFrom.length > 0) { const candidate = String(chatId); - const allowed = allowFrom.map(String); - const allowedWithPrefix = allowFrom.map((v) => `telegram:${String(v)}`); const permitted = - allowed.includes(candidate) || - allowedWithPrefix.includes(`telegram:${candidate}`) || - allowed.includes("*"); + hasAllowFromWildcard || normalizedAllowFrom.includes(candidate); if (!permitted) { logVerbose( `Blocked unauthorized telegram sender ${candidate} (not in allowFrom)`, @@ -144,21 +148,18 @@ export function createTelegramBot(opts: TelegramBotOptions) { } const botUsername = primaryCtx.me?.username?.toLowerCase(); - const allowFromList = Array.isArray(allowFrom) - ? allowFrom.map((entry) => String(entry).trim()).filter(Boolean) - : []; + 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 || - allowFromList.includes("*") || + hasAllowFromWildcard || (senderId && allowFromList.includes(senderId)) || - (senderId && allowFromList.includes(`telegram:${senderId}`)) || (senderUsername && - allowFromList.some( + normalizedAllowFromLower.some( (entry) => - entry.toLowerCase() === senderUsername.toLowerCase() || - entry.toLowerCase() === `@${senderUsername.toLowerCase()}`, + entry === senderUsernameLower || entry === `@${senderUsernameLower}`, )); const wasMentioned = (Boolean(botUsername) && hasBotMention(msg, botUsername)) || @@ -350,10 +351,47 @@ export function createTelegramBot(opts: TelegramBotOptions) { const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup"; - // Group policy check - skip disallowed groups early if (isGroup) { - const groupPolicy = resolveGroupPolicy(chatId); - if (groupPolicy.allowlistEnabled && !groupPolicy.allowed) { + // 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 + const groupPolicy = cfg.telegram?.groupPolicy ?? "open"; + if (groupPolicy === "disabled") { + logVerbose(`Blocked telegram group message (groupPolicy: disabled)`); + return; + } + if (groupPolicy === "allowlist") { + // For allowlist mode, the sender (msg.from.id) must be in allowFrom + const senderId = msg.from?.id; + if (senderId == null) { + logVerbose( + `Blocked telegram group message (no sender ID, groupPolicy: allowlist)`, + ); + 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 (!hasAllowFromWildcard && !senderIdAllowed && !usernameAllowed) { + logVerbose( + `Blocked telegram group message from ${senderId} (groupPolicy: allowlist)`, + ); + return; + } + } + + // Group allowlist based on configured group IDs. + const groupAllowlist = resolveGroupPolicy(chatId); + if (groupAllowlist.allowlistEnabled && !groupAllowlist.allowed) { logger.info( { chatId, title: msg.chat.title, reason: "not-allowed" }, "skipping group message", diff --git a/src/web/inbound.ts b/src/web/inbound.ts index 2043b0d39..0261291c1 100644 --- a/src/web/inbound.ts +++ b/src/web/inbound.ts @@ -176,16 +176,44 @@ export async function monitorWebInbox(options: { 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; + const normalizedAllowFrom = + allowFrom && allowFrom.length > 0 ? allowFrom.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 + const groupPolicy = cfg.whatsapp?.groupPolicy ?? "open"; + if (group && groupPolicy === "disabled") { + logVerbose(`Blocked group message (groupPolicy: disabled)`); + continue; + } + 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 + const senderAllowed = + hasWildcard || + (senderE164 != null && normalizedAllowFrom.includes(senderE164)); + if (!senderAllowed) { + logVerbose( + `Blocked group message from ${senderE164 ?? "unknown sender"} (groupPolicy: allowlist)`, + ); + continue; + } + } + + // DM allowlist filtering (unchanged behavior) const allowlistEnabled = !group && Array.isArray(allowFrom) && allowFrom.length > 0; if (!isSamePhone && allowlistEnabled) { const candidate = from; - const allowedList = allowFrom.map(normalizeE164); - if (!allowFrom.includes("*") && !allowedList.includes(candidate)) { + if (!hasWildcard && !normalizedAllowFrom.includes(candidate)) { logVerbose( `Blocked unauthorized sender ${candidate} (not in allowFrom list)`, ); - continue; // Skip processing entirely + continue; } } diff --git a/src/web/monitor-inbox.test.ts b/src/web/monitor-inbox.test.ts index 3f285b6b0..02af9d057 100644 --- a/src/web/monitor-inbox.test.ts +++ b/src/web/monitor-inbox.test.ts @@ -670,6 +670,175 @@ describe("web monitor inbox", () => { await listener.close(); }); + it("blocks all group messages when groupPolicy is 'disabled'", async () => { + mockLoadConfig.mockReturnValue({ + whatsapp: { + allowFrom: ["+1234"], + groupPolicy: "disabled", + }, + 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-disabled", + fromMe: false, + remoteJid: "11111@g.us", + participant: "999@s.whatsapp.net", + }, + message: { conversation: "group message should be blocked" }, + }, + ], + }; + + sock.ev.emit("messages.upsert", upsert); + await new Promise((resolve) => setImmediate(resolve)); + + // Should NOT call onMessage because groupPolicy is disabled + expect(onMessage).not.toHaveBeenCalled(); + + await listener.close(); + }); + + it("blocks group messages from senders not in allowFrom when groupPolicy is 'allowlist'", async () => { + mockLoadConfig.mockReturnValue({ + whatsapp: { + allowFrom: ["+1234"], // Does not include +999 + 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-blocked", + fromMe: false, + remoteJid: "11111@g.us", + participant: "999@s.whatsapp.net", + }, + message: { conversation: "unauthorized group sender" }, + }, + ], + }; + + sock.ev.emit("messages.upsert", upsert); + await new Promise((resolve) => setImmediate(resolve)); + + // Should NOT call onMessage because sender +999 not in allowFrom + expect(onMessage).not.toHaveBeenCalled(); + + await listener.close(); + }); + + it("allows group messages from senders in allowFrom when groupPolicy is 'allowlist'", async () => { + mockLoadConfig.mockReturnValue({ + whatsapp: { + allowFrom: ["+15551234567"], // Includes the sender + 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-allowed", + fromMe: false, + remoteJid: "11111@g.us", + participant: "15551234567@s.whatsapp.net", + }, + message: { conversation: "authorized group sender" }, + }, + ], + }; + + sock.ev.emit("messages.upsert", upsert); + await new Promise((resolve) => setImmediate(resolve)); + + // Should call onMessage because sender is in allowFrom + expect(onMessage).toHaveBeenCalledTimes(1); + const payload = onMessage.mock.calls[0][0]; + expect(payload.chatType).toBe("group"); + expect(payload.senderE164).toBe("+15551234567"); + + await listener.close(); + }); + + it("allows all group senders with wildcard in groupPolicy allowlist", async () => { + mockLoadConfig.mockReturnValue({ + whatsapp: { + allowFrom: ["*"], // Wildcard allows everyone + 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-wildcard-test", + fromMe: false, + remoteJid: "22222@g.us", + participant: "9999999999@s.whatsapp.net", // Random sender + }, + message: { conversation: "wildcard group sender" }, + }, + ], + }; + + sock.ev.emit("messages.upsert", upsert); + await new Promise((resolve) => setImmediate(resolve)); + + // Should call onMessage because wildcard allows all senders + expect(onMessage).toHaveBeenCalledTimes(1); + const payload = onMessage.mock.calls[0][0]; + expect(payload.chatType).toBe("group"); + + await listener.close(); + }); + it("allows messages from senders in allowFrom list", async () => { mockLoadConfig.mockReturnValue({ whatsapp: {