diff --git a/src/database/repositories/message.repository.ts b/src/database/repositories/message.repository.ts index 7f6d0ea..fe78ae5 100644 --- a/src/database/repositories/message.repository.ts +++ b/src/database/repositories/message.repository.ts @@ -2,7 +2,7 @@ * Message repository - handles all message-related database operations */ -import { and, eq } from "drizzle-orm"; +import { and, desc, eq } from "drizzle-orm"; import { db } from "../connection"; import { messages, users, type InsertMessage, type Message } from "../schema"; @@ -28,4 +28,57 @@ export const messageRepository = { userName: r.users?.name ?? null, })); }, + + async findRecentContext( + guildId: string, + channelId: string, + options?: { + maxMessages?: number; + withinMinutes?: number; + excludeMessageId?: string; + } + ): Promise> { + const maxMessages = options?.maxMessages ?? 5; + const withinMinutes = options?.withinMinutes ?? 20; + const scanLimit = Math.max(20, maxMessages * 5); + + const results = await db + .select() + .from(messages) + .where( + and(eq(messages.guild_id, guildId), eq(messages.channel_id, channelId)) + ) + .leftJoin(users, eq(users.id, messages.user_id)) + .orderBy(desc(messages.timestamp)) + .limit(scanLimit); + + const now = Date.now(); + const cutoff = now - withinMinutes * 60 * 1000; + + const filtered = results + .filter((r) => { + if (options?.excludeMessageId && r.messages.id === options.excludeMessageId) { + return false; + } + + const timestamp = r.messages.timestamp; + if (!timestamp) { + return true; + } + + const parsedTs = Date.parse(timestamp); + if (Number.isNaN(parsedTs)) { + return true; + } + + return parsedTs >= cutoff; + }) + .slice(0, maxMessages) + .reverse(); + + return filtered.map((r) => ({ + message: r.messages, + userName: r.users?.name ?? null, + })); + }, }; diff --git a/src/features/joel/responder.ts b/src/features/joel/responder.ts index 6805a3f..31cad94 100644 --- a/src/features/joel/responder.ts +++ b/src/features/joel/responder.ts @@ -7,7 +7,7 @@ import type { BotClient } from "../../core/client"; import { config } from "../../core/config"; import { createLogger } from "../../core/logger"; import { getAiService, getVisionService, type MessageStyle, type ToolContext, type Attachment } from "../../services/ai"; -import { db } from "../../database"; +import { db, messageRepository } from "../../database"; import { personalities, botOptions } from "../../database/schema"; import { eq } from "drizzle-orm"; import { buildStyledPrompt, STYLE_MODIFIERS } from "./personalities"; @@ -20,6 +20,8 @@ const logger = createLogger("Features:Joel"); const DIRECTED_CLASSIFICATION_LIMIT_PER_HOUR = 20; const DIRECTED_CLASSIFICATION_WINDOW_MS = 60 * 60 * 1000; const directedClassificationBudget = new Map(); +const CONVERSATION_CONTEXT_MAX_MESSAGES = 5; +const CONVERSATION_CONTEXT_WINDOW_MINUTES = 20; type ResponseTrigger = "free-will" | "summoned" | "classifier" | "none"; @@ -348,6 +350,12 @@ The image URL will appear in your response for the user to see.`; } } + // Add recent conversation context so Joel can follow ongoing chat + const conversationContext = await this.buildConversationContext(message); + if (conversationContext) { + prompt = `${conversationContext}\n\nCurrent message:\n${prompt}`; + } + // Analyze attachments if present (images, etc.) const attachmentDescriptions = await this.analyzeAttachments(message); if (attachmentDescriptions) { @@ -364,6 +372,55 @@ The image URL will appear in your response for the user to see.`; return response.text || null; }, + /** + * Build a compact conversation context from recent messages in this channel. + */ + async buildConversationContext(message: Message): Promise { + try { + const recent = await messageRepository.findRecentContext( + message.guildId, + message.channelId, + { + maxMessages: CONVERSATION_CONTEXT_MAX_MESSAGES, + withinMinutes: CONVERSATION_CONTEXT_WINDOW_MINUTES, + excludeMessageId: message.id, + } + ); + + if (recent.length === 0) { + return null; + } + + const contextLines = recent + .map(({ message: recentMessage, userName }) => { + const author = userName || recentMessage.user_id || "Unknown"; + const content = (recentMessage.content || "") + .replace(/\s+/g, " ") + .trim() + .slice(0, 220); + + if (!content) { + return null; + } + + return `- ${author}: ${content}`; + }) + .filter((line): line is string => Boolean(line)); + + if (contextLines.length === 0) { + return null; + } + + return [ + `Recent channel context (last ${CONVERSATION_CONTEXT_MAX_MESSAGES} messages within ${CONVERSATION_CONTEXT_WINDOW_MINUTES} minutes):`, + ...contextLines, + ].join("\n"); + } catch (error) { + logger.error("Failed to build conversation context", error); + return null; + } + }, + /** * Extract and analyze attachments from a message using vision AI */