feat(whatsapp,telegram): add groupPolicy config option (#216)

Co-authored-by: Marcus Neves <conhecendo.contato@gmail.com>
Co-authored-by: Shadow <hi@shadowing.dev>
This commit is contained in:
Marcus Neves
2026-01-06 01:41:19 -03:00
committed by GitHub
parent f6d9d3ce67
commit 9ab0b88ac6
10 changed files with 917 additions and 21 deletions

View File

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

View File

@@ -16,6 +16,33 @@ Clawdbot treats group chats consistently across surfaces: WhatsApp, Telegram, Di
- UI labels use `displayName` when available, formatted as `surface:<token>`.
- `#room` is reserved for rooms/channels; group chats use `g-<slug>` (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."*"`.

View File

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

View File

@@ -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:<chatId>`. 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 its added to a chat; no pre-history access.

View File

@@ -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<string | number>;
/**
* 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;

View File

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

View File

@@ -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<string, unknown>,
) => Promise<void>;
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<string, unknown>,
) => Promise<void>;
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<string, unknown>,
) => Promise<void>;
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<string, unknown>,
) => Promise<void>;
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<string, unknown>,
) => Promise<void>;
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<string, unknown>,
) => Promise<void>;
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<string, unknown>,
) => Promise<void>;
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<string, unknown>,
) => Promise<void>;
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<string, unknown>,
) => Promise<void>;
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<string, unknown>,
) => Promise<void>;
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<string, unknown>,
) => Promise<void>;
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<string, unknown>,
) => Promise<void>;
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<string, unknown>,
) => Promise<void>;
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<string, unknown>,
) => Promise<void>;
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<string, unknown>,
) => Promise<void>;
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();
});
});

View File

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

View File

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

View File

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