-
Notifications
You must be signed in to change notification settings - Fork 2
feat: AI conversation threading #57
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 2 commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
29198d4
feat: add AI conversation threading (issue #23)
BillChirico cd2e572
📝 Add docstrings to `feat/ai-conversation-threading`
coderabbitai[bot] 2880194
fix: validate config values and snap autoArchiveDuration to valid Dis…
BillChirico 71bfdb6
fix: clamp thread name to 100-char Discord limit for long usernames
BillChirico 4e93b84
fix: add periodic cache eviction and max-size cap for activeThreads
BillChirico 2f9c583
fix: prevent duplicate thread creation with per-key lock
BillChirico 6d7d8a5
style: add missing trailing newlines for Biome formatting
BillChirico 4a99c87
fix: check parent channel ID for thread messages in allowlist
BillChirico File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,282 @@ | ||
| /** | ||
| * Threading Module | ||
| * Manages Discord thread creation and reuse for AI conversations. | ||
| * | ||
| * When the bot is @mentioned in a regular channel, instead of replying inline, | ||
| * it creates (or reuses) a thread and continues the conversation there. | ||
| * This keeps channels clean while preserving conversation context. | ||
| */ | ||
|
|
||
| import { ChannelType, PermissionFlagsBits } from 'discord.js'; | ||
| import { info, error as logError, warn } from '../logger.js'; | ||
| import { getConfig } from './config.js'; | ||
|
|
||
| /** | ||
| * Active thread tracker: Map<`${userId}:${channelId}`, { threadId, lastActive, threadName }> | ||
| * Tracks which thread to reuse for a given user+channel combination. | ||
| */ | ||
| const activeThreads = new Map(); | ||
|
|
||
| /** Default thread auto-archive duration in minutes */ | ||
| const DEFAULT_AUTO_ARCHIVE_MINUTES = 60; | ||
|
|
||
| /** Default thread reuse window in milliseconds (30 minutes) */ | ||
| const DEFAULT_REUSE_WINDOW_MS = 30 * 60 * 1000; | ||
BillChirico marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| /** Maximum thread name length (Discord limit) */ | ||
| const MAX_THREAD_NAME_LENGTH = 100; | ||
|
|
||
| /** | ||
| * Retrieve threading configuration derived from the bot config, falling back to sensible defaults. | ||
| * @returns {{ enabled: boolean, autoArchiveMinutes: number, reuseWindowMs: number }} An object where `enabled` is `true` if threading is enabled; `autoArchiveMinutes` is the thread auto-archive duration in minutes; and `reuseWindowMs` is the thread reuse window in milliseconds. | ||
| */ | ||
| export function getThreadConfig() { | ||
| try { | ||
| const config = getConfig(); | ||
| const threadMode = config?.ai?.threadMode; | ||
| return { | ||
| enabled: threadMode?.enabled ?? false, | ||
| autoArchiveMinutes: threadMode?.autoArchiveMinutes ?? DEFAULT_AUTO_ARCHIVE_MINUTES, | ||
| reuseWindowMs: (threadMode?.reuseWindowMinutes ?? 30) * 60 * 1000, | ||
cursor[bot] marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| }; | ||
| } catch { | ||
| return { | ||
| enabled: false, | ||
| autoArchiveMinutes: DEFAULT_AUTO_ARCHIVE_MINUTES, | ||
| reuseWindowMs: DEFAULT_REUSE_WINDOW_MS, | ||
| }; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Determine whether a given Discord message should be handled in a thread. | ||
| * @param {import('discord.js').Message} message - The message to evaluate. | ||
| * @returns {boolean} `true` if the message is eligible for thread handling, `false` otherwise. | ||
| */ | ||
| export function shouldUseThread(message) { | ||
| const threadConfig = getThreadConfig(); | ||
| if (!threadConfig.enabled) return false; | ||
|
|
||
| // Don't create threads in DMs | ||
| if (!message.guild) return false; | ||
|
|
||
| // Don't create threads inside existing threads — reply inline | ||
| if (message.channel.isThread()) return false; | ||
|
|
||
| // Channel must be a text-based guild channel that supports threads | ||
| const threadableTypes = [ChannelType.GuildText, ChannelType.GuildAnnouncement]; | ||
| if (!threadableTypes.includes(message.channel.type)) return false; | ||
|
|
||
| return true; | ||
| } | ||
|
|
||
| /** | ||
| * Determines whether the bot can create public threads and send messages in threads for the message's channel. | ||
| * @param {import('discord.js').Message} message - The triggering Discord message. | ||
| * @returns {boolean} `true` if the bot has CreatePublicThreads and SendMessagesInThreads permissions in the channel and the message is in a guild, `false` otherwise. | ||
| */ | ||
| export function canCreateThread(message) { | ||
| if (!message.guild) return false; | ||
|
|
||
| try { | ||
| const botMember = message.guild.members.me; | ||
| if (!botMember) return false; | ||
|
|
||
| const permissions = message.channel.permissionsFor(botMember); | ||
| if (!permissions) return false; | ||
|
|
||
| return ( | ||
| permissions.has(PermissionFlagsBits.CreatePublicThreads) && | ||
| permissions.has(PermissionFlagsBits.SendMessagesInThreads) | ||
| ); | ||
| } catch (err) { | ||
| warn('Failed to check thread permissions', { error: err.message }); | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Build a Discord thread name from a user's display name and the first line of their message. | ||
| * @param {string} username - The user's display name used as a prefix. | ||
| * @param {string} messageContent - The cleaned message content; only its first line is used. | ||
| * @returns {string} The constructed thread name, truncated to fit Discord's length limit. | ||
| */ | ||
| export function generateThreadName(username, messageContent) { | ||
| // Use first line of message content, truncated | ||
| const firstLine = messageContent.split('\n')[0].trim(); | ||
|
|
||
| let name; | ||
| if (firstLine.length > 0) { | ||
| // Truncate to fit within Discord's limit with username prefix | ||
| const prefix = `${username}: `; | ||
| const maxContentLength = MAX_THREAD_NAME_LENGTH - prefix.length; | ||
| const truncatedContent = | ||
| firstLine.length > maxContentLength | ||
| ? `${firstLine.substring(0, maxContentLength - 1)}…` | ||
| : firstLine; | ||
| name = `${prefix}${truncatedContent}`; | ||
| } else { | ||
| name = `Chat with ${username}`; | ||
| } | ||
|
|
||
| return name; | ||
| } | ||
BillChirico marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| /** | ||
| * Build the cache key for active thread tracking | ||
| * @param {string} userId - User ID | ||
| * @param {string} channelId - Channel ID | ||
| * @returns {string} Cache key | ||
| */ | ||
| export function buildThreadKey(userId, channelId) { | ||
| return `${userId}:${channelId}`; | ||
| } | ||
|
|
||
| /** | ||
| * Locate a previously cached thread for the message author in the same channel and prepare it for reuse. | ||
| * | ||
| * If a valid, non-expired thread is found it will be returned; the function will update the thread's last-active timestamp | ||
| * and attempt to unarchive the thread if necessary. Stale, missing, or inaccessible entries are removed from the cache. | ||
| * @param {import('discord.js').Message} message - The triggering Discord message (used to identify user and channel). | ||
| * @returns {Promise<import('discord.js').ThreadChannel|null>} `ThreadChannel` if a reusable thread was found and prepared, `null` otherwise. | ||
| */ | ||
| export async function findExistingThread(message) { | ||
| const threadConfig = getThreadConfig(); | ||
| const key = buildThreadKey(message.author.id, message.channel.id); | ||
| const entry = activeThreads.get(key); | ||
|
|
||
| if (!entry) return null; | ||
|
|
||
| // Check if the thread is still within the reuse window | ||
| const now = Date.now(); | ||
| if (now - entry.lastActive > threadConfig.reuseWindowMs) { | ||
| activeThreads.delete(key); | ||
| return null; | ||
| } | ||
|
|
||
| // Try to fetch the thread — it may have been deleted or archived | ||
| try { | ||
| const thread = await message.channel.threads.fetch(entry.threadId); | ||
| if (!thread) { | ||
| activeThreads.delete(key); | ||
| return null; | ||
| } | ||
|
|
||
| // If thread is archived, try to unarchive it | ||
| if (thread.archived) { | ||
| try { | ||
| await thread.setArchived(false); | ||
| info('Unarchived thread for reuse', { | ||
| threadId: thread.id, | ||
| userId: message.author.id, | ||
| }); | ||
| } catch (err) { | ||
| warn('Failed to unarchive thread, creating new one', { | ||
| threadId: thread.id, | ||
| error: err.message, | ||
| }); | ||
| activeThreads.delete(key); | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| // Update last active time | ||
| entry.lastActive = now; | ||
| return thread; | ||
| } catch (_err) { | ||
| // Thread not found or inaccessible | ||
| activeThreads.delete(key); | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Start a new thread for the triggering message and record it for reuse. | ||
| * @param {import('discord.js').Message} message - The message that triggers thread creation. | ||
| * @param {string} cleanContent - The cleaned message content used to generate the thread name. | ||
| * @returns {Promise<import('discord.js').ThreadChannel>} The created thread channel. | ||
| */ | ||
| export async function createThread(message, cleanContent) { | ||
| const threadConfig = getThreadConfig(); | ||
| const threadName = generateThreadName( | ||
| message.author.displayName || message.author.username, | ||
| cleanContent, | ||
| ); | ||
|
|
||
| const thread = await message.startThread({ | ||
| name: threadName, | ||
| autoArchiveDuration: threadConfig.autoArchiveMinutes, | ||
| }); | ||
BillChirico marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| // Track this thread for reuse | ||
| const key = buildThreadKey(message.author.id, message.channel.id); | ||
| activeThreads.set(key, { | ||
| threadId: thread.id, | ||
| lastActive: Date.now(), | ||
| threadName, | ||
| }); | ||
cursor[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| info('Created conversation thread', { | ||
| threadId: thread.id, | ||
| threadName, | ||
| userId: message.author.id, | ||
| channelId: message.channel.id, | ||
| }); | ||
|
|
||
| return thread; | ||
| } | ||
|
|
||
| /** | ||
| * Obtain an existing thread for the user in the channel or create a new one for the AI conversation. | ||
| * @param {import('discord.js').Message} message - The triggering message. | ||
| * @param {string} cleanContent - Cleaned content used to generate the thread name when creating a new thread. | ||
| * @returns {Promise<{ thread: import('discord.js').ThreadChannel|null, isNew: boolean }>} An object containing the thread to use (or `null` if threading was skipped) and `isNew` set to `true` when a new thread was created, `false` otherwise. | ||
| */ | ||
| export async function getOrCreateThread(message, cleanContent) { | ||
| // Check permissions first | ||
| if (!canCreateThread(message)) { | ||
| warn('Missing thread creation permissions, falling back to inline reply', { | ||
| channelId: message.channel.id, | ||
| guildId: message.guild.id, | ||
| }); | ||
| return { thread: null, isNew: false }; | ||
| } | ||
|
|
||
| // Try to reuse an existing thread | ||
| const existingThread = await findExistingThread(message); | ||
| if (existingThread) { | ||
| info('Reusing existing thread', { | ||
| threadId: existingThread.id, | ||
| userId: message.author.id, | ||
| channelId: message.channel.id, | ||
| }); | ||
| return { thread: existingThread, isNew: false }; | ||
| } | ||
|
|
||
| // Create a new thread | ||
| try { | ||
| const thread = await createThread(message, cleanContent); | ||
| return { thread, isNew: true }; | ||
cursor[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } catch (err) { | ||
| logError('Failed to create thread, falling back to inline reply', { | ||
| channelId: message.channel.id, | ||
| error: err.message, | ||
| }); | ||
| return { thread: null, isNew: false }; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Get the active threads map (for testing) | ||
| * @returns {Map} Active threads map | ||
| */ | ||
| export function getActiveThreads() { | ||
| return activeThreads; | ||
| } | ||
|
|
||
| /** | ||
| * Clear all active thread tracking (for testing) | ||
| */ | ||
| export function clearActiveThreads() { | ||
| activeThreads.clear(); | ||
| } | ||
coderabbitai[bot] marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.