diff --git a/_opt/ansi.js b/_opt/ansi.js new file mode 100644 index 0000000..b6a31ad --- /dev/null +++ b/_opt/ansi.js @@ -0,0 +1,119 @@ +import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js'; +import { MessageFlags } from 'discord-api-types/v10'; +import { CODES } from '../_src/ansiColors.js'; + +/** + * Combined ANSI utilities module + * - /ansi: preview nested [tag]…[/] ANSI coloring + * - /ansitheme: display full BG×FG theme chart + * Both commands are Admin-only. + */ +export const commands = [ + // Preview arbitrary ANSI tags + { + data: new SlashCommandBuilder() + .setName('ansi') + .setDescription('Preview an ANSI-colored code block') + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) + .setDMPermission(false) + .addStringOption(opt => + opt + .setName('text') + .setDescription('Use [red]…[/], [bold,blue]…[/], escape \\[/]') + .setRequired(true) + ) + .addBooleanOption(opt => + opt + .setName('ephemeral') + .setDescription('Reply ephemerally?') + .setRequired(false) + ), + async execute(interaction, client) { + const raw = interaction.options.getString('text', true); + const ephemeral = interaction.options.getBoolean('ephemeral') ?? true; + const colored = client.ansi`${raw}`; + const block = client.wrapAnsi(colored); + const opts = { content: block }; + if (ephemeral) opts.flags = MessageFlags.Ephemeral; + await interaction.reply(opts); + } + }, + + // Show complete ANSI theme chart + { + data: new SlashCommandBuilder() + .setName('ansitheme') + .setDescription('Show ANSI color theme chart') + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) + .setDMPermission(false) + .addBooleanOption(opt => + opt + .setName('ephemeral') + .setDescription('Reply ephemerally?') + .setRequired(false) + ), + async execute(interaction, client) { + const fgs = ['gray', 'red', 'green', 'yellow', 'blue', 'pink', 'cyan', 'white']; + const bgs = ['bgGray', 'bgOrange', 'bgBlue', 'bgTurquoise', 'bgFirefly', 'bgIndigo', 'bgLightGray', 'bgWhite']; + const pad = 8; + // Column header with padded labels (no colors) - shifted right by 1 + const header = ' ' + fgs.map(f => f.padEnd(pad, ' ')).join(''); + // Sample row with no background (padded cells) + let defaultRow = ''; + for (const fg of fgs) { + const fgCode = CODES[fg]; + const openNormal = `\u001b[${fgCode}m`; + const openBold = `\u001b[${fgCode};${CODES.bold}m`; + const openUnder = `\u001b[${fgCode};${CODES.underline}m`; + const cell = `${openNormal}sa\u001b[0m${openBold}mp\u001b[0m${openUnder}le\u001b[0m`; + defaultRow += ' ' + cell + ' '; + } + // Append default row label after one pad + defaultRow += 'default'; + // Colored rows per background + const rows = []; + for (const bg of bgs) { + let row = ''; + const bgCode = CODES[bg]; + for (const fg of fgs) { + const fgCode = CODES[fg]; + const openNormal = `\u001b[${bgCode};${fgCode}m`; + const openBold = `\u001b[${bgCode};${fgCode};${CODES.bold}m`; + const openUnder = `\u001b[${bgCode};${fgCode};${CODES.underline}m`; + const cell = `${openNormal}sa\u001b[0m${openBold}mp\u001b[0m${openUnder}le\u001b[0m`; + row += ' ' + cell + ' '; + } + // Append uncolored row label immediately after cell padding + row += bg; + rows.push(row); + } + // Determine ephemeral setting + const ephemeral = interaction.options.getBoolean('ephemeral') ?? true; + // Initial sample table (header + default row) + const sampleContent = [header, defaultRow].join('\n'); + const optsSample = { content: client.wrapAnsi(sampleContent) }; + if (ephemeral) optsSample.flags = MessageFlags.Ephemeral; + await interaction.reply(optsSample); + // Split colored rows into two tables + const half = Math.ceil(rows.length / 2); + const firstRows = rows.slice(0, half); + const secondRows = rows.slice(half); + // First colored table + const table1 = [header, ...firstRows].join('\n'); + const opts1 = { content: client.wrapAnsi(table1) }; + if (ephemeral) opts1.flags = MessageFlags.Ephemeral; + await interaction.followUp(opts1); + // Second colored table + if (secondRows.length > 0) { + const table2 = [header, ...secondRows].join('\n'); + const opts2 = { content: client.wrapAnsi(table2) }; + if (ephemeral) opts2.flags = MessageFlags.Ephemeral; + await interaction.followUp(opts2); + } + } + } +]; + +export async function init(client) { + client.logger.info('[module:ansi] Loaded ANSI utilities'); +} \ No newline at end of file diff --git a/_opt/botUtils.js b/_opt/botUtils.js index ac03268..071f59f 100644 --- a/_opt/botUtils.js +++ b/_opt/botUtils.js @@ -1,4 +1,5 @@ import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder } from 'discord.js'; +import { MessageFlags } from 'discord-api-types/v10'; /** * botUtils module - provides administrative bot control commands @@ -28,16 +29,16 @@ export const commands = [ const ownerId = client.config.owner; // Check invoking user is the bot owner if (interaction.user.id !== String(ownerId)) { - return interaction.reply({ content: 'Only the bot owner can shutdown the bot.', ephemeral: true }); + return interaction.reply({ content: 'Only the bot owner can shutdown the bot.', flags: MessageFlags.Ephemeral }); } // Determine desired exit code (default 0) const exitCode = interaction.options.getInteger('code') ?? 0; // Validate exit code bounds if (exitCode < 0 || exitCode > 254) { - return interaction.reply({ content: 'Exit code must be between 0 and 254 inclusive.', ephemeral: true }); + return interaction.reply({ content: 'Exit code must be between 0 and 254 inclusive.', flags: MessageFlags.Ephemeral }); } // Acknowledge before shutting down - await interaction.reply({ content: `Shutting down with exit code ${exitCode}...`, ephemeral: true }); + await interaction.reply({ content: `Shutting down with exit code ${exitCode}...`, flags: MessageFlags.Ephemeral }); client.logger.info( `[cmd:exit] Shutdown initiated by owner ${interaction.user.tag} (${interaction.user.id}), exit code ${exitCode}` ); @@ -72,9 +73,8 @@ export const commands = [ .setRequired(false) ), async execute(interaction, client) { - // Determine if response should be ephemeral (default true) const ephemeral = interaction.options.getBoolean('ephemeral') ?? true; - await interaction.deferReply({ ephemeral }); + await interaction.deferReply({ flags: ephemeral ? MessageFlags.Ephemeral : undefined }); // Process metrics const uptimeSec = process.uptime(); const hours = Math.floor(uptimeSec / 3600); diff --git a/_opt/gitUtils.js b/_opt/gitUtils.js index 9996605..466d3c4 100644 --- a/_opt/gitUtils.js +++ b/_opt/gitUtils.js @@ -1,4 +1,5 @@ import { SlashCommandBuilder } from 'discord.js'; +import { MessageFlags } from 'discord-api-types/v10'; import { exec } from 'child_process'; import { promisify } from 'util'; const execAsync = promisify(exec); @@ -72,12 +73,12 @@ export const commands = [ async execute(interaction, client) { const ownerId = client.config.owner; if (interaction.user.id !== ownerId) { - return interaction.reply({ content: 'Only the bot owner can run git commands.', ephemeral: true }); + return interaction.reply({ content: 'Only the bot owner can run git commands.', flags: MessageFlags.Ephemeral }); } const raw = interaction.options.getString('args'); // Disallow semicolons to prevent command chaining if (raw.includes(';')) { - return interaction.reply({ content: 'Semicolons are not allowed in git arguments.', ephemeral: true }); + return interaction.reply({ content: 'Semicolons are not allowed in git arguments.', flags: MessageFlags.Ephemeral }); } const ephemeral = interaction.options.getBoolean('ephemeral') ?? true; const args = raw.match(/(?:[^\s"]+|"[^"]*")+/g) @@ -98,14 +99,18 @@ export const commands = [ const outputChunks = chunkString(output, firstChunkSize); // Send first block with header + first output chunk const firstBlock = header + (outputChunks[0] || ''); - await interaction.reply({ content: formatCodeBlock(firstBlock), ephemeral }); + const replyOpts = { content: formatCodeBlock(firstBlock) }; + if (ephemeral) replyOpts.flags = MessageFlags.Ephemeral; + await interaction.reply(replyOpts); // Send any remaining blocks without the header for (let i = 1; i < outputChunks.length; i++) { - await interaction.followUp({ content: formatCodeBlock(outputChunks[i]), ephemeral }); + const fuOpts = { content: formatCodeBlock(outputChunks[i]) }; + if (ephemeral) fuOpts.flags = MessageFlags.Ephemeral; + await interaction.followUp(fuOpts); } } catch (err) { const msg = err instanceof GitError ? err.message : String(err); - await interaction.reply({ content: `Error: ${msg}`, ephemeral: true }); + await interaction.reply({ content: `Error: ${msg}`, flags: MessageFlags.Ephemeral }); } } } diff --git a/_opt/responsesPrompt.js b/_opt/responsesPrompt.js index 6107a54..e634e3a 100644 --- a/_opt/responsesPrompt.js +++ b/_opt/responsesPrompt.js @@ -1,3 +1,4 @@ +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'; @@ -30,10 +31,10 @@ export const commands = [ // URL-based update if (url) { client.logger.info(`[cmd:prompt] URL update requested for client ${clientId}: ${url}`); - await interaction.deferReply({ ephemeral: true }); + 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.', ephemeral: true }); + return interaction.editReply({ content: 'URL must point to a .txt file.', flags: MessageFlags.Ephemeral}); } try { const res = await fetch(url); @@ -48,7 +49,7 @@ export const commands = [ updatedBy: interaction.user.id }); client.responsesPrompt = text; - return interaction.editReply({ content: 'Prompt updated from URL.', ephemeral: true }); + return interaction.editReply({ content: 'Prompt updated from URL.', flags: MessageFlags.Ephemeral}); } catch (err) { client.logger.error(`[cmd:prompt] URL update failed: ${err.message}`); return interaction.editReply({ content: `Error fetching URL: ${err.message}`, ephemeral: true }); @@ -151,7 +152,7 @@ export async function init(client, clientConfig) { updatedBy: interaction.user.id }); client.responsesPrompt = newPrompt; - await interaction.reply({ content: 'Prompt updated!', ephemeral: true }); + 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 }); diff --git a/_opt/responsesQuery.js b/_opt/responsesQuery.js index f2214ce..caa327c 100644 --- a/_opt/responsesQuery.js +++ b/_opt/responsesQuery.js @@ -1,3 +1,4 @@ +import { MessageFlags } from 'discord-api-types/v10'; /** * Slash command module for '/query'. * Defines and handles the /query command via the OpenAI Responses API, @@ -182,7 +183,7 @@ export const commands = [ } } catch (err) { client.logger.error(`[cmd:query] Error checking score: ${err.message}`); - return interaction.reply({ content: 'Error verifying your score. Please try again later.', ephemeral: true }); + return interaction.reply({ content: 'Error verifying your score. Please try again later.', flags: MessageFlags.Ephemeral}); } } const prompt = interaction.options.getString('prompt'); diff --git a/_opt/scExecHangarStatus.js b/_opt/scExecHangarStatus.js index 65b07b4..c6d17f2 100644 --- a/_opt/scExecHangarStatus.js +++ b/_opt/scExecHangarStatus.js @@ -1,3 +1,4 @@ +import { MessageFlags } from 'discord-api-types/v10'; // _opt/schangar.js import { SlashCommandBuilder } from 'discord.js'; diff --git a/_opt/scorekeeper.js b/_opt/scorekeeper.js index cf5b292..d900046 100644 --- a/_opt/scorekeeper.js +++ b/_opt/scorekeeper.js @@ -1,3 +1,4 @@ +import { MessageFlags } from 'discord-api-types/v10'; // opt/scorekeeper.js import cron from 'node-cron'; import { SlashCommandBuilder, EmbedBuilder, PermissionFlagsBits } from 'discord.js'; @@ -846,7 +847,7 @@ export const commands = [ await interaction.reply({ content: `Category '${name}' created.`, ephemeral: true }); } catch (err) { client.logger.error(`Error in addcategory: ${err.message}`); - await interaction.reply({ content: 'Failed to create category.', ephemeral: true }); + await interaction.reply({ content: 'Failed to create category.', flags: MessageFlags.Ephemeral}); } } } @@ -875,7 +876,7 @@ export const commands = [ await interaction.reply({ content: `Category '${name}' removed.`, ephemeral: true }); } catch (err) { client.logger.error(`Error in removecategory: ${err.message}`); - await interaction.reply({ content: 'Failed to remove category.', ephemeral: true }); + await interaction.reply({ content: 'Failed to remove category.', flags: MessageFlags.Ephemeral}); } } } diff --git a/_src/ansiColors.js b/_src/ansiColors.js new file mode 100644 index 0000000..d0b6068 --- /dev/null +++ b/_src/ansiColors.js @@ -0,0 +1,90 @@ +// ANSI Colors helper - provides nested [tag]…[/] parsing and code-block wrapping. + +// ANSI color/style codes +const CODES = { + // text colors + gray: 30, red: 31, green: 32, yellow: 33, + blue: 34, pink: 35, cyan: 36, white: 37, + // background colors + bgGray: 40, bgOrange: 41, bgBlue: 42, + bgTurquoise: 43, bgFirefly: 44, bgIndigo: 45, + bgLightGray: 46, bgWhite: 47, + // styles + bold: 1, underline: 4, + // reset + reset: 0 +}; + +/** + * Escape literal brackets so users can write \[ and \] without triggering tags. + */ +export function escapeBrackets(str) { + return str + .replace(/\\\[/g, '__ESC_LB__') + .replace(/\\\]/g, '__ESC_RB__'); +} + +/** Restore any escaped brackets after formatting. */ +export function restoreBrackets(str) { + return str + .replace(/__ESC_LB__/g, '[') + .replace(/__ESC_RB__/g, ']'); +} + +/** + * Parse nested [tag1,tag2]…[/] patterns into ANSI codes (stack-based). + */ +export function formatAnsi(input) { + const stack = []; + let output = ''; + const pattern = /\[\/\]|\[([^\]]+)\]/g; + let lastIndex = 0; + let match; + + while ((match = pattern.exec(input)) !== null) { + output += input.slice(lastIndex, match.index); + + if (match[0] === '[/]') { + if (stack.length) stack.pop(); + output += `\u001b[${CODES.reset}m`; + for (const tag of stack) { + const code = CODES[tag] ?? CODES.gray; + output += `\u001b[${code}m`; + } + } else { + const tags = match[1].split(/[,;\s]+/).filter(Boolean); + for (const tag of tags) { + stack.push(tag); + const code = CODES[tag] ?? CODES.gray; + output += `\u001b[${code}m`; + } + } + + lastIndex = pattern.lastIndex; + } + + output += input.slice(lastIndex); + if (stack.length) output += `\u001b[${CODES.reset}m`; + return output; +} + +/** + * Template-tag: ansi`[red]…[/] text` + * Escapes brackets, parses ANSI, and restores literals. + */ +export function ansi(strings, ...values) { + let built = ''; + for (let i = 0; i < strings.length; i++) { + built += strings[i]; + if (i < values.length) built += values[i]; + } + return restoreBrackets(formatAnsi(escapeBrackets(built))); +} + +/** Wrap text in a ```ansi code block for Discord. */ +export function wrapAnsi(text) { + return '```ansi\n' + text + '\n```'; +} + +// Export raw codes for advanced use (e.g., ansitheme module) +export { CODES }; \ No newline at end of file diff --git a/config.js b/config.js index 7aec92d..7357aad 100644 --- a/config.js +++ b/config.js @@ -66,6 +66,7 @@ export default { }, modules: [ + 'ansi', 'botUtils', 'pbUtils', 'gitUtils', @@ -194,6 +195,7 @@ export default { }, modules: [ + 'ansi', 'botUtils', 'pbUtils', 'gitUtils', diff --git a/index.js b/index.js index 195e6ff..38f06c6 100644 --- a/index.js +++ b/index.js @@ -3,6 +3,8 @@ import { createLogger } from './_src/logger.js'; import { initializePocketbase } from './_src/pocketbase.js'; import { loadModules } from './_src/loader.js'; import config from './config.js'; +// For deprecated ephemeral option: convert to flags +import { ansi, wrapAnsi } from './_src/ansiColors.js'; // Initialize Discord client const initializeClient = async (clientConfig) => { @@ -27,8 +29,11 @@ const initializeClient = async (clientConfig) => { // Set up Pocketbase client.pb = await initializePocketbase(clientConfig, client.logger); - // Commands collection - client.commands = new Collection(); + // Commands collection + client.commands = new Collection(); + // ANSI helper attached to client + client.ansi = ansi; + client.wrapAnsi = wrapAnsi; // Load optional modules await loadModules(clientConfig, client); @@ -39,6 +44,7 @@ const initializeClient = async (clientConfig) => { client.on('interactionCreate', async (interaction) => { if (!interaction.isChatInputCommand()) return; + const commandName = interaction.commandName; try { diff --git a/tmp.js b/tmp.js new file mode 100644 index 0000000..a0e3c80 --- /dev/null +++ b/tmp.js @@ -0,0 +1,2 @@ +import { ansi, wrapAnsi } from './_src/ansiColors.js'; +console.log(wrapAnsi(ansi`[red]Hello[/] World`));