feat: unify group policy allowlists

This commit is contained in:
Peter Steinberger
2026-01-06 06:40:42 +00:00
parent 51e8bbd2a8
commit dbb51006cd
23 changed files with 729 additions and 88 deletions

View File

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

View File

@@ -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.<id>.slug`: optional friendly slug used for display names.

View File

@@ -11,7 +11,7 @@ Note: `routing.groupChat.mentionPatterns` is now used by Telegram/Discord/Slack/
## Whats implemented (2025-12-03)
- Activation modes: `mention` (default) or `always`. `mention` requires a ping (real WhatsApp @-mentions via `mentionedJids`, regex patterns, or the bots E.164 anywhere in the text). `always` wakes the agent on every message but it should reply only when it can add meaningful value; otherwise it returns the silent token `NO_REPLY`. 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:<jid>` so commands such as `/verbose on` or `/think:high` are scoped to that group; personal DM state is untouched. Heartbeats are skipped for group threads.
- Context injection: last N (default 50) group messages are prefixed under `[Chat messages since your last reply - for context]`, with the triggering line under `[Current message - respond to this]`.
- Sender surfacing: every group batch now ends with `[from: Sender Name (+E164)]` so Pi knows who is speaking.

View File

@@ -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:<id>` session keys (rooms/channels use `surface:channel:<id>`).
@@ -16,32 +16,53 @@ 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:
## 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.<id>.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."*"`.

View File

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

View File

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

View File

@@ -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:<channelId>` sessions.
- Slash commands use `slack:slash:<userId>` 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:<id>` for DMs

View File

@@ -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:<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`, `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 its 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.<chatId>.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",

View File

@@ -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:<jid>` 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`

View File

@@ -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<string | number>;
/** Optional allowlist for Telegram group senders (user ids or usernames). */
groupAllowFrom?: 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
* - "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<string | number>;
/** Optional allowlist for Signal group senders (E.164). */
groupAllowFrom?: Array<string | number>;
/**
* 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<string | number>;
/** Optional allowlist for group senders or chat_id targets. */
groupAllowFrom?: Array<string | number>;
/**
* 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. */

View File

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

View File

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

View File

@@ -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<string | number> | undefined;
channelId: string;

View File

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

View File

@@ -52,6 +52,7 @@ export type MonitorIMessageOpts = {
cliPath?: string;
dbPath?: string;
allowFrom?: Array<string | number>;
groupAllowFrom?: Array<string | number>;
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 &&

View File

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

View File

@@ -55,6 +55,7 @@ export type MonitorSignalOpts = {
ignoreStories?: boolean;
sendReadReceipts?: boolean;
allowFrom?: Array<string | number>;
groupAllowFrom?: Array<string | number>;
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;

55
src/slack/monitor.test.ts Normal file
View File

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

View File

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

View File

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

View File

@@ -86,6 +86,7 @@ export type TelegramBotOptions = {
runtime?: RuntimeEnv;
requireMention?: boolean;
allowFrom?: Array<string | number>;
groupAllowFrom?: Array<string | number>;
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<string | number>) => {
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<typeof normalizeAllowFrom>;
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)`,
);

View File

@@ -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)`,
);

View File

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