// _opt/responses.js // Simplified OpenAI Responses module with clear flow and context management import fs from 'fs/promises'; import path from 'path'; import { OpenAI } from 'openai'; import axios from 'axios'; import { AttachmentBuilder } from 'discord.js'; // Discord message max length const MAX_DISCORD_MSG_LENGTH = 2000; /** * Split a long message into chunks <= maxLength, preserving code fences by closing and reopening. * @param {string} text * @param {number} maxLength * @returns {string[]} */ function splitMessage(text, maxLength = MAX_DISCORD_MSG_LENGTH) { const lines = text.split(/\n/); const chunks = []; let chunk = ''; let codeBlockOpen = false; let codeBlockFence = '```'; for (let line of lines) { const trimmed = line.trim(); const isFenceLine = trimmed.startsWith('```'); if (isFenceLine) { if (!codeBlockOpen) { codeBlockOpen = true; codeBlockFence = trimmed; } else if (trimmed === '```') { // closing fence codeBlockOpen = false; } } // include the newline that was removed by split const segment = line + '\n'; // if adding segment exceeds limit if (chunk.length + segment.length > maxLength) { if (chunk.length > 0) { // close open code block if needed if (codeBlockOpen) chunk += '\n```'; chunks.push(chunk); // start new chunk, reopen code block if needed chunk = codeBlockOpen ? (codeBlockFence + '\n' + segment) : segment; continue; } // single segment too long, split it directly let rest = segment; while (rest.length > maxLength) { let part = rest.slice(0, maxLength); if (codeBlockOpen) part += '\n```'; chunks.push(part); rest = codeBlockOpen ? (codeBlockFence + '\n' + rest.slice(maxLength)) : rest.slice(maxLength); } chunk = rest; continue; } chunk += segment; } if (chunk) { // close any unclosed code block if (codeBlockOpen) chunk += '\n```'; chunks.push(chunk); } // remove trailing newline from each chunk return chunks.map(c => c.endsWith('\n') ? c.slice(0, -1) : c); } /** * Load the system prompt from disk. */ async function loadSystemPrompt(filePath, logger) { try { const prompt = await fs.readFile(path.resolve(filePath), 'utf8'); logger.info(`Loaded system prompt: ${filePath}`); return prompt; } catch (err) { logger.error(`Failed to load system prompt: ${err.message}`); return ''; } } /** * Determine if the bot should respond: * - Mentioned * - Direct reply */ async function shouldRespond(message, botId, logger) { if (message.author.bot || !botId) return false; const isMention = message.mentions.users.has(botId); let isReply = false; if (message.reference?.messageId) { try { const ref = await message.channel.messages.fetch(message.reference.messageId); isReply = ref.author.id === botId; } catch {} } logger.debug(`Trigger? mention=${isMention} reply=${isReply}`); return isMention || isReply; } /** * Cache the last response ID for context continuity. */ function cacheResponse(client, key, id, ttlSeconds) { client.pb?.cache?.set(key, id, ttlSeconds); } /** * Award output tokens via the scorekeeper module. */ function awardOutput(client, guildId, userId, amount) { if (client.scorekeeper && amount > 0) { client.scorekeeper.addOutput(guildId, userId, amount) .catch(err => client.logger.error(`Scorekeeper error: ${err.message}`)); } } /** * Handle image generation function calls. * Returns true if an image was handled and replied. */ async function handleImage(client, message, resp, cfg) { const calls = Array.isArray(resp.output) ? resp.output : []; const fn = calls.find(o => o.type === 'function_call' && o.name === 'generate_image'); if (!fn?.arguments) return false; client.logger.debug(`Image function args: ${fn.arguments}`); let args; try { args = JSON.parse(fn.arguments); } catch { return false; } if (!args.prompt?.trim()) { await message.reply('Cannot generate image: empty prompt.'); return true; } // Determine image size based on aspect: square, landscape, or portrait // Square will always use 1024x1024 let size; switch (args.aspect) { case 'landscape': size = '1792x1024'; break; case 'portrait': size = '1024x1792'; break; case 'square': size = '1024x1024'; break; default: size = '1024x1024'; } // Determine image quality, defaulting to cfg.imageGeneration.defaultQuality const quality = ['standard', 'hd'].includes(args.quality) ? args.quality : cfg.imageGeneration.defaultQuality; try { // Generate image via OpenAI const imgRes = await client.openai.images.generate({ model: 'dall-e-3', prompt: args.prompt, quality: quality, size: size, n: 1 }); const url = imgRes.data?.[0]?.url; if (!url) throw new Error('No image URL'); // Download and save locally const dl = await axios.get(url, { responseType: 'arraybuffer' }); const buf = Buffer.from(dl.data); const filename = `${message.author.id}-${Date.now()}.png`; const dir = cfg.imageGeneration.imageSavePath || './images'; await fs.mkdir(dir, { recursive: true }); const filePath = path.join(dir, filename); await fs.writeFile(filePath, buf); client.logger.info(`Saved image: ${filePath}`); // Reply with attachment const attachment = new AttachmentBuilder(buf, { name: filename }); await message.reply({ content: args.prompt, files: [attachment] }); // Follow-up recap to preserve conversation context (submit function tool output) try { const convKey = message.thread?.id || message.channel.id; // Build a function_call_output input item for the Responses API const toolOutputItem = { type: 'function_call_output', call_id: fn.call_id, output: JSON.stringify({ url }), }; const recapBody = { model: cfg.defaultModel, // re-use original system/developer instructions instructions: client.responsesSystemPrompt, previous_response_id: resp.id, input: [toolOutputItem], max_output_tokens: Math.min(100, cfg.defaultMaxTokens), temperature: cfg.defaultTemperature, }; const recapResp = await client.openai.responses.create(recapBody); cacheResponse(client, convKey, recapResp.id, Math.floor(cfg.conversationExpiry / 1000)); // Award tokens for the recap chat response const recapTokens = recapResp.usage?.total_tokens ?? recapResp.usage?.completion_tokens ?? 0; awardOutput(client, message.guild.id, message.author.id, recapTokens); } catch (err) { client.logger.error(`Recap failed: ${err.message}`); } } catch (err) { client.logger.error(`Image error: ${err.message}`); await message.reply(`Image generation error: ${err.message}`); } return true; } /** * Main message handler: * 1. Determine if bot should respond * 2. Build and send AI request * 3. Cache response ID * 4. Handle image or text reply * 5. Award output points */ async function onMessage(client, cfg, message) { const logger = client.logger; const botId = client.user?.id; if (!(await shouldRespond(message, botId, logger))) return; await message.channel.sendTyping(); // Determine channel/thread key for context const key = message.thread?.id || message.channel.id; // Initialize per-channel lock map const lockMap = client._responseLockMap || (client._responseLockMap = new Map()); // Get last pending promise for this key const last = lockMap.get(key) || Promise.resolve(); // Handler to run in sequence const handler = async () => { try { // Previous response ID for context continuity const prev = client.pb?.cache?.get(key); // Enforce minimum score to use AI responses try { const scoreData = await client.scorekeeper.getScore(message.guild.id, message.author.id); if (scoreData.totalScore < cfg.minScore) { await message.reply( `You need a score of at least ${cfg.minScore} to use AI responses. Your current score is ${scoreData.totalScore.toFixed(2)}.` ); return; } } catch (err) { client.logger.error(`Error checking score: ${err.message}`); } // Build request body, prefixing with a mention of who spoke const speakerMention = `<@${message.author.id}>`; const body = { model: cfg.defaultModel, instructions: client.responsesSystemPrompt, input: `${speakerMention} said to you: ${message.content}`, previous_response_id: prev, max_output_tokens: cfg.defaultMaxTokens, temperature: cfg.defaultTemperature, }; // Assemble any enabled tools const tools = []; if (cfg.tools?.imageGeneration) { tools.push({ type: 'function', name: 'generate_image', description: 'Generate an image with a given prompt, aspect, and quality.', parameters: { type: 'object', properties: { prompt: { type: 'string' }, aspect: { type: 'string', enum: ['square','portrait','landscape'] }, quality: { type: 'string', enum: ['standard', 'hd'] }, }, required: ['prompt','aspect','quality'], additionalProperties: false, }, strict: true }); } if (cfg.tools?.webSearch) { tools.push({ type: 'web_search_preview' }); } if (tools.length) { body.tools = tools; } // Call OpenAI Responses logger.debug(`Calling AI with body: ${JSON.stringify(body)}`); const resp = await client.openai.responses.create(body); logger.info(`AI response id=${resp.id}`); // Award tokens for the AI chat response const chatTokens = resp.usage?.total_tokens ?? resp.usage?.completion_tokens ?? 0; awardOutput(client, message.guild.id, message.author.id, chatTokens); // Cache response ID if not a function call const isFuncCall = Array.isArray(resp.output) && resp.output.some(o => o.type === 'function_call'); if (!isFuncCall && resp.id && cfg.conversationExpiry) { cacheResponse(client, key, resp.id, Math.floor(cfg.conversationExpiry / 1000)); } // Handle image function call if present if (await handleImage(client, message, resp, cfg)) return; // Otherwise reply with text const text = resp.output_text?.trim(); if (text) { const parts = splitMessage(text, MAX_DISCORD_MSG_LENGTH); for (const part of parts) { await message.reply(part); } } } catch (err) { logger.error(`Queued onMessage error for ${key}: ${err.message}`); } }; // Chain the handler to the last promise const next = last.then(handler).catch(err => logger.error(err)); lockMap.set(key, next); // Queue enqueued; handler will send response when its turn arrives return; // Call OpenAI Responses let resp; try { logger.debug(`Calling AI with body: ${JSON.stringify(body)}`); resp = await client.openai.responses.create(body); logger.info(`AI response id=${resp.id}`); // Award tokens for the AI chat response immediately (captures token usage even if image follows) const chatTokens = resp.usage?.total_tokens ?? resp.usage?.completion_tokens ?? 0; awardOutput(client, message.guild.id, message.author.id, chatTokens); } catch (err) { logger.error(`AI error: ${err.message}`); return message.reply('Error generating response.'); } // Cache for next turn only if this was a text response const isFuncCall = Array.isArray(resp.output) && resp.output.some(o => o.type === 'function_call'); if (!isFuncCall && resp.id && cfg.conversationExpiry) { cacheResponse(client, key, resp.id, Math.floor(cfg.conversationExpiry / 1000)); } // Handle image function call if present if (await handleImage(client, message, resp, cfg)) return; // Otherwise reply with text (split if over Discord limit) const text = resp.output_text?.trim(); if (text) { const parts = splitMessage(text, MAX_DISCORD_MSG_LENGTH); for (const part of parts) { await message.reply(part); } } } /** * Send a narrative response to a specific Discord channel or thread. * @param {import('discord.js').Client} client - Discord client instance. * @param {Object} cfg - Responses module configuration. * @param {string} channelId - ID of the Discord channel or thread. * @param {string} text - Narrative input text to process. */ export async function sendNarrative(client, cfg, channelId, text) { const logger = client.logger; try { // Build the narrative instructions const instructions = `${client.responsesSystemPrompt}\n\nGenerate the following as an engaging narrative:`; const body = { model: cfg.defaultModel, instructions, input: text, max_output_tokens: cfg.defaultMaxTokens, temperature: cfg.defaultTemperature, }; logger.debug('sendNarrative: calling AI with body', body); const resp = await client.openai.responses.create(body); logger.info(`sendNarrative AI response id=${resp.id}`); // Fetch the target channel or thread const channel = await client.channels.fetch(channelId); if (!channel || typeof channel.send !== 'function') { logger.error(`sendNarrative: cannot send to channel ID ${channelId}`); return; } // Split the output and send const content = resp.output_text?.trim(); if (content) { const parts = splitMessage(content, MAX_DISCORD_MSG_LENGTH); for (const part of parts) { await channel.send(part); } } } catch (err) { client.logger.error(`sendNarrative error: ${err.message}`); } } /** * Initialize the Responses module */ export async function init(client, clientConfig) { const cfg = clientConfig.responses; client.logger.info('Initializing Responses module'); client.responsesSystemPrompt = await loadSystemPrompt(cfg.systemPromptPath, client.logger); client.openai = new OpenAI({ apiKey: cfg.apiKey }); client.on('messageCreate', m => onMessage(client, cfg, m)); client.logger.info('Responses module ready'); }