diff --git a/src/features/joel/responder.ts b/src/features/joel/responder.ts index 31cad94..fa0e35c 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, messageRepository } from "../../database"; +import { db } from "../../database"; import { personalities, botOptions } from "../../database/schema"; import { eq } from "drizzle-orm"; import { buildStyledPrompt, STYLE_MODIFIERS } from "./personalities"; @@ -22,6 +22,11 @@ const DIRECTED_CLASSIFICATION_WINDOW_MS = 60 * 60 * 1000; const directedClassificationBudget = new Map(); const CONVERSATION_CONTEXT_MAX_MESSAGES = 5; const CONVERSATION_CONTEXT_WINDOW_MINUTES = 20; +const CONVERSATION_CONTEXT_SCAN_LIMIT = Math.max(20, CONVERSATION_CONTEXT_MAX_MESSAGES * 5); +const CONVERSATION_CONTEXT_MAX_LINKS_PER_MESSAGE = 2; +const CONVERSATION_CONTEXT_MAX_MEDIA_MESSAGES = 2; +const CONVERSATION_CONTEXT_MAX_MEDIA_ATTACHMENTS = 3; +const URL_REGEX = /https?:\/\/[^\s<>()]+/gi; type ResponseTrigger = "free-will" | "summoned" | "classifier" | "none"; @@ -377,50 +382,133 @@ The image URL will appear in your response for the user to see.`; */ 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, - } - ); + const fetched = await message.channel.messages.fetch({ + limit: CONVERSATION_CONTEXT_SCAN_LIMIT, + }); + + const cutoffTimestamp = Date.now() - CONVERSATION_CONTEXT_WINDOW_MINUTES * 60 * 1000; + const recent = Array.from(fetched.values()) + .filter((recentMessage) => recentMessage.id !== message.id) + .filter((recentMessage) => recentMessage.createdTimestamp >= cutoffTimestamp) + .sort((a, b) => a.createdTimestamp - b.createdTimestamp) + .slice(-CONVERSATION_CONTEXT_MAX_MESSAGES); if (recent.length === 0) { return null; } const contextLines = recent - .map(({ message: recentMessage, userName }) => { - const author = userName || recentMessage.user_id || "Unknown"; - const content = (recentMessage.content || "") + .map((recentMessage) => { + const author = recentMessage.member?.displayName || recentMessage.author.username || "Unknown"; + const content = (recentMessage.cleanContent || "") .replace(/\s+/g, " ") .trim() .slice(0, 220); + const links = this.extractLinks(recentMessage.content); + const linkPreview = links.slice(0, CONVERSATION_CONTEXT_MAX_LINKS_PER_MESSAGE).join(", "); + const linksSuffix = links.length > 0 + ? ` [links: ${linkPreview}${links.length > CONVERSATION_CONTEXT_MAX_LINKS_PER_MESSAGE ? ", ..." : ""}]` + : ""; + + if (!content && links.length > 0) { + return `- ${author}: [shared links] ${linkPreview}${links.length > CONVERSATION_CONTEXT_MAX_LINKS_PER_MESSAGE ? ", ..." : ""}`; + } + + if (!content && recentMessage.attachments.size > 0) { + const attachmentWord = recentMessage.attachments.size === 1 ? "attachment" : "attachments"; + return `- ${author}: [shared ${recentMessage.attachments.size} ${attachmentWord}]`; + } + if (!content) { return null; } - return `- ${author}: ${content}`; + return `- ${author}: ${content}${linksSuffix}`; }) .filter((line): line is string => Boolean(line)); - if (contextLines.length === 0) { + const mediaContext = await this.buildRecentMediaContext(recent); + + if (contextLines.length === 0 && !mediaContext) { return null; } - return [ + const sections = [ `Recent channel context (last ${CONVERSATION_CONTEXT_MAX_MESSAGES} messages within ${CONVERSATION_CONTEXT_WINDOW_MINUTES} minutes):`, ...contextLines, - ].join("\n"); + ]; + + if (mediaContext) { + sections.push("", mediaContext); + } + + return sections.join("\n"); } catch (error) { logger.error("Failed to build conversation context", error); return null; } }, + extractLinks(content: string): string[] { + const matches = content.match(URL_REGEX) ?? []; + const cleaned = matches.map((url) => url.replace(/[),.!?]+$/, "")); + return Array.from(new Set(cleaned)); + }, + + async buildRecentMediaContext(recentMessages: Message[]): Promise { + const mediaMessages = recentMessages + .filter((recentMessage) => recentMessage.attachments.size > 0) + .slice(-CONVERSATION_CONTEXT_MAX_MEDIA_MESSAGES); + + if (mediaMessages.length === 0) { + return null; + } + + const vision = getVisionService(); + const mediaLines: string[] = []; + let remainingAttachments = CONVERSATION_CONTEXT_MAX_MEDIA_ATTACHMENTS; + + for (const mediaMessage of mediaMessages) { + if (remainingAttachments <= 0) { + break; + } + + const attachments: Attachment[] = mediaMessage.attachments + .map((att) => ({ + url: att.url, + name: att.name, + contentType: att.contentType, + size: att.size, + })) + .slice(0, remainingAttachments); + + if (attachments.length === 0) { + continue; + } + + const analyses = await vision.analyzeAttachments(attachments, mediaMessage.cleanContent); + if (analyses.length === 0) { + continue; + } + + remainingAttachments -= analyses.length; + + const author = mediaMessage.member?.displayName || mediaMessage.author.username || "Unknown"; + const attachmentSummaries = analyses + .map((analysis) => `${analysis.attachmentName}: ${analysis.description}`) + .join(" | "); + + mediaLines.push(`- ${author}: ${attachmentSummaries}`); + } + + if (mediaLines.length === 0) { + return null; + } + + return ["Recent shared media:", ...mediaLines].join("\n"); + }, + /** * Extract and analyze attachments from a message using vision AI */