feat: unified group modes (open/listen/mention-only) (#267)

Consolidates listeningGroups and groups.requireMention into a single
groups config with explicit mode per group. Backward compatible --
legacy formats auto-normalize with deprecation warnings.

- Add shared group-mode.ts with isGroupAllowed/resolveGroupMode helpers
- Update all 5 channel adapters to use mode-based gating
- Default to mention-only for configured entries (safe), open when no config
- Listening mode now set at adapter level, bot.ts has legacy fallback
- Fix YAML large-ID parsing for groups map keys (Discord snowflakes)
- Add migration in normalizeAgents for listeningGroups + requireMention
- Add unit tests for group-mode helpers + update all gating tests
- Update docs, README, and example config

Closes #266

Written by Cameron and Letta Code

"Perfection is achieved not when there is nothing more to add, but when there is nothing left to take away." -- Antoine de Saint-Exupery
This commit is contained in:
Cameron
2026-02-10 16:01:21 -08:00
committed by GitHub
parent 745291841d
commit c410decd18
23 changed files with 677 additions and 274 deletions

View File

@@ -213,14 +213,16 @@ At least one channel is required. Telegram is the easiest to start with.
### Group Settings (Optional)
Configure group batching and listening mode in `lettabot.yaml`:
Configure group batching and per-group response modes in `lettabot.yaml`:
```yaml
channels:
slack:
groupDebounceSec: 5
instantGroups: ["C0123456789"]
listeningGroups: ["C0987654321"] # observe only, reply on mention
groups:
"*": { mode: open }
"C0987654321": { mode: listen } # observe only, reply on mention
```
See `SKILL.md` for the full environment variable list and examples.

View File

@@ -265,6 +265,30 @@ channels:
The deprecated `groupPollIntervalMin` (minutes) still works for backward compatibility but `groupDebounceSec` takes priority.
### Group Modes
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
You can also use `*` as a wildcard default:
```yaml
channels:
telegram:
groups:
"*": { mode: listen }
"-1001234567890": { mode: open }
"-1009876543210": { mode: mention-only }
```
Deprecated formats are still supported and auto-normalized with warnings:
- `listeningGroups: ["id"]` -> `groups: { "id": { mode: listen } }`
- `groups: { "id": { requireMention: true/false } }` -> `mode: mention-only/open`
### DM Policies
**Note:** For WhatsApp/Signal with `selfChat: true` (personal number), dmPolicy is ignored - only you can message via "Message Yourself" / "Note to Self".

View File

@@ -38,27 +38,25 @@ channels:
dmPolicy: pairing # 'pairing', 'allowlist', or 'open'
# groupPollIntervalMin: 5 # Batch interval for group messages (default: 10)
# instantGroups: ["-100123456"] # Groups that bypass batching
# listeningGroups: ["-100123456"] # Groups where bot observes but only replies when @mentioned
# Group access control (which groups can interact, mention requirement):
# Group access + response mode:
# groups:
# "*": { requireMention: true } # Default: only respond when @mentioned
# "-1001234567890": { requireMention: false } # This group gets all messages
# "*": { mode: listen } # Observe all groups; only reply when @mentioned
# "-1001234567890": { mode: open } # This group gets all messages
# "-1009876543210": { mode: mention-only } # Drop unless @mentioned
# mentionPatterns: ["hey bot"] # Additional regex patterns for mention detection
# slack:
# enabled: true
# appToken: xapp-...
# botToken: xoxb-...
# listeningGroups: ["C0123456789"] # Channels where bot observes only
# # groups:
# # "*": { requireMention: true } # Default: only respond when @mentioned
# # "C0123456789": { requireMention: false }
# groups:
# "*": { mode: listen }
# "C0123456789": { mode: open }
# discord:
# enabled: true
# token: YOUR-DISCORD-BOT-TOKEN
# listeningGroups: ["1234567890123456789"] # Server/channel IDs where bot observes only
# # groups:
# # "*": { requireMention: true } # Default: only respond when @mentioned
# # "1234567890123456789": { requireMention: false } # Server or channel ID
# groups:
# "*": { mode: listen }
# "1234567890123456789": { mode: open } # Server or channel ID
# whatsapp:
# enabled: true
# selfChat: false

View File

@@ -11,6 +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';
// Dynamic import to avoid requiring Discord deps if not used
let Client: typeof import('discord.js').Client;
@@ -23,7 +24,7 @@ export interface DiscordConfig {
allowedUsers?: string[]; // Discord user IDs
attachmentsDir?: string;
attachmentsMaxBytes?: number;
groups?: Record<string, { requireMention?: boolean }>; // Per-guild/channel settings
groups?: Record<string, GroupModeConfig>; // Per-guild/channel settings
}
export class DiscordAdapter implements ChannelAdapter {
@@ -242,32 +243,24 @@ Ask the bot owner to approve with:
const groupName = isGroup && 'name' in message.channel ? message.channel.name : undefined;
const displayName = message.member?.displayName || message.author.globalName || message.author.username;
const wasMentioned = isGroup && !!this.client?.user && message.mentions.has(this.client.user);
let isListeningMode = false;
// Group gating: config-based allowlist + mention requirement
// Group gating: config-based allowlist + mode
if (isGroup && this.config.groups) {
const groups = this.config.groups;
const chatId = message.channel.id;
const serverId = message.guildId;
const allowlistEnabled = Object.keys(groups).length > 0;
if (allowlistEnabled) {
const hasWildcard = Object.hasOwn(groups, '*');
const hasSpecific = Object.hasOwn(groups, chatId)
|| (serverId && Object.hasOwn(groups, serverId));
if (!hasWildcard && !hasSpecific) {
console.log(`[Discord] Group ${chatId} not in allowlist, ignoring`);
return;
}
const keys = [chatId];
if (serverId) keys.push(serverId);
if (!isGroupAllowed(this.config.groups, keys)) {
console.log(`[Discord] Group ${chatId} not in allowlist, ignoring`);
return;
}
const groupConfig = groups[chatId]
?? (serverId ? groups[serverId] : undefined)
?? groups['*'];
const requireMention = groupConfig?.requireMention ?? true;
if (requireMention && !wasMentioned) {
const mode = resolveGroupMode(this.config.groups, keys, 'open');
if (mode === 'mention-only' && !wasMentioned) {
return; // Mention required but not mentioned -- silent drop
}
isListeningMode = mode === 'listen' && !wasMentioned;
}
await this.onMessage({
@@ -283,6 +276,7 @@ Ask the bot owner to approve with:
groupName,
serverId: message.guildId || undefined,
wasMentioned,
isListeningMode,
attachments,
});
}

View File

@@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest';
import { isGroupAllowed, resolveGroupMode, type GroupsConfig } from './group-mode.js';
describe('group-mode helpers', () => {
describe('isGroupAllowed', () => {
it('allows when groups config is missing', () => {
expect(isGroupAllowed(undefined, ['group-1'])).toBe(true);
});
it('allows when groups config is empty', () => {
expect(isGroupAllowed({}, ['group-1'])).toBe(true);
});
it('allows via wildcard', () => {
const groups: GroupsConfig = { '*': { mode: 'mention-only' } };
expect(isGroupAllowed(groups, ['group-1'])).toBe(true);
});
it('allows when any provided key matches', () => {
const groups: GroupsConfig = { 'server-1': { mode: 'open' } };
expect(isGroupAllowed(groups, ['chat-1', 'server-1'])).toBe(true);
});
it('rejects when no keys match and no wildcard', () => {
const groups: GroupsConfig = { 'group-2': { mode: 'open' } };
expect(isGroupAllowed(groups, ['group-1'])).toBe(false);
});
});
describe('resolveGroupMode', () => {
it('returns fallback when groups config is missing', () => {
expect(resolveGroupMode(undefined, ['group-1'], 'open')).toBe('open');
});
it('uses specific key before wildcard', () => {
const groups: GroupsConfig = {
'*': { mode: 'mention-only' },
'group-1': { mode: 'open' },
};
expect(resolveGroupMode(groups, ['group-1'], 'open')).toBe('open');
});
it('uses wildcard when no specific key matches', () => {
const groups: GroupsConfig = { '*': { mode: 'listen' } };
expect(resolveGroupMode(groups, ['group-1'], 'open')).toBe('listen');
});
it('maps legacy requireMention=true to mention-only', () => {
const groups: GroupsConfig = { 'group-1': { requireMention: true } };
expect(resolveGroupMode(groups, ['group-1'], 'open')).toBe('mention-only');
});
it('maps legacy requireMention=false to open', () => {
const groups: GroupsConfig = { 'group-1': { requireMention: false } };
expect(resolveGroupMode(groups, ['group-1'], 'mention-only')).toBe('open');
});
it('defaults to mention-only for explicit empty group entries', () => {
const groups: GroupsConfig = { 'group-1': {} };
expect(resolveGroupMode(groups, ['group-1'], 'open')).toBe('mention-only');
});
it('defaults to mention-only for wildcard empty entry', () => {
const groups: GroupsConfig = { '*': {} };
expect(resolveGroupMode(groups, ['group-1'], 'open')).toBe('mention-only');
});
it('uses first matching key in priority order', () => {
const groups: GroupsConfig = {
'chat-1': { mode: 'listen' },
'server-1': { mode: 'open' },
};
expect(resolveGroupMode(groups, ['chat-1', 'server-1'], 'mention-only')).toBe('listen');
expect(resolveGroupMode(groups, ['chat-2', 'server-1'], 'mention-only')).toBe('open');
});
});
});

View File

@@ -0,0 +1,63 @@
/**
* Shared group mode helpers across channel adapters.
*/
export type GroupMode = 'open' | 'listen' | 'mention-only';
export interface GroupModeConfig {
mode?: GroupMode;
/**
* @deprecated Use mode: "mention-only" (true) or "open" (false).
*/
requireMention?: boolean;
}
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') {
return config.mode;
}
if (typeof config.requireMention === 'boolean') {
return config.requireMention ? 'mention-only' : 'open';
}
// For explicitly configured group entries with no mode, default safely.
return 'mention-only';
}
/**
* Whether a group/channel is allowed by groups config.
*
* If no groups config exists, this returns true (open allowlist).
*/
export function isGroupAllowed(groups: GroupsConfig | undefined, keys: string[]): boolean {
if (!groups) return true;
if (Object.keys(groups).length === 0) return true;
if (Object.hasOwn(groups, '*')) return true;
return keys.some((key) => Object.hasOwn(groups, key));
}
/**
* Resolve effective mode for a group/channel.
*
* Priority:
* 1. First matching key in provided order
* 2. Wildcard "*"
* 3. Fallback (default: "open")
*/
export function resolveGroupMode(
groups: GroupsConfig | undefined,
keys: string[],
fallback: GroupMode = 'open',
): GroupMode {
if (groups) {
for (const key of keys) {
const mode = coerceMode(groups[key]);
if (mode) return mode;
}
const wildcardMode = coerceMode(groups['*']);
if (wildcardMode) return wildcardMode;
}
return fallback;
}

View File

@@ -21,10 +21,9 @@ import { homedir } from 'node:os';
import { join } from 'node:path';
import { copyFile, stat, access } from 'node:fs/promises';
import { constants } from 'node:fs';
import type { GroupModeConfig } from './group-mode.js';
export interface SignalGroupConfig {
requireMention?: boolean; // Default: true (only respond when mentioned)
}
export interface SignalGroupConfig extends GroupModeConfig {}
export interface SignalConfig {
phoneNumber: string; // Bot's phone number (E.164 format, e.g., +15551234567)
@@ -762,8 +761,9 @@ This code expires in 1 hour.`;
const isGroup = chatId.startsWith('group:');
// Apply group gating - only respond when mentioned (unless configured otherwise)
// Apply group gating mode
let wasMentioned: boolean | undefined;
let isListeningMode = false;
if (isGroup && groupInfo?.groupId) {
const mentions = dataMessage?.mentions || syncMessage?.mentions;
const quote = dataMessage?.quote || syncMessage?.quote;
@@ -784,6 +784,7 @@ This code expires in 1 hour.`;
}
wasMentioned = gatingResult.wasMentioned;
isListeningMode = gatingResult.mode === 'listen' && !wasMentioned;
if (wasMentioned) {
console.log(`[Signal] Bot mentioned via ${gatingResult.method}`);
}
@@ -798,6 +799,7 @@ This code expires in 1 hour.`;
isGroup,
groupName: groupInfo?.groupName,
wasMentioned,
isListeningMode,
attachments: collectedAttachments.length > 0 ? collectedAttachments : undefined,
};

View File

@@ -5,16 +5,16 @@ describe('applySignalGroupGating', () => {
const selfPhoneNumber = '+15551234567';
const selfUuid = 'abc-123-uuid';
describe('requireMention: true (default)', () => {
it('filters messages without mention', () => {
describe('open mode (default)', () => {
it('allows messages without mention', () => {
const result = applySignalGroupGating({
text: 'Hello everyone!',
groupId: 'test-group',
selfPhoneNumber,
});
expect(result.shouldProcess).toBe(false);
expect(result.reason).toBe('mention-required');
expect(result.shouldProcess).toBe(true);
expect(result.mode).toBe('open');
});
it('allows messages with native mention matching phone', () => {
@@ -43,7 +43,7 @@ describe('applySignalGroupGating', () => {
expect(result.method).toBe('native');
});
it('filters when mentions exist for others', () => {
it('still allows when mentions exist for others', () => {
const result = applySignalGroupGating({
text: 'Hey @alice',
groupId: 'test-group',
@@ -51,8 +51,8 @@ describe('applySignalGroupGating', () => {
selfPhoneNumber,
});
expect(result.shouldProcess).toBe(false);
expect(result.reason).toBe('mention-required');
expect(result.shouldProcess).toBe(true);
expect(result.wasMentioned).toBe(false);
});
it('allows messages matching regex pattern', () => {
@@ -91,8 +91,8 @@ describe('applySignalGroupGating', () => {
});
});
describe('requireMention: false', () => {
it('allows all messages when requireMention is false for group', () => {
describe('legacy requireMention mapping', () => {
it('maps requireMention=false to open mode', () => {
const result = applySignalGroupGating({
text: 'Hello everyone!',
groupId: 'test-group',
@@ -103,10 +103,11 @@ describe('applySignalGroupGating', () => {
});
expect(result.shouldProcess).toBe(true);
expect(result.mode).toBe('open');
expect(result.wasMentioned).toBe(false);
});
it('allows all messages when wildcard has requireMention: false', () => {
it('maps wildcard requireMention=false to open mode', () => {
const result = applySignalGroupGating({
text: 'Hello everyone!',
groupId: 'random-group',
@@ -117,6 +118,7 @@ describe('applySignalGroupGating', () => {
});
expect(result.shouldProcess).toBe(true);
expect(result.mode).toBe('open');
});
it('specific group config overrides wildcard', () => {
@@ -133,6 +135,21 @@ describe('applySignalGroupGating', () => {
expect(result.shouldProcess).toBe(false);
expect(result.reason).toBe('mention-required');
});
it('supports listen mode', () => {
const result = applySignalGroupGating({
text: 'Hello everyone!',
groupId: 'special-group',
selfPhoneNumber,
groupsConfig: {
'special-group': { mode: 'listen' },
},
});
expect(result.shouldProcess).toBe(true);
expect(result.mode).toBe('listen');
expect(result.wasMentioned).toBe(false);
});
});
describe('group allowlist', () => {

View File

@@ -1,12 +1,14 @@
/**
* Signal Group Gating
*
* Filters group messages based on mention detection.
* Only processes messages where the bot is mentioned (unless requireMention: false).
* Filters group messages based on per-group mode and mention detection.
*/
import { isGroupAllowed, resolveGroupMode, type GroupMode } from '../group-mode.js';
export interface SignalGroupConfig {
requireMention?: boolean; // Default: true
mode?: GroupMode;
requireMention?: boolean; // @deprecated legacy alias
}
export interface SignalMention {
@@ -52,6 +54,9 @@ export interface SignalGroupGatingParams {
export interface SignalGroupGatingResult {
/** Whether the message should be processed */
shouldProcess: boolean;
/** Effective mode for this group */
mode: GroupMode;
/** Whether bot was mentioned */
wasMentioned?: boolean;
@@ -77,41 +82,19 @@ export interface SignalGroupGatingResult {
*/
export function applySignalGroupGating(params: SignalGroupGatingParams): SignalGroupGatingResult {
const { text, groupId, mentions, quote, selfPhoneNumber, selfUuid, groupsConfig, mentionPatterns } = params;
const groupKeys = [groupId, `group:${groupId}`];
// Step 1: Check group allowlist (if groups config exists)
const groups = groupsConfig ?? {};
const allowlistEnabled = Object.keys(groups).length > 0;
if (allowlistEnabled) {
const hasWildcard = Object.hasOwn(groups, '*');
const hasSpecific = Object.hasOwn(groups, groupId) || Object.hasOwn(groups, `group:${groupId}`);
if (!hasWildcard && !hasSpecific) {
return {
shouldProcess: false,
reason: 'group-not-in-allowlist',
};
}
}
// Step 2: Resolve requireMention setting (default: true)
// Priority: specific group → wildcard → true
const groupConfig = groups[groupId] ?? groups[`group:${groupId}`];
const wildcardConfig = groups['*'];
const requireMention =
groupConfig?.requireMention ??
wildcardConfig?.requireMention ??
true; // Default: require mention for safety
// If requireMention is false, allow all messages from this group
if (!requireMention) {
if (!isGroupAllowed(groupsConfig, groupKeys)) {
return {
shouldProcess: true,
wasMentioned: false,
shouldProcess: false,
mode: 'open',
reason: 'group-not-in-allowlist',
};
}
// Step 3: Detect mentions
// Step 2: Resolve mode (default: open)
const mode = resolveGroupMode(groupsConfig, groupKeys, 'open');
// METHOD 1: Native Signal mentions array
if (mentions && mentions.length > 0) {
@@ -133,12 +116,15 @@ export function applySignalGroupGating(params: SignalGroupGatingParams): SignalG
});
if (mentioned) {
return { shouldProcess: true, wasMentioned: true, method: 'native' };
return { shouldProcess: true, mode, wasMentioned: true, method: 'native' };
}
// If explicit mentions exist for other users, skip fallback methods
// (User specifically mentioned someone else, not the bot)
return { shouldProcess: false, wasMentioned: false, reason: 'mention-required' };
// (User specifically mentioned someone else, not the bot).
if (mode === 'mention-only') {
return { shouldProcess: false, mode, wasMentioned: false, reason: 'mention-required' };
}
return { shouldProcess: true, mode, wasMentioned: false };
}
// METHOD 2: Regex pattern matching
@@ -149,7 +135,7 @@ export function applySignalGroupGating(params: SignalGroupGatingParams): SignalG
try {
const regex = new RegExp(pattern, 'i');
if (regex.test(cleanText)) {
return { shouldProcess: true, wasMentioned: true, method: 'regex' };
return { shouldProcess: true, mode, wasMentioned: true, method: 'regex' };
}
} catch (err) {
console.warn(`[Signal] Invalid mention pattern: ${pattern}`, err);
@@ -167,7 +153,7 @@ export function applySignalGroupGating(params: SignalGroupGatingParams): SignalG
(quote.author && quote.author.replace(/\D/g, '') === selfDigits);
if (isReplyToBot) {
return { shouldProcess: true, wasMentioned: true, method: 'reply' };
return { shouldProcess: true, mode, wasMentioned: true, method: 'reply' };
}
}
@@ -177,14 +163,22 @@ export function applySignalGroupGating(params: SignalGroupGatingParams): SignalG
const textDigits = text.replace(/\D/g, '');
if (textDigits.includes(selfDigits)) {
return { shouldProcess: true, wasMentioned: true, method: 'e164' };
return { shouldProcess: true, mode, wasMentioned: true, method: 'e164' };
}
}
// No mention detected and mention required - skip this message
// No mention detected.
if (mode === 'mention-only') {
return {
shouldProcess: false,
mode,
wasMentioned: false,
reason: 'mention-required',
};
}
return {
shouldProcess: false,
shouldProcess: true,
mode,
wasMentioned: false,
reason: 'mention-required',
};
}

View File

@@ -11,6 +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';
// Dynamic import to avoid requiring Slack deps if not used
let App: typeof import('@slack/bolt').App;
@@ -22,7 +23,7 @@ export interface SlackConfig {
allowedUsers?: string[]; // Slack user IDs (e.g., U01234567)
attachmentsDir?: string;
attachmentsMaxBytes?: number;
groups?: Record<string, { requireMention?: boolean }>; // Per-channel settings
groups?: Record<string, GroupModeConfig>; // Per-channel settings
}
export class SlackAdapter implements ChannelAdapter {
@@ -130,14 +131,15 @@ export class SlackAdapter implements ChannelAdapter {
// Determine if this is a group/channel (not a DM)
// DMs have channel IDs starting with 'D', channels start with 'C'
const isGroup = !channelId.startsWith('D');
let mode: GroupMode = 'open';
// Group gating: config-based allowlist + mention requirement
if (isGroup && this.config.groups) {
// Group gating: config-based allowlist + mode
if (isGroup) {
if (!this.isChannelAllowed(channelId)) {
return; // Channel not in allowlist -- silent drop
}
const requireMention = this.resolveRequireMention(channelId);
if (requireMention) {
mode = this.resolveChannelMode(channelId);
if (mode === 'mention-only') {
// Non-mention message in channel that requires mentions.
// The app_mention handler will process actual @mentions.
return;
@@ -156,6 +158,7 @@ export class SlackAdapter implements ChannelAdapter {
isGroup,
groupName: isGroup ? channelId : undefined, // Would need conversations.info for name
wasMentioned: false, // Regular messages; app_mention handles mentions
isListeningMode: mode === 'listen',
attachments,
});
}
@@ -306,20 +309,12 @@ export class SlackAdapter implements ChannelAdapter {
/** Check if a channel is allowed by the groups config allowlist */
private isChannelAllowed(channelId: string): boolean {
const groups = this.config.groups;
if (!groups) return true;
const allowlistEnabled = Object.keys(groups).length > 0;
if (!allowlistEnabled) return true;
return Object.hasOwn(groups, '*') || Object.hasOwn(groups, channelId);
return isGroupAllowed(this.config.groups, [channelId]);
}
/** Resolve requireMention for a channel (specific > wildcard > default true) */
private resolveRequireMention(channelId: string): boolean {
const groups = this.config.groups;
if (!groups) return true;
const groupConfig = groups[channelId];
const wildcardConfig = groups['*'];
return groupConfig?.requireMention ?? wildcardConfig?.requireMention ?? true;
/** Resolve group mode for a channel (specific > wildcard > open). */
private resolveChannelMode(channelId: string): GroupMode {
return resolveGroupMode(this.config.groups, [channelId], 'open');
}
async sendTypingIndicator(_chatId: string): Promise<void> {

View File

@@ -41,7 +41,7 @@ describe('applyTelegramGroupGating', () => {
});
it('allows all groups when no groupsConfig provided', () => {
// No config = no allowlist filtering (mention gating still applies by default)
// No config = no allowlist filtering (open mode)
const result = applyTelegramGroupGating(createParams({
text: '@mybot hello',
groupsConfig: undefined,
@@ -50,22 +50,44 @@ describe('applyTelegramGroupGating', () => {
});
});
describe('requireMention', () => {
it('defaults to requiring mention when not specified', () => {
describe('mode resolution', () => {
it('defaults to mention-only when group entry has no mode', () => {
const result = applyTelegramGroupGating(createParams({
text: 'hello everyone',
groupsConfig: { '*': {} }, // No requireMention specified
}));
expect(result.shouldProcess).toBe(false);
expect(result.mode).toBe('mention-only');
expect(result.reason).toBe('mention-required');
});
it('allows all messages when requireMention is false', () => {
it('maps legacy requireMention=false to open mode', () => {
const result = applyTelegramGroupGating(createParams({
text: 'hello everyone',
groupsConfig: { '*': { requireMention: false } },
}));
expect(result.shouldProcess).toBe(true);
expect(result.mode).toBe('open');
expect(result.wasMentioned).toBe(false);
});
it('maps legacy requireMention=true to mention-only mode', () => {
const result = applyTelegramGroupGating(createParams({
text: 'hello',
groupsConfig: { '*': { requireMention: true } },
}));
expect(result.shouldProcess).toBe(false);
expect(result.mode).toBe('mention-only');
expect(result.reason).toBe('mention-required');
});
it('supports listen mode (processes non-mention messages)', () => {
const result = applyTelegramGroupGating(createParams({
text: 'hello',
groupsConfig: { '*': { mode: 'listen' } },
}));
expect(result.shouldProcess).toBe(true);
expect(result.mode).toBe('listen');
expect(result.wasMentioned).toBe(false);
});
@@ -73,8 +95,8 @@ describe('applyTelegramGroupGating', () => {
const result = applyTelegramGroupGating(createParams({
text: 'hello',
groupsConfig: {
'*': { requireMention: true },
'-1001234567890': { requireMention: false },
'*': { mode: 'mention-only' },
'-1001234567890': { mode: 'open' },
},
}));
expect(result.shouldProcess).toBe(true);
@@ -85,8 +107,8 @@ describe('applyTelegramGroupGating', () => {
text: 'hello',
chatId: '-100999999',
groupsConfig: {
'*': { requireMention: true },
'-1001234567890': { requireMention: false },
'*': { mode: 'mention-only' },
'-1001234567890': { mode: 'open' },
},
}));
expect(result.shouldProcess).toBe(false);
@@ -174,7 +196,7 @@ describe('applyTelegramGroupGating', () => {
});
describe('no groupsConfig (open mode)', () => {
it('processes messages with mention when no config (default requireMention=true)', () => {
it('processes messages with mention when no config', () => {
const result = applyTelegramGroupGating(createParams({
text: '@mybot hello',
}));
@@ -182,12 +204,12 @@ describe('applyTelegramGroupGating', () => {
expect(result.wasMentioned).toBe(true);
});
it('rejects messages without mention when no config (default requireMention=true)', () => {
it('processes messages without mention when no config', () => {
const result = applyTelegramGroupGating(createParams({
text: 'hello everyone',
}));
expect(result.shouldProcess).toBe(false);
expect(result.reason).toBe('mention-required');
expect(result.shouldProcess).toBe(true);
expect(result.mode).toBe('open');
});
});
});

View File

@@ -11,6 +11,8 @@
* actively participate in?"
*/
import { isGroupAllowed, resolveGroupMode, type GroupMode, type GroupModeConfig } from './group-mode.js';
export interface TelegramGroupGatingParams {
/** Message text */
text: string;
@@ -25,7 +27,7 @@ export interface TelegramGroupGatingParams {
entities?: { type: string; offset: number; length: number }[];
/** Per-group configuration */
groupsConfig?: Record<string, { requireMention?: boolean }>;
groupsConfig?: Record<string, GroupModeConfig>;
/** Regex patterns for additional mention detection */
mentionPatterns?: string[];
@@ -35,6 +37,9 @@ export interface TelegramGroupGatingResult {
/** Whether the message should be processed */
shouldProcess: boolean;
/** Effective mode for this group */
mode: GroupMode;
/** Whether bot was mentioned */
wasMentioned?: boolean;
@@ -59,7 +64,7 @@ export interface TelegramGroupGatingResult {
* text: '@mybot hello!',
* chatId: '-1001234567890',
* botUsername: 'mybot',
* groupsConfig: { '*': { requireMention: true } },
* groupsConfig: { '*': { mode: 'mention-only' } },
* });
*
* if (!result.shouldProcess) return;
@@ -68,39 +73,44 @@ export function applyTelegramGroupGating(params: TelegramGroupGatingParams): Tel
const { text, chatId, botUsername, entities, groupsConfig, mentionPatterns } = params;
// Step 1: Group allowlist
const groups = groupsConfig ?? {};
const allowlistEnabled = Object.keys(groups).length > 0;
if (allowlistEnabled) {
const hasWildcard = Object.hasOwn(groups, '*');
const hasSpecific = Object.hasOwn(groups, chatId);
if (!hasWildcard && !hasSpecific) {
return {
shouldProcess: false,
reason: 'group-not-in-allowlist',
};
}
}
// Step 2: Resolve requireMention setting (default: true)
// Priority: specific group > wildcard > default true
const groupConfig = groups[chatId];
const wildcardConfig = groups['*'];
const requireMention =
groupConfig?.requireMention ??
wildcardConfig?.requireMention ??
true; // Default: require mention for safety
// If requireMention is false, allow all messages from this group
if (!requireMention) {
if (!isGroupAllowed(groupsConfig, [chatId])) {
return {
shouldProcess: true,
wasMentioned: false,
shouldProcess: false,
mode: 'open',
reason: 'group-not-in-allowlist',
};
}
// Step 2: Resolve mode (default: open)
const mode = resolveGroupMode(groupsConfig, [chatId], 'open');
// Step 3: Detect mentions
const mention = detectTelegramMention({ text, botUsername, entities, mentionPatterns });
// open/listen modes always pass (listen mode response suppression is handled downstream)
if (mode === 'open' || mode === 'listen') {
return {
shouldProcess: true,
mode,
wasMentioned: mention.wasMentioned,
method: mention.method,
};
}
// mention-only mode: mention required
if (mention.wasMentioned) {
return { shouldProcess: true, mode, wasMentioned: true, method: mention.method };
}
return { shouldProcess: false, mode, wasMentioned: false, reason: 'mention-required' };
}
function detectTelegramMention(params: {
text: string;
botUsername: string;
entities?: { type: string; offset: number; length: number }[];
mentionPatterns?: string[];
}): { wasMentioned: boolean; method?: 'entity' | 'text' | 'command' | 'regex' } {
const { text, botUsername, entities, mentionPatterns } = params;
// METHOD 1: Telegram entity-based mention detection (most reliable)
if (entities && entities.length > 0 && botUsername) {
@@ -111,9 +121,8 @@ export function applyTelegramGroupGating(params: TelegramGroupGatingParams): Tel
}
return false;
});
if (mentioned) {
return { shouldProcess: true, wasMentioned: true, method: 'entity' };
return { wasMentioned: true, method: 'entity' };
}
}
@@ -121,7 +130,7 @@ export function applyTelegramGroupGating(params: TelegramGroupGatingParams): Tel
if (botUsername) {
const usernameRegex = new RegExp(`@${botUsername}\\b`, 'i');
if (usernameRegex.test(text)) {
return { shouldProcess: true, wasMentioned: true, method: 'text' };
return { wasMentioned: true, method: 'text' };
}
}
@@ -129,7 +138,7 @@ export function applyTelegramGroupGating(params: TelegramGroupGatingParams): Tel
if (botUsername) {
const commandRegex = new RegExp(`^/\\w+@${botUsername}\\b`, 'i');
if (commandRegex.test(text.trim())) {
return { shouldProcess: true, wasMentioned: true, method: 'command' };
return { wasMentioned: true, method: 'command' };
}
}
@@ -139,7 +148,7 @@ export function applyTelegramGroupGating(params: TelegramGroupGatingParams): Tel
try {
const regex = new RegExp(pattern, 'i');
if (regex.test(text)) {
return { shouldProcess: true, wasMentioned: true, method: 'regex' };
return { wasMentioned: true, method: 'regex' };
}
} catch {
// Invalid pattern -- skip silently
@@ -147,10 +156,5 @@ export function applyTelegramGroupGating(params: TelegramGroupGatingParams): Tel
}
}
// No mention detected and mention required -- skip this message
return {
shouldProcess: false,
wasMentioned: false,
reason: 'mention-required',
};
return { wasMentioned: false };
}

View File

@@ -18,6 +18,7 @@ import { isGroupApproved, approveGroup } from '../pairing/group-store.js';
import { basename } from 'node:path';
import { buildAttachmentPath, downloadToFile } from './attachments.js';
import { applyTelegramGroupGating } from './telegram-group-gating.js';
import type { GroupModeConfig } from './group-mode.js';
export interface TelegramConfig {
token: string;
@@ -26,7 +27,7 @@ export interface TelegramConfig {
attachmentsDir?: string;
attachmentsMaxBytes?: number;
mentionPatterns?: string[]; // Regex patterns for mention detection
groups?: Record<string, { requireMention?: boolean }>; // Per-group settings
groups?: Record<string, GroupModeConfig>; // Per-group settings
}
export class TelegramAdapter implements ChannelAdapter {
@@ -55,9 +56,9 @@ export class TelegramAdapter implements ChannelAdapter {
/**
* Apply group gating for a message context.
* Returns null if the message should be dropped, or { isGroup, groupName, wasMentioned } if it should proceed.
* Returns null if the message should be dropped, or message metadata if it should proceed.
*/
private applyGroupGating(ctx: { chat: { type: string; id: number; title?: string }; message?: { text?: string; entities?: { type: string; offset: number; length: number }[] } }): { isGroup: boolean; groupName?: string; wasMentioned: boolean } | null {
private applyGroupGating(ctx: { chat: { type: string; id: number; title?: string }; message?: { text?: string; entities?: { type: string; offset: number; length: number }[] } }): { isGroup: boolean; groupName?: string; wasMentioned: boolean; isListeningMode?: boolean } | null {
const chatType = ctx.chat.type;
const isGroup = chatType === 'group' || chatType === 'supergroup';
const groupName = isGroup && 'title' in ctx.chat ? ctx.chat.title : undefined;
@@ -69,43 +70,26 @@ export class TelegramAdapter implements ChannelAdapter {
const text = ctx.message?.text || '';
const botUsername = this.bot.botInfo?.username || '';
if (this.config.groups) {
const gatingResult = applyTelegramGroupGating({
text,
chatId: String(ctx.chat.id),
botUsername,
entities: ctx.message?.entities?.map(e => ({
type: e.type,
offset: e.offset,
length: e.length,
})),
groupsConfig: this.config.groups,
mentionPatterns: this.config.mentionPatterns,
});
const gatingResult = applyTelegramGroupGating({
text,
chatId: String(ctx.chat.id),
botUsername,
entities: ctx.message?.entities?.map(e => ({
type: e.type,
offset: e.offset,
length: e.length,
})),
groupsConfig: this.config.groups,
mentionPatterns: this.config.mentionPatterns,
});
if (!gatingResult.shouldProcess) {
console.log(`[Telegram] Group message filtered: ${gatingResult.reason}`);
return null;
}
return { isGroup, groupName, wasMentioned: gatingResult.wasMentioned ?? false };
if (!gatingResult.shouldProcess) {
console.log(`[Telegram] Group message filtered: ${gatingResult.reason}`);
return null;
}
// No groups config: detect mentions for batcher (no gating)
let wasMentioned = false;
if (botUsername) {
const entities = ctx.message?.entities || [];
wasMentioned = entities.some((e) => {
if (e.type === 'mention') {
const mentioned = text.substring(e.offset, e.offset + e.length);
return mentioned.toLowerCase() === `@${botUsername.toLowerCase()}`;
}
return false;
});
if (!wasMentioned) {
wasMentioned = text.toLowerCase().includes(`@${botUsername.toLowerCase()}`);
}
}
return { isGroup, groupName, wasMentioned };
const wasMentioned = gatingResult.wasMentioned ?? false;
const isListeningMode = gatingResult.mode === 'listen' && !wasMentioned;
return { isGroup, groupName, wasMentioned, isListeningMode };
}
/**
@@ -275,7 +259,7 @@ export class TelegramAdapter implements ChannelAdapter {
// Group gating (runs AFTER pairing middleware)
const gating = this.applyGroupGating(ctx);
if (!gating) return; // Filtered by group gating
const { isGroup, groupName, wasMentioned } = gating;
const { isGroup, groupName, wasMentioned, isListeningMode } = gating;
if (this.onMessage) {
await this.onMessage({
@@ -290,6 +274,7 @@ export class TelegramAdapter implements ChannelAdapter {
isGroup,
groupName,
wasMentioned,
isListeningMode,
});
}
});
@@ -349,7 +334,7 @@ export class TelegramAdapter implements ChannelAdapter {
// Group gating
const gating = this.applyGroupGating(ctx);
if (!gating) return;
const { isGroup, groupName, wasMentioned } = gating;
const { isGroup, groupName, wasMentioned, isListeningMode } = gating;
// Check if transcription is configured (config or env)
const { loadConfig } = await import('../config/index.js');
@@ -395,6 +380,7 @@ export class TelegramAdapter implements ChannelAdapter {
isGroup,
groupName,
wasMentioned,
isListeningMode,
});
}
} catch (error) {
@@ -412,6 +398,7 @@ export class TelegramAdapter implements ChannelAdapter {
isGroup,
groupName,
wasMentioned,
isListeningMode,
});
}
}
@@ -427,7 +414,7 @@ export class TelegramAdapter implements ChannelAdapter {
// Group gating
const gating = this.applyGroupGating(ctx);
if (!gating) return;
const { isGroup, groupName, wasMentioned } = gating;
const { isGroup, groupName, wasMentioned, isListeningMode } = gating;
const { attachments, caption } = await this.collectAttachments(ctx.message, String(chatId));
if (attachments.length === 0 && !caption) return;
@@ -444,6 +431,7 @@ export class TelegramAdapter implements ChannelAdapter {
isGroup,
groupName,
wasMentioned,
isListeningMode,
attachments,
});
}

View File

@@ -73,12 +73,12 @@ describe('applyGroupGating', () => {
}),
}));
// No allowlist = allowed (but mention still required by default)
// No allowlist = allowed (open mode)
expect(result.shouldProcess).toBe(true);
});
});
describe('requireMention setting', () => {
describe('mode resolution', () => {
it('allows when mentioned and requireMention=true', () => {
const result = applyGroupGating(createParams({
groupsConfig: { '*': { requireMention: true } },
@@ -117,7 +117,7 @@ describe('applyGroupGating', () => {
expect(result.wasMentioned).toBe(false);
});
it('defaults to requireMention=true when not specified', () => {
it('defaults to mention-only when group entry has no mode', () => {
const result = applyGroupGating(createParams({
groupsConfig: { '*': {} }, // No requireMention specified
msg: createMessage({
@@ -126,8 +126,22 @@ describe('applyGroupGating', () => {
}));
expect(result.shouldProcess).toBe(false);
expect(result.mode).toBe('mention-only');
expect(result.reason).toBe('mention-required');
});
it('supports listen mode', () => {
const result = applyGroupGating(createParams({
groupsConfig: { '*': { mode: 'listen' } },
msg: createMessage({
body: 'hello everyone',
}),
}));
expect(result.shouldProcess).toBe(true);
expect(result.mode).toBe('listen');
expect(result.wasMentioned).toBe(false);
});
});
describe('config priority', () => {

View File

@@ -5,8 +5,9 @@
* Based on OpenClaw's group gating patterns.
*/
import { detectMention, type MentionConfig } from './mentions.js';
import { detectMention } from './mentions.js';
import type { WebInboundMessage } from './types.js';
import { isGroupAllowed, resolveGroupMode, type GroupMode, type GroupModeConfig } from '../../group-mode.js';
export interface GroupGatingParams {
/** Extracted message */
@@ -25,7 +26,7 @@ export interface GroupGatingParams {
selfE164: string | null;
/** Per-group configuration */
groupsConfig?: Record<string, { requireMention?: boolean }>;
groupsConfig?: Record<string, GroupModeConfig>;
/** Mention patterns from config */
mentionPatterns?: string[];
@@ -35,6 +36,9 @@ export interface GroupGatingResult {
/** Whether message should be processed */
shouldProcess: boolean;
/** Effective mode for this group */
mode: GroupMode;
/** Whether bot was mentioned */
wasMentioned?: boolean;
@@ -47,9 +51,9 @@ export interface GroupGatingResult {
*
* Steps:
* 1. Check group allowlist (if groups config exists)
* 2. Resolve requireMention setting
* 2. Resolve group mode
* 3. Detect mentions (JID, regex, E.164, reply)
* 4. Apply mention gating
* 4. Apply mode gating
*
* @param params - Gating parameters
* @returns Gating decision
@@ -60,7 +64,7 @@ export interface GroupGatingResult {
* groupJid: "12345@g.us",
* selfJid: "555@s.whatsapp.net",
* selfE164: "+15551234567",
* groupsConfig: { "*": { requireMention: true } },
* groupsConfig: { "*": { mode: "mention-only" } },
* mentionPatterns: ["@?bot"]
* });
*
@@ -73,39 +77,17 @@ export function applyGroupGating(params: GroupGatingParams): GroupGatingResult {
const { msg, groupJid, selfJid, selfLid, selfE164, groupsConfig, mentionPatterns } = params;
// Step 1: Check group allowlist (if groups config exists)
const groups = groupsConfig ?? {};
const allowlistEnabled = Object.keys(groups).length > 0;
if (allowlistEnabled) {
// Check if this specific group is allowed
const hasWildcard = Object.hasOwn(groups, '*');
const hasSpecific = Object.hasOwn(groups, groupJid);
if (!hasWildcard && !hasSpecific) {
return {
shouldProcess: false,
reason: 'group-not-in-allowlist',
};
}
}
// Step 2: Resolve requireMention setting (default: true)
// Priority: specific group → wildcard → true
const groupConfig = groups[groupJid];
const wildcardConfig = groups['*'];
const requireMention =
groupConfig?.requireMention ??
wildcardConfig?.requireMention ??
true; // Default: require mention for safety
// If requireMention is false, allow all messages from this group
if (!requireMention) {
if (!isGroupAllowed(groupsConfig, [groupJid])) {
return {
shouldProcess: true,
wasMentioned: false, // Didn't check, not required
shouldProcess: false,
mode: 'open',
reason: 'group-not-in-allowlist',
};
}
// Step 2: Resolve mode (default: open)
const mode = resolveGroupMode(groupsConfig, [groupJid], 'open');
// Step 3: Detect mentions
const mentionResult = detectMention({
body: msg.body,
@@ -120,21 +102,19 @@ export function applyGroupGating(params: GroupGatingParams): GroupGatingResult {
},
});
// Step 4: Apply mention gating
if (!mentionResult.wasMentioned) {
// Not mentioned and mention required - skip this message
// Note: In a full implementation, this message could be stored in
// "pending history" for context injection when bot IS mentioned
// Step 4: Apply mode
if (mode === 'mention-only' && !mentionResult.wasMentioned) {
return {
shouldProcess: false,
mode,
wasMentioned: false,
reason: 'mention-required',
};
}
// Mentioned! Process this message
return {
shouldProcess: true,
wasMentioned: true,
mode,
wasMentioned: mentionResult.wasMentioned,
};
}

View File

@@ -754,6 +754,7 @@ export class WhatsAppAdapter implements ChannelAdapter {
// Apply group gating (mention detection + allowlist)
let wasMentioned = false;
let isListeningMode = false;
if (isGroup) {
const gatingResult = applyGroupGating({
msg: extracted,
@@ -771,6 +772,7 @@ export class WhatsAppAdapter implements ChannelAdapter {
}
wasMentioned = gatingResult.wasMentioned ?? false;
isListeningMode = gatingResult.mode === 'listen' && !wasMentioned;
}
// Set mention status for agent context
@@ -814,6 +816,7 @@ export class WhatsAppAdapter implements ChannelAdapter {
isGroup,
groupName: extracted.groupSubject,
wasMentioned: extracted.wasMentioned,
isListeningMode,
replyToUser: extracted.replyContext?.senderE164,
attachments: extracted.attachments,
});

View File

@@ -6,6 +6,7 @@
*/
import type { DmPolicy } from "../../pairing/types.js";
import type { GroupModeConfig } from "../group-mode.js";
import type {
WASocket,
WAMessage,
@@ -51,9 +52,7 @@ export interface WhatsAppConfig {
mentionPatterns?: string[];
/** Per-group settings (JID or "*" for defaults) */
groups?: Record<string, {
requireMention?: boolean; // Default: true
}>;
groups?: Record<string, GroupModeConfig>;
}
/**

View File

@@ -348,10 +348,12 @@ export async function syncProviders(config: Partial<LettaBotConfig> & Pick<Letta
}
/**
* Fix group ID arrays that may contain large numeric IDs parsed by YAML.
* Fix group identifiers that may contain large numeric IDs parsed by YAML.
* Discord snowflake IDs exceed Number.MAX_SAFE_INTEGER, so YAML parses them
* as lossy JavaScript numbers. We re-read from the document AST to get the
* original string representation.
* as lossy JavaScript numbers. We re-read from the document AST to preserve
* the original source text for:
* - instantGroups/listeningGroups arrays
* - groups map keys (new group mode config)
*/
function fixLargeGroupIds(yamlContent: string, parsed: Partial<LettaBotConfig>): void {
if (!parsed.channels) return;
@@ -383,6 +385,53 @@ function fixLargeGroupIds(yamlContent: string, parsed: Partial<LettaBotConfig>):
}
}
}
// Also fix groups map keys (e.g. discord snowflake IDs)
const groupsNode = doc.getIn(['channels', ch, 'groups'], true);
if (YAML.isMap(groupsNode)) {
const fixedGroups: Record<string, unknown> = {};
for (const pair of groupsNode.items) {
const keyNode = (pair as { key?: unknown }).key;
const valueNode = (pair as { value?: unknown }).value;
let groupKey: string;
if (YAML.isScalar(keyNode)) {
if (typeof keyNode.value === 'number' && keyNode.source) {
groupKey = keyNode.source;
} else {
groupKey = String(keyNode.value);
}
} else {
groupKey = String(keyNode);
}
if (YAML.isMap(valueNode)) {
const groupConfig: Record<string, unknown> = {};
for (const settingPair of valueNode.items) {
const settingKeyNode = (settingPair as { key?: unknown }).key;
const settingValueNode = (settingPair as { value?: unknown }).value;
const settingKey = YAML.isScalar(settingKeyNode)
? String(settingKeyNode.value)
: String(settingKeyNode);
if (YAML.isScalar(settingValueNode)) {
groupConfig[settingKey] = settingValueNode.value;
} else {
groupConfig[settingKey] = settingValueNode as unknown;
}
}
fixedGroups[groupKey] = groupConfig;
} else if (YAML.isScalar(valueNode)) {
fixedGroups[groupKey] = valueNode.value;
} else {
fixedGroups[groupKey] = valueNode as unknown;
}
}
const cfg = parsed.channels[ch];
if (cfg) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(cfg as any).groups = fixedGroups;
}
}
}
} catch {
// Fallback: just ensure entries are strings (won't fix precision, but safe)
@@ -394,6 +443,13 @@ function fixLargeGroupIds(yamlContent: string, parsed: Partial<LettaBotConfig>):
cfg[field] = cfg[field].map((v: unknown) => String(v));
}
}
if (cfg && cfg.groups && typeof cfg.groups === 'object') {
const fixedGroups: Record<string, unknown> = {};
for (const [key, value] of Object.entries(cfg.groups as Record<string, unknown>)) {
fixedGroups[String(key)] = value;
}
cfg.groups = fixedGroups;
}
}
}
}

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { normalizeAgents, type LettaBotConfig, type AgentConfig } from './types.js';
describe('normalizeAgents', () => {
@@ -156,6 +156,62 @@ describe('normalizeAgents', () => {
expect(agents[0].id).toBe('agent-123');
});
it('should normalize legacy listeningGroups + requireMention to groups.mode and warn', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const config: LettaBotConfig = {
server: { mode: 'cloud' },
agent: { name: 'TestBot' },
channels: {
telegram: {
enabled: true,
token: 'test-token',
listeningGroups: ['-100123', '-100456'],
groups: {
'*': { requireMention: true },
'-100456': { requireMention: false },
},
},
},
};
const agents = normalizeAgents(config);
const groups = agents[0].channels.telegram?.groups;
expect(groups?.['*']?.mode).toBe('mention-only');
expect(groups?.['-100123']?.mode).toBe('listen');
expect(groups?.['-100456']?.mode).toBe('listen');
expect((agents[0].channels.telegram as any).listeningGroups).toBeUndefined();
expect(
warnSpy.mock.calls.some((args) => String(args[0]).includes('listeningGroups'))
).toBe(true);
expect(
warnSpy.mock.calls.some((args) => String(args[0]).includes('requireMention'))
).toBe(true);
warnSpy.mockRestore();
});
it('should preserve legacy listeningGroups semantics by adding wildcard open', () => {
const config: LettaBotConfig = {
server: { mode: 'cloud' },
agent: { name: 'TestBot' },
channels: {
discord: {
enabled: true,
token: 'discord-token',
listeningGroups: ['1234567890'],
},
},
};
const agents = normalizeAgents(config);
const groups = agents[0].channels.discord?.groups;
expect(groups?.['*']?.mode).toBe('open');
expect(groups?.['1234567890']?.mode).toBe('listen');
});
describe('env var fallback (container deploys)', () => {
const envVars = [
'TELEGRAM_BOT_TOKEN', 'TELEGRAM_DM_POLICY', 'TELEGRAM_ALLOWED_USERS',

View File

@@ -145,6 +145,16 @@ export interface ProviderConfig {
apiKey: string;
}
export type GroupMode = 'open' | 'listen' | 'mention-only';
export interface GroupConfig {
mode?: GroupMode;
/**
* @deprecated Use mode: "mention-only" (true) or "open" (false).
*/
requireMention?: boolean;
}
export interface TelegramConfig {
enabled: boolean;
token?: string;
@@ -153,9 +163,9 @@ export interface TelegramConfig {
groupDebounceSec?: number; // Debounce interval in seconds (default: 5, 0 = immediate)
groupPollIntervalMin?: number; // @deprecated Use groupDebounceSec instead
instantGroups?: string[]; // Group chat IDs that bypass batching
listeningGroups?: string[]; // Group IDs where bot only observes (replies only when mentioned)
listeningGroups?: string[]; // @deprecated Use groups.<id>.mode = "listen"
mentionPatterns?: string[]; // Regex patterns for mention detection (e.g., ["@mybot"])
groups?: Record<string, { requireMention?: boolean }>; // Per-group settings, "*" for defaults
groups?: Record<string, GroupConfig>; // Per-group settings, "*" for defaults
}
export interface SlackConfig {
@@ -167,8 +177,8 @@ export interface SlackConfig {
groupDebounceSec?: number; // Debounce interval in seconds (default: 5, 0 = immediate)
groupPollIntervalMin?: number; // @deprecated Use groupDebounceSec instead
instantGroups?: string[]; // Channel IDs that bypass batching
listeningGroups?: string[]; // Group IDs where bot only observes (replies only when mentioned)
groups?: Record<string, { requireMention?: boolean }>; // Per-channel settings, "*" for defaults
listeningGroups?: string[]; // @deprecated Use groups.<id>.mode = "listen"
groups?: Record<string, GroupConfig>; // Per-channel settings, "*" for defaults
}
export interface WhatsAppConfig {
@@ -179,11 +189,11 @@ export interface WhatsAppConfig {
groupPolicy?: 'open' | 'disabled' | 'allowlist';
groupAllowFrom?: string[];
mentionPatterns?: string[];
groups?: Record<string, { requireMention?: boolean }>;
groups?: Record<string, GroupConfig>;
groupDebounceSec?: number; // Debounce interval in seconds (default: 5, 0 = immediate)
groupPollIntervalMin?: number; // @deprecated Use groupDebounceSec instead
instantGroups?: string[]; // Group JIDs that bypass batching
listeningGroups?: string[]; // Group IDs where bot only observes (replies only when mentioned)
listeningGroups?: string[]; // @deprecated Use groups.<id>.mode = "listen"
}
export interface SignalConfig {
@@ -194,11 +204,11 @@ export interface SignalConfig {
allowedUsers?: string[];
// Group gating
mentionPatterns?: string[]; // Regex patterns for mention detection (e.g., ["@bot"])
groups?: Record<string, { requireMention?: boolean }>; // Per-group settings, "*" for defaults
groups?: Record<string, GroupConfig>; // Per-group settings, "*" for defaults
groupDebounceSec?: number; // Debounce interval in seconds (default: 5, 0 = immediate)
groupPollIntervalMin?: number; // @deprecated Use groupDebounceSec instead
instantGroups?: string[]; // Group IDs that bypass batching
listeningGroups?: string[]; // Group IDs where bot only observes (replies only when mentioned)
listeningGroups?: string[]; // @deprecated Use groups.<id>.mode = "listen"
}
export interface DiscordConfig {
@@ -209,8 +219,8 @@ export interface DiscordConfig {
groupDebounceSec?: number; // Debounce interval in seconds (default: 5, 0 = immediate)
groupPollIntervalMin?: number; // @deprecated Use groupDebounceSec instead
instantGroups?: string[]; // Guild/server IDs or channel IDs that bypass batching
listeningGroups?: string[]; // Group IDs where bot only observes (replies only when mentioned)
groups?: Record<string, { requireMention?: boolean }>; // Per-guild/channel settings, "*" for defaults
listeningGroups?: string[]; // @deprecated Use groups.<id>.mode = "listen"
groups?: Record<string, GroupConfig>; // Per-guild/channel settings, "*" for defaults
}
export interface GoogleAccountConfig {
@@ -238,6 +248,93 @@ export const DEFAULT_CONFIG: LettaBotConfig = {
channels: {},
};
type ChannelWithLegacyGroupFields = {
groups?: Record<string, GroupConfig>;
listeningGroups?: string[];
};
const warnedGroupConfigDeprecations = new Set<string>();
function warnGroupConfigDeprecation(path: string, detail: string): void {
const key = `${path}:${detail}`;
if (warnedGroupConfigDeprecations.has(key)) return;
warnedGroupConfigDeprecations.add(key);
console.warn(`[Config] WARNING: ${path} ${detail}`);
}
function normalizeLegacyGroupFields(
channel: ChannelWithLegacyGroupFields | undefined,
path: string,
): void {
if (!channel) return;
const hadOriginalGroups = !!(
channel.groups &&
typeof channel.groups === 'object' &&
Object.keys(channel.groups).length > 0
);
const groups: Record<string, GroupConfig> = channel.groups && typeof channel.groups === 'object'
? { ...channel.groups }
: {};
const modeDerivedFromRequireMention = new Set<string>();
let sawLegacyRequireMention = false;
for (const [groupId, value] of Object.entries(groups)) {
const group = value && typeof value === 'object' ? { ...value } : {};
const hasLegacyRequireMention = typeof group.requireMention === 'boolean';
if (hasLegacyRequireMention) {
sawLegacyRequireMention = true;
}
if (!group.mode && hasLegacyRequireMention) {
group.mode = group.requireMention ? 'mention-only' : 'open';
modeDerivedFromRequireMention.add(groupId);
}
if ('requireMention' in group) {
delete group.requireMention;
}
groups[groupId] = group;
}
if (sawLegacyRequireMention) {
warnGroupConfigDeprecation(
`${path}.groups.<id>.requireMention`,
'is deprecated. Use groups.<id>.mode: "mention-only" | "open" | "listen".'
);
}
const legacyListeningGroups = Array.isArray(channel.listeningGroups)
? channel.listeningGroups.map((id) => String(id).trim()).filter(Boolean)
: [];
if (legacyListeningGroups.length > 0) {
warnGroupConfigDeprecation(
`${path}.listeningGroups`,
'is deprecated. Use groups.<id>.mode: "listen".'
);
for (const id of legacyListeningGroups) {
const existing = groups[id] ? { ...groups[id] } : {};
if (!existing.mode || modeDerivedFromRequireMention.has(id)) {
existing.mode = 'listen';
} else if (existing.mode !== 'listen') {
warnGroupConfigDeprecation(
`${path}.groups.${id}.mode`,
`is "${existing.mode}" while ${path}.listeningGroups also includes "${id}". Keeping mode "${existing.mode}".`
);
}
groups[id] = existing;
}
// Legacy listeningGroups never restricted other groups.
// Add wildcard open when there was no explicit groups config.
if (!hadOriginalGroups && !groups['*']) {
groups['*'] = { mode: 'open' };
}
}
channel.groups = Object.keys(groups).length > 0 ? groups : undefined;
delete channel.listeningGroups;
}
/**
* Normalize config to multi-agent format.
*
@@ -246,25 +343,35 @@ export const DEFAULT_CONFIG: LettaBotConfig = {
* Channels with `enabled: false` are dropped during normalization.
*/
export function normalizeAgents(config: LettaBotConfig): AgentConfig[] {
const normalizeChannels = (channels?: AgentConfig['channels']): AgentConfig['channels'] => {
const normalizeChannels = (channels?: AgentConfig['channels'], sourcePath = 'channels'): AgentConfig['channels'] => {
const normalized: AgentConfig['channels'] = {};
if (!channels) return normalized;
if (channels.telegram?.enabled !== false && channels.telegram?.token) {
normalized.telegram = channels.telegram;
const telegram = { ...channels.telegram };
normalizeLegacyGroupFields(telegram, `${sourcePath}.telegram`);
normalized.telegram = telegram;
}
if (channels.slack?.enabled !== false && channels.slack?.botToken && channels.slack?.appToken) {
normalized.slack = channels.slack;
const slack = { ...channels.slack };
normalizeLegacyGroupFields(slack, `${sourcePath}.slack`);
normalized.slack = slack;
}
// WhatsApp has no credential to check (uses QR pairing), so just check enabled
if (channels.whatsapp?.enabled) {
normalized.whatsapp = channels.whatsapp;
const whatsapp = { ...channels.whatsapp };
normalizeLegacyGroupFields(whatsapp, `${sourcePath}.whatsapp`);
normalized.whatsapp = whatsapp;
}
if (channels.signal?.enabled !== false && channels.signal?.phone) {
normalized.signal = channels.signal;
const signal = { ...channels.signal };
normalizeLegacyGroupFields(signal, `${sourcePath}.signal`);
normalized.signal = signal;
}
if (channels.discord?.enabled !== false && channels.discord?.token) {
normalized.discord = channels.discord;
const discord = { ...channels.discord };
normalizeLegacyGroupFields(discord, `${sourcePath}.discord`);
normalized.discord = discord;
}
return normalized;
@@ -272,9 +379,9 @@ export function normalizeAgents(config: LettaBotConfig): AgentConfig[] {
// Multi-agent mode: normalize channels for each configured agent
if (config.agents && config.agents.length > 0) {
return config.agents.map(agent => ({
return config.agents.map((agent, index) => ({
...agent,
channels: normalizeChannels(agent.channels),
channels: normalizeChannels(agent.channels, `agents[${index}].channels`),
}));
}
@@ -284,7 +391,7 @@ export function normalizeAgents(config: LettaBotConfig): AgentConfig[] {
const id = config.agent?.id;
// Filter out disabled/misconfigured channels
const channels = normalizeChannels(config.channels);
const channels = normalizeChannels(config.channels, 'channels');
// Env var fallback for container deploys without lettabot.yaml (e.g. Railway)
// Helper: parse comma-separated env var into string array (or undefined)

View File

@@ -374,11 +374,13 @@ export class LettaBot implements AgentSession {
? msg.batchedMessages[0]
: msg;
// Check if this group is in listening mode
const isListening = this.listeningGroupIds.has(`${msg.channel}:${msg.chatId}`)
|| (msg.serverId && this.listeningGroupIds.has(`${msg.channel}:${msg.serverId}`));
if (isListening && !msg.wasMentioned) {
effective.isListeningMode = true;
// Legacy listeningGroups fallback (new mode-based configs set isListeningMode in adapters)
if (effective.isListeningMode === undefined) {
const isListening = this.listeningGroupIds.has(`${msg.channel}:${msg.chatId}`)
|| (msg.serverId && this.listeningGroupIds.has(`${msg.channel}:${msg.serverId}`));
if (isListening && !msg.wasMentioned) {
effective.isListeningMode = true;
}
}
this.messageQueue.push({ msg: effective, adapter });

View File

@@ -91,6 +91,8 @@ export class GroupBatcher {
isGroup: true,
groupName: last.groupName,
wasMentioned: messages.some((m) => m.wasMentioned),
// Preserve listening-mode intent only if every message in the batch is non-mentioned listen mode.
isListeningMode: messages.every((m) => m.isListeningMode === true) ? true : undefined,
isBatch: true,
batchedMessages: messages,
};

View File

@@ -315,6 +315,8 @@ function createChannelsForAgent(
selfChatMode,
attachmentsDir,
attachmentsMaxBytes,
groups: agentConfig.channels.whatsapp.groups,
mentionPatterns: agentConfig.channels.whatsapp.mentionPatterns,
}));
}
@@ -336,6 +338,8 @@ function createChannelsForAgent(
selfChatMode,
attachmentsDir,
attachmentsMaxBytes,
groups: agentConfig.channels.signal.groups,
mentionPatterns: agentConfig.channels.signal.mentionPatterns,
}));
}