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).
|
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
|
### Finding Group IDs
|
||||||
|
|
||||||
Each channel uses different identifiers for groups:
|
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.
|
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
|
### 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.
|
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 { 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';
|
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', () => {
|
describe('shouldProcessDiscordBotMessage', () => {
|
||||||
it('allows non-bot messages', () => {
|
it('allows non-bot messages', () => {
|
||||||
expect(shouldProcessDiscordBotMessage({
|
expect(shouldProcessDiscordBotMessage({
|
||||||
|
|||||||
@@ -51,6 +51,59 @@ export function shouldProcessDiscordBotMessage(params: {
|
|||||||
return resolveReceiveBotMessages(params.groups, params.keys);
|
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 {
|
export class DiscordAdapter implements ChannelAdapter {
|
||||||
readonly id = 'discord' as const;
|
readonly id = 'discord' as const;
|
||||||
readonly name = 'Discord';
|
readonly name = 'Discord';
|
||||||
@@ -77,6 +130,27 @@ export class DiscordAdapter implements ChannelAdapter {
|
|||||||
return checkDmAccess('discord', userId, this.config.dmPolicy, this.config.allowedUsers);
|
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
|
* Format pairing message for Discord
|
||||||
*/
|
*/
|
||||||
@@ -146,8 +220,14 @@ Ask the bot owner to approve with:
|
|||||||
const isFromBot = !!message.author?.bot;
|
const isFromBot = !!message.author?.bot;
|
||||||
const isGroup = !!message.guildId;
|
const isGroup = !!message.guildId;
|
||||||
const chatId = message.channel.id;
|
const chatId = message.channel.id;
|
||||||
const keys = [chatId];
|
const channelWithThread = message.channel as { isThread?: () => boolean; parentId?: string | null };
|
||||||
if (message.guildId) keys.push(message.guildId);
|
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;
|
const selfUserId = this.client?.user?.id;
|
||||||
|
|
||||||
if (!shouldProcessDiscordBotMessage({
|
if (!shouldProcessDiscordBotMessage({
|
||||||
@@ -248,18 +328,15 @@ Ask the bot owner to approve with:
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this.onMessage) {
|
if (this.onMessage) {
|
||||||
const isGroup = !!message.guildId;
|
|
||||||
const groupName = isGroup && 'name' in message.channel ? message.channel.name : undefined;
|
const groupName = isGroup && 'name' in message.channel ? message.channel.name : undefined;
|
||||||
const displayName = message.member?.displayName || message.author.globalName || message.author.username;
|
const displayName = message.member?.displayName || message.author.globalName || message.author.username;
|
||||||
const wasMentioned = isGroup && !!this.client?.user && message.mentions.has(this.client.user);
|
const wasMentioned = isGroup && !!this.client?.user && message.mentions.has(this.client.user);
|
||||||
let isListeningMode = false;
|
let isListeningMode = false;
|
||||||
|
let effectiveChatId = message.channel.id;
|
||||||
|
let effectiveGroupName = groupName;
|
||||||
|
|
||||||
// Group gating: config-based allowlist + mode
|
// Group gating: config-based allowlist + mode
|
||||||
if (isGroup && this.config.groups) {
|
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)) {
|
if (!isGroupAllowed(this.config.groups, keys)) {
|
||||||
log.info(`Group ${chatId} not in allowlist, ignoring`);
|
log.info(`Group ${chatId} not in allowlist, ignoring`);
|
||||||
return;
|
return;
|
||||||
@@ -278,7 +355,8 @@ Ask the bot owner to approve with:
|
|||||||
}
|
}
|
||||||
isListeningMode = mode === 'listen' && !wasMentioned;
|
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 limits = resolveDailyLimits(this.config.groups, keys);
|
||||||
const counterScope = limits.matchedKey ?? chatId;
|
const counterScope = limits.matchedKey ?? chatId;
|
||||||
const counterKey = `${this.config.agentName ?? ''}:discord:${counterScope}`;
|
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})`);
|
log.info(`Daily limit reached for ${counterKey} (${limitResult.reason})`);
|
||||||
return;
|
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({
|
await this.onMessage({
|
||||||
channel: 'discord',
|
channel: 'discord',
|
||||||
chatId: message.channel.id,
|
chatId: effectiveChatId,
|
||||||
userId,
|
userId,
|
||||||
userName: displayName,
|
userName: displayName,
|
||||||
userHandle: message.author.username,
|
userHandle: message.author.username,
|
||||||
@@ -299,7 +393,7 @@ Ask the bot owner to approve with:
|
|||||||
text: content || '',
|
text: content || '',
|
||||||
timestamp: message.createdAt,
|
timestamp: message.createdAt,
|
||||||
isGroup,
|
isGroup,
|
||||||
groupName,
|
groupName: effectiveGroupName,
|
||||||
serverId: message.guildId || undefined,
|
serverId: message.guildId || undefined,
|
||||||
wasMentioned,
|
wasMentioned,
|
||||||
isListeningMode,
|
isListeningMode,
|
||||||
@@ -456,9 +550,54 @@ Ask the bot owner to approve with:
|
|||||||
const channelId = message.channel?.id;
|
const channelId = message.channel?.id;
|
||||||
if (!channelId) return;
|
if (!channelId) return;
|
||||||
|
|
||||||
const access = await this.checkAccess(user.id);
|
const isGroup = !!message.guildId;
|
||||||
if (access !== 'allowed') {
|
const channelWithThread = message.channel as { isThread?: () => boolean; parentId?: string | null };
|
||||||
return;
|
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
|
const emoji = reaction.emoji.id
|
||||||
@@ -466,7 +605,6 @@ Ask the bot owner to approve with:
|
|||||||
: (reaction.emoji.name || reaction.emoji.toString());
|
: (reaction.emoji.name || reaction.emoji.toString());
|
||||||
if (!emoji) return;
|
if (!emoji) return;
|
||||||
|
|
||||||
const isGroup = !!message.guildId;
|
|
||||||
const groupName = isGroup && 'name' in message.channel
|
const groupName = isGroup && 'name' in message.channel
|
||||||
? message.channel.name || undefined
|
? message.channel.name || undefined
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -488,6 +626,7 @@ Ask the bot owner to approve with:
|
|||||||
isGroup,
|
isGroup,
|
||||||
groupName,
|
groupName,
|
||||||
serverId: message.guildId || undefined,
|
serverId: message.guildId || undefined,
|
||||||
|
isListeningMode,
|
||||||
reaction: {
|
reaction: {
|
||||||
emoji,
|
emoji,
|
||||||
messageId: message.id,
|
messageId: message.id,
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ export interface GroupModeConfig {
|
|||||||
dailyLimit?: number;
|
dailyLimit?: number;
|
||||||
/** Maximum bot triggers per user per day in this group. Omit for unlimited. */
|
/** Maximum bot triggers per user per day in this group. Omit for unlimited. */
|
||||||
dailyUserLimit?: number;
|
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).
|
* @deprecated Use mode: "mention-only" (true) or "open" (false).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -281,6 +281,10 @@ export interface GroupConfig {
|
|||||||
dailyLimit?: number;
|
dailyLimit?: number;
|
||||||
/** Maximum bot triggers per user per day in this group. Omit for unlimited. */
|
/** Maximum bot triggers per user per day in this group. Omit for unlimited. */
|
||||||
dailyUserLimit?: number;
|
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).
|
* @deprecated Use mode: "mention-only" (true) or "open" (false).
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user