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:
@@ -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.
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
77
src/channels/group-mode.test.ts
Normal file
77
src/channels/group-mode.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
63
src/channels/group-mode.ts
Normal file
63
src/channels/group-mode.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user