Skip to content

Commit 29198d4

Browse files
committed
feat: add AI conversation threading (issue #23)
When the bot is @mentioned in a regular channel, create a Discord thread and continue the conversation there instead of replying inline. If a thread already exists for that user's recent conversation (within 30 min), reuse it. - Add threading module with thread creation, reuse, and permission checks - Integrate threading into message handler with graceful fallback - Conversation history is scoped to thread ID when in thread mode - Handle edge cases: existing threads (inline), DMs (no threads), missing permissions (fallback to inline) - Add config options: ai.threadMode.enabled, autoArchiveMinutes, reuseWindowMinutes - 37 new threading tests + 3 new events integration tests - 100% coverage on threading module Closes #23
1 parent 433b679 commit 29198d4

5 files changed

Lines changed: 984 additions & 5 deletions

File tree

config.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,12 @@
66
"systemPrompt": "You are Volvox Bot, the friendly AI assistant for the Volvox developer community Discord server.\n\nYou're witty, snarky (but warm), and deeply knowledgeable about programming, software development, and tech.\n\nKey traits:\n- Helpful but not boring\n- Can roast people lightly when appropriate\n- Enthusiastic about cool tech and projects\n- Supportive of beginners learning to code\n- Concise - this is Discord, not an essay\n\n⚠️ CRITICAL RULES:\n- NEVER type @.everyone or @.here (remove the dots) - these ping hundreds of people\n- NEVER use mass mention pings under any circumstances\n- If you need to address the group, say \"everyone\" or \"folks\" without the @ symbol\n\nKeep responses under 2000 chars. Use Discord markdown when helpful.",
77
"channels": [],
88
"historyLength": 20,
9-
"historyTTLDays": 30
9+
"historyTTLDays": 30,
10+
"threadMode": {
11+
"enabled": false,
12+
"autoArchiveMinutes": 60,
13+
"reuseWindowMinutes": 30
14+
}
1015
},
1116
"chimeIn": {
1217
"enabled": false,

src/modules/events.js

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { needsSplitting, splitMessage } from '../utils/splitMessage.js';
1010
import { generateResponse } from './ai.js';
1111
import { accumulate, resetCounter } from './chimeIn.js';
1212
import { isSpam, sendSpamAlert } from './spam.js';
13+
import { getOrCreateThread, shouldUseThread } from './threading.js';
1314
import { recordCommunityActivity, sendWelcomeMessage } from './welcome.js';
1415

1516
/** @type {boolean} Guard against duplicate process-level handler registration */
@@ -100,10 +101,25 @@ export function registerMessageCreateHandler(client, config, healthMonitor) {
100101
return;
101102
}
102103

103-
await message.channel.sendTyping();
104+
// Determine whether to use threading
105+
const useThread = shouldUseThread(message);
106+
let targetChannel = message.channel;
107+
108+
if (useThread) {
109+
const { thread } = await getOrCreateThread(message, cleanContent);
110+
if (thread) {
111+
targetChannel = thread;
112+
}
113+
// If thread is null, fall back to inline reply (targetChannel stays as message.channel)
114+
}
115+
116+
await targetChannel.sendTyping();
117+
118+
// Use thread ID for conversation history when in a thread, otherwise channel ID
119+
const historyId = targetChannel.id;
104120

105121
const response = await generateResponse(
106-
message.channel.id,
122+
historyId,
107123
cleanContent,
108124
message.author.username,
109125
config,
@@ -114,10 +130,14 @@ export function registerMessageCreateHandler(client, config, healthMonitor) {
114130
if (needsSplitting(response)) {
115131
const chunks = splitMessage(response);
116132
for (const chunk of chunks) {
117-
await message.channel.send(chunk);
133+
await targetChannel.send(chunk);
118134
}
119-
} else {
135+
} else if (targetChannel === message.channel) {
136+
// Inline reply — use message.reply for the reference
120137
await message.reply(response);
138+
} else {
139+
// Thread reply — send directly to the thread
140+
await targetChannel.send(response);
121141
}
122142
} catch (sendErr) {
123143
logError('Failed to send AI response', {

src/modules/threading.js

Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
/**
2+
* Threading Module
3+
* Manages Discord thread creation and reuse for AI conversations.
4+
*
5+
* When the bot is @mentioned in a regular channel, instead of replying inline,
6+
* it creates (or reuses) a thread and continues the conversation there.
7+
* This keeps channels clean while preserving conversation context.
8+
*/
9+
10+
import { ChannelType, PermissionFlagsBits } from 'discord.js';
11+
import { info, error as logError, warn } from '../logger.js';
12+
import { getConfig } from './config.js';
13+
14+
/**
15+
* Active thread tracker: Map<`${userId}:${channelId}`, { threadId, lastActive, threadName }>
16+
* Tracks which thread to reuse for a given user+channel combination.
17+
*/
18+
const activeThreads = new Map();
19+
20+
/** Default thread auto-archive duration in minutes */
21+
const DEFAULT_AUTO_ARCHIVE_MINUTES = 60;
22+
23+
/** Default thread reuse window in milliseconds (30 minutes) */
24+
const DEFAULT_REUSE_WINDOW_MS = 30 * 60 * 1000;
25+
26+
/** Maximum thread name length (Discord limit) */
27+
const MAX_THREAD_NAME_LENGTH = 100;
28+
29+
/**
30+
* Get threading configuration from bot config
31+
* @returns {{ enabled: boolean, autoArchiveMinutes: number, reuseWindowMs: number }}
32+
*/
33+
export function getThreadConfig() {
34+
try {
35+
const config = getConfig();
36+
const threadMode = config?.ai?.threadMode;
37+
return {
38+
enabled: threadMode?.enabled ?? false,
39+
autoArchiveMinutes: threadMode?.autoArchiveMinutes ?? DEFAULT_AUTO_ARCHIVE_MINUTES,
40+
reuseWindowMs: (threadMode?.reuseWindowMinutes ?? 30) * 60 * 1000,
41+
};
42+
} catch {
43+
return {
44+
enabled: false,
45+
autoArchiveMinutes: DEFAULT_AUTO_ARCHIVE_MINUTES,
46+
reuseWindowMs: DEFAULT_REUSE_WINDOW_MS,
47+
};
48+
}
49+
}
50+
51+
/**
52+
* Check if a message should be handled via threading
53+
* @param {import('discord.js').Message} message - Discord message
54+
* @returns {boolean} Whether threading should be used
55+
*/
56+
export function shouldUseThread(message) {
57+
const threadConfig = getThreadConfig();
58+
if (!threadConfig.enabled) return false;
59+
60+
// Don't create threads in DMs
61+
if (!message.guild) return false;
62+
63+
// Don't create threads inside existing threads — reply inline
64+
if (message.channel.isThread()) return false;
65+
66+
// Channel must be a text-based guild channel that supports threads
67+
const threadableTypes = [ChannelType.GuildText, ChannelType.GuildAnnouncement];
68+
if (!threadableTypes.includes(message.channel.type)) return false;
69+
70+
return true;
71+
}
72+
73+
/**
74+
* Check if the bot has permission to create threads in a channel
75+
* @param {import('discord.js').Message} message - Discord message
76+
* @returns {boolean} Whether the bot can create threads
77+
*/
78+
export function canCreateThread(message) {
79+
if (!message.guild) return false;
80+
81+
try {
82+
const botMember = message.guild.members.me;
83+
if (!botMember) return false;
84+
85+
const permissions = message.channel.permissionsFor(botMember);
86+
if (!permissions) return false;
87+
88+
return (
89+
permissions.has(PermissionFlagsBits.CreatePublicThreads) &&
90+
permissions.has(PermissionFlagsBits.SendMessagesInThreads)
91+
);
92+
} catch (err) {
93+
warn('Failed to check thread permissions', { error: err.message });
94+
return false;
95+
}
96+
}
97+
98+
/**
99+
* Generate a thread name from the user message
100+
* Truncates to Discord's limit and sanitizes
101+
* @param {string} username - The user's display name
102+
* @param {string} messageContent - The cleaned message content
103+
* @returns {string} Thread name
104+
*/
105+
export function generateThreadName(username, messageContent) {
106+
// Use first line of message content, truncated
107+
const firstLine = messageContent.split('\n')[0].trim();
108+
109+
let name;
110+
if (firstLine.length > 0) {
111+
// Truncate to fit within Discord's limit with username prefix
112+
const prefix = `${username}: `;
113+
const maxContentLength = MAX_THREAD_NAME_LENGTH - prefix.length;
114+
const truncatedContent =
115+
firstLine.length > maxContentLength
116+
? `${firstLine.substring(0, maxContentLength - 1)}…`
117+
: firstLine;
118+
name = `${prefix}${truncatedContent}`;
119+
} else {
120+
name = `Chat with ${username}`;
121+
}
122+
123+
return name;
124+
}
125+
126+
/**
127+
* Build the cache key for active thread tracking
128+
* @param {string} userId - User ID
129+
* @param {string} channelId - Channel ID
130+
* @returns {string} Cache key
131+
*/
132+
export function buildThreadKey(userId, channelId) {
133+
return `${userId}:${channelId}`;
134+
}
135+
136+
/**
137+
* Find an existing thread to reuse for this user+channel combination
138+
* @param {import('discord.js').Message} message - Discord message
139+
* @returns {Promise<import('discord.js').ThreadChannel|null>} Thread to reuse, or null
140+
*/
141+
export async function findExistingThread(message) {
142+
const threadConfig = getThreadConfig();
143+
const key = buildThreadKey(message.author.id, message.channel.id);
144+
const entry = activeThreads.get(key);
145+
146+
if (!entry) return null;
147+
148+
// Check if the thread is still within the reuse window
149+
const now = Date.now();
150+
if (now - entry.lastActive > threadConfig.reuseWindowMs) {
151+
activeThreads.delete(key);
152+
return null;
153+
}
154+
155+
// Try to fetch the thread — it may have been deleted or archived
156+
try {
157+
const thread = await message.channel.threads.fetch(entry.threadId);
158+
if (!thread) {
159+
activeThreads.delete(key);
160+
return null;
161+
}
162+
163+
// If thread is archived, try to unarchive it
164+
if (thread.archived) {
165+
try {
166+
await thread.setArchived(false);
167+
info('Unarchived thread for reuse', {
168+
threadId: thread.id,
169+
userId: message.author.id,
170+
});
171+
} catch (err) {
172+
warn('Failed to unarchive thread, creating new one', {
173+
threadId: thread.id,
174+
error: err.message,
175+
});
176+
activeThreads.delete(key);
177+
return null;
178+
}
179+
}
180+
181+
// Update last active time
182+
entry.lastActive = now;
183+
return thread;
184+
} catch (_err) {
185+
// Thread not found or inaccessible
186+
activeThreads.delete(key);
187+
return null;
188+
}
189+
}
190+
191+
/**
192+
* Create a new thread for the conversation
193+
* @param {import('discord.js').Message} message - The triggering message
194+
* @param {string} cleanContent - The cleaned message content (mention removed)
195+
* @returns {Promise<import('discord.js').ThreadChannel>} The created thread
196+
*/
197+
export async function createThread(message, cleanContent) {
198+
const threadConfig = getThreadConfig();
199+
const threadName = generateThreadName(
200+
message.author.displayName || message.author.username,
201+
cleanContent,
202+
);
203+
204+
const thread = await message.startThread({
205+
name: threadName,
206+
autoArchiveDuration: threadConfig.autoArchiveMinutes,
207+
});
208+
209+
// Track this thread for reuse
210+
const key = buildThreadKey(message.author.id, message.channel.id);
211+
activeThreads.set(key, {
212+
threadId: thread.id,
213+
lastActive: Date.now(),
214+
threadName,
215+
});
216+
217+
info('Created conversation thread', {
218+
threadId: thread.id,
219+
threadName,
220+
userId: message.author.id,
221+
channelId: message.channel.id,
222+
});
223+
224+
return thread;
225+
}
226+
227+
/**
228+
* Get or create a thread for a user's AI conversation
229+
* Returns the thread to respond in, or null if threading should be skipped (fallback to inline)
230+
* @param {import('discord.js').Message} message - The triggering message
231+
* @param {string} cleanContent - The cleaned message content
232+
* @returns {Promise<{ thread: import('discord.js').ThreadChannel|null, isNew: boolean }>}
233+
*/
234+
export async function getOrCreateThread(message, cleanContent) {
235+
// Check permissions first
236+
if (!canCreateThread(message)) {
237+
warn('Missing thread creation permissions, falling back to inline reply', {
238+
channelId: message.channel.id,
239+
guildId: message.guild.id,
240+
});
241+
return { thread: null, isNew: false };
242+
}
243+
244+
// Try to reuse an existing thread
245+
const existingThread = await findExistingThread(message);
246+
if (existingThread) {
247+
info('Reusing existing thread', {
248+
threadId: existingThread.id,
249+
userId: message.author.id,
250+
channelId: message.channel.id,
251+
});
252+
return { thread: existingThread, isNew: false };
253+
}
254+
255+
// Create a new thread
256+
try {
257+
const thread = await createThread(message, cleanContent);
258+
return { thread, isNew: true };
259+
} catch (err) {
260+
logError('Failed to create thread, falling back to inline reply', {
261+
channelId: message.channel.id,
262+
error: err.message,
263+
});
264+
return { thread: null, isNew: false };
265+
}
266+
}
267+
268+
/**
269+
* Get the active threads map (for testing)
270+
* @returns {Map} Active threads map
271+
*/
272+
export function getActiveThreads() {
273+
return activeThreads;
274+
}
275+
276+
/**
277+
* Clear all active thread tracking (for testing)
278+
*/
279+
export function clearActiveThreads() {
280+
activeThreads.clear();
281+
}

0 commit comments

Comments
 (0)