From c405c96c9dc60215406d6dd881fd953eb77f033c Mon Sep 17 00:00:00 2001 From: Cameron Date: Wed, 11 Feb 2026 15:20:01 -0800 Subject: [PATCH] feat: add per-group allowedUsers filtering for all channels (#283) --- docs/configuration.md | 34 ++++++ docs/discord-setup.md | 23 ++++ src/channels/discord.ts | 9 +- src/channels/group-mode.test.ts | 100 +++++++++++++++++- src/channels/group-mode.ts | 44 +++++++- src/channels/signal.ts | 1 + src/channels/signal/group-gating.test.ts | 69 ++++++++++++ src/channels/signal/group-gating.ts | 24 ++++- src/channels/slack.ts | 16 ++- src/channels/telegram-group-gating.test.ts | 67 ++++++++++++ src/channels/telegram-group-gating.ts | 24 ++++- src/channels/telegram.ts | 1 + .../whatsapp/inbound/group-gating.test.ts | 67 ++++++++++++ src/channels/whatsapp/inbound/group-gating.ts | 24 ++++- src/channels/whatsapp/index.ts | 1 + src/config/types.ts | 4 +- 16 files changed, 492 insertions(+), 16 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 050852f..d45e8c6 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -271,6 +271,7 @@ Use `groups..mode` to control how each group/channel behaves: - `open`: process and respond to all messages (default behavior) - `listen`: process all messages for context/memory, only respond when mentioned - `mention-only`: drop group messages unless the bot is mentioned +- `disabled`: drop all group messages unconditionally, even if the bot is mentioned You can also use `*` as a wildcard default: @@ -283,6 +284,39 @@ channels: "-1009876543210": { mode: mention-only } ``` +### Per-Group User Filtering + +Use `groups..allowedUsers` to restrict which users can trigger the bot in a specific group. When set, messages from users not in the list are silently dropped before reaching the agent (no token cost). + +```yaml +channels: + discord: + groups: + "*": + mode: mention-only + allowedUsers: + - "123456789012345678" # Only this user triggers the bot + "TESTING_CHANNEL": + mode: open + # No allowedUsers -- anyone can interact in this channel +``` + +Resolution follows the same priority as `mode`: specific channel/group ID > guild/server ID > `*` wildcard. Omitting `allowedUsers` means all users are allowed. + +This works across all channels (Discord, Telegram, Slack, Signal, WhatsApp). + +### Finding Group IDs + +Each channel uses different identifiers for groups: + +- **Telegram**: Group IDs are negative numbers (e.g., `-1001234567890`). To find one: add `@userinfobot` to the group, or forward a group message to `@userinfobot`. You can also check the bot logs -- group IDs are printed when the bot receives a message. +- **Discord**: Channel and server IDs are numeric strings (e.g., `123456789012345678`). Enable **Developer Mode** in Discord settings (User Settings > Advanced > Developer Mode), then right-click any channel or server and select "Copy Channel ID" or "Copy Server ID". +- **Slack**: Channel IDs start with `C` (e.g., `C01ABC23DEF`). Right-click a channel > "View channel details" > scroll to the bottom to find the Channel ID. +- **WhatsApp**: Group JIDs look like `120363123456@g.us`. These appear in the bot logs when the bot receives a group message. +- **Signal**: Group IDs appear in the bot logs on first group message. Use the `group:` prefix in config (e.g., `group:abc123`). + +**Tip**: If you don't know the ID yet, start the bot with `"*": { mode: mention-only }`, send a message in the group, and check the logs for the ID. + Deprecated formats are still supported and auto-normalized with warnings: - `listeningGroups: ["id"]` -> `groups: { "id": { mode: listen } }` diff --git a/docs/discord-setup.md b/docs/discord-setup.md index 6f95a12..4be55d8 100644 --- a/docs/discord-setup.md +++ b/docs/discord-setup.md @@ -149,6 +149,7 @@ Three modes are available: - **`open`** -- Bot responds to all messages in the channel (default) - **`listen`** -- Bot processes all messages for context/memory, but only responds when @mentioned - **`mention-only`** -- Bot completely ignores messages unless @mentioned (cheapest option -- messages are dropped at the adapter level before reaching the agent) +- **`disabled`** -- Bot drops all messages in the channel unconditionally, even if @mentioned ### Configuring group modes @@ -167,6 +168,8 @@ channels: Mode resolution priority: channel ID > guild ID > `*` wildcard > `open` (built-in default). +To find channel and server IDs: enable **Developer Mode** in Discord settings (User Settings > Advanced > Developer Mode), then right-click any channel or server and select "Copy Channel ID" or "Copy Server ID". + ### Channel allowlisting If you define `groups` with specific IDs and **do not** include a `*` wildcard, the bot will only be active in those listed channels. Messages in unlisted channels are silently dropped -- they never reach the agent and consume no tokens. @@ -183,6 +186,26 @@ channels: This is the recommended approach when you want to restrict the bot to specific channels. +### Per-group user filtering + +Use `allowedUsers` within a group entry to restrict which Discord users can trigger the bot. Messages from other users are silently dropped before reaching the agent. + +```yaml +channels: + discord: + token: "your-bot-token" + groups: + "*": + mode: mention-only + allowedUsers: + - "YOUR_DISCORD_USER_ID" # Only you can trigger the bot + "TESTING_CHANNEL": + mode: open + # No allowedUsers -- anyone can interact here +``` + +Find your Discord user ID: enable Developer Mode in Discord settings, then right-click your name and select "Copy User ID". + ## Multiple Bots on Discord If you run multiple agents in a [multi-agent configuration](./configuration.md#multi-agent-configuration), each with their own Discord adapter, there are two scenarios to consider. diff --git a/src/channels/discord.ts b/src/channels/discord.ts index 5f23d0a..2739f1c 100644 --- a/src/channels/discord.ts +++ b/src/channels/discord.ts @@ -11,7 +11,7 @@ import type { DmPolicy } from '../pairing/types.js'; import { isUserAllowed, upsertPairingRequest } from '../pairing/store.js'; import { buildAttachmentPath, downloadToFile } from './attachments.js'; import { HELP_TEXT } from '../core/commands.js'; -import { isGroupAllowed, resolveGroupMode, type GroupModeConfig } from './group-mode.js'; +import { isGroupAllowed, isGroupUserAllowed, resolveGroupMode, type GroupModeConfig } from './group-mode.js'; // Dynamic import to avoid requiring Discord deps if not used let Client: typeof import('discord.js').Client; @@ -256,7 +256,14 @@ Ask the bot owner to approve with: return; } + if (!isGroupUserAllowed(this.config.groups, keys, userId)) { + return; // User not in group allowedUsers -- silent drop + } + const mode = resolveGroupMode(this.config.groups, keys, 'open'); + if (mode === 'disabled') { + return; // Groups disabled for this channel -- silent drop + } if (mode === 'mention-only' && !wasMentioned) { return; // Mention required but not mentioned -- silent drop } diff --git a/src/channels/group-mode.test.ts b/src/channels/group-mode.test.ts index 8f685d5..d1b60b6 100644 --- a/src/channels/group-mode.test.ts +++ b/src/channels/group-mode.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { isGroupAllowed, resolveGroupMode, type GroupsConfig } from './group-mode.js'; +import { isGroupAllowed, isGroupUserAllowed, resolveGroupAllowedUsers, resolveGroupMode, type GroupsConfig } from './group-mode.js'; describe('group-mode helpers', () => { describe('isGroupAllowed', () => { @@ -7,8 +7,8 @@ describe('group-mode helpers', () => { expect(isGroupAllowed(undefined, ['group-1'])).toBe(true); }); - it('allows when groups config is empty', () => { - expect(isGroupAllowed({}, ['group-1'])).toBe(true); + it('rejects when groups config is empty (explicit empty allowlist)', () => { + expect(isGroupAllowed({}, ['group-1'])).toBe(false); }); it('allows via wildcard', () => { @@ -45,6 +45,11 @@ describe('group-mode helpers', () => { expect(resolveGroupMode(groups, ['group-1'], 'open')).toBe('listen'); }); + it('resolves disabled mode', () => { + const groups: GroupsConfig = { '*': { mode: 'disabled' } }; + expect(resolveGroupMode(groups, ['group-1'], 'open')).toBe('disabled'); + }); + it('maps legacy requireMention=true to mention-only', () => { const groups: GroupsConfig = { 'group-1': { requireMention: true } }; expect(resolveGroupMode(groups, ['group-1'], 'open')).toBe('mention-only'); @@ -74,4 +79,93 @@ describe('group-mode helpers', () => { expect(resolveGroupMode(groups, ['chat-2', 'server-1'], 'mention-only')).toBe('open'); }); }); + + describe('resolveGroupAllowedUsers', () => { + it('returns undefined when groups config is missing', () => { + expect(resolveGroupAllowedUsers(undefined, ['group-1'])).toBeUndefined(); + }); + + it('returns undefined when no allowedUsers configured', () => { + const groups: GroupsConfig = { 'group-1': { mode: 'open' } }; + expect(resolveGroupAllowedUsers(groups, ['group-1'])).toBeUndefined(); + }); + + it('returns allowedUsers from specific key', () => { + const groups: GroupsConfig = { + 'group-1': { mode: 'open', allowedUsers: ['user-a', 'user-b'] }, + }; + expect(resolveGroupAllowedUsers(groups, ['group-1'])).toEqual(['user-a', 'user-b']); + }); + + it('returns allowedUsers from wildcard', () => { + const groups: GroupsConfig = { + '*': { mode: 'mention-only', allowedUsers: ['user-a'] }, + }; + expect(resolveGroupAllowedUsers(groups, ['group-1'])).toEqual(['user-a']); + }); + + it('prefers specific key over wildcard', () => { + const groups: GroupsConfig = { + '*': { mode: 'mention-only', allowedUsers: ['wildcard-user'] }, + 'group-1': { mode: 'open', allowedUsers: ['specific-user'] }, + }; + expect(resolveGroupAllowedUsers(groups, ['group-1'])).toEqual(['specific-user']); + }); + + it('uses first matching key in priority order', () => { + const groups: GroupsConfig = { + 'chat-1': { mode: 'open', allowedUsers: ['chat-user'] }, + 'server-1': { mode: 'open', allowedUsers: ['server-user'] }, + }; + expect(resolveGroupAllowedUsers(groups, ['chat-1', 'server-1'])).toEqual(['chat-user']); + expect(resolveGroupAllowedUsers(groups, ['chat-2', 'server-1'])).toEqual(['server-user']); + }); + }); + + describe('isGroupUserAllowed', () => { + it('allows all users when no groups config', () => { + expect(isGroupUserAllowed(undefined, ['group-1'], 'any-user')).toBe(true); + }); + + it('allows all users when no allowedUsers configured', () => { + const groups: GroupsConfig = { 'group-1': { mode: 'open' } }; + expect(isGroupUserAllowed(groups, ['group-1'], 'any-user')).toBe(true); + }); + + it('allows user in the list', () => { + const groups: GroupsConfig = { + 'group-1': { mode: 'open', allowedUsers: ['user-a', 'user-b'] }, + }; + expect(isGroupUserAllowed(groups, ['group-1'], 'user-a')).toBe(true); + expect(isGroupUserAllowed(groups, ['group-1'], 'user-b')).toBe(true); + }); + + it('rejects user not in the list', () => { + const groups: GroupsConfig = { + 'group-1': { mode: 'open', allowedUsers: ['user-a'] }, + }; + expect(isGroupUserAllowed(groups, ['group-1'], 'user-c')).toBe(false); + }); + + it('uses wildcard allowedUsers as fallback', () => { + const groups: GroupsConfig = { + '*': { mode: 'mention-only', allowedUsers: ['owner'] }, + }; + expect(isGroupUserAllowed(groups, ['group-1'], 'owner')).toBe(true); + expect(isGroupUserAllowed(groups, ['group-1'], 'stranger')).toBe(false); + }); + + it('specific group overrides wildcard allowedUsers', () => { + const groups: GroupsConfig = { + '*': { mode: 'mention-only', allowedUsers: ['owner'] }, + 'open-group': { mode: 'open', allowedUsers: ['guest'] }, + }; + // open-group has its own list + expect(isGroupUserAllowed(groups, ['open-group'], 'guest')).toBe(true); + expect(isGroupUserAllowed(groups, ['open-group'], 'owner')).toBe(false); + // other groups fall back to wildcard + expect(isGroupUserAllowed(groups, ['other-group'], 'owner')).toBe(true); + expect(isGroupUserAllowed(groups, ['other-group'], 'guest')).toBe(false); + }); + }); }); diff --git a/src/channels/group-mode.ts b/src/channels/group-mode.ts index 8cacd95..dcca1c5 100644 --- a/src/channels/group-mode.ts +++ b/src/channels/group-mode.ts @@ -2,10 +2,12 @@ * Shared group mode helpers across channel adapters. */ -export type GroupMode = 'open' | 'listen' | 'mention-only'; +export type GroupMode = 'open' | 'listen' | 'mention-only' | 'disabled'; export interface GroupModeConfig { mode?: GroupMode; + /** Only process group messages from these user IDs. Omit to allow all users. */ + allowedUsers?: string[]; /** * @deprecated Use mode: "mention-only" (true) or "open" (false). */ @@ -16,7 +18,7 @@ export type GroupsConfig = Record; function coerceMode(config?: GroupModeConfig): GroupMode | undefined { if (!config) return undefined; - if (config.mode === 'open' || config.mode === 'listen' || config.mode === 'mention-only') { + if (config.mode === 'open' || config.mode === 'listen' || config.mode === 'mention-only' || config.mode === 'disabled') { return config.mode; } if (typeof config.requireMention === 'boolean') { @@ -33,11 +35,47 @@ function coerceMode(config?: GroupModeConfig): GroupMode | undefined { */ export function isGroupAllowed(groups: GroupsConfig | undefined, keys: string[]): boolean { if (!groups) return true; - if (Object.keys(groups).length === 0) return true; + if (Object.keys(groups).length === 0) return false; if (Object.hasOwn(groups, '*')) return true; return keys.some((key) => Object.hasOwn(groups, key)); } +/** + * Resolve the effective allowedUsers list for a group/channel. + * + * Priority: + * 1. First matching key in provided order + * 2. Wildcard "*" + * 3. undefined (no user filtering) + */ +export function resolveGroupAllowedUsers( + groups: GroupsConfig | undefined, + keys: string[], +): string[] | undefined { + if (groups) { + for (const key of keys) { + if (groups[key]?.allowedUsers) return groups[key].allowedUsers; + } + if (groups['*']?.allowedUsers) return groups['*'].allowedUsers; + } + return undefined; +} + +/** + * Check whether a user is allowed to trigger the bot in a group. + * + * Returns true when no allowedUsers list is configured (open to all). + */ +export function isGroupUserAllowed( + groups: GroupsConfig | undefined, + keys: string[], + userId: string, +): boolean { + const allowed = resolveGroupAllowedUsers(groups, keys); + if (!allowed) return true; + return allowed.includes(userId); +} + /** * Resolve effective mode for a group/channel. * diff --git a/src/channels/signal.ts b/src/channels/signal.ts index e0bf47a..48617cc 100644 --- a/src/channels/signal.ts +++ b/src/channels/signal.ts @@ -771,6 +771,7 @@ This code expires in 1 hour.`; const gatingResult = applySignalGroupGating({ text: messageText || '', groupId: groupInfo.groupId, + senderId: source, mentions, quote, selfPhoneNumber: this.config.phoneNumber, diff --git a/src/channels/signal/group-gating.test.ts b/src/channels/signal/group-gating.test.ts index 3fca024..20e82ec 100644 --- a/src/channels/signal/group-gating.test.ts +++ b/src/channels/signal/group-gating.test.ts @@ -136,6 +136,21 @@ describe('applySignalGroupGating', () => { expect(result.reason).toBe('mention-required'); }); + it('blocks all messages in disabled mode', () => { + const result = applySignalGroupGating({ + text: 'Hello everyone!', + groupId: 'test-group', + selfPhoneNumber, + mentions: [{ number: selfPhoneNumber, start: 0, length: 5 }], + groupsConfig: { + '*': { mode: 'disabled' }, + }, + }); + expect(result.shouldProcess).toBe(false); + expect(result.mode).toBe('disabled'); + expect(result.reason).toBe('groups-disabled'); + }); + it('supports listen mode', () => { const result = applySignalGroupGating({ text: 'Hello everyone!', @@ -152,6 +167,60 @@ describe('applySignalGroupGating', () => { }); }); + describe('per-group allowedUsers', () => { + it('allows user in the allowedUsers list', () => { + const result = applySignalGroupGating({ + text: 'Hello', + groupId: 'test-group', + senderId: '+19876543210', + selfPhoneNumber, + groupsConfig: { + '*': { mode: 'open', allowedUsers: ['+19876543210'] }, + }, + }); + expect(result.shouldProcess).toBe(true); + }); + + it('blocks user not in the allowedUsers list', () => { + const result = applySignalGroupGating({ + text: 'Hello', + groupId: 'test-group', + senderId: '+10000000000', + selfPhoneNumber, + groupsConfig: { + '*': { mode: 'open', allowedUsers: ['+19876543210'] }, + }, + }); + expect(result.shouldProcess).toBe(false); + expect(result.reason).toBe('user-not-allowed'); + }); + + it('allows all users when no allowedUsers configured', () => { + const result = applySignalGroupGating({ + text: 'Hello', + groupId: 'test-group', + senderId: '+10000000000', + selfPhoneNumber, + groupsConfig: { + '*': { mode: 'open' }, + }, + }); + expect(result.shouldProcess).toBe(true); + }); + + it('skips user check when senderId is undefined', () => { + const result = applySignalGroupGating({ + text: 'Hello', + groupId: 'test-group', + selfPhoneNumber, + groupsConfig: { + '*': { mode: 'open', allowedUsers: ['+19876543210'] }, + }, + }); + expect(result.shouldProcess).toBe(true); + }); + }); + describe('group allowlist', () => { it('filters messages from groups not in allowlist', () => { const result = applySignalGroupGating({ diff --git a/src/channels/signal/group-gating.ts b/src/channels/signal/group-gating.ts index 5d9cab2..15747b9 100644 --- a/src/channels/signal/group-gating.ts +++ b/src/channels/signal/group-gating.ts @@ -4,7 +4,7 @@ * Filters group messages based on per-group mode and mention detection. */ -import { isGroupAllowed, resolveGroupMode, type GroupMode } from '../group-mode.js'; +import { isGroupAllowed, isGroupUserAllowed, resolveGroupMode, type GroupMode } from '../group-mode.js'; export interface SignalGroupConfig { mode?: GroupMode; @@ -44,6 +44,9 @@ export interface SignalGroupGatingParams { /** Bot's Signal UUID (if known) */ selfUuid?: string; + /** Sender identifier (phone number or UUID) for per-group allowedUsers check */ + senderId?: string; + /** Per-group configuration */ groupsConfig?: Record; @@ -81,7 +84,7 @@ export interface SignalGroupGatingResult { * @returns Gating decision */ export function applySignalGroupGating(params: SignalGroupGatingParams): SignalGroupGatingResult { - const { text, groupId, mentions, quote, selfPhoneNumber, selfUuid, groupsConfig, mentionPatterns } = params; + const { text, groupId, senderId, mentions, quote, selfPhoneNumber, selfUuid, groupsConfig, mentionPatterns } = params; const groupKeys = [groupId, `group:${groupId}`]; // Step 1: Check group allowlist (if groups config exists) @@ -93,9 +96,26 @@ export function applySignalGroupGating(params: SignalGroupGatingParams): SignalG }; } + // Step 1b: Per-group user allowlist + if (senderId && !isGroupUserAllowed(groupsConfig, groupKeys, senderId)) { + return { + shouldProcess: false, + mode: 'open', + reason: 'user-not-allowed', + }; + } + // Step 2: Resolve mode (default: open) const mode = resolveGroupMode(groupsConfig, groupKeys, 'open'); + if (mode === 'disabled') { + return { + shouldProcess: false, + mode, + reason: 'groups-disabled', + }; + } + // METHOD 1: Native Signal mentions array if (mentions && mentions.length > 0) { const selfDigits = selfPhoneNumber.replace(/\D/g, ''); diff --git a/src/channels/slack.ts b/src/channels/slack.ts index 0ed4a0a..bf83660 100644 --- a/src/channels/slack.ts +++ b/src/channels/slack.ts @@ -11,7 +11,7 @@ import { basename } from 'node:path'; import { buildAttachmentPath, downloadToFile } from './attachments.js'; import { parseCommand, HELP_TEXT } from '../core/commands.js'; import { markdownToSlackMrkdwn } from './slack-format.js'; -import { isGroupAllowed, resolveGroupMode, type GroupMode, type GroupModeConfig } from './group-mode.js'; +import { isGroupAllowed, isGroupUserAllowed, resolveGroupMode, type GroupMode, type GroupModeConfig } from './group-mode.js'; // Dynamic import to avoid requiring Slack deps if not used let App: typeof import('@slack/bolt').App; @@ -138,7 +138,13 @@ export class SlackAdapter implements ChannelAdapter { if (!this.isChannelAllowed(channelId)) { return; // Channel not in allowlist -- silent drop } + if (!isGroupUserAllowed(this.config.groups, [channelId], userId || '')) { + return; // User not in group allowedUsers -- silent drop + } mode = this.resolveChannelMode(channelId); + if (mode === 'disabled') { + return; // Groups disabled for this channel -- silent drop + } if (mode === 'mention-only') { // Non-mention message in channel that requires mentions. // The app_mention handler will process actual @mentions. @@ -178,10 +184,16 @@ export class SlackAdapter implements ChannelAdapter { } } - // Group gating: allowlist check (mention already satisfied by app_mention) + // Group gating: allowlist + mode + user check (mention already satisfied by app_mention) if (this.config.groups && !this.isChannelAllowed(channelId)) { return; // Channel not in allowlist -- silent drop } + if (this.resolveChannelMode(channelId) === 'disabled') { + return; // Groups disabled for this channel -- silent drop + } + if (!isGroupUserAllowed(this.config.groups, [channelId], userId)) { + return; // User not in group allowedUsers -- silent drop + } // Handle slash commands const command = parseCommand(text); diff --git a/src/channels/telegram-group-gating.test.ts b/src/channels/telegram-group-gating.test.ts index 24a7420..b15d740 100644 --- a/src/channels/telegram-group-gating.test.ts +++ b/src/channels/telegram-group-gating.test.ts @@ -81,6 +81,17 @@ describe('applyTelegramGroupGating', () => { expect(result.reason).toBe('mention-required'); }); + it('blocks all messages in disabled mode', () => { + const result = applyTelegramGroupGating(createParams({ + text: '@mybot hello', + entities: [{ type: 'mention', offset: 0, length: 6 }], + groupsConfig: { '*': { mode: 'disabled' } }, + })); + expect(result.shouldProcess).toBe(false); + expect(result.mode).toBe('disabled'); + expect(result.reason).toBe('groups-disabled'); + }); + it('supports listen mode (processes non-mention messages)', () => { const result = applyTelegramGroupGating(createParams({ text: 'hello', @@ -195,6 +206,62 @@ describe('applyTelegramGroupGating', () => { }); }); + describe('per-group allowedUsers', () => { + it('allows user in the allowedUsers list', () => { + const result = applyTelegramGroupGating(createParams({ + senderId: 'user-123', + text: '@mybot hello', + groupsConfig: { + '*': { mode: 'mention-only', allowedUsers: ['user-123', 'user-456'] }, + }, + })); + expect(result.shouldProcess).toBe(true); + }); + + it('blocks user not in the allowedUsers list', () => { + const result = applyTelegramGroupGating(createParams({ + senderId: 'user-999', + text: '@mybot hello', + groupsConfig: { + '*': { mode: 'open', allowedUsers: ['user-123'] }, + }, + })); + expect(result.shouldProcess).toBe(false); + expect(result.reason).toBe('user-not-allowed'); + }); + + it('allows all users when no allowedUsers configured', () => { + const result = applyTelegramGroupGating(createParams({ + senderId: 'anyone', + text: 'hello', + groupsConfig: { '*': { mode: 'open' } }, + })); + expect(result.shouldProcess).toBe(true); + }); + + it('uses specific group allowedUsers over wildcard', () => { + const result = applyTelegramGroupGating(createParams({ + senderId: 'vip', + text: 'hello', + groupsConfig: { + '*': { mode: 'open', allowedUsers: ['owner'] }, + '-1001234567890': { mode: 'open', allowedUsers: ['vip'] }, + }, + })); + expect(result.shouldProcess).toBe(true); + }); + + it('skips user check when senderId is undefined', () => { + const result = applyTelegramGroupGating(createParams({ + senderId: undefined, + text: 'hello', + groupsConfig: { '*': { mode: 'open', allowedUsers: ['user-123'] } }, + })); + // No senderId = skip user check (can't verify) + expect(result.shouldProcess).toBe(true); + }); + }); + describe('no groupsConfig (open mode)', () => { it('processes messages with mention when no config', () => { const result = applyTelegramGroupGating(createParams({ diff --git a/src/channels/telegram-group-gating.ts b/src/channels/telegram-group-gating.ts index 3aecf04..c74829b 100644 --- a/src/channels/telegram-group-gating.ts +++ b/src/channels/telegram-group-gating.ts @@ -11,7 +11,7 @@ * actively participate in?" */ -import { isGroupAllowed, resolveGroupMode, type GroupMode, type GroupModeConfig } from './group-mode.js'; +import { isGroupAllowed, isGroupUserAllowed, resolveGroupMode, type GroupMode, type GroupModeConfig } from './group-mode.js'; export interface TelegramGroupGatingParams { /** Message text */ @@ -26,6 +26,9 @@ export interface TelegramGroupGatingParams { /** Telegram message entities (for structured mention detection) */ entities?: { type: string; offset: number; length: number }[]; + /** Sender's user ID (for per-group allowedUsers check) */ + senderId?: string; + /** Per-group configuration */ groupsConfig?: Record; @@ -70,7 +73,7 @@ export interface TelegramGroupGatingResult { * if (!result.shouldProcess) return; */ export function applyTelegramGroupGating(params: TelegramGroupGatingParams): TelegramGroupGatingResult { - const { text, chatId, botUsername, entities, groupsConfig, mentionPatterns } = params; + const { text, chatId, senderId, botUsername, entities, groupsConfig, mentionPatterns } = params; // Step 1: Group allowlist if (!isGroupAllowed(groupsConfig, [chatId])) { @@ -81,9 +84,26 @@ export function applyTelegramGroupGating(params: TelegramGroupGatingParams): Tel }; } + // Step 1b: Per-group user allowlist + if (senderId && !isGroupUserAllowed(groupsConfig, [chatId], senderId)) { + return { + shouldProcess: false, + mode: 'open', + reason: 'user-not-allowed', + }; + } + // Step 2: Resolve mode (default: open) const mode = resolveGroupMode(groupsConfig, [chatId], 'open'); + if (mode === 'disabled') { + return { + shouldProcess: false, + mode, + reason: 'groups-disabled', + }; + } + // Step 3: Detect mentions const mention = detectTelegramMention({ text, botUsername, entities, mentionPatterns }); diff --git a/src/channels/telegram.ts b/src/channels/telegram.ts index 2844ce4..df7f70d 100644 --- a/src/channels/telegram.ts +++ b/src/channels/telegram.ts @@ -73,6 +73,7 @@ export class TelegramAdapter implements ChannelAdapter { const gatingResult = applyTelegramGroupGating({ text, chatId: String(ctx.chat.id), + senderId: ctx.from?.id ? String(ctx.from.id) : undefined, botUsername, entities: ctx.message?.entities?.map(e => ({ type: e.type, diff --git a/src/channels/whatsapp/inbound/group-gating.test.ts b/src/channels/whatsapp/inbound/group-gating.test.ts index 6035012..9185f57 100644 --- a/src/channels/whatsapp/inbound/group-gating.test.ts +++ b/src/channels/whatsapp/inbound/group-gating.test.ts @@ -78,6 +78,60 @@ describe('applyGroupGating', () => { }); }); + describe('per-group allowedUsers', () => { + it('allows sender in the allowedUsers list', () => { + const result = applyGroupGating(createParams({ + senderId: '+19876543210', + groupsConfig: { + '*': { mode: 'open', allowedUsers: ['+19876543210'] }, + }, + })); + expect(result.shouldProcess).toBe(true); + }); + + it('blocks sender not in the allowedUsers list', () => { + const result = applyGroupGating(createParams({ + senderId: '+10000000000', + groupsConfig: { + '*': { mode: 'open', allowedUsers: ['+19876543210'] }, + }, + })); + expect(result.shouldProcess).toBe(false); + expect(result.reason).toBe('user-not-allowed'); + }); + + it('allows all senders when no allowedUsers configured', () => { + const result = applyGroupGating(createParams({ + senderId: '+10000000000', + groupsConfig: { + '*': { mode: 'open' }, + }, + })); + expect(result.shouldProcess).toBe(true); + }); + + it('uses specific group allowedUsers over wildcard', () => { + const result = applyGroupGating(createParams({ + senderId: 'vip-user', + groupsConfig: { + '*': { mode: 'open', allowedUsers: ['owner'] }, + '120363123456@g.us': { mode: 'open', allowedUsers: ['vip-user'] }, + }, + })); + expect(result.shouldProcess).toBe(true); + }); + + it('skips user check when senderId is undefined', () => { + const result = applyGroupGating(createParams({ + senderId: undefined, + groupsConfig: { + '*': { mode: 'open', allowedUsers: ['someone'] }, + }, + })); + expect(result.shouldProcess).toBe(true); + }); + }); + describe('mode resolution', () => { it('allows when mentioned and requireMention=true', () => { const result = applyGroupGating(createParams({ @@ -130,6 +184,19 @@ describe('applyGroupGating', () => { expect(result.reason).toBe('mention-required'); }); + it('blocks all messages in disabled mode', () => { + const result = applyGroupGating(createParams({ + groupsConfig: { '*': { mode: 'disabled' } }, + msg: createMessage({ + body: '@bot hello', + mentionedJids: ['15551234567@s.whatsapp.net'], + }), + })); + expect(result.shouldProcess).toBe(false); + expect(result.mode).toBe('disabled'); + expect(result.reason).toBe('groups-disabled'); + }); + it('supports listen mode', () => { const result = applyGroupGating(createParams({ groupsConfig: { '*': { mode: 'listen' } }, diff --git a/src/channels/whatsapp/inbound/group-gating.ts b/src/channels/whatsapp/inbound/group-gating.ts index 1ca902d..7abc845 100644 --- a/src/channels/whatsapp/inbound/group-gating.ts +++ b/src/channels/whatsapp/inbound/group-gating.ts @@ -7,7 +7,7 @@ import { detectMention } from './mentions.js'; import type { WebInboundMessage } from './types.js'; -import { isGroupAllowed, resolveGroupMode, type GroupMode, type GroupModeConfig } from '../../group-mode.js'; +import { isGroupAllowed, isGroupUserAllowed, resolveGroupMode, type GroupMode, type GroupModeConfig } from '../../group-mode.js'; export interface GroupGatingParams { /** Extracted message */ @@ -25,6 +25,9 @@ export interface GroupGatingParams { /** Bot's E.164 number */ selfE164: string | null; + /** Sender identifier (JID or E.164) for per-group allowedUsers check */ + senderId?: string; + /** Per-group configuration */ groupsConfig?: Record; @@ -74,7 +77,7 @@ export interface GroupGatingResult { * } */ export function applyGroupGating(params: GroupGatingParams): GroupGatingResult { - const { msg, groupJid, selfJid, selfLid, selfE164, groupsConfig, mentionPatterns } = params; + const { msg, groupJid, senderId, selfJid, selfLid, selfE164, groupsConfig, mentionPatterns } = params; // Step 1: Check group allowlist (if groups config exists) if (!isGroupAllowed(groupsConfig, [groupJid])) { @@ -85,9 +88,26 @@ export function applyGroupGating(params: GroupGatingParams): GroupGatingResult { }; } + // Step 1b: Per-group user allowlist + if (senderId && !isGroupUserAllowed(groupsConfig, [groupJid], senderId)) { + return { + shouldProcess: false, + mode: 'open', + reason: 'user-not-allowed', + }; + } + // Step 2: Resolve mode (default: open) const mode = resolveGroupMode(groupsConfig, [groupJid], 'open'); + if (mode === 'disabled') { + return { + shouldProcess: false, + mode, + reason: 'groups-disabled', + }; + } + // Step 3: Detect mentions const mentionResult = detectMention({ body: msg.body, diff --git a/src/channels/whatsapp/index.ts b/src/channels/whatsapp/index.ts index 294413b..c547bb9 100644 --- a/src/channels/whatsapp/index.ts +++ b/src/channels/whatsapp/index.ts @@ -759,6 +759,7 @@ export class WhatsAppAdapter implements ChannelAdapter { const gatingResult = applyGroupGating({ msg: extracted, groupJid: remoteJid, + senderId: userId, selfJid: this.myJid, selfLid: this.myLid, selfE164: this.myNumber, diff --git a/src/config/types.ts b/src/config/types.ts index ccb036f..76043fa 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -174,10 +174,12 @@ export interface ProviderConfig { apiKey: string; } -export type GroupMode = 'open' | 'listen' | 'mention-only'; +export type GroupMode = 'open' | 'listen' | 'mention-only' | 'disabled'; export interface GroupConfig { mode?: GroupMode; + /** Only process group messages from these user IDs. Omit to allow all users. */ + allowedUsers?: string[]; /** * @deprecated Use mode: "mention-only" (true) or "open" (false). */