import { SlashCommandBuilder, PermissionFlagsBits, ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder } from 'discord.js'; import fs from 'fs'; import path from 'path'; import fetch from 'node-fetch'; // 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 */ export const commands = [ { data: new SlashCommandBuilder() .setName('prompt') .setDescription('View or update the AI response prompt') .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) .setDMPermission(false) .addStringOption(opt => opt.setName('url') .setDescription('URL to a .txt file containing the prompt') .setRequired(false) ), 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({ ephemeral: true }); 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.', ephemeral: true }); } 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.', ephemeral: true }); } catch (err) { client.logger.error(`[cmd:prompt] URL update failed: ${err.message}`); return interaction.editReply({ content: `Error fetching URL: ${err.message}`, ephemeral: true }); } } // 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 const modal = new ModalBuilder() .setCustomId(`promptModal-${clientId}`) .setTitle('Edit AI Prompt'); // Add text inputs for existing chunks chunks.forEach((chunk, idx) => { const input = new TextInputBuilder() .setCustomId(`prompt_${idx}`) .setLabel(`Part ${idx + 1}`) .setStyle(TextInputStyle.Paragraph) .setRequired(idx === 0) .setMaxLength(MAX_LEN) .setValue(chunk); 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)); } client.logger.info(`[cmd:prompt] Showing modal editor for client ${clientId} with ${chunks.length} parts`); await interaction.showModal(modal); } } ]; /** track clients for modal handling */ 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 = ''; 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'); } } catch (err) { client.logger.error(`responsesPrompt init error: ${err.message}`); } client.responsesPrompt = prompt; _clients.push({ client, clientConfig }); // Modal submit listener 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 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); } catch {} } const newPrompt = parts.join('\n'); // Save to PocketBase 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 }); client.responsesPrompt = newPrompt; await interaction.reply({ content: 'Prompt updated!', ephemeral: true }); } catch (err) { client.logger.error(`responsesPrompt modal submit error: ${err.message}`); await interaction.reply({ content: `Error saving prompt: ${err.message}`, ephemeral: true }); } }); }