From c410decd18050025b70fed99daf726c81f8c11b5 Mon Sep 17 00:00:00 2001 From: Cameron Date: Tue, 10 Feb 2026 16:01:21 -0800 Subject: [PATCH] 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 --- README.md | 6 +- docs/configuration.md | 24 +++ lettabot.example.yaml | 22 ++- src/channels/discord.ts | 32 ++-- src/channels/group-mode.test.ts | 77 ++++++++++ src/channels/group-mode.ts | 63 ++++++++ src/channels/signal.ts | 10 +- src/channels/signal/group-gating.test.ts | 37 +++-- src/channels/signal/group-gating.ts | 76 +++++---- src/channels/slack.ts | 29 ++-- src/channels/telegram-group-gating.test.ts | 46 ++++-- src/channels/telegram-group-gating.ts | 86 ++++++----- src/channels/telegram.ts | 70 ++++----- .../whatsapp/inbound/group-gating.test.ts | 20 ++- src/channels/whatsapp/inbound/group-gating.ts | 62 +++----- src/channels/whatsapp/index.ts | 3 + src/channels/whatsapp/types.ts | 5 +- src/config/io.ts | 62 +++++++- src/config/normalize.test.ts | 58 ++++++- src/config/types.ts | 145 +++++++++++++++--- src/core/bot.ts | 12 +- src/core/group-batcher.ts | 2 + src/main.ts | 4 + 23 files changed, 677 insertions(+), 274 deletions(-) create mode 100644 src/channels/group-mode.test.ts create mode 100644 src/channels/group-mode.ts diff --git a/README.md b/README.md index e6e6cf3..717262e 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/configuration.md b/docs/configuration.md index f4d836b..1b8e9ec 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -265,6 +265,30 @@ channels: The deprecated `groupPollIntervalMin` (minutes) still works for backward compatibility but `groupDebounceSec` takes priority. +### Group Modes + +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 + +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". diff --git a/lettabot.example.yaml b/lettabot.example.yaml index 844c3eb..e543215 100644 --- a/lettabot.example.yaml +++ b/lettabot.example.yaml @@ -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 diff --git a/src/channels/discord.ts b/src/channels/discord.ts index 9981e05..5f23d0a 100644 --- a/src/channels/discord.ts +++ b/src/channels/discord.ts @@ -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; // Per-guild/channel settings + groups?: Record; // 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, }); } diff --git a/src/channels/group-mode.test.ts b/src/channels/group-mode.test.ts new file mode 100644 index 0000000..8f685d5 --- /dev/null +++ b/src/channels/group-mode.test.ts @@ -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'); + }); + }); +}); diff --git a/src/channels/group-mode.ts b/src/channels/group-mode.ts new file mode 100644 index 0000000..8cacd95 --- /dev/null +++ b/src/channels/group-mode.ts @@ -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; + +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; +} diff --git a/src/channels/signal.ts b/src/channels/signal.ts index 382ea0c..e0bf47a 100644 --- a/src/channels/signal.ts +++ b/src/channels/signal.ts @@ -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, }; diff --git a/src/channels/signal/group-gating.test.ts b/src/channels/signal/group-gating.test.ts index 3d234cf..3fca024 100644 --- a/src/channels/signal/group-gating.test.ts +++ b/src/channels/signal/group-gating.test.ts @@ -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', () => { diff --git a/src/channels/signal/group-gating.ts b/src/channels/signal/group-gating.ts index 774b567..5d9cab2 100644 --- a/src/channels/signal/group-gating.ts +++ b/src/channels/signal/group-gating.ts @@ -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', }; } diff --git a/src/channels/slack.ts b/src/channels/slack.ts index 6771349..0ed4a0a 100644 --- a/src/channels/slack.ts +++ b/src/channels/slack.ts @@ -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; // Per-channel settings + groups?: Record; // 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 { diff --git a/src/channels/telegram-group-gating.test.ts b/src/channels/telegram-group-gating.test.ts index 5b54e99..24a7420 100644 --- a/src/channels/telegram-group-gating.test.ts +++ b/src/channels/telegram-group-gating.test.ts @@ -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'); }); }); }); diff --git a/src/channels/telegram-group-gating.ts b/src/channels/telegram-group-gating.ts index 0e0a663..3aecf04 100644 --- a/src/channels/telegram-group-gating.ts +++ b/src/channels/telegram-group-gating.ts @@ -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; + groupsConfig?: Record; /** 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 }; } diff --git a/src/channels/telegram.ts b/src/channels/telegram.ts index e7e06fa..2844ce4 100644 --- a/src/channels/telegram.ts +++ b/src/channels/telegram.ts @@ -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; // Per-group settings + groups?: Record; // 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, }); } diff --git a/src/channels/whatsapp/inbound/group-gating.test.ts b/src/channels/whatsapp/inbound/group-gating.test.ts index c777b2f..6035012 100644 --- a/src/channels/whatsapp/inbound/group-gating.test.ts +++ b/src/channels/whatsapp/inbound/group-gating.test.ts @@ -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', () => { diff --git a/src/channels/whatsapp/inbound/group-gating.ts b/src/channels/whatsapp/inbound/group-gating.ts index 4a4504b..1ca902d 100644 --- a/src/channels/whatsapp/inbound/group-gating.ts +++ b/src/channels/whatsapp/inbound/group-gating.ts @@ -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; + groupsConfig?: Record; /** 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, }; } diff --git a/src/channels/whatsapp/index.ts b/src/channels/whatsapp/index.ts index 7fd85f9..294413b 100644 --- a/src/channels/whatsapp/index.ts +++ b/src/channels/whatsapp/index.ts @@ -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, }); diff --git a/src/channels/whatsapp/types.ts b/src/channels/whatsapp/types.ts index 069d548..19fc474 100644 --- a/src/channels/whatsapp/types.ts +++ b/src/channels/whatsapp/types.ts @@ -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; + groups?: Record; } /** diff --git a/src/config/io.ts b/src/config/io.ts index 46f778a..4bbb8f6 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -348,10 +348,12 @@ export async function syncProviders(config: Partial & Pick): void { if (!parsed.channels) return; @@ -383,6 +385,53 @@ function fixLargeGroupIds(yamlContent: string, parsed: Partial): } } } + + // 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 = {}; + 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 = {}; + 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): cfg[field] = cfg[field].map((v: unknown) => String(v)); } } + if (cfg && cfg.groups && typeof cfg.groups === 'object') { + const fixedGroups: Record = {}; + for (const [key, value] of Object.entries(cfg.groups as Record)) { + fixedGroups[String(key)] = value; + } + cfg.groups = fixedGroups; + } } } } diff --git a/src/config/normalize.test.ts b/src/config/normalize.test.ts index 0d1a32a..152a501 100644 --- a/src/config/normalize.test.ts +++ b/src/config/normalize.test.ts @@ -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', diff --git a/src/config/types.ts b/src/config/types.ts index eee2954..7aa8900 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -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..mode = "listen" mentionPatterns?: string[]; // Regex patterns for mention detection (e.g., ["@mybot"]) - groups?: Record; // Per-group settings, "*" for defaults + groups?: Record; // 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; // Per-channel settings, "*" for defaults + listeningGroups?: string[]; // @deprecated Use groups..mode = "listen" + groups?: Record; // 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; + groups?: Record; 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..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; // Per-group settings, "*" for defaults + groups?: Record; // 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..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; // Per-guild/channel settings, "*" for defaults + listeningGroups?: string[]; // @deprecated Use groups..mode = "listen" + groups?: Record; // Per-guild/channel settings, "*" for defaults } export interface GoogleAccountConfig { @@ -238,6 +248,93 @@ export const DEFAULT_CONFIG: LettaBotConfig = { channels: {}, }; +type ChannelWithLegacyGroupFields = { + groups?: Record; + listeningGroups?: string[]; +}; + +const warnedGroupConfigDeprecations = new Set(); + +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 = channel.groups && typeof channel.groups === 'object' + ? { ...channel.groups } + : {}; + const modeDerivedFromRequireMention = new Set(); + + 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..requireMention`, + 'is deprecated. Use groups..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..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) diff --git a/src/core/bot.ts b/src/core/bot.ts index 7926efc..c5ce463 100644 --- a/src/core/bot.ts +++ b/src/core/bot.ts @@ -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 }); diff --git a/src/core/group-batcher.ts b/src/core/group-batcher.ts index 8d34c16..eb3ce1c 100644 --- a/src/core/group-batcher.ts +++ b/src/core/group-batcher.ts @@ -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, }; diff --git a/src/main.ts b/src/main.ts index 65fbd65..f9da007 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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, })); }