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

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

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

Closes #266

Written by Cameron and Letta Code

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

View File

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