From 30c74a716cca25b20cb7acb4d161c0f6b17d0877 Mon Sep 17 00:00:00 2001 From: Cameron Date: Mon, 9 Mar 2026 16:32:38 -0700 Subject: [PATCH] feat(discord): thread-only groups with auto-thread mentions + reaction gating (#540) Co-authored-by: Letta Code --- docs/configuration.md | 21 +++++ docs/discord-setup.md | 30 +++++++ src/channels/discord.test.ts | 60 ++++++++++++- src/channels/discord.ts | 167 ++++++++++++++++++++++++++++++++--- src/channels/group-mode.ts | 4 + src/config/types.ts | 4 + 6 files changed, 271 insertions(+), 15 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 2bbdec9..d27d3f4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -440,6 +440,27 @@ Resolution follows the same priority as `mode`: specific channel/group ID > guil This works across all channels (Discord, Telegram, Slack, Signal, WhatsApp). +### Discord Thread Controls + +Discord supports extra per-group controls for thread-first workflows: + +- `groups..threadMode: thread-only` -- bot responds only to messages in threads +- `groups..autoCreateThreadOnMention: true` -- for top-level @mentions, create a thread and reply there + +Example (`#ezra` style): + +```yaml +channels: + discord: + groups: + "EZRA_CHANNEL_ID": + mode: open + threadMode: thread-only + autoCreateThreadOnMention: true +``` + +Thread messages inherit parent channel config, so child threads under `EZRA_CHANNEL_ID` use the same group rules. + ### Finding Group IDs Each channel uses different identifiers for groups: diff --git a/docs/discord-setup.md b/docs/discord-setup.md index 4be55d8..12ca6ae 100644 --- a/docs/discord-setup.md +++ b/docs/discord-setup.md @@ -186,6 +186,36 @@ channels: This is the recommended approach when you want to restrict the bot to specific channels. +### Thread-only mode (Discord) + +If you want the bot to reply only inside threads, set `threadMode: thread-only` on a channel (for example `#ezra`). + +You can also set `autoCreateThreadOnMention: true` so a top-level @mention creates a thread and the bot replies there. + +```yaml +channels: + discord: + token: "your-bot-token" + groups: + "EZRA_CHANNEL_ID": + mode: open + threadMode: thread-only + autoCreateThreadOnMention: true +``` + +Behavior summary: + +- Messages already inside threads are processed normally. +- Top-level messages are ignored in `thread-only` mode. +- Top-level @mentions create a thread and are answered in that thread when `autoCreateThreadOnMention` is enabled. +- Thread messages inherit parent channel config. If `EZRA_CHANNEL_ID` is configured, replies in its child threads use that same config. + +Required Discord permissions for auto-create: + +- `Send Messages` +- `Create Public Threads` (or relevant thread creation permission for your channel type) +- `Send Messages in Threads` + ### Per-group user filtering Use `allowedUsers` within a group entry to restrict which Discord users can trigger the bot. Messages from other users are silently dropped before reaching the agent. diff --git a/src/channels/discord.test.ts b/src/channels/discord.test.ts index bda47a1..23a1c89 100644 --- a/src/channels/discord.test.ts +++ b/src/channels/discord.test.ts @@ -1,7 +1,65 @@ import { describe, expect, it } from 'vitest'; -import { shouldProcessDiscordBotMessage } from './discord.js'; +import { + buildDiscordGroupKeys, + resolveDiscordAutoCreateThreadOnMention, + resolveDiscordThreadMode, + shouldProcessDiscordBotMessage, +} from './discord.js'; import type { GroupModeConfig } from './group-mode.js'; +describe('buildDiscordGroupKeys', () => { + it('includes chat, parent, and server IDs in priority order', () => { + expect(buildDiscordGroupKeys({ + chatId: 'thread-1', + parentChatId: 'channel-1', + serverId: 'guild-1', + })).toEqual(['thread-1', 'channel-1', 'guild-1']); + }); + + it('deduplicates repeated IDs', () => { + expect(buildDiscordGroupKeys({ + chatId: 'channel-1', + parentChatId: 'channel-1', + serverId: 'guild-1', + })).toEqual(['channel-1', 'guild-1']); + }); +}); + +describe('resolveDiscordThreadMode', () => { + it('resolves from the first matching key', () => { + const groups: Record = { + 'channel-1': { threadMode: 'thread-only' }, + 'guild-1': { threadMode: 'any' }, + }; + expect(resolveDiscordThreadMode(groups, ['channel-1', 'guild-1'])).toBe('thread-only'); + }); + + it('falls back to wildcard when no explicit key matches', () => { + const groups: Record = { + '*': { threadMode: 'thread-only' }, + }; + expect(resolveDiscordThreadMode(groups, ['channel-1', 'guild-1'])).toBe('thread-only'); + }); + + it('defaults to any when unset', () => { + expect(resolveDiscordThreadMode(undefined, ['channel-1'])).toBe('any'); + }); +}); + +describe('resolveDiscordAutoCreateThreadOnMention', () => { + it('resolves from matching key before wildcard', () => { + const groups: Record = { + 'channel-1': { autoCreateThreadOnMention: true }, + '*': { autoCreateThreadOnMention: false }, + }; + expect(resolveDiscordAutoCreateThreadOnMention(groups, ['channel-1', 'guild-1'])).toBe(true); + }); + + it('defaults to false when unset', () => { + expect(resolveDiscordAutoCreateThreadOnMention(undefined, ['channel-1'])).toBe(false); + }); +}); + describe('shouldProcessDiscordBotMessage', () => { it('allows non-bot messages', () => { expect(shouldProcessDiscordBotMessage({ diff --git a/src/channels/discord.ts b/src/channels/discord.ts index a729629..ffafd52 100644 --- a/src/channels/discord.ts +++ b/src/channels/discord.ts @@ -51,6 +51,59 @@ export function shouldProcessDiscordBotMessage(params: { return resolveReceiveBotMessages(params.groups, params.keys); } +export type DiscordThreadMode = 'any' | 'thread-only'; + +export function buildDiscordGroupKeys(params: { + chatId: string; + serverId?: string | null; + parentChatId?: string | null; +}): string[] { + const keys: string[] = []; + const add = (value?: string | null) => { + if (!value) return; + if (keys.includes(value)) return; + keys.push(value); + }; + + add(params.chatId); + add(params.parentChatId); + add(params.serverId); + return keys; +} + +export function resolveDiscordThreadMode( + groups: Record | undefined, + keys: string[], + fallback: DiscordThreadMode = 'any', +): DiscordThreadMode { + if (groups) { + for (const key of keys) { + const mode = groups[key]?.threadMode; + if (mode === 'any' || mode === 'thread-only') return mode; + } + const wildcard = groups['*']?.threadMode; + if (wildcard === 'any' || wildcard === 'thread-only') return wildcard; + } + return fallback; +} + +export function resolveDiscordAutoCreateThreadOnMention( + groups: Record | undefined, + keys: string[], +): boolean { + if (groups) { + for (const key of keys) { + if (groups[key]?.autoCreateThreadOnMention !== undefined) { + return !!groups[key].autoCreateThreadOnMention; + } + } + if (groups['*']?.autoCreateThreadOnMention !== undefined) { + return !!groups['*'].autoCreateThreadOnMention; + } + } + return false; +} + export class DiscordAdapter implements ChannelAdapter { readonly id = 'discord' as const; readonly name = 'Discord'; @@ -77,6 +130,27 @@ export class DiscordAdapter implements ChannelAdapter { return checkDmAccess('discord', userId, this.config.dmPolicy, this.config.allowedUsers); } + private async createThreadForMention( + message: import('discord.js').Message, + seedText: string, + ): Promise<{ id: string; name?: string } | null> { + const normalized = seedText.replace(/<@!?\d+>/g, '').trim(); + const firstLine = normalized.split('\n')[0]?.trim(); + const baseName = firstLine || `${message.author.username} question`; + const threadName = baseName.slice(0, 100); + + try { + const thread = await message.startThread({ + name: threadName, + reason: 'lettabot thread-only mention trigger', + }); + return { id: thread.id, name: thread.name }; + } catch (error) { + log.warn('Failed to create thread for mention:', error instanceof Error ? error.message : error); + return null; + } + } + /** * Format pairing message for Discord */ @@ -146,8 +220,14 @@ Ask the bot owner to approve with: const isFromBot = !!message.author?.bot; const isGroup = !!message.guildId; const chatId = message.channel.id; - const keys = [chatId]; - if (message.guildId) keys.push(message.guildId); + const channelWithThread = message.channel as { isThread?: () => boolean; parentId?: string | null }; + const isThreadMessage = typeof channelWithThread.isThread === 'function' && channelWithThread.isThread(); + const parentChannelId = isThreadMessage ? channelWithThread.parentId ?? undefined : undefined; + const keys = buildDiscordGroupKeys({ + chatId, + parentChatId: parentChannelId, + serverId: message.guildId, + }); const selfUserId = this.client?.user?.id; if (!shouldProcessDiscordBotMessage({ @@ -248,18 +328,15 @@ Ask the bot owner to approve with: } if (this.onMessage) { - const isGroup = !!message.guildId; 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; + let effectiveChatId = message.channel.id; + let effectiveGroupName = groupName; // Group gating: config-based allowlist + mode if (isGroup && this.config.groups) { - const chatId = message.channel.id; - const serverId = message.guildId; - const keys = [chatId]; - if (serverId) keys.push(serverId); if (!isGroupAllowed(this.config.groups, keys)) { log.info(`Group ${chatId} not in allowlist, ignoring`); return; @@ -278,7 +355,8 @@ Ask the bot owner to approve with: } isListeningMode = mode === 'listen' && !wasMentioned; - // Daily rate limit check (after all other gating so we only count real triggers) + // Daily rate limit check before side-effectful actions (like thread creation) + // so over-limit mentions don't create empty threads. const limits = resolveDailyLimits(this.config.groups, keys); const counterScope = limits.matchedKey ?? chatId; const counterKey = `${this.config.agentName ?? ''}:discord:${counterScope}`; @@ -287,11 +365,27 @@ Ask the bot owner to approve with: log.info(`Daily limit reached for ${counterKey} (${limitResult.reason})`); return; } + + const threadMode = resolveDiscordThreadMode(this.config.groups, keys); + if (threadMode === 'thread-only' && !isThreadMessage) { + const shouldCreateThread = + wasMentioned && resolveDiscordAutoCreateThreadOnMention(this.config.groups, keys); + if (!shouldCreateThread) { + return; // Thread-only mode drops non-thread messages unless auto-create is enabled on @mention + } + + const createdThread = await this.createThreadForMention(message, content); + if (!createdThread) { + return; + } + effectiveChatId = createdThread.id; + effectiveGroupName = createdThread.name || effectiveGroupName; + } } await this.onMessage({ channel: 'discord', - chatId: message.channel.id, + chatId: effectiveChatId, userId, userName: displayName, userHandle: message.author.username, @@ -299,7 +393,7 @@ Ask the bot owner to approve with: text: content || '', timestamp: message.createdAt, isGroup, - groupName, + groupName: effectiveGroupName, serverId: message.guildId || undefined, wasMentioned, isListeningMode, @@ -456,9 +550,54 @@ Ask the bot owner to approve with: const channelId = message.channel?.id; if (!channelId) return; - const access = await this.checkAccess(user.id); - if (access !== 'allowed') { - return; + const isGroup = !!message.guildId; + const channelWithThread = message.channel as { isThread?: () => boolean; parentId?: string | null }; + const isThreadMessage = typeof channelWithThread.isThread === 'function' && channelWithThread.isThread(); + const parentChannelId = isThreadMessage ? channelWithThread.parentId ?? undefined : undefined; + const keys = buildDiscordGroupKeys({ + chatId: channelId, + parentChatId: parentChannelId, + serverId: message.guildId, + }); + + // DM policy should only gate DMs, not guild reactions. + if (!isGroup) { + const access = await this.checkAccess(user.id); + if (access !== 'allowed') { + return; + } + } + + let isListeningMode = false; + if (isGroup && this.config.groups) { + if (!isGroupAllowed(this.config.groups, keys)) { + log.info(`Reaction group ${channelId} not in allowlist, ignoring`); + return; + } + + if (!isGroupUserAllowed(this.config.groups, keys, user.id)) { + return; + } + + const mode = resolveGroupMode(this.config.groups, keys, 'open'); + if (mode === 'disabled' || mode === 'mention-only') { + return; + } + isListeningMode = mode === 'listen'; + + const threadMode = resolveDiscordThreadMode(this.config.groups, keys); + if (threadMode === 'thread-only' && !isThreadMessage) { + return; + } + + const limits = resolveDailyLimits(this.config.groups, keys); + const counterScope = limits.matchedKey ?? channelId; + const counterKey = `${this.config.agentName ?? ''}:discord:${counterScope}`; + const limitResult = checkDailyLimit(counterKey, user.id, limits); + if (!limitResult.allowed) { + log.info(`Daily limit reached for ${counterKey} (${limitResult.reason})`); + return; + } } const emoji = reaction.emoji.id @@ -466,7 +605,6 @@ Ask the bot owner to approve with: : (reaction.emoji.name || reaction.emoji.toString()); if (!emoji) return; - const isGroup = !!message.guildId; const groupName = isGroup && 'name' in message.channel ? message.channel.name || undefined : undefined; @@ -488,6 +626,7 @@ Ask the bot owner to approve with: isGroup, groupName, serverId: message.guildId || undefined, + isListeningMode, reaction: { emoji, messageId: message.id, diff --git a/src/channels/group-mode.ts b/src/channels/group-mode.ts index 7cb9fe8..ad23301 100644 --- a/src/channels/group-mode.ts +++ b/src/channels/group-mode.ts @@ -14,6 +14,10 @@ export interface GroupModeConfig { dailyLimit?: number; /** Maximum bot triggers per user per day in this group. Omit for unlimited. */ dailyUserLimit?: number; + /** Discord only: require messages to be in a thread before the bot responds. */ + threadMode?: 'any' | 'thread-only'; + /** Discord only: when true, @mentions in parent channels auto-create a thread. */ + autoCreateThreadOnMention?: boolean; /** * @deprecated Use mode: "mention-only" (true) or "open" (false). */ diff --git a/src/config/types.ts b/src/config/types.ts index 40a33f8..d98d9e6 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -281,6 +281,10 @@ export interface GroupConfig { dailyLimit?: number; /** Maximum bot triggers per user per day in this group. Omit for unlimited. */ dailyUserLimit?: number; + /** Discord only: require messages to be in a thread before the bot responds. */ + threadMode?: 'any' | 'thread-only'; + /** Discord only: when true, @mentions in parent channels auto-create a thread. */ + autoCreateThreadOnMention?: boolean; /** * @deprecated Use mode: "mention-only" (true) or "open" (false). */