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:
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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).
|
||||
*/
|
||||
|
||||
@@ -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).
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user