400 lines
14 KiB
JavaScript
400 lines
14 KiB
JavaScript
|
|
// _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: ${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');
|
||
|
|
}
|