feat(discord): thread-only groups with auto-thread mentions + reaction gating (#540)

Co-authored-by: Letta Code <noreply@letta.com>
This commit is contained in:
Cameron
2026-03-09 16:32:38 -07:00
committed by GitHub
parent d038c1361a
commit 30c74a716c
6 changed files with 271 additions and 15 deletions

View File

@@ -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.<id>.threadMode: thread-only` -- bot responds only to messages in threads
- `groups.<id>.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:

View File

@@ -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.

View File

@@ -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<string, GroupModeConfig> = {
'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<string, GroupModeConfig> = {
'*': { 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<string, GroupModeConfig> = {
'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({

View File

@@ -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<string, GroupModeConfig> | 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<string, GroupModeConfig> | 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,17 +550,61 @@ Ask the bot owner to approve with:
const channelId = message.channel?.id;
if (!channelId) 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
? reaction.emoji.toString()
: (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,

View File

@@ -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).
*/

View File

@@ -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).
*/