ANSI color codeblocks, ephemeral message flag update, etc.

This commit is contained in:
jrmyr 2025-05-04 14:29:13 +00:00
parent b497423ba7
commit 4f5a90b3bb
11 changed files with 247 additions and 19 deletions

119
_opt/ansi.js Normal file
View File

@ -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');
}

View File

@ -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);

View File

@ -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 });
}
}
}

View File

@ -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 });

View File

@ -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');

View File

@ -1,3 +1,4 @@
import { MessageFlags } from 'discord-api-types/v10';
// _opt/schangar.js
import { SlashCommandBuilder } from 'discord.js';

View File

@ -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});
}
}
}

90
_src/ansiColors.js Normal file
View File

@ -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 };

View File

@ -66,6 +66,7 @@ export default {
},
modules: [
'ansi',
'botUtils',
'pbUtils',
'gitUtils',
@ -194,6 +195,7 @@ export default {
},
modules: [
'ansi',
'botUtils',
'pbUtils',
'gitUtils',

View File

@ -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 {

2
tmp.js Normal file
View File

@ -0,0 +1,2 @@
import { ansi, wrapAnsi } from './_src/ansiColors.js';
console.log(wrapAnsi(ansi`[red]Hello[/] World`));