From 8231b5a105d6bbcbf3419567d68573ce9e656924 Mon Sep 17 00:00:00 2001 From: jrmyr Date: Tue, 6 May 2025 19:21:55 +0000 Subject: [PATCH] Reponses prompt updates and templates. --- _opt/gitUtils.js | 22 +++-- _opt/pbUtils.js | 35 ++------ _opt/responses.js | 88 ++++++++++--------- _opt/responsesPrompt.js | 190 +++++++++++++++++++--------------------- _opt/responsesQuery.js | 50 ++++++++--- _src/template.js | 15 ++++ 6 files changed, 219 insertions(+), 181 deletions(-) create mode 100644 _src/template.js diff --git a/_opt/gitUtils.js b/_opt/gitUtils.js index 466d3c4..6408c01 100644 --- a/_opt/gitUtils.js +++ b/_opt/gitUtils.js @@ -1,8 +1,9 @@ import { SlashCommandBuilder } from 'discord.js'; import { MessageFlags } from 'discord-api-types/v10'; -import { exec } from 'child_process'; +import { execFile } from 'child_process'; import { promisify } from 'util'; -const execAsync = promisify(exec); +// Use execFile to avoid shell interpretation of arguments +const execFileAsync = promisify(execFile); // Wrap Git errors class GitError extends Error { @@ -19,12 +20,23 @@ class GitError extends Error { * @throws {GitError} - When the git command exits with an error. */ async function runGit(args) { + // Sanitize arguments: disallow dangerous shell metacharacters + if (!Array.isArray(args)) { + throw new GitError('Invalid git arguments'); + } + const dangerous = /[;&|<>`$\\]/; + for (const arg of args) { + if (dangerous.test(arg)) { + throw new GitError(`Illegal character in git argument: ${arg}`); + } + } try { - const { stdout, stderr } = await execAsync(`git ${args.join(' ')}`); - const out = stdout.trim() || stderr.trim(); + // Exec git directly without shell + const { stdout, stderr } = await execFileAsync('git', args); + const out = (stdout || stderr || '').toString().trim(); return out || '(no output)'; } catch (err) { - const msg = err.stderr?.trim() || err.message; + const msg = err.stderr?.toString().trim() || err.message; throw new GitError(msg); } } diff --git a/_opt/pbUtils.js b/_opt/pbUtils.js index 95050b2..ba8739b 100644 --- a/_opt/pbUtils.js +++ b/_opt/pbUtils.js @@ -361,29 +361,6 @@ const extendPocketBase = (client, pb, logger) => { return await pb.deleteOne('message_queue', id); }; - // ===== PUB/SUB OPERATIONS ===== - - /** - * Publish a message into the "message_queue" collection. - * @param {string} source - Origin identifier for the message. - * @param {string} destination - Target identifier (e.g. channel or client ID). - * @param {string} dataType - A short string describing the type of data. - * @param {object} data - The payload object to deliver. - * @returns {Promise} The created message_queue record. - */ - pb.publishMessage = async (source, destination, dataType, data) => { - try { - return await pb.collection('message_queue').create({ - source, - destination, - dataType, - data: JSON.stringify(data) - }); - } catch (error) { - logger.error(`Failed to publish message to message_queue: ${error.message}`); - throw error; - } - }; // ===== CACHE MANAGEMENT ===== @@ -509,13 +486,15 @@ const setupConnectionHandling = (pb, logger) => { logger.info('Reconnecting to PocketBase...'); // Attempt to refresh the auth if we have a refresh token if (pb.authStore.token && pb.authStore.model?.id) { - await pb.admins.authRefresh(); + // Refresh session using the configured users collection + await pb.collection('_users').authRefresh(); } else if (pb._config.username && pb._config.password) { // Fall back to full re-authentication if credentials available - await pb.admins.authWithPassword( - pb._config.username, - pb._config.password - ); + // Re-authenticate using the configured users collection credentials + await pb.collection('_users').authWithPassword( + pb._config.username, + pb._config.password + ); } else { logger.error('No credentials available to reconnect PocketBase'); pb.isConnected = false; diff --git a/_opt/responses.js b/_opt/responses.js index e803dc3..bea6c59 100644 --- a/_opt/responses.js +++ b/_opt/responses.js @@ -7,6 +7,7 @@ import { OpenAI } from 'openai'; import axios from 'axios'; import { AttachmentBuilder, PermissionFlagsBits } from 'discord.js'; +import { expandTemplate } from '../_src/template.js'; import fs from 'fs/promises'; import path from 'path'; @@ -132,7 +133,7 @@ async function handleImage(client, message, resp, cfg) { if (!fn?.arguments) return false; client.logger.debug(`Image function args: ${fn.arguments}`); let args; - try { args = JSON.parse(fn.arguments); } catch { return false; } + try { args = JSON.parse(fn.arguments); } catch (e) { return false; } if (!args.prompt?.trim()) { await message.reply('Cannot generate image: empty prompt.'); return true; @@ -248,7 +249,6 @@ async function onMessage(client, cfg, message) { client.logger.debug(`[onMessage] Received message ${message.id} from ${message.author.id}`); // Check if bot should respond, based on config (mentions/replies) if (!(await shouldRespond(message, botId, cfg, logger))) return; - await message.channel.sendTyping(); // Determine channel/thread key for context const key = message.thread?.id || message.channel.id; @@ -258,6 +258,12 @@ async function onMessage(client, cfg, message) { const last = lockMap.get(key) || Promise.resolve(); // Handler to run in sequence const handler = async () => { + // Start typing indicator loop every 9 seconds + const typingInterval = setInterval(() => { + message.channel.sendTyping().catch(() => {}); + }, 9000); + // Initial typing + message.channel.sendTyping().catch(() => {}); try { // Previous response ID for context continuity const prev = client.pb?.cache?.get(key); @@ -298,13 +304,33 @@ async function onMessage(client, cfg, message) { const userInput = referencePrefix ? `${referencePrefix}\n${speakerMention} said to you: ${message.content}` : `${speakerMention} said to you: ${message.content}`; + // Prepare template context + const locationName = message.thread?.name || message.channel.name; + const locationId = message.thread?.id || message.channel.id; + const now = new Date(); + const date = now.toISOString().split('T')[0]; + const time = now.toTimeString().split(' ')[0]; + const datetime = now.toISOString().replace('T',' ').replace(/\..+$/,''); + const ctx = { + clientId: client.config.id, + userName: message.author.username, + userId: message.author.id, + userTag: message.author.tag, + // add guild context + guildName: message.guild?.name || '', + guildId: message.guild?.id || '', + input: userInput, + locationName, locationId, + date, time, datetime + }; + const instructions = expandTemplate(client.responsesPrompt, ctx); const body = { model: cfg.defaultModel, - instructions: client.responsesPrompt, + instructions, input: userInput, previous_response_id: prev, max_output_tokens: cfg.defaultMaxTokens, - temperature: cfg.defaultTemperature, + temperature: cfg.defaultTemperature }; // Assemble any enabled tools const tools = []; @@ -416,6 +442,8 @@ async function onMessage(client, cfg, message) { } } catch (err) { logger.error(`Queued onMessage error for ${key}: ${err.message}`); + } finally { + clearInterval(typingInterval); } }; // Chain the handler to the last promise @@ -423,39 +451,6 @@ async function onMessage(client, cfg, message) { 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); - } - } - } /** @@ -469,13 +464,28 @@ export async function sendNarrative(client, cfg, channelId, text) { const logger = client.logger; try { // Build the narrative instructions - const instructions = `${client.responsesPrompt}\n\nGenerate the following as an engaging narrative:`; + // Expand template for sendNarrative + const now = new Date(); + const date = now.toISOString().split('T')[0]; + const time = now.toTimeString().split(' ')[0]; + const datetime = now.toISOString().replace('T',' ').replace(/\..+$/,''); + const ctx = { + clientId: client.config.id, + userName: client.user.username, + userId: client.user.id, + input: text, + locationName: channel.name, + locationId: channel.id, + date, time, datetime + }; + const raw = `${client.responsesPrompt}\n\nGenerate the following as an engaging narrative:`; + const instructions = expandTemplate(raw, ctx); const body = { model: cfg.defaultModel, instructions, input: text, max_output_tokens: cfg.defaultMaxTokens, - temperature: cfg.defaultTemperature, + temperature: cfg.defaultTemperature }; logger.debug(`[sendNarrative] Calling AI with body: ${JSON.stringify(body).slice(0,1000)}`); const resp = await client.openai.responses.create(body); diff --git a/_opt/responsesPrompt.js b/_opt/responsesPrompt.js index e634e3a..3ca215d 100644 --- a/_opt/responsesPrompt.js +++ b/_opt/responsesPrompt.js @@ -1,161 +1,153 @@ -import { MessageFlags } from 'discord-api-types/v10'; import { SlashCommandBuilder, PermissionFlagsBits, ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder } from 'discord.js'; import fs from 'fs'; import path from 'path'; -import fetch from 'node-fetch'; +// Placeholder info for template variables +const TEMPLATE_KEYS_INFO = 'Available keys: userName, userId, locationName, locationId, date, time, datetime, clientId'; + // Modal text input limits const MAX_LEN = 4000; const MAX_FIELDS = 5; /** * responsesPrompt module - * Provides a /prompt command to view or update the AI response prompt - * Stored in PocketBase collection 'responses_prompts' with fields: - * clientId (string), prompt (text), updatedBy (string), created/updated timestamps + * Implements `/prompt [version]` to edit the current or historical prompt in a single PocketBase collection. + * responses_prompts collection holds all versions; newest record per client is the live prompt. */ export const commands = [ { data: new SlashCommandBuilder() .setName('prompt') - .setDescription('View or update the AI response prompt') + .setDescription('Edit the AI response prompt (current or past version)') .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) .setDMPermission(false) .addStringOption(opt => - opt.setName('url') - .setDescription('URL to a .txt file containing the prompt') + opt.setName('version') + .setDescription('ID of a past prompt version to load') .setRequired(false) + .setAutocomplete(true) ), async execute(interaction, client) { - const url = interaction.options.getString('url'); const clientId = client.config.id; - // URL-based update - if (url) { - client.logger.info(`[cmd:prompt] URL update requested for client ${clientId}: ${url}`); - await interaction.deferReply({ flags: MessageFlags.Ephemeral}); - if (!url.toLowerCase().endsWith('.txt')) { - client.logger.warn(`[cmd:prompt] Invalid URL extension, must end .txt: ${url}`); - return interaction.editReply({ content: 'URL must point to a .txt file.', flags: MessageFlags.Ephemeral}); - } + const versionId = interaction.options.getString('version'); + // Fetch prompt: live latest or selected historic + let promptText = client.responsesPrompt || ''; + if (versionId) { try { - const res = await fetch(url); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const text = await res.text(); - // Upsert into PocketBase - const existing = await client.pb.getFirst('responses_prompts', `clientId="${clientId}"`); - const recId = existing?.id; - await client.pb.upsert('responses_prompts', recId, { - clientId, - prompt: text, - updatedBy: interaction.user.id - }); - client.responsesPrompt = text; - return interaction.editReply({ content: 'Prompt updated from URL.', flags: MessageFlags.Ephemeral}); + const rec = await client.pb.getOne('responses_prompts', versionId); + if (rec?.prompt) promptText = rec.prompt; } catch (err) { - client.logger.error(`[cmd:prompt] URL update failed: ${err.message}`); - return interaction.editReply({ content: `Error fetching URL: ${err.message}`, ephemeral: true }); + client.logger.error(`Failed to load prompt version ${versionId}: ${err.message}`); } } - // Modal-based edit - const existingPrompt = client.responsesPrompt || ''; - // Prevent modal if prompt exceeds capacity - if (existingPrompt.length > MAX_LEN * MAX_FIELDS) { - return interaction.reply({ - content: 'Current prompt too large for modal editing (exceeds 20000 chars); please use URL.', - ephemeral: true - }); - } - // Prepare chunks - const chunks = []; - for (let offset = 0; offset < existingPrompt.length; offset += MAX_LEN) { - chunks.push(existingPrompt.slice(offset, offset + MAX_LEN)); - } - // Build modal + // Prepare modal fields: one SHORT help, then paragraph chunks + // Help field + const helpField = new TextInputBuilder() + .setCustomId('template_help') + .setLabel('Template variables (no edits)') + .setStyle(TextInputStyle.Short) + .setRequired(false) + // prefill with the list of usable keys + .setValue(TEMPLATE_KEYS_INFO); const modal = new ModalBuilder() - .setCustomId(`promptModal-${clientId}`) - .setTitle('Edit AI Prompt'); - // Add text inputs for existing chunks - chunks.forEach((chunk, idx) => { + .setCustomId(`promptModal-${versionId || 'current'}`) + .setTitle('Edit AI Prompt') + .addComponents(new ActionRowBuilder().addComponents(helpField)); + // Prompt chunks + const chunks = []; + for (let off = 0; off < promptText.length && chunks.length < MAX_FIELDS - 1; off += MAX_LEN) { + chunks.push(promptText.slice(off, off + MAX_LEN)); + } + chunks.forEach((text, idx) => { const input = new TextInputBuilder() .setCustomId(`prompt_${idx}`) .setLabel(`Part ${idx + 1}`) .setStyle(TextInputStyle.Paragraph) .setRequired(idx === 0) .setMaxLength(MAX_LEN) - .setValue(chunk); + .setValue(text); modal.addComponents(new ActionRowBuilder().addComponents(input)); }); - // Add one extra empty field to allow expansion (up to MAX_FIELDS) - if (chunks.length < MAX_FIELDS) { - const i = chunks.length; - const input = new TextInputBuilder() - .setCustomId(`prompt_${i}`) - .setLabel(`Part ${i + 1}`) - .setStyle(TextInputStyle.Paragraph) - .setRequired(false) - .setMaxLength(MAX_LEN); - modal.addComponents(new ActionRowBuilder().addComponents(input)); + // Empty fields to fill out to MAX_FIELDS + for (let i = chunks.length; i < MAX_FIELDS - 1; i++) { + modal.addComponents(new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId(`prompt_${i}`) + .setLabel(`Part ${i + 1}`) + .setStyle(TextInputStyle.Paragraph) + .setRequired(false) + .setMaxLength(MAX_LEN) + )); } - client.logger.info(`[cmd:prompt] Showing modal editor for client ${clientId} with ${chunks.length} parts`); await interaction.showModal(modal); } } ]; -/** track clients for modal handling */ +// Store clients for event hooks const _clients = []; -/** - * init hook: load existing prompt and register modal submit handler - */ export async function init(client, clientConfig) { - client.logger.info('[module:responsesPrompt] Module loaded'); const clientId = clientConfig.id; - // Load prompt from PocketBase or fallback to file - let prompt = ''; + client.logger.info('[module:responsesPrompt] initialized'); + // Load live prompt (latest version) try { - const record = await client.pb.getFirst('responses_prompts', `clientId="${clientId}"`); - if (record && record.prompt) { - prompt = record.prompt; - } else if (clientConfig.responses?.systemPromptPath) { - const filePath = path.isAbsolute(clientConfig.responses.systemPromptPath) - ? clientConfig.responses.systemPromptPath - : path.join(process.cwd(), clientConfig.responses.systemPromptPath); - prompt = fs.readFileSync(filePath, 'utf8'); - } + const { items } = await client.pb.collection('responses_prompts') + .getList(1, 1, { filter: `clientId="${clientId}"`, sort: '-created' }); + client.responsesPrompt = items[0]?.prompt || ''; } catch (err) { - client.logger.error(`responsesPrompt init error: ${err.message}`); + client.logger.error(`Error loading current prompt: ${err.message}`); + client.responsesPrompt = ''; } - client.responsesPrompt = prompt; _clients.push({ client, clientConfig }); - // Modal submit listener + // Autocomplete versions + client.on('interactionCreate', async interaction => { + if (!interaction.isAutocomplete() || interaction.commandName !== 'prompt') return; + const focused = interaction.options.getFocused(true); + if (focused.name === 'version') { + try { + const { items } = await client.pb.collection('responses_prompts') + .getList(1, 25, { filter: `clientId="${clientId}"`, sort: '-created' }); + const choices = items.map(r => ({ name: new Date(r.created).toLocaleString(), value: r.id })); + await interaction.respond(choices); + } catch (err) { + client.logger.error(`Prompt autocomplete error: ${err.message}`); + await interaction.respond([]); + } + } + }); + // Modal submission: save new version & prune old client.on('interactionCreate', async interaction => { if (!interaction.isModalSubmit()) return; - if (interaction.customId !== `promptModal-${clientId}`) return; - // Reassemble prompt from modal fields, inserting line breaks between parts - client.logger.info(`[cmd:prompt] Modal submission received for client ${clientId}`); + const id = interaction.customId; + if (!id.startsWith('promptModal-')) return; const parts = []; for (let i = 0; i < MAX_FIELDS; i++) { try { - const value = interaction.fields.getTextInputValue(`prompt_${i}`) || ''; - if (value.trim().length > 0) parts.push(value); + const v = interaction.fields.getTextInputValue(`prompt_${i}`) || ''; + if (v.trim()) parts.push(v); } catch {} } const newPrompt = parts.join('\n'); - // Save to PocketBase + // Persist new version + let newRec; try { - client.logger.debug(`[cmd:prompt] Saving new prompt for client ${clientId}, length=${newPrompt.length}`); - const existing = await client.pb.getFirst('responses_prompts', `clientId="${clientId}"`); - const recId = existing?.id; - await client.pb.upsert('responses_prompts', recId, { - clientId, - prompt: newPrompt, - updatedBy: interaction.user.id - }); + newRec = await client.pb.createOne('responses_prompts', { clientId, prompt: newPrompt, updatedBy: interaction.user.id }); client.responsesPrompt = newPrompt; - await interaction.reply({ content: 'Prompt updated!', flags: MessageFlags.Ephemeral}); } catch (err) { - client.logger.error(`responsesPrompt modal submit error: ${err.message}`); - await interaction.reply({ content: `Error saving prompt: ${err.message}`, ephemeral: true }); + client.logger.error(`Failed to save prompt: ${err.message}`); + return interaction.reply({ content: `Error saving prompt: ${err.message}`, ephemeral: true }); } + // Prune older versions beyond the 10 most recent + try { + const { items } = await client.pb.collection('responses_prompts') + .getList(1, 100, { filter: `clientId="${clientId}"`, sort: '-created' }); + const toDelete = items.map(r => r.id).slice(10); + for (const id of toDelete) { + await client.pb.deleteOne('responses_prompts', id); + } + } catch (err) { + client.logger.error(`Failed to prune old prompts: ${err.message}`); + } + await interaction.reply({ content: 'Prompt saved!', ephemeral: true }); }); } \ No newline at end of file diff --git a/_opt/responsesQuery.js b/_opt/responsesQuery.js index 2cde234..6c4a364 100644 --- a/_opt/responsesQuery.js +++ b/_opt/responsesQuery.js @@ -5,6 +5,7 @@ import { MessageFlags } from 'discord-api-types/v10'; * including optional image generation function calls. */ import { SlashCommandBuilder, AttachmentBuilder, PermissionFlagsBits } from 'discord.js'; +import { expandTemplate } from '../_src/template.js'; import fs from 'fs/promises'; import path from 'path'; import axios from 'axios'; @@ -47,7 +48,7 @@ async function handleImageInteraction(client, interaction, resp, cfg, ephemeral) if (!fn?.arguments) return false; client.logger.debug(`Image function args: ${fn.arguments}`); let args; - try { args = JSON.parse(fn.arguments); } catch { return false; } + try { args = JSON.parse(fn.arguments); } catch (e) { return false; } if (!args.prompt?.trim()) { await interaction.editReply({ content: 'Cannot generate image: empty prompt.', ephemeral }); return true; @@ -206,17 +207,42 @@ export const commands = [ const last = lockMap.get(key) || Promise.resolve(); // Handler to run in sequence const handler = async () => { - // Read previous response ID + // Kick off a repeated typing indicator during processing + const typingInterval = setInterval(() => interaction.channel.sendTyping().catch(() => {}), 9000); + // initial typing + interaction.channel.sendTyping().catch(() => {}); + // Read previous response ID const previous = client.pb?.cache?.get(key); // Build request body - const body = { - model: cfg.defaultModel, - instructions: client.responsesPrompt, - input: prompt, - previous_response_id: previous, - max_output_tokens: cfg.defaultMaxTokens, - temperature: cfg.defaultTemperature, - }; + // Expand template for query + const now = new Date(); + const date = now.toISOString().split('T')[0]; + const time = now.toTimeString().split(' ')[0]; + const datetime = now.toISOString().replace('T',' ').replace(/\..+$/,''); + const channel = await client.channels.fetch(interaction.channelId); + const locationName = channel.name; + const locationId = channel.id; + const ctx = { + clientId: client.config.id, + userName: interaction.user.username, + userId: interaction.user.id, + userTag: interaction.user.tag, + // add guild context + guildName: interaction.guild?.name || '', + guildId: interaction.guild?.id || '', + input: prompt, + locationName, locationId, + date, time, datetime + }; + const instructions = expandTemplate(client.responsesPrompt, ctx); + const body = { + model: cfg.defaultModel, + instructions, + input: prompt, + previous_response_id: previous, + max_output_tokens: cfg.defaultMaxTokens, + temperature: cfg.defaultTemperature + }; // Assemble enabled tools const tools = []; if (cfg.tools?.imageGeneration) { @@ -292,6 +318,7 @@ export const commands = [ } } catch (err) { client.logger.error(`AI error in /query: ${err.message}`); + clearInterval(typingInterval); return interaction.editReply({ content: 'Error generating response.', ephemeral }); } @@ -303,11 +330,13 @@ export const commands = [ // Handle image function call if present if (await handleImageInteraction(client, interaction, resp, cfg, ephemeral)) { + clearInterval(typingInterval); return; } // Send text reply chunks const text = resp.output_text?.trim() || ''; if (!text) { + clearInterval(typingInterval); return interaction.editReply({ content: 'No response generated.', ephemeral }); } const chunks = splitLongMessage(text, 2000); @@ -318,6 +347,7 @@ export const commands = [ await interaction.followUp({ content: chunks[i], ephemeral }); } } + clearInterval(typingInterval); }; // Chain handler after last and await const next = last.then(handler).catch(err => client.logger.error(`Queued /query error for ${key}: ${err.message}`)); diff --git a/_src/template.js b/_src/template.js new file mode 100644 index 0000000..ed1d5b0 --- /dev/null +++ b/_src/template.js @@ -0,0 +1,15 @@ +"use strict"; +/** + * expandTemplate: simple variable substitution in {{key}} placeholders. + * @param {string} template - The template string with {{key}} tokens. + * @param {object} context - Mapping of key -> replacement value. + * @returns {string} - The template with keys replaced by context values. + */ +export function expandTemplate(template, context) { + if (typeof template !== 'string') return ''; + return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (_match, key) => { + return Object.prototype.hasOwnProperty.call(context, key) + ? String(context[key]) + : ''; + }); +} \ No newline at end of file