diff --git a/src/modules/ai.js b/src/modules/ai.js index 158bbb39..a2b3aad9 100644 --- a/src/modules/ai.js +++ b/src/modules/ai.js @@ -197,14 +197,16 @@ export async function getHistoryAsync(channelId) { /** * Append a message to the in-memory conversation history for a channel and attempt to persist it to the database. * - * Also attempts a fire-and-forget write to the DB; database errors are logged and do not throw. - * @param {string} channelId - Channel identifier used to scope the conversation. + * The in-memory history is trimmed to the configured maximum length. If a database pool is configured, the message + * is written to the conversations table in a fire-and-forget manner; DB errors are logged and do not throw. + * @param {string} channelId - Channel identifier that scopes the conversation. * @param {string} role - Message role (e.g., "user" or "assistant"). * @param {string} content - Message text content. * @param {string} [username] - Optional display name associated with the message. - * @param {string} [discordMessageId] - Optional native Discord message ID (used to construct jump URLs in the dashboard). + * @param {string} [discordMessageId] - Optional native Discord message ID. + * @param {string} [guildId] - Optional guild ID for the conversation (used for dashboard/jump URLs). */ -export function addToHistory(channelId, role, content, username, discordMessageId) { +export function addToHistory(channelId, role, content, username, discordMessageId, guildId) { if (!conversationHistory.has(channelId)) { conversationHistory.set(channelId, []); } @@ -223,15 +225,16 @@ export function addToHistory(channelId, role, content, username, discordMessageI if (pool) { pool .query( - `INSERT INTO conversations (channel_id, role, content, username, discord_message_id) - VALUES ($1, $2, $3, $4, $5)`, - [channelId, role, content, username || null, discordMessageId || null], + `INSERT INTO conversations (channel_id, role, content, username, discord_message_id, guild_id) + VALUES ($1, $2, $3, $4, $5, $6)`, + [channelId, role, content, username || null, discordMessageId || null, guildId || null], ) .catch((err) => { logError('Failed to persist message to DB', { channelId, role, username: username || null, + guildId: guildId || null, error: err.message, }); }); diff --git a/src/modules/triage-respond.js b/src/modules/triage-respond.js index 6363c2d5..88f1aa4c 100644 --- a/src/modules/triage-respond.js +++ b/src/modules/triage-respond.js @@ -8,11 +8,35 @@ import { info, error as logError, warn } from '../logger.js'; import { buildDebugEmbed, extractStats, logAiUsage } from '../utils/debugFooter.js'; import { safeSend } from '../utils/safeSend.js'; import { splitMessage } from '../utils/splitMessage.js'; +import { addToHistory } from './ai.js'; import { resolveMessageId, sanitizeText } from './triage-filter.js'; /** Maximum characters to keep from fetched context messages. */ const CONTEXT_MESSAGE_CHAR_LIMIT = 500; +// ── History helpers ────────────────────────────────────────────────────────── + +/** + * Log an assistant message (or multiple messages when safeSend splits into an array) + * to conversation history. + * + * `safeSend` can return either a single Message object or an array of Message objects + * when the content was split across multiple Discord messages. Both cases are handled + * here so history is never silently dropped. + * + * @param {string} channelId - The channel the message was sent in. + * @param {string|null} guildId - The guild ID, or null for DMs. + * @param {string} fallbackContent - Text to use when the sent message has no `.content`. + * @param {import('discord.js').Message|import('discord.js').Message[]|null} sentMsg - Return value of safeSend. + */ +function logAssistantHistory(channelId, guildId, fallbackContent, sentMsg) { + const sentMessages = Array.isArray(sentMsg) ? sentMsg : [sentMsg]; + for (const m of sentMessages) { + if (!m?.id) continue; + addToHistory(channelId, 'assistant', m.content || fallbackContent, null, m.id, guildId || null); + } +} + // ── Channel context fetching ───────────────────────────────────────────────── /** @@ -177,7 +201,8 @@ export async function sendResponses( const msgOpts = { content: chunks[i] }; if (debugEmbed && i === 0) msgOpts.embeds = [debugEmbed]; if (replyRef && i === 0) msgOpts.reply = { messageReference: replyRef }; - await safeSend(channel, msgOpts); + const sentMsg = await safeSend(channel, msgOpts); + logAssistantHistory(channelId, channel.guild?.id || null, chunks[i], sentMsg); } } } catch (err) { @@ -214,7 +239,9 @@ export async function sendResponses( const msgOpts = { content: chunks[i] }; if (debugEmbed && i === 0) msgOpts.embeds = [debugEmbed]; if (replyRef && i === 0) msgOpts.reply = { messageReference: replyRef }; - await safeSend(channel, msgOpts); + const sentMsg = await safeSend(channel, msgOpts); + // Log AI response to conversation history + logAssistantHistory(channelId, channel.guild?.id || null, chunks[i], sentMsg); } info('Triage response sent', { diff --git a/src/modules/triage.js b/src/modules/triage.js index e9f4f2a4..97f181ad 100644 --- a/src/modules/triage.js +++ b/src/modules/triage.js @@ -23,6 +23,8 @@ import { buildMemoryContext, extractAndStoreMemories } from './memory.js'; // ── Sub-module imports ─────────────────────────────────────────────────────── +import { addToHistory } from './ai.js'; +import { getConfig } from './config.js'; import { channelBuffers, clearEvaluatedMessages, @@ -30,9 +32,13 @@ import { pushToBuffer, } from './triage-buffer.js'; import { getDynamicInterval, isChannelEligible, resolveTriageConfig } from './triage-config.js'; + import { checkTriggerWords, sanitizeText } from './triage-filter.js'; + import { parseClassifyResult, parseRespondResult } from './triage-parse.js'; + import { buildClassifyPrompt, buildRespondPrompt } from './triage-prompt.js'; + import { buildStatsAndLog, fetchChannelContext, @@ -545,13 +551,20 @@ export function stopTriage() { } /** - * Append a Discord message to the channel's triage buffer and trigger evaluation when necessary. + * Append a Discord message to the channel's triage buffer and trigger evaluation when conditions are met. + * + * Skips processing if triage is disabled, the channel is not eligible, or the message is empty/attachment-only. + * Truncates message content to 1000 characters and, when the message is a reply, captures up to 500 characters of the referenced message as reply context. + * Adds the entry to the per-channel bounded ring buffer and records the message in conversation history. + * If configured trigger words are present, forces an immediate evaluation (and falls back to scheduling if forcing fails); otherwise schedules a dynamic evaluation timer for the channel. * * @param {import('discord.js').Message} message - The Discord message to accumulate. - * @param {Object} msgConfig - Bot configuration containing the `triage` settings. + * @param {Object} _msgConfig - Ignored; retained for backwards compatibility. Live config is + * fetched via {@link getConfig} on each invocation to avoid stale references. */ -export async function accumulateMessage(message, msgConfig) { - const triageConfig = msgConfig.triage; +export async function accumulateMessage(message, _msgConfig) { + const liveConfig = getConfig(message.guild?.id || null); + const triageConfig = liveConfig.triage; if (!triageConfig?.enabled) return; if (!isChannelEligible(message.channel.id, triageConfig)) return; @@ -597,18 +610,28 @@ export async function accumulateMessage(message, msgConfig) { // Push to ring buffer (with truncation warning) pushToBuffer(channelId, entry, maxBufferSize); + // Log user message to conversation history + addToHistory( + channelId, + 'user', + entry.content, + entry.author, + entry.messageId, + message.guild?.id || null, + ); + // Check for trigger words -- instant evaluation - if (checkTriggerWords(message.content, msgConfig)) { + if (checkTriggerWords(message.content, liveConfig)) { info('Trigger word detected, forcing evaluation', { channelId }); - evaluateNow(channelId, msgConfig, client, healthMonitor).catch((err) => { + evaluateNow(channelId, liveConfig, client, healthMonitor).catch((err) => { logError('Trigger word evaluateNow failed', { channelId, error: err.message }); - scheduleEvaluation(channelId, msgConfig); + scheduleEvaluation(channelId, liveConfig); }); return; } // Schedule or reset the dynamic timer - scheduleEvaluation(channelId, msgConfig); + scheduleEvaluation(channelId, liveConfig); } const MAX_REEVAL_DEPTH = 3; diff --git a/tests/modules/ai.coverage.test.js b/tests/modules/ai.coverage.test.js index afaeef42..b319e91a 100644 --- a/tests/modules/ai.coverage.test.js +++ b/tests/modules/ai.coverage.test.js @@ -186,6 +186,7 @@ describe('ai module coverage', () => { 'hello', 'testuser', null, + null, ]); }); diff --git a/tests/modules/ai.test.js b/tests/modules/ai.test.js index 56b4624c..2ac91ce6 100644 --- a/tests/modules/ai.test.js +++ b/tests/modules/ai.test.js @@ -155,6 +155,24 @@ describe('ai module', () => { 'hello', 'testuser', null, + null, + ]); + }); + + it('should write guildId to DB when provided', () => { + const mockQuery = vi.fn().mockResolvedValue({}); + const mockPool = { query: mockQuery }; + setPool(mockPool); + + addToHistory('ch1', 'user', 'hello', 'testuser', 'msg-123', 'guild-456'); + + expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO conversations'), [ + 'ch1', + 'user', + 'hello', + 'testuser', + 'msg-123', + 'guild-456', ]); }); }); diff --git a/tests/modules/triage.test.js b/tests/modules/triage.test.js index e9184dbc..947cd8ee 100644 --- a/tests/modules/triage.test.js +++ b/tests/modules/triage.test.js @@ -50,8 +50,33 @@ vi.mock('../../src/logger.js', () => ({ warn: vi.fn(), debug: vi.fn(), })); +vi.mock('../../src/modules/ai.js', () => ({ + addToHistory: vi.fn(), + _setPoolGetter: vi.fn(), + setPool: vi.fn(), + getConversationHistory: vi.fn().mockReturnValue(new Map()), + setConversationHistory: vi.fn(), + getHistoryAsync: vi.fn().mockResolvedValue([]), + initConversationHistory: vi.fn().mockResolvedValue(undefined), + startConversationCleanup: vi.fn(), + stopConversationCleanup: vi.fn(), +})); + +let mockGlobalConfig = {}; + +vi.mock('../../src/modules/config.js', () => ({ + getConfig: vi.fn((_guildId) => mockGlobalConfig), + loadConfigFromFile: vi.fn(), + loadConfig: vi.fn().mockResolvedValue(undefined), + onConfigChange: vi.fn(), + offConfigChange: vi.fn(), + clearConfigListeners: vi.fn(), + setConfigValue: vi.fn().mockResolvedValue(undefined), + resetConfig: vi.fn().mockResolvedValue(undefined), +})); import { info, warn } from '../../src/logger.js'; +import { addToHistory } from '../../src/modules/ai.js'; import { isSpam } from '../../src/modules/spam.js'; import { accumulateMessage, @@ -194,6 +219,7 @@ describe('triage module', () => { config = makeConfig(); healthMonitor = makeHealthMonitor(); await startTriage(client, config, healthMonitor); + mockGlobalConfig = config; }); afterEach(() => { @@ -223,8 +249,34 @@ describe('triage module', () => { expect(mockResponderSend).toHaveBeenCalled(); }); + it('should call addToHistory with correct args for guild message', () => { + const msg = makeMessage('ch1', 'hello world', { + id: 'msg-99', + username: 'alice', + userId: 'u99', + guild: { id: 'g1' }, + }); + accumulateMessage(msg, config); + expect(addToHistory).toHaveBeenCalledWith( + 'ch1', + 'user', + 'hello world', + 'alice', + 'msg-99', + 'g1', + ); + }); + + it('should call addToHistory with null guildId for DM (no guild)', () => { + const msg = makeMessage('ch1', 'dm message', { id: 'msg-dm', username: 'bob', userId: 'u2' }); + // No guild property — guild?.id resolves to undefined, coerced to null + accumulateMessage(msg, config); + expect(addToHistory).toHaveBeenCalledWith('ch1', 'user', 'dm message', 'bob', 'msg-dm', null); + }); + it('should skip when triage is disabled', async () => { const disabledConfig = makeConfig({ triage: { enabled: false } }); + mockGlobalConfig = disabledConfig; accumulateMessage(makeMessage('ch1', 'hello'), disabledConfig); await evaluateNow('ch1', config, client, healthMonitor); @@ -233,6 +285,7 @@ describe('triage module', () => { it('should skip excluded channels', async () => { const excConfig = makeConfig({ triage: { excludeChannels: ['ch1'] } }); + mockGlobalConfig = excConfig; accumulateMessage(makeMessage('ch1', 'hello'), excConfig); await evaluateNow('ch1', config, client, healthMonitor); @@ -241,6 +294,7 @@ describe('triage module', () => { it('should skip channels not in allow list when allow list is non-empty', async () => { const restrictedConfig = makeConfig({ triage: { channels: ['allowed-ch'] } }); + mockGlobalConfig = restrictedConfig; accumulateMessage(makeMessage('not-allowed-ch', 'hello'), restrictedConfig); await evaluateNow('not-allowed-ch', config, client, healthMonitor); @@ -277,6 +331,7 @@ describe('triage module', () => { it('should respect maxBufferSize cap', async () => { const smallConfig = makeConfig({ triage: { maxBufferSize: 3 } }); + mockGlobalConfig = smallConfig; for (let i = 0; i < 5; i++) { accumulateMessage(makeMessage('ch1', `msg ${i}`), smallConfig); } @@ -303,6 +358,7 @@ describe('triage module', () => { describe('checkTriggerWords', () => { it('should force evaluation when trigger words match', async () => { const twConfig = makeConfig({ triage: { triggerWords: ['help'] } }); + mockGlobalConfig = twConfig; const classResult = { classification: 'respond', reasoning: 'test', @@ -325,6 +381,7 @@ describe('triage module', () => { it('should trigger on moderation keywords', async () => { const modConfig = makeConfig({ triage: { moderationKeywords: ['badword'] } }); + mockGlobalConfig = modConfig; const classResult = { classification: 'moderate', reasoning: 'bad content', @@ -504,6 +561,7 @@ describe('triage module', () => { it('should suppress moderation response when moderationResponse is false', async () => { const modConfig = makeConfig({ triage: { moderationResponse: false } }); + mockGlobalConfig = modConfig; const classResult = { classification: 'moderate', reasoning: 'spam detected', @@ -872,6 +930,7 @@ describe('triage module', () => { it('should use config.triage.defaultInterval as base interval', () => { const customConfig = makeConfig({ triage: { defaultInterval: 20000 } }); + mockGlobalConfig = customConfig; accumulateMessage(makeMessage('ch1', 'single'), customConfig); vi.advanceTimersByTime(19999); expect(mockClassifierSend).not.toHaveBeenCalled(); @@ -926,6 +985,7 @@ describe('triage module', () => { it('should evict oldest channels when over 100-channel cap', async () => { const longConfig = makeConfig({ triage: { defaultInterval: 999999 } }); + mockGlobalConfig = longConfig; const classResult = { classification: 'ignore', @@ -1003,6 +1063,7 @@ describe('triage module', () => { it('should NOT add 👀 reaction when statusReactions is false', async () => { const noReactConfig = makeConfig({ triage: { statusReactions: false } }); + mockGlobalConfig = noReactConfig; const classResult = { classification: 'respond', reasoning: 'test', @@ -1053,6 +1114,7 @@ describe('triage module', () => { it('should NOT add 🔍 reaction when statusReactions is false', async () => { const noReactConfig = makeConfig({ triage: { statusReactions: false } }); + mockGlobalConfig = noReactConfig; const classResult = { classification: 'respond', reasoning: 'test', @@ -1082,6 +1144,7 @@ describe('triage module', () => { it('should transition 👀 → 💬 → removed (no thinking tokens)', async () => { const noThinkConfig = makeConfig({ triage: { thinkingTokens: 0 } }); + mockGlobalConfig = noThinkConfig; const classResult = { classification: 'respond', reasoning: 'test', @@ -1128,6 +1191,7 @@ describe('triage module', () => { it('should NOT add or remove reactions when statusReactions is false', async () => { const noReactConfig = makeConfig({ triage: { statusReactions: false } }); + mockGlobalConfig = noReactConfig; const classResult = { classification: 'respond', reasoning: 'test', @@ -1174,6 +1238,7 @@ describe('triage module', () => { describe('trigger word evaluation', () => { it('should call evaluateNow on trigger word detection', async () => { const twConfig = makeConfig({ triage: { triggerWords: ['urgent'] } }); + mockGlobalConfig = twConfig; const classResult = { classification: 'respond', reasoning: 'trigger',