feat: add per-group allowedUsers filtering for all channels (#283)

This commit is contained in:
Cameron
2026-02-11 15:20:01 -08:00
committed by GitHub
parent 9550fc0c03
commit c405c96c9d
16 changed files with 492 additions and 16 deletions

View File

@@ -271,6 +271,7 @@ Use `groups.<id>.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.<id>.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 } }`

View File

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

View File

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

View File

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

View File

@@ -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<string, GroupModeConfig>;
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.
*

View File

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

View File

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

View File

@@ -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<string, SignalGroupConfig>;
@@ -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, '');

View File

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

View File

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

View File

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

View File

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

View File

@@ -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' } },

View File

@@ -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<string, GroupModeConfig>;
@@ -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,

View File

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

View File

@@ -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).
*/