feat: unify group policy allowlists
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -11,7 +11,7 @@ Note: `routing.groupChat.mentionPatterns` is now used by Telegram/Discord/Slack/
|
||||
|
||||
## What’s implemented (2025-12-03)
|
||||
- Activation modes: `mention` (default) or `always`. `mention` requires a ping (real WhatsApp @-mentions via `mentionedJids`, regex patterns, or the bot’s E.164 anywhere in the text). `always` wakes the agent on every message but it should reply only when it can add meaningful value; otherwise it returns the silent token `NO_REPLY`. Defaults can be set in config (`whatsapp.groups`) and overridden per group via `/activation`. When `whatsapp.groups` is set, it also acts as a group allowlist (include `"*"` to allow all).
|
||||
- Group allowlist: `whatsapp.groups` gates which group JIDs are allowed; `whatsapp.allowFrom` still gates participants for direct chats.
|
||||
- Group policy: `whatsapp.groupPolicy` controls whether group messages are accepted (`open|disabled|allowlist`). `allowlist` uses `whatsapp.groupAllowFrom` (fallback: explicit `whatsapp.allowFrom`).
|
||||
- Per-group sessions: session keys look like `whatsapp:group:<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.
|
||||
|
||||
@@ -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."*"`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 it’s added to a chat; no pre-history access.
|
||||
@@ -37,7 +39,7 @@ Status: ready for bot-mode use with grammY (long-polling by default; webhook sup
|
||||
- Library: grammY is the only client for send + gateway (fetch fallback removed); grammY throttler is enabled by default to stay under Bot API limits.
|
||||
- Inbound normalization: maps Bot API updates to `MsgContext` with `Surface: "telegram"`, `ChatType: direct|group`, `SenderName`, `MediaPath`/`MediaType` when attachments arrive, `Timestamp`, and reply-to metadata (`ReplyToId`, `ReplyToBody`, `ReplyToSender`) when the user replies; reply context is appended to `Body` as a `[Replying to ...]` block (includes `id:` when available); groups require @bot mention or a `routing.groupChat.mentionPatterns` match by default (override per chat in config).
|
||||
- Outbound: text and media (photo/video/audio/document) with optional caption; chunked to limits. Typing cue sent best-effort.
|
||||
- Config: `TELEGRAM_BOT_TOKEN` env or `telegram.botToken` required; `telegram.groups` (group allowlist + mention defaults), `telegram.allowFrom`, `telegram.mediaMaxMb`, `telegram.replyToMode`, `telegram.proxy`, `telegram.webhookSecret`, `telegram.webhookUrl`, `telegram.webhookPath` supported.
|
||||
- Config: `TELEGRAM_BOT_TOKEN` env or `telegram.botToken` required; `telegram.groups` (group allowlist + mention defaults), `telegram.allowFrom`, `telegram.groupAllowFrom`, `telegram.groupPolicy`, `telegram.mediaMaxMb`, `telegram.replyToMode`, `telegram.proxy`, `telegram.webhookSecret`, `telegram.webhookUrl`, `telegram.webhookPath` supported.
|
||||
- Ack reactions are controlled globally via `messages.ackReaction` + `messages.ackReactionScope`.
|
||||
- Mention gating precedence (most specific wins): `telegram.groups.<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",
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
55
src/signal/monitor.test.ts
Normal file
55
src/signal/monitor.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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
55
src/slack/monitor.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)`,
|
||||
);
|
||||
|
||||
@@ -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)`,
|
||||
);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user