This commit is contained in:
jrmyr 2025-05-08 01:52:12 +00:00
parent 8231b5a105
commit 601e5a703f
26 changed files with 7182 additions and 4314 deletions

6
.eslintignore Normal file
View File

@ -0,0 +1,6 @@
node_modules/
logs/
images/
dist/
coverage/
*.min.js

67
.eslintrc.json Normal file
View File

@ -0,0 +1,67 @@
{
"env": {
"node": true,
"es2022": true
},
"extends": [
"eslint:recommended",
"plugin:import/recommended"
],
"plugins": [
"import"
],
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"settings": {
"import/resolver": {
"node": {
"extensions": [
".js",
".mjs"
]
}
}
},
"rules": {
// Error prevention
"no-const-assign": "error",
"no-dupe-args": "error",
"no-dupe-keys": "error",
"no-duplicate-case": "error",
"no-unreachable": "error",
"valid-typeof": "error",
// Best practices
"eqeqeq": "error",
"no-eval": "error",
"no-unused-vars": ["error", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }],
"no-var": "error",
"prefer-const": "error",
"no-empty": ["error", { "allowEmptyCatch": true }],
// Style
"indent": ["error", 4, { "SwitchCase": 1 }],
"linebreak-style": ["error", "unix"],
"quotes": ["error", "single"],
"semi": ["error", "always"],
"no-multiple-empty-lines": ["error", { "max": 1 }],
"no-trailing-spaces": "error",
"eol-last": "error",
"no-mixed-spaces-and-tabs": "error",
// Object and array formatting
"object-curly-spacing": ["error", "always"],
"array-bracket-spacing": ["error", "never"],
"comma-dangle": ["error", "never"],
// Import/Export
"import/no-duplicates": "error",
"import/order": ["error", {
"groups": ["builtin", "external", "internal", "parent", "sibling", "index"],
"newlines-between": "always",
"alphabetize": { "order": "asc" }
}]
}
}

View File

@ -1,5 +1,6 @@
import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js';
import { MessageFlags } from 'discord-api-types/v10'; import { MessageFlags } from 'discord-api-types/v10';
import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js';
import { CODES } from '../_src/ansiColors.js'; import { CODES } from '../_src/ansiColors.js';
/** /**
@ -9,111 +10,111 @@ import { CODES } from '../_src/ansiColors.js';
* Both commands are Admin-only. * Both commands are Admin-only.
*/ */
export const commands = [ export const commands = [
// Preview arbitrary ANSI tags // Preview arbitrary ANSI tags
{ {
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName('ansi') .setName('ansi')
.setDescription('Preview an ANSI-colored code block') .setDescription('Preview an ANSI-colored code block')
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator) .setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
.setDMPermission(false) .setDMPermission(false)
.addStringOption(opt => .addStringOption(opt =>
opt opt
.setName('text') .setName('text')
.setDescription('Use [red]…[/], [bold,blue]…[/], escape \\[/]') .setDescription('Use [red]…[/], [bold,blue]…[/], escape \\[/]')
.setRequired(true) .setRequired(true)
) )
.addBooleanOption(opt => .addBooleanOption(opt =>
opt opt
.setName('ephemeral') .setName('ephemeral')
.setDescription('Reply ephemerally?') .setDescription('Reply ephemerally?')
.setRequired(false) .setRequired(false)
), ),
async execute(interaction, client) { async execute(interaction, client) {
const raw = interaction.options.getString('text', true); const raw = interaction.options.getString('text', true);
const ephemeral = interaction.options.getBoolean('ephemeral') ?? true; const ephemeral = interaction.options.getBoolean('ephemeral') ?? true;
const colored = client.ansi`${raw}`; const colored = client.ansi`${raw}`;
const block = client.wrapAnsi(colored); const block = client.wrapAnsi(colored);
const opts = { content: block }; const opts = { content: block };
if (ephemeral) opts.flags = MessageFlags.Ephemeral; if (ephemeral) opts.flags = MessageFlags.Ephemeral;
await interaction.reply(opts); await interaction.reply(opts);
} }
}, },
// Show complete ANSI theme chart // Show complete ANSI theme chart
{ {
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName('ansitheme') .setName('ansitheme')
.setDescription('Show ANSI color theme chart') .setDescription('Show ANSI color theme chart')
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator) .setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
.setDMPermission(false) .setDMPermission(false)
.addBooleanOption(opt => .addBooleanOption(opt =>
opt opt
.setName('ephemeral') .setName('ephemeral')
.setDescription('Reply ephemerally?') .setDescription('Reply ephemerally?')
.setRequired(false) .setRequired(false)
), ),
async execute(interaction, client) { async execute(interaction, client) {
const fgs = ['gray', 'red', 'green', 'yellow', 'blue', 'pink', 'cyan', 'white']; const fgs = ['gray', 'red', 'green', 'yellow', 'blue', 'pink', 'cyan', 'white'];
const bgs = ['bgGray', 'bgOrange', 'bgBlue', 'bgTurquoise', 'bgFirefly', 'bgIndigo', 'bgLightGray', 'bgWhite']; const bgs = ['bgGray', 'bgOrange', 'bgBlue', 'bgTurquoise', 'bgFirefly', 'bgIndigo', 'bgLightGray', 'bgWhite'];
const pad = 8; const pad = 8;
// Column header with padded labels (no colors) - shifted right by 1 // Column header with padded labels (no colors) - shifted right by 1
const header = ' ' + fgs.map(f => f.padEnd(pad, ' ')).join(''); const header = ' ' + fgs.map(f => f.padEnd(pad, ' ')).join('');
// Sample row with no background (padded cells) // Sample row with no background (padded cells)
let defaultRow = ''; let defaultRow = '';
for (const fg of fgs) { for (const fg of fgs) {
const fgCode = CODES[fg]; const fgCode = CODES[fg];
const openNormal = `\u001b[${fgCode}m`; const openNormal = `\u001b[${fgCode}m`;
const openBold = `\u001b[${fgCode};${CODES.bold}m`; const openBold = `\u001b[${fgCode};${CODES.bold}m`;
const openUnder = `\u001b[${fgCode};${CODES.underline}m`; const openUnder = `\u001b[${fgCode};${CODES.underline}m`;
const cell = `${openNormal}sa\u001b[0m${openBold}mp\u001b[0m${openUnder}le\u001b[0m`; const cell = `${openNormal}sa\u001b[0m${openBold}mp\u001b[0m${openUnder}le\u001b[0m`;
defaultRow += ' ' + cell + ' '; defaultRow += ' ' + cell + ' ';
} }
// Append default row label after one pad // Append default row label after one pad
defaultRow += 'default'; defaultRow += 'default';
// Colored rows per background // Colored rows per background
const rows = []; const rows = [];
for (const bg of bgs) { for (const bg of bgs) {
let row = ''; let row = '';
const bgCode = CODES[bg]; const bgCode = CODES[bg];
for (const fg of fgs) { for (const fg of fgs) {
const fgCode = CODES[fg]; const fgCode = CODES[fg];
const openNormal = `\u001b[${bgCode};${fgCode}m`; const openNormal = `\u001b[${bgCode};${fgCode}m`;
const openBold = `\u001b[${bgCode};${fgCode};${CODES.bold}m`; const openBold = `\u001b[${bgCode};${fgCode};${CODES.bold}m`;
const openUnder = `\u001b[${bgCode};${fgCode};${CODES.underline}m`; const openUnder = `\u001b[${bgCode};${fgCode};${CODES.underline}m`;
const cell = `${openNormal}sa\u001b[0m${openBold}mp\u001b[0m${openUnder}le\u001b[0m`; const cell = `${openNormal}sa\u001b[0m${openBold}mp\u001b[0m${openUnder}le\u001b[0m`;
row += ' ' + cell + ' '; 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);
}
} }
// 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) { export async function init(client) {
client.logger.info('[module:ansi] Loaded ANSI utilities'); client.logger.info('[module:ansi] Loaded ANSI utilities');
} }

View File

@ -1,5 +1,5 @@
import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder } from 'discord.js';
import { MessageFlags } from 'discord-api-types/v10'; import { MessageFlags } from 'discord-api-types/v10';
import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder } from 'discord.js';
/** /**
* botUtils module - provides administrative bot control commands * botUtils module - provides administrative bot control commands
@ -7,157 +7,161 @@ import { MessageFlags } from 'discord-api-types/v10';
*/ */
// Define slash commands // Define slash commands
export const commands = [ export const commands = [
{ {
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName('exit') .setName('exit')
.setDescription('Gracefully shutdown the bot (Owner only)') .setDescription('Gracefully shutdown the bot (Owner only)')
// Restrict to server administrators by default // Restrict to server administrators by default
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator) .setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
.setDMPermission(false) .setDMPermission(false)
.addIntegerOption(option => .addIntegerOption(option =>
option option
.setName('code') .setName('code')
.setDescription('Exit code to use (default 0)') .setDescription('Exit code to use (default 0)')
.setRequired(false) .setRequired(false)
), ),
/** /**
* Execute the exit command: only the configured owner can invoke. * Execute the exit command: only the configured owner can invoke.
* @param {import('discord.js').CommandInteraction} interaction * @param {import('discord.js').CommandInteraction} interaction
* @param {import('discord.js').Client} client * @param {import('discord.js').Client} client
*/ */
async execute(interaction, client) { async execute(interaction, client) {
const ownerId = client.config.owner; const ownerId = client.config.owner;
// Check invoking user is the bot owner // Check invoking user is the bot owner
if (interaction.user.id !== String(ownerId)) { if (interaction.user.id !== String(ownerId)) {
return interaction.reply({ content: 'Only the bot owner can shutdown the bot.', flags: MessageFlags.Ephemeral }); return interaction.reply({ content: 'Only the bot owner can shutdown the bot.', flags: MessageFlags.Ephemeral });
} }
// Determine desired exit code (default 0) // Determine desired exit code (default 0)
const exitCode = interaction.options.getInteger('code') ?? 0; const exitCode = interaction.options.getInteger('code') ?? 0;
// Validate exit code bounds // Validate exit code bounds
if (exitCode < 0 || exitCode > 254) { if (exitCode < 0 || exitCode > 254) {
return interaction.reply({ content: 'Exit code must be between 0 and 254 inclusive.', flags: MessageFlags.Ephemeral }); return interaction.reply({ content: 'Exit code must be between 0 and 254 inclusive.', flags: MessageFlags.Ephemeral });
} }
// Acknowledge before shutting down // Acknowledge before shutting down
await interaction.reply({ content: `Shutting down with exit code ${exitCode}...`, flags: MessageFlags.Ephemeral }); await interaction.reply({ content: `Shutting down with exit code ${exitCode}...`, flags: MessageFlags.Ephemeral });
client.logger.info( client.logger.info(
`[cmd:exit] Shutdown initiated by owner ${interaction.user.tag} (${interaction.user.id}), exit code ${exitCode}` `[cmd:exit] Shutdown initiated by owner ${interaction.user.tag} (${interaction.user.id}), exit code ${exitCode}`
); );
// Destroy Discord client and exit process // Destroy Discord client and exit process
try { try {
await client.destroy(); await client.destroy();
} catch (err) { } catch (err) {
client.logger.error(`[cmd:exit] Error during client.destroy(): ${err}`); client.logger.error(`[cmd:exit] Error during client.destroy(): ${err}`);
} }
process.exit(exitCode); process.exit(exitCode);
} }
}, },
/** /**
* Slash command `/status` (Administrator only): * Slash command `/status` (Administrator only):
* Shows this bot clients status including CPU, memory, environment, * Shows this bot client's status including CPU, memory, environment,
* uptime, module list, and entity counts. Optionally displays Git info * uptime, module list, and entity counts. Optionally displays Git info
* (Git Reference and Git Status) when the gitUtils module is loaded. * (Git Reference and Git Status) when the gitUtils module is loaded.
* @param {import('discord.js').CommandInteraction} interaction * @param {import('discord.js').CommandInteraction} interaction
* @param {import('discord.js').Client} client * @param {import('discord.js').Client} client
*/ */
// /status: admin-only, shows current client info // /status: admin-only, shows current client info
{ {
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName('status') .setName('status')
.setDescription('Show this bot client status and process info') .setDescription('Show this bot client status and process info')
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator) .setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
.setDMPermission(false) .setDMPermission(false)
.addBooleanOption(option => .addBooleanOption(option =>
option option
.setName('ephemeral') .setName('ephemeral')
.setDescription('Whether the response should be ephemeral') .setDescription('Whether the response should be ephemeral')
.setRequired(false) .setRequired(false)
), ),
async execute(interaction, client) { async execute(interaction, client) {
const ephemeral = interaction.options.getBoolean('ephemeral') ?? true; const ephemeral = interaction.options.getBoolean('ephemeral') ?? true;
await interaction.deferReply({ flags: ephemeral ? MessageFlags.Ephemeral : undefined }); await interaction.deferReply({ flags: ephemeral ? MessageFlags.Ephemeral : undefined });
// Process metrics // Process metrics
const uptimeSec = process.uptime(); const uptimeSec = process.uptime();
const hours = Math.floor(uptimeSec / 3600); const hours = Math.floor(uptimeSec / 3600);
const minutes = Math.floor((uptimeSec % 3600) / 60); const minutes = Math.floor((uptimeSec % 3600) / 60);
const seconds = Math.floor(uptimeSec % 60); const seconds = Math.floor(uptimeSec % 60);
const uptime = `${hours}h ${minutes}m ${seconds}s`; const uptime = `${hours}h ${minutes}m ${seconds}s`;
const mem = process.memoryUsage(); const mem = process.memoryUsage();
const toMB = bytes => (bytes / 1024 / 1024).toFixed(2); const toMB = bytes => (bytes / 1024 / 1024).toFixed(2);
const memoryInfo = `RSS: ${toMB(mem.rss)} MB, Heap: ${toMB(mem.heapUsed)}/${toMB(mem.heapTotal)} MB`; const memoryInfo = `RSS: ${toMB(mem.rss)} MB, Heap: ${toMB(mem.heapUsed)}/${toMB(mem.heapTotal)} MB`;
const cpu = process.cpuUsage(); const cpu = process.cpuUsage();
const cpuInfo = `User: ${(cpu.user / 1000).toFixed(2)} ms, System: ${(cpu.system / 1000).toFixed(2)} ms`; const cpuInfo = `User: ${(cpu.user / 1000).toFixed(2)} ms, System: ${(cpu.system / 1000).toFixed(2)} ms`;
const nodeVersion = process.version; const nodeVersion = process.version;
const platform = `${process.platform} ${process.arch}`; const platform = `${process.platform} ${process.arch}`;
// Client-specific stats // Client-specific stats
const guildCount = client.guilds.cache.size; const guildCount = client.guilds.cache.size;
const userCount = client.guilds.cache.reduce((sum, g) => sum + (g.memberCount || 0), 0); const userCount = client.guilds.cache.reduce((sum, g) => sum + (g.memberCount || 0), 0);
const commandCount = client.commands.size; const commandCount = client.commands.size;
// List of loaded optional modules // List of loaded optional modules
const loadedModules = client.modules ? Array.from(client.modules.keys()) : []; const loadedModules = client.modules ? Array.from(client.modules.keys()) : [];
// Build embed for status // Build embed for status
// Determine if gitUtils module is loaded // Determine if gitUtils module is loaded
const gitLoaded = client.modules?.has('gitUtils'); const gitLoaded = client.modules?.has('gitUtils');
let branch, build, statusRaw, statusBlock; let branch, build, statusRaw, statusBlock;
if (gitLoaded) { if (gitLoaded) {
const git = client.modules.get('gitUtils'); const git = client.modules.get('gitUtils');
try { try {
branch = await git.getBranch(); branch = await git.getBranch();
build = await git.getShortHash(); build = await git.getShortHash();
statusRaw = await git.getStatusShort(); statusRaw = await git.getStatusShort();
// Format status as fenced code block using template literals // Format status as fenced code block using template literals
// Normalize each line with a leading space in a code fence // Normalize each line with a leading space in a code fence
// Prefix raw status output with a single space // Prefix raw status output with a single space
// Prefix raw status output with a space, and only if non-empty // Prefix raw status output with a space, and only if non-empty
if (statusRaw) { if (statusRaw) {
statusBlock = '```\n ' + statusRaw + '\n```'; statusBlock = '```\n ' + statusRaw + '\n```';
} }
} catch { } catch {
branch = 'error'; branch = 'error';
build = 'error'; build = 'error';
// Represent error status in code fence // Represent error status in code fence
statusBlock = '```\n (error)\n```'; statusBlock = '```\n (error)\n```';
}
}
// Prepare module list as bullet points
const moduleList = loadedModules.length > 0
? loadedModules.map(m => `${m}`).join('\n')
: 'None';
// Assemble fields
const fields = [];
// Client identification
fields.push({ name: 'Client', value: client.config.id, inline: false });
// Performance metrics
fields.push({ name: 'CPU Usage', value: cpuInfo, inline: false });
fields.push({ name: 'Memory', value: memoryInfo, inline: false });
// Environment
fields.push({ name: 'Node.js', value: nodeVersion, inline: true });
fields.push({ name: 'Platform', value: platform, inline: true });
// Uptime
fields.push({ name: 'Uptime', value: uptime, inline: true });
// Loaded modules
fields.push({ name: 'Modules', value: moduleList, inline: false });
// Entity counts
fields.push({ name: 'Commands', value: commandCount.toString(), inline: true });
fields.push({ name: 'Guilds', value: guildCount.toString(), inline: true });
fields.push({ name: 'Users', value: userCount.toString(), inline: true });
// Git reference and status if available
if (gitLoaded) {
fields.push({ name: 'Git Reference', value: `${branch}/${build}`, inline: false });
fields.push({ name: 'Git Status', value: statusBlock, inline: false });
}
// Create embed
const embed = new EmbedBuilder()
.setAuthor({ name: 'ClientX', iconURL: client.user.displayAvatarURL() })
.setThumbnail(client.user.displayAvatarURL())
.addFields(fields)
.setTimestamp();
await interaction.editReply({ embeds: [embed] });
client.logger.info(`[cmd:status] Returned status embed for client ${client.config.id}`);
} }
}
// Prepare module list as bullet points
const moduleList = loadedModules.length > 0
? loadedModules.map(m => `${m}`).join('\n')
: 'None';
// Assemble fields
const fields = [];
// Client identification
fields.push({ name: 'Client', value: client.config.id, inline: false });
// Performance metrics
fields.push({ name: 'CPU Usage', value: cpuInfo, inline: false });
fields.push({ name: 'Memory', value: memoryInfo, inline: false });
// Environment
fields.push({ name: 'Node.js', value: nodeVersion, inline: true });
fields.push({ name: 'Platform', value: platform, inline: true });
// Uptime
fields.push({ name: 'Uptime', value: uptime, inline: true });
// Loaded modules
fields.push({ name: 'Modules', value: moduleList, inline: false });
// Entity counts
fields.push({ name: 'Commands', value: commandCount.toString(), inline: true });
fields.push({ name: 'Guilds', value: guildCount.toString(), inline: true });
fields.push({ name: 'Users', value: userCount.toString(), inline: true });
// Git reference and status if available
if (gitLoaded) {
fields.push({ name: 'Git Reference', value: `${branch}/${build}`, inline: false });
fields.push({ name: 'Git Status', value: statusBlock, inline: false });
}
// Create embed
const embed = new EmbedBuilder()
.setAuthor({ name: 'ClientX', iconURL: client.user.displayAvatarURL() })
.setThumbnail(client.user.displayAvatarURL())
.addFields(fields)
.setTimestamp();
await interaction.editReply({ embeds: [embed] });
client.logger.info(`[cmd:status] Returned status embed for client ${client.config.id}`);
} }
}
]; ];
// Module loaded logging // Module loaded logging
export async function init(client, clientConfig) { export async function init(_client, _clientConfig) {
client.logger.info('[module:botUtils] Module loaded'); _client.logger.info('[module:botUtils] Module loaded');
}
export async function handleInteractionCreate(_client, _clientConfig, _interaction) {
// ... existing code ...
} }

View File

@ -78,7 +78,6 @@ export const init = async (client, config) => {
// Used as a prefix before any line that runs within a loop. // Used as a prefix before any line that runs within a loop.
const bullet = '>'; const bullet = '>';
// === OpenAI Interaction === // === OpenAI Interaction ===
// Chat completion via OpenAI with provided instructions. // Chat completion via OpenAI with provided instructions.
async function ai(prompt = '') { async function ai(prompt = '') {
@ -86,17 +85,17 @@ export const init = async (client, config) => {
debug(`**AI Prompt**: ${prompt}`); debug(`**AI Prompt**: ${prompt}`);
// Read instructions. // Read instructions.
let openAIInstructions = fs.readFileSync(openAIInstructionsFile, 'utf8'); const openAIInstructions = fs.readFileSync(openAIInstructionsFile, 'utf8');
const unmention = /<@(\w+)>/g; const unmention = /<@(\w+)>/g;
const completion = await openai.chat.completions.create({ const completion = await openai.chat.completions.create({
model: 'gpt-4o-mini', model: 'gpt-4o-mini',
messages: [ messages: [
{role: 'user', content: `${prompt.replace(unmention, '$1')}`}, { role: 'user', content: `${prompt.replace(unmention, '$1')}` },
{role: 'system', content: `${openAIInstructions}`}, { role: 'system', content: `${openAIInstructions}` }
], ]
}); });
let chunk = completion.choices[0]?.message?.content; const chunk = completion.choices[0]?.message?.content;
if (chunk != '') { if (chunk !== '') {
for (const line of chunk.split(/\n\s*\n/).filter(Boolean)) { for (const line of chunk.split(/\n\s*\n/).filter(Boolean)) {
debug(`${bullet} ${line}`); debug(`${bullet} ${line}`);
openAIWebhookClient.send(line); openAIWebhookClient.send(line);
@ -142,7 +141,7 @@ export const init = async (client, config) => {
// === Message Fetching Helpers === // === Message Fetching Helpers ===
// Retrieve recent messages from every text channel since a given timestamp. // Retrieve recent messages from every text channel since a given timestamp.
async function fetchRecentMessages(since) { async function _fetchRecentMessages(since) {
const allMessages = new Collection(); const allMessages = new Collection();
// Get all text channels in the guild // Get all text channels in the guild
@ -181,7 +180,7 @@ export const init = async (client, config) => {
debug(`**Incident Cycle #${incidentCounter++}**`); debug(`**Incident Cycle #${incidentCounter++}**`);
// Rebuild the list of current index cases, if any. // Rebuild the list of current index cases, if any.
let indexesList = guild.members.cache.filter(member => member.roles.cache.has(indexRole.id)); const indexesList = guild.members.cache.filter(member => member.roles.cache.has(indexRole.id));
debug(`${bullet} Index Cases: **${indexesList.size}**`); debug(`${bullet} Index Cases: **${indexesList.size}**`);
// Build the victimsList using whitelisted roles. // Build the victimsList using whitelisted roles.
@ -206,7 +205,7 @@ export const init = async (client, config) => {
} }
// Conditions for potentially starting an incident. // Conditions for potentially starting an incident.
if (indexesList.size == 0 && victimsList.size > 0) { if (indexesList.size === 0 && victimsList.size > 0) {
if ((Math.floor(Math.random() * incidenceDenominator) + 1) === 1) { if ((Math.floor(Math.random() * incidenceDenominator) + 1) === 1) {
debug(`${bullet} Incidence Check: **Success**`); debug(`${bullet} Incidence Check: **Success**`);
const newIndex = victimsList.random(); const newIndex = victimsList.random();
@ -240,7 +239,7 @@ export const init = async (client, config) => {
} }
// Prepare the next cycle. // Prepare the next cycle.
let interval = cycleInterval + Math.floor(Math.random() * (2 * cycleIntervalRange + 1)) - cycleIntervalRange; const interval = cycleInterval + Math.floor(Math.random() * (2 * cycleIntervalRange + 1)) - cycleIntervalRange;
setTimeout(cycleIncidents, interval); setTimeout(cycleIncidents, interval);
debug(`${bullet} Cycle #${incidentCounter} **<t:${Math.floor((Date.now() + interval) / 1000)}:R>** at **<t:${Math.floor((Date.now() + interval) / 1000)}:t>**`); debug(`${bullet} Cycle #${incidentCounter} **<t:${Math.floor((Date.now() + interval) / 1000)}:R>** at **<t:${Math.floor((Date.now() + interval) / 1000)}:t>**`);
} catch (error) { } catch (error) {
@ -291,17 +290,16 @@ export const init = async (client, config) => {
if (message.webhookId) return; if (message.webhookId) return;
guild = client.guilds.cache.get(guildID); guild = client.guilds.cache.get(guildID);
// Someone mentioned us - respond if openAI is enabled, the message was in the webhook channel, and a trigger was used.
// Someone mentioned us - respond if openAI is enabled, the message was in the webhook channel, and a trigger was used. if (openAI === true && openAIWebhook.channel.id === message.channel.id && openAITriggers.some(word => message.content.replace(/[^\w\s]/gi, '').toLowerCase().includes(word.toLowerCase()))) {
if (openAI === true && openAIWebhook.channel.id === message.channel.id && openAITriggers.some(word => message.content.replace(/[^\w\s]/gi, '').toLowerCase().includes(word.toLowerCase()))) { // Also check if an active incident is required to respond.
// Also check if an active incident is required to respond. if ((openAITriggerOnlyDuringIncident === true && guild.members.cache.filter(member => member.roles.cache.has(indexRole.id)).size > 0) || openAITriggerOnlyDuringIncident === false) {
if ((openAITriggerOnlyDuringIncident === true && guild.members.cache.filter(member => member.roles.cache.has(indexRole.id)).size > 0) || openAITriggerOnlyDuringIncident === false) { // Finally, random roll to respond.
// Finally, random roll to respond. if ((Math.floor(Math.random() * openAIResponseDenominator) + 1) === 1) {
if ((Math.floor(Math.random() * openAIResponseDenominator) + 1) === 1) { ai(`${message.member.displayName} said: ${message.cleanContent}`);
ai(`${message.member.displayName} said: ${message.cleanContent}`); }
} }
} }
}
if (blacklistUsers.includes(message.author.id)) return; if (blacklistUsers.includes(message.author.id)) return;
if (message.member.roles.cache.some(r => blacklistRoles.includes(r.id))) return; if (message.member.roles.cache.some(r => blacklistRoles.includes(r.id))) return;
@ -334,7 +332,7 @@ export const init = async (client, config) => {
let percentage = Math.min(infections / prox.size * 100, probabilityLimit); let percentage = Math.min(infections / prox.size * 100, probabilityLimit);
// Reduce base probability by ${antiViralEffectiveness}% for those with ${antiViralRole} // Reduce base probability by ${antiViralEffectiveness}% for those with ${antiViralRole}
if (message.member.roles.cache.has(antiViralRole.id)) { if (message.member.roles.cache.has(antiViralRole.id) && Math.random() * 100 === antiViralEffectiveness) {
percentage = Math.round(percentage - (antiViralEffectiveness * (percentage / 100))); percentage = Math.round(percentage - (antiViralEffectiveness * (percentage / 100)));
} }
@ -366,7 +364,7 @@ export const init = async (client, config) => {
if (openAI === true) { if (openAI === true) {
openai = new OpenAI({ apiKey: openAIToken }); // credentials loaded openai = new OpenAI({ apiKey: openAIToken }); // credentials loaded
openAIWebhook = await client.fetchWebhook(openAIWebhookID, openAIWebhookToken).catch(error => { openAIWebhook = await client.fetchWebhook(openAIWebhookID, openAIWebhookToken).catch(error => {
client.logger.error(`[module:condimentX] Could not fetch webhook: ${error.message}`); client.logger.error(`[module:condimentX] Could not fetch webhook: ${error.message}`);
return null; return null;
}); });
if (openAIWebhook) openAIWebhookClient = new WebhookClient({ id: openAIWebhookID, token: openAIWebhookToken }); if (openAIWebhook) openAIWebhookClient = new WebhookClient({ id: openAIWebhookID, token: openAIWebhookToken });
@ -374,7 +372,7 @@ export const init = async (client, config) => {
try { try {
guild = client.guilds.cache.get(guildID); guild = client.guilds.cache.get(guildID);
if (!guild) { if (!guild) {
client.logger.error(`[module:condimentX] Guild ${guildID} not found`); client.logger.error(`[module:condimentX] Guild ${guildID} not found`);
return; return;
} }
indexRole = await guild.roles.fetch(indexRoleID); indexRole = await guild.roles.fetch(indexRoleID);

View File

@ -1,16 +1,17 @@
import { SlashCommandBuilder } from 'discord.js';
import { MessageFlags } from 'discord-api-types/v10';
import { execFile } from 'child_process'; import { execFile } from 'child_process';
import { promisify } from 'util'; import { promisify } from 'util';
import { MessageFlags } from 'discord-api-types/v10';
import { SlashCommandBuilder } from 'discord.js';
// Use execFile to avoid shell interpretation of arguments // Use execFile to avoid shell interpretation of arguments
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
// Wrap Git errors // Wrap Git errors
class GitError extends Error { class GitError extends Error {
constructor(message) { constructor(message) {
super(message); super(message);
this.name = 'GitError'; this.name = 'GitError';
} }
} }
/** /**
@ -20,25 +21,25 @@ class GitError extends Error {
* @throws {GitError} - When the git command exits with an error. * @throws {GitError} - When the git command exits with an error.
*/ */
async function runGit(args) { async function runGit(args) {
// Sanitize arguments: disallow dangerous shell metacharacters // Sanitize arguments: disallow dangerous shell metacharacters
if (!Array.isArray(args)) { if (!Array.isArray(args)) {
throw new GitError('Invalid git arguments'); 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}`);
} }
} const dangerous = /[;&|<>`$\\]/;
try { for (const arg of args) {
if (dangerous.test(arg)) {
throw new GitError(`Illegal character in git argument: ${arg}`);
}
}
try {
// Exec git directly without shell // Exec git directly without shell
const { stdout, stderr } = await execFileAsync('git', args); const { stdout, stderr } = await execFileAsync('git', args);
const out = (stdout || stderr || '').toString().trim(); const out = (stdout || stderr || '').toString().trim();
return out || '(no output)'; return out || '(no output)';
} catch (err) { } catch (err) {
const msg = err.stderr?.toString().trim() || err.message; const msg = err.stderr?.toString().trim() || err.message;
throw new GitError(msg); throw new GitError(msg);
} }
} }
/** /**
@ -48,10 +49,10 @@ async function runGit(args) {
* @returns {string} - The content wrapped in triple backticks. * @returns {string} - The content wrapped in triple backticks.
*/ */
function formatCodeBlock(content, lang = '') { function formatCodeBlock(content, lang = '') {
const fence = '```'; const fence = '```';
return lang return lang
? `${fence}${lang}\n${content}\n${fence}` ? `${fence}${lang}\n${content}\n${fence}`
: `${fence}\n${content}\n${fence}`; : `${fence}\n${content}\n${fence}`;
} }
/** /**
@ -61,76 +62,76 @@ function formatCodeBlock(content, lang = '') {
* @returns {string[]} - An array of substring chunks. * @returns {string[]} - An array of substring chunks.
*/ */
function chunkString(str, chunkSize) { function chunkString(str, chunkSize) {
const chunks = []; const chunks = [];
for (let i = 0; i < str.length; i += chunkSize) { for (let i = 0; i < str.length; i += chunkSize) {
chunks.push(str.slice(i, i + chunkSize)); chunks.push(str.slice(i, i + chunkSize));
} }
return chunks; return chunks;
} }
// Single /git command: run arbitrary git <args> // Single /git command: run arbitrary git <args>
export const commands = [ export const commands = [
{ {
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName('git') .setName('git')
.setDescription('Run an arbitrary git command (Owner only)') .setDescription('Run an arbitrary git command (Owner only)')
.addStringOption(opt => .addStringOption(opt =>
opt.setName('args') opt.setName('args')
.setDescription('Arguments to pass to git') .setDescription('Arguments to pass to git')
.setRequired(true)) .setRequired(true))
.addBooleanOption(opt => .addBooleanOption(opt =>
opt.setName('ephemeral') opt.setName('ephemeral')
.setDescription('Make the reply ephemeral') .setDescription('Make the reply ephemeral')
.setRequired(false)), .setRequired(false)),
async execute(interaction, client) { async execute(interaction, client) {
const ownerId = client.config.owner; const ownerId = client.config.owner;
if (interaction.user.id !== ownerId) { if (interaction.user.id !== ownerId) {
return interaction.reply({ content: 'Only the bot owner can run git commands.', flags: MessageFlags.Ephemeral }); return interaction.reply({ content: 'Only the bot owner can run git commands.', flags: MessageFlags.Ephemeral });
} }
const raw = interaction.options.getString('args'); const raw = interaction.options.getString('args');
// Disallow semicolons to prevent command chaining // Disallow semicolons to prevent command chaining
if (raw.includes(';')) { if (raw.includes(';')) {
return interaction.reply({ content: 'Semicolons are not allowed in git arguments.', flags: MessageFlags.Ephemeral }); return interaction.reply({ content: 'Semicolons are not allowed in git arguments.', flags: MessageFlags.Ephemeral });
} }
const ephemeral = interaction.options.getBoolean('ephemeral') ?? true; const ephemeral = interaction.options.getBoolean('ephemeral') ?? true;
const args = raw.match(/(?:[^\s"]+|"[^"]*")+/g) const args = raw.match(/(?:[^\s"]+|"[^"]*")+/g)
.map(s => s.replace(/^"(.+)"$/, '$1')); .map(s => s.replace(/^"(.+)"$/, '$1'));
try { try {
// Log the exact git command being executed // Log the exact git command being executed
const cmdStr = args.join(' '); const cmdStr = args.join(' ');
client.logger.warn(`[cmd:git] Executing git command: git ${cmdStr}`); client.logger.warn(`[cmd:git] Executing git command: git ${cmdStr}`);
const output = await runGit(args); const output = await runGit(args);
// Prepend the git command as a header; keep it intact when chunking // Prepend the git command as a header; keep it intact when chunking
const header = `git ${cmdStr}\n`; const header = `git ${cmdStr}\n`;
// Discord message limit ~2000; reserve for code fences // Discord message limit ~2000; reserve for code fences
const maxContent = 1990; const maxContent = 1990;
// Calculate how much output can fit after the header in the first chunk // Calculate how much output can fit after the header in the first chunk
const firstChunkSize = Math.max(0, maxContent - header.length); const firstChunkSize = Math.max(0, maxContent - header.length);
// Split the raw output into chunks // Split the raw output into chunks
const outputChunks = chunkString(output, firstChunkSize); const outputChunks = chunkString(output, firstChunkSize);
// Send first block with header + first output chunk // Send first block with header + first output chunk
const firstBlock = header + (outputChunks[0] || ''); const firstBlock = header + (outputChunks[0] || '');
const replyOpts = { content: formatCodeBlock(firstBlock) }; const replyOpts = { content: formatCodeBlock(firstBlock) };
if (ephemeral) replyOpts.flags = MessageFlags.Ephemeral; if (ephemeral) replyOpts.flags = MessageFlags.Ephemeral;
await interaction.reply(replyOpts); await interaction.reply(replyOpts);
// Send any remaining blocks without the header // Send any remaining blocks without the header
for (let i = 1; i < outputChunks.length; i++) { for (let i = 1; i < outputChunks.length; i++) {
const fuOpts = { content: formatCodeBlock(outputChunks[i]) }; const fuOpts = { content: formatCodeBlock(outputChunks[i]) };
if (ephemeral) fuOpts.flags = MessageFlags.Ephemeral; if (ephemeral) fuOpts.flags = MessageFlags.Ephemeral;
await interaction.followUp(fuOpts); await interaction.followUp(fuOpts);
}
} catch (err) {
const msg = err instanceof GitError ? err.message : String(err);
await interaction.reply({ content: `Error: ${msg}`, flags: MessageFlags.Ephemeral });
}
} }
} catch (err) {
const msg = err instanceof GitError ? err.message : String(err);
await interaction.reply({ content: `Error: ${msg}`, flags: MessageFlags.Ephemeral });
}
} }
}
]; ];
// No special init logic // No special init logic
export async function init(client) { export async function init(client) {
client.logger.warn('[module:gitUtils] Git utilities module loaded - dangerous module, use with caution'); client.logger.warn('[module:gitUtils] Git utilities module loaded - dangerous module, use with caution');
} }
// Helper functions for external use // Helper functions for external use
/** /**
@ -138,28 +139,28 @@ export async function init(client) {
* @returns {Promise<string>} * @returns {Promise<string>}
*/ */
export async function getBranch() { export async function getBranch() {
return runGit(['rev-parse', '--abbrev-ref', 'HEAD']); return runGit(['rev-parse', '--abbrev-ref', 'HEAD']);
} }
/** /**
* Get short commit hash of HEAD * Get short commit hash of HEAD
* @returns {Promise<string>} * @returns {Promise<string>}
*/ */
export async function getShortHash() { export async function getShortHash() {
return runGit(['rev-parse', '--short', 'HEAD']); return runGit(['rev-parse', '--short', 'HEAD']);
} }
/** /**
* Get concise working tree status (git status --porcelain) * Get concise working tree status (git status --porcelain)
* @returns {Promise<string>} * @returns {Promise<string>}
*/ */
export async function getStatusShort() { export async function getStatusShort() {
return runGit(['status', '--porcelain']); return runGit(['status', '--porcelain']);
} }
/** /**
* Get Git remote origin URL * Get Git remote origin URL
* @returns {Promise<string>} * @returns {Promise<string>}
*/ */
export async function getRemoteUrl() { export async function getRemoteUrl() {
return runGit(['config', '--get', 'remote.origin.url']); return runGit(['config', '--get', 'remote.origin.url']);
} }
/** /**
* Get recent commit log (n lines, one-line format) * Get recent commit log (n lines, one-line format)
@ -167,12 +168,12 @@ export async function getRemoteUrl() {
* @returns {Promise<string>} * @returns {Promise<string>}
*/ */
export async function getLog(n = 5) { export async function getLog(n = 5) {
return runGit(['log', `-n${n}`, '--oneline']); return runGit(['log', `-n${n}`, '--oneline']);
} }
/** /**
* Get diff summary (git diff --stat) * Get diff summary (git diff --stat)
* @returns {Promise<string>} * @returns {Promise<string>}
*/ */
export async function getDiffStat() { export async function getDiffStat() {
return runGit(['diff', '--stat']); return runGit(['diff', '--stat']);
} }

View File

@ -4,25 +4,25 @@ import { onMessageQueueEvent } from './pbUtils.js';
/** /**
* Example module that listens for 'test' messages in the message_queue collection. * Example module that listens for 'test' messages in the message_queue collection.
*/ */
export const init = async (client, config) => { export async function init(client, _config) {
client.logger.info('[module:messageQueueExample] Initializing Message Queue Example module'); client.logger.info('[module:messageQueueExample] Message Queue Example module initialized');
onMessageQueueEvent(client, async (action, record) => { onMessageQueueEvent(client, async (action, record) => {
// Only process newly created records // Only process newly created records
if (action !== 'create') return; if (action !== 'create') return;
// Only process messages meant for this client // Only process messages meant for this client
if (record.destination !== client.config.id) return; if (record.destination !== client.config.id) return;
// Only handle test dataType // Only handle test dataType
if (record.dataType !== 'test') return; if (record.dataType !== 'test') return;
// At this point we have a test message for us // At this point we have a test message for us
client.logger.info('[module:messageQueueExample] Test message received'); client.logger.info('[module:messageQueueExample] Test message received');
// Delete the processed message from the queue // Delete the processed message from the queue
try { try {
await client.pb.deleteMessageQueue(record.id); await client.pb.deleteMessageQueue(record.id);
client.logger.debug(`[module:messageQueueExample] Deleted message_queue record ${record.id}`); client.logger.debug(`[module:messageQueueExample] Deleted message_queue record ${record.id}`);
} catch (err) { } catch (err) {
client.logger.error(`[module:messageQueueExample] Failed to delete message_queue record ${record.id}: ${err.message}`); client.logger.error(`[module:messageQueueExample] Failed to delete message_queue record ${record.id}: ${err.message}`);
} }
}); });
}; }

View File

@ -1,10 +1,11 @@
// _opt/pbutils.js // _opt/pbutils.js
// Polyfill global EventSource for PocketBase realtime in Node.js (using CommonJS require) // Polyfill global EventSource for PocketBase realtime in Node.js (using CommonJS require)
import { createRequire } from 'module'; import { createRequire } from 'module';
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
const { EventSource } = require('eventsource'); const { EventSource } = require('eventsource');
if (typeof global.EventSource === 'undefined') { if (typeof global.EventSource === 'undefined') {
global.EventSource = EventSource; global.EventSource = EventSource;
} }
/** /**
@ -16,7 +17,7 @@ if (typeof global.EventSource === 'undefined') {
* @param {Object} client - Discord client with attached PocketBase instance * @param {Object} client - Discord client with attached PocketBase instance
* @param {Object} config - Client configuration * @param {Object} config - Client configuration
*/ */
export const init = async (client, config) => { export async function init(client, _config) {
const { pb, logger } = client; const { pb, logger } = client;
logger.info('[module:pbUtils] Initializing PocketBase utilities module'); logger.info('[module:pbUtils] Initializing PocketBase utilities module');
@ -24,24 +25,24 @@ export const init = async (client, config) => {
// Attach utility methods to the pb object // Attach utility methods to the pb object
extendPocketBase(client, pb, logger); extendPocketBase(client, pb, logger);
// Add connection state handling // Add connection state handling
setupConnectionHandling(pb, logger); setupConnectionHandling(pb, logger);
// Subscribe to real-time message queue events and re-emit via client // Subscribe to real-time message queue events and re-emit via client
try { try {
pb.collection('message_queue').subscribe('*', (e) => { pb.collection('message_queue').subscribe('*', (e) => {
client.emit('message_queue_event', e.action, e.record); client.emit('message_queue_event', e.action, e.record);
logger.debug(`PubSub event: ${e.action} on message_queue: ${JSON.stringify(e.record)}`); logger.debug(`PubSub event: ${e.action} on message_queue: ${JSON.stringify(e.record)}`);
}); });
logger.info('[module:pbUtils] Subscribed to PocketBase message_queue realtime events'); logger.info('[module:pbUtils] Subscribed to PocketBase message_queue realtime events');
} catch (error) { } catch (error) {
logger.error(`[module:pbUtils] Failed to subscribe to message_queue realtime: ${error.message}`); logger.error(`[module:pbUtils] Failed to subscribe to message_queue realtime: ${error.message}`);
} }
// end of init() // end of init()
logger.info('PocketBase utilities module initialized'); logger.info('PocketBase utilities module initialized');
}; }
/** /**
* Register a handler for incoming message_queue pub/sub events. * Register a handler for incoming message_queue pub/sub events.
@ -50,13 +51,13 @@ export const init = async (client, config) => {
* @param {(action: string, record: object) => void} handler - Callback for each event * @param {(action: string, record: object) => void} handler - Callback for each event
*/ */
export function onMessageQueueEvent(client, handler) { export function onMessageQueueEvent(client, handler) {
client.on('message_queue_event', (action, record) => { client.on('message_queue_event', (action, record) => {
try { try {
handler(action, record); handler(action, record);
} catch (err) { } catch (err) {
client.logger.error(`[module:pbUtils] Error in message_queue handler: ${err.message}`); client.logger.error(`[module:pbUtils] Error in message_queue handler: ${err.message}`);
} }
}); });
} }
/** /**
@ -71,77 +72,77 @@ export function onMessageQueueEvent(client, handler) {
* @param {object} logger - Logger instance * @param {object} logger - Logger instance
*/ */
const extendPocketBase = (client, pb, logger) => { const extendPocketBase = (client, pb, logger) => {
// ===== COLLECTION OPERATIONS ===== // ===== COLLECTION OPERATIONS =====
/** /**
* Get a single record with better error handling * Get a single record with better error handling
* @param {string} collection - Collection name * @param {string} collection - Collection name
* @param {string} id - Record ID * @param {string} id - Record ID
* @param {Object} options - Additional options * @param {Object} options - Additional options
* @returns {Promise<Object>} The record or null * @returns {Promise<Object>} The record or null
*/ */
pb.getOne = async (collection, id, options = {}) => { pb.getOne = async (collection, id, options = {}) => {
try { try {
return await pb.collection(collection).getOne(id, options); return await pb.collection(collection).getOne(id, options);
} catch (error) { } catch (error) {
if (error.status === 404) { if (error.status === 404) {
return null; return null;
} }
logger.error(`Failed to get record ${id} from ${collection}: ${error.message}`); logger.error(`Failed to get record ${id} from ${collection}: ${error.message}`);
throw error; throw error;
} }
}; };
/** /**
* Creates a record with validation and error handling * Creates a record with validation and error handling
* @param {string} collection - Collection name * @param {string} collection - Collection name
* @param {Object} data - Record data * @param {Object} data - Record data
* @returns {Promise<Object>} Created record * @returns {Promise<Object>} Created record
*/ */
pb.createOne = async (collection, data) => { pb.createOne = async (collection, data) => {
try { try {
return await pb.collection(collection).create(data); return await pb.collection(collection).create(data);
} catch (error) { } catch (error) {
logger.error(`Failed to create record in ${collection}: ${error.message}`); logger.error(`Failed to create record in ${collection}: ${error.message}`);
throw error; throw error;
} }
}; };
/** /**
* Updates a record with better error handling * Updates a record with better error handling
* @param {string} collection - Collection name * @param {string} collection - Collection name
* @param {string} id - Record ID * @param {string} id - Record ID
* @param {Object} data - Record data * @param {Object} data - Record data
* @returns {Promise<Object>} Updated record * @returns {Promise<Object>} Updated record
*/ */
pb.updateOne = async (collection, id, data) => { pb.updateOne = async (collection, id, data) => {
try { try {
return await pb.collection(collection).update(id, data); return await pb.collection(collection).update(id, data);
} catch (error) { } catch (error) {
logger.error(`Failed to update record ${id} in ${collection}: ${error.message}`); logger.error(`Failed to update record ${id} in ${collection}: ${error.message}`);
throw error; throw error;
} }
}; };
/** /**
* Deletes a record with better error handling * Deletes a record with better error handling
* @param {string} collection - Collection name * @param {string} collection - Collection name
* @param {string} id - Record ID * @param {string} id - Record ID
* @returns {Promise<boolean>} Success status * @returns {Promise<boolean>} Success status
*/ */
pb.deleteOne = async (collection, id) => { pb.deleteOne = async (collection, id) => {
try { try {
await pb.collection(collection).delete(id); await pb.collection(collection).delete(id);
return true; return true;
} catch (error) { } catch (error) {
if (error.status === 404) { if (error.status === 404) {
logger.warn(`Record ${id} not found in ${collection} for deletion`); logger.warn(`Record ${id} not found in ${collection} for deletion`);
return false; return false;
} }
logger.error(`Failed to delete record ${id} from ${collection}: ${error.message}`); logger.error(`Failed to delete record ${id} from ${collection}: ${error.message}`);
throw error; throw error;
} }
}; };
/** /**
* Convenience: publish a message into the "message_queue" collection, * Convenience: publish a message into the "message_queue" collection,
@ -162,196 +163,196 @@ const extendPocketBase = (client, pb, logger) => {
return await pb.collection('message_queue').create({ source, destination, dataType, data: JSON.stringify(data) }); return await pb.collection('message_queue').create({ source, destination, dataType, data: JSON.stringify(data) });
}; };
/** /**
* Upsert - creates or updates a record based on whether it exists * Upsert - creates or updates a record based on whether it exists
* @param {string} collection - Collection name * @param {string} collection - Collection name
* @param {string} id - Record ID or null for new record * @param {string} id - Record ID or null for new record
* @param {Object} data - Record data * @param {Object} data - Record data
* @returns {Promise<Object>} Created/updated record * @returns {Promise<Object>} Created/updated record
*/ */
pb.upsert = async (collection, id, data) => { pb.upsert = async (collection, id, data) => {
if (id) { if (id) {
const exists = await pb.getOne(collection, id); const exists = await pb.getOne(collection, id);
if (exists) { if (exists) {
return await pb.updateOne(collection, id, data); return await pb.updateOne(collection, id, data);
} }
} }
return await pb.createOne(collection, data); return await pb.createOne(collection, data);
}; };
// ===== QUERY SHORTCUTS ===== // ===== QUERY SHORTCUTS =====
/** /**
* Get first record matching a filter * Get first record matching a filter
* @param {string} collection - Collection name * @param {string} collection - Collection name
* @param {string} filter - Filter query * @param {string} filter - Filter query
* @param {Object} options - Additional options * @param {Object} options - Additional options
* @returns {Promise<Object>} First matching record or null * @returns {Promise<Object>} First matching record or null
*/ */
pb.getFirst = async (collection, filter, options = {}) => { pb.getFirst = async (collection, filter, options = {}) => {
try { try {
const result = await pb.collection(collection).getList(1, 1, { const result = await pb.collection(collection).getList(1, 1, {
filter, filter,
...options ...options
}); });
return result.items.length > 0 ? result.items[0] : null; return result.items.length > 0 ? result.items[0] : null;
} catch (error) { } catch (error) {
if (error.status === 404) { if (error.status === 404) {
return null; return null;
} }
logger.error(`Failed to get first record from ${collection}: ${error.message}`); logger.error(`Failed to get first record from ${collection}: ${error.message}`);
throw error; throw error;
} }
}; };
/** /**
* Get all records from a collection (handles pagination) * Get all records from a collection (handles pagination)
* @param {string} collection - Collection name * @param {string} collection - Collection name
* @param {Object} options - Query options * @param {Object} options - Query options
* @returns {Promise<Array>} Array of records * @returns {Promise<Array>} Array of records
*/ */
pb.getAll = async (collection, options = {}) => { pb.getAll = async (collection, options = {}) => {
const records = []; const records = [];
const pageSize = options.pageSize || 200; const pageSize = options.pageSize || 200;
let page = 1; let page = 1;
const isRunning = true;
while (isRunning) {
try {
const result = await pb.collection(collection).getList(page, pageSize, options);
records.push(...result.items);
try { if (records.length >= result.totalItems) {
while (true) { break;
const result = await pb.collection(collection).getList(page, pageSize, options); }
records.push(...result.items);
if (records.length >= result.totalItems) { page++;
break; } catch (error) {
} logger.error(`Failed to get all records from ${collection}: ${error.message}`);
throw error;
}
}
page++; return records;
} };
return records; /**
} catch (error) {
logger.error(`Failed to get all records from ${collection}: ${error.message}`);
throw error;
}
};
/**
* Count records matching a filter * Count records matching a filter
* @param {string} collection - Collection name * @param {string} collection - Collection name
* @param {string} filter - Filter query * @param {string} filter - Filter query
* @returns {Promise<number>} Count of matching records * @returns {Promise<number>} Count of matching records
*/ */
pb.count = async (collection, filter = '') => { pb.count = async (collection, filter = '') => {
try { try {
const result = await pb.collection(collection).getList(1, 1, { const result = await pb.collection(collection).getList(1, 1, {
filter, filter,
fields: 'id' fields: 'id'
}); });
return result.totalItems; return result.totalItems;
} catch (error) { } catch (error) {
logger.error(`Failed to count records in ${collection}: ${error.message}`); logger.error(`Failed to count records in ${collection}: ${error.message}`);
throw error; throw error;
} }
}; };
// ===== BATCH OPERATIONS ===== // ===== BATCH OPERATIONS =====
/** /**
* Perform batch create * Perform batch create
* @param {string} collection - Collection name * @param {string} collection - Collection name
* @param {Array<Object>} items - Array of items to create * @param {Array<Object>} items - Array of items to create
* @returns {Promise<Array>} Created records * @returns {Promise<Array>} Created records
*/ */
pb.batchCreate = async (collection, items) => { pb.batchCreate = async (collection, items) => {
if (!items || items.length === 0) { if (!items || items.length === 0) {
return []; return [];
} }
const results = []; const results = [];
try { try {
// Process in chunks to avoid rate limits // Process in chunks to avoid rate limits
const chunkSize = 50; const chunkSize = 50;
for (let i = 0; i < items.length; i += chunkSize) { for (let i = 0; i < items.length; i += chunkSize) {
const chunk = items.slice(i, i + chunkSize); const chunk = items.slice(i, i + chunkSize);
const promises = chunk.map(item => pb.createOne(collection, item)); const promises = chunk.map(item => pb.createOne(collection, item));
const chunkResults = await Promise.all(promises); const chunkResults = await Promise.all(promises);
results.push(...chunkResults); results.push(...chunkResults);
} }
return results; return results;
} catch (error) { } catch (error) {
logger.error(`Failed batch create in ${collection}: ${error.message}`); logger.error(`Failed batch create in ${collection}: ${error.message}`);
throw error; throw error;
} }
}; };
/** /**
* Perform batch update * Perform batch update
* @param {string} collection - Collection name * @param {string} collection - Collection name
* @param {Array<Object>} items - Array of items with id field * @param {Array<Object>} items - Array of items with id field
* @returns {Promise<Array>} Updated records * @returns {Promise<Array>} Updated records
*/ */
pb.batchUpdate = async (collection, items) => { pb.batchUpdate = async (collection, items) => {
if (!items || items.length === 0) { if (!items || items.length === 0) {
return []; return [];
} }
const results = []; const results = [];
try { try {
// Process in chunks to avoid rate limits // Process in chunks to avoid rate limits
const chunkSize = 50; const chunkSize = 50;
for (let i = 0; i < items.length; i += chunkSize) { for (let i = 0; i < items.length; i += chunkSize) {
const chunk = items.slice(i, i + chunkSize); const chunk = items.slice(i, i + chunkSize);
const promises = chunk.map(item => { const promises = chunk.map(item => {
const { id, ...data } = item; const { id, ...data } = item;
return pb.updateOne(collection, id, data); return pb.updateOne(collection, id, data);
}); });
const chunkResults = await Promise.all(promises); const chunkResults = await Promise.all(promises);
results.push(...chunkResults); results.push(...chunkResults);
} }
return results; return results;
} catch (error) { } catch (error) {
logger.error(`Failed batch update in ${collection}: ${error.message}`); logger.error(`Failed batch update in ${collection}: ${error.message}`);
throw error; throw error;
} }
}; };
/** /**
* Perform batch delete * Perform batch delete
* @param {string} collection - Collection name * @param {string} collection - Collection name
* @param {Array<string>} ids - Array of record IDs to delete * @param {Array<string>} ids - Array of record IDs to delete
* @returns {Promise<Array>} Results of deletion operations * @returns {Promise<Array>} Results of deletion operations
*/ */
pb.batchDelete = async (collection, ids) => { pb.batchDelete = async (collection, ids) => {
if (!ids || ids.length === 0) { if (!ids || ids.length === 0) {
return []; return [];
} }
const results = []; const results = [];
try { try {
// Process in chunks to avoid rate limits // Process in chunks to avoid rate limits
const chunkSize = 50; const chunkSize = 50;
for (let i = 0; i < ids.length; i += chunkSize) { for (let i = 0; i < ids.length; i += chunkSize) {
const chunk = ids.slice(i, i + chunkSize); const chunk = ids.slice(i, i + chunkSize);
const promises = chunk.map(id => pb.deleteOne(collection, id)); const promises = chunk.map(id => pb.deleteOne(collection, id));
const chunkResults = await Promise.all(promises); const chunkResults = await Promise.all(promises);
results.push(...chunkResults); results.push(...chunkResults);
} }
return results; return results;
} catch (error) { } catch (error) {
logger.error(`Failed batch delete in ${collection}: ${error.message}`); logger.error(`Failed batch delete in ${collection}: ${error.message}`);
throw error; throw error;
} }
}; };
/** /**
* Delete a message in the "message_queue" collection by its record ID. * Delete a message in the "message_queue" collection by its record ID.
* @param {string} id - Record ID to delete. * @param {string} id - Record ID to delete.
@ -361,100 +362,99 @@ const extendPocketBase = (client, pb, logger) => {
return await pb.deleteOne('message_queue', id); return await pb.deleteOne('message_queue', id);
}; };
// ===== CACHE MANAGEMENT =====
// ===== CACHE MANAGEMENT ===== // Simple in-memory cache
pb.cache = {
_store: new Map(),
_ttls: new Map(),
// Simple in-memory cache /**
pb.cache = {
_store: new Map(),
_ttls: new Map(),
/**
* Get a value from cache * Get a value from cache
* @param {string} key - Cache key * @param {string} key - Cache key
* @returns {*} Cached value or undefined * @returns {*} Cached value or undefined
*/ */
get(key) { get(key) {
if (this._ttls.has(key) && this._ttls.get(key) < Date.now()) { if (this._ttls.has(key) && this._ttls.get(key) < Date.now()) {
this.delete(key); this.delete(key);
return undefined; return undefined;
} }
return this._store.get(key); return this._store.get(key);
}, },
/** /**
* Set a value in cache * Set a value in cache
* @param {string} key - Cache key * @param {string} key - Cache key
* @param {*} value - Value to store * @param {*} value - Value to store
* @param {number} ttlSeconds - Time to live in seconds * @param {number} ttlSeconds - Time to live in seconds
*/ */
set(key, value, ttlSeconds = 300) { set(key, value, ttlSeconds = 300) {
this._store.set(key, value); this._store.set(key, value);
if (ttlSeconds > 0) { if (ttlSeconds > 0) {
this._ttls.set(key, Date.now() + (ttlSeconds * 1000)); this._ttls.set(key, Date.now() + (ttlSeconds * 1000));
} }
}, },
/** /**
* Delete a value from cache * Delete a value from cache
* @param {string} key - Cache key * @param {string} key - Cache key
*/ */
delete(key) { delete(key) {
this._store.delete(key); this._store.delete(key);
this._ttls.delete(key); this._ttls.delete(key);
}, },
/** /**
* Clear all cache * Clear all cache
*/ */
clear() { clear() {
this._store.clear(); this._store.clear();
this._ttls.clear(); this._ttls.clear();
} }
}; };
/** /**
* Get a record with caching * Get a record with caching
* @param {string} collection - Collection name * @param {string} collection - Collection name
* @param {string} id - Record ID * @param {string} id - Record ID
* @param {number} ttlSeconds - Cache TTL in seconds * @param {number} ttlSeconds - Cache TTL in seconds
* @returns {Promise<Object>} Record or null * @returns {Promise<Object>} Record or null
*/ */
pb.getCached = async (collection, id, ttlSeconds = 60) => { pb.getCached = async (collection, id, ttlSeconds = 60) => {
const cacheKey = `${collection}:${id}`; const cacheKey = `${collection}:${id}`;
const cached = pb.cache.get(cacheKey); const cached = pb.cache.get(cacheKey);
if (cached !== undefined) { if (cached !== undefined) {
return cached; return cached;
} }
const record = await pb.getOne(collection, id); const record = await pb.getOne(collection, id);
pb.cache.set(cacheKey, record, ttlSeconds); pb.cache.set(cacheKey, record, ttlSeconds);
return record; return record;
}; };
/** /**
* Get list with caching * Get list with caching
* @param {string} collection - Collection name * @param {string} collection - Collection name
* @param {Object} options - Query options * @param {Object} options - Query options
* @param {number} ttlSeconds - Cache TTL in seconds * @param {number} ttlSeconds - Cache TTL in seconds
* @returns {Promise<Object>} List result * @returns {Promise<Object>} List result
*/ */
pb.getListCached = async (collection, options = {}, ttlSeconds = 30) => { pb.getListCached = async (collection, options = {}, ttlSeconds = 30) => {
const cacheKey = `${collection}:list:${JSON.stringify(options)}`; const cacheKey = `${collection}:list:${JSON.stringify(options)}`;
const cached = pb.cache.get(cacheKey); const cached = pb.cache.get(cacheKey);
if (cached !== undefined) { if (cached !== undefined) {
return cached; return cached;
} }
const { page = 1, perPage = 50, ...restOptions } = options; const { page = 1, perPage = 50, ...restOptions } = options;
const result = await pb.collection(collection).getList(page, perPage, restOptions); const result = await pb.collection(collection).getList(page, perPage, restOptions);
pb.cache.set(cacheKey, result, ttlSeconds); pb.cache.set(cacheKey, result, ttlSeconds);
return result; return result;
}; };
}; };
/** /**
@ -463,82 +463,82 @@ const extendPocketBase = (client, pb, logger) => {
* @param {Object} logger - Winston logger * @param {Object} logger - Winston logger
*/ */
const setupConnectionHandling = (pb, logger) => { const setupConnectionHandling = (pb, logger) => {
// Add connection state tracking // Add connection state tracking
pb.isConnected = true; pb.isConnected = true;
pb.lastSuccessfulAuth = null; pb.lastSuccessfulAuth = null;
// Add auto-reconnect and token refresh // Add auto-reconnect and token refresh
pb.authStore.onChange(() => { pb.authStore.onChange(() => {
pb.isConnected = pb.authStore.isValid; pb.isConnected = pb.authStore.isValid;
if (pb.isConnected) { if (pb.isConnected) {
pb.lastSuccessfulAuth = new Date(); pb.lastSuccessfulAuth = new Date();
logger.info('PocketBase authentication successful'); logger.info('PocketBase authentication successful');
} else { } else {
logger.warn('PocketBase auth token expired or invalid'); logger.warn('PocketBase auth token expired or invalid');
} }
}); });
// Helper to check health and reconnect if needed // Helper to check health and reconnect if needed
pb.ensureConnection = async () => { pb.ensureConnection = async () => {
if (!pb.isConnected || !pb.authStore.isValid) { if (!pb.isConnected || !pb.authStore.isValid) {
try { try {
logger.info('Reconnecting to PocketBase...'); logger.info('Reconnecting to PocketBase...');
// Attempt to refresh the auth if we have a refresh token // Attempt to refresh the auth if we have a refresh token
if (pb.authStore.token && pb.authStore.model?.id) { if (pb.authStore.token && pb.authStore.model?.id) {
// Refresh session using the configured users collection // Refresh session using the configured users collection
await pb.collection('_users').authRefresh(); await pb.collection('_users').authRefresh();
} else if (pb._config.username && pb._config.password) { } else if (pb._config.username && pb._config.password) {
// Fall back to full re-authentication if credentials available // Fall back to full re-authentication if credentials available
// Re-authenticate using the configured users collection credentials // Re-authenticate using the configured users collection credentials
await pb.collection('_users').authWithPassword( await pb.collection('_users').authWithPassword(
pb._config.username, pb._config.username,
pb._config.password pb._config.password
); );
} else { } else {
logger.error('No credentials available to reconnect PocketBase'); logger.error('No credentials available to reconnect PocketBase');
pb.isConnected = false; pb.isConnected = false;
return false; return false;
} }
pb.isConnected = true; pb.isConnected = true;
pb.lastSuccessfulAuth = new Date(); pb.lastSuccessfulAuth = new Date();
logger.info('Successfully reconnected to PocketBase'); logger.info('Successfully reconnected to PocketBase');
return true; return true;
} catch (error) { } catch (error) {
logger.error(`Failed to reconnect to PocketBase: ${error.message}`); logger.error(`Failed to reconnect to PocketBase: ${error.message}`);
pb.isConnected = false; pb.isConnected = false;
return false; return false;
} }
} }
return true; return true;
}; };
// Store credentials for reconnection // Store credentials for reconnection
pb._config = pb._config || {}; pb._config = pb._config || {};
// Ensure only if env provided // Ensure only if env provided
if (process.env.SHARED_POCKETBASE_USERNAME && process.env.SHARED_POCKETBASE_PASSWORD) { if (process.env.SHARED_POCKETBASE_USERNAME && process.env.SHARED_POCKETBASE_PASSWORD) {
pb._config.username = process.env.SHARED_POCKETBASE_USERNAME; pb._config.username = process.env.SHARED_POCKETBASE_USERNAME;
pb._config.password = process.env.SHARED_POCKETBASE_PASSWORD; pb._config.password = process.env.SHARED_POCKETBASE_PASSWORD;
} }
// Heartbeat function to check connection periodically // Heartbeat function to check connection periodically
const heartbeatInterval = setInterval(async () => { const heartbeatInterval = setInterval(async () => {
try { try {
// Simple health check // Simple health check
await pb.health.check(); await pb.health.check();
pb.isConnected = true; pb.isConnected = true;
} catch (error) { } catch (error) {
logger.warn(`PocketBase connection issue: ${error.message}`); logger.warn(`PocketBase connection issue: ${error.message}`);
pb.isConnected = false; pb.isConnected = false;
await pb.ensureConnection(); await pb.ensureConnection();
} }
}, 5 * 60 * 1000); // Check every 5 minutes }, 5 * 60 * 1000); // Check every 5 minutes
// Clean up on client disconnect // Clean up on client disconnect
pb.cleanup = () => { pb.cleanup = () => {
clearInterval(heartbeatInterval); clearInterval(heartbeatInterval);
}; };
}; };

View File

@ -4,13 +4,15 @@
* and handles text or image (function_call) outputs. * and handles text or image (function_call) outputs.
*/ */
// Removed local file fallback; prompt now comes exclusively from PocketBase via responsesPrompt module // Removed local file fallback; prompt now comes exclusively from PocketBase via responsesPrompt module
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 fs from 'fs/promises';
import path from 'path'; import path from 'path';
import axios from 'axios';
import { AttachmentBuilder, PermissionFlagsBits } from 'discord.js';
import { OpenAI } from 'openai';
import { expandTemplate } from '../_src/template.js';
// Discord message max length // Discord message max length
const MAX_DISCORD_MSG_LENGTH = 2000; const MAX_DISCORD_MSG_LENGTH = 2000;
@ -21,76 +23,75 @@ const MAX_DISCORD_MSG_LENGTH = 2000;
* @returns {string[]} Array of message chunks. * @returns {string[]} Array of message chunks.
*/ */
function splitMessage(text, maxLength = MAX_DISCORD_MSG_LENGTH) { function splitMessage(text, maxLength = MAX_DISCORD_MSG_LENGTH) {
const lines = text.split(/\n/); const lines = text.split(/\n/);
const chunks = []; const chunks = [];
let chunk = ''; let chunk = '';
let codeBlockOpen = false; let codeBlockOpen = false;
let codeBlockFence = '```'; let codeBlockFence = '```';
for (let line of lines) { for (const line of lines) {
const trimmed = line.trim(); const trimmed = line.trim();
const isFenceLine = trimmed.startsWith('```'); const isFenceLine = trimmed.startsWith('```');
if (isFenceLine) { if (isFenceLine) {
if (!codeBlockOpen) { if (!codeBlockOpen) {
codeBlockOpen = true; codeBlockOpen = true;
codeBlockFence = trimmed; codeBlockFence = trimmed;
} else if (trimmed === '```') { } else if (trimmed === '```') {
// closing fence // closing fence
codeBlockOpen = false; 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;
} }
// include the newline that was removed by split if (chunk) {
const segment = line + '\n'; // close any unclosed code block
// 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```'; if (codeBlockOpen) chunk += '\n```';
chunks.push(chunk); 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; // remove trailing newline from each chunk
} return chunks.map(c => c.endsWith('\n') ? c.slice(0, -1) : c);
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);
} }
/** /**
* Determine whether the bot should respond to a message. * Determine whether the bot should respond to a message.
* Controlled by enableMentions and enableReplies in config. * Controlled by enableMentions and enableReplies in config.
*/ */
async function shouldRespond(message, botId, cfg, logger) { async function shouldRespond(message, botId, cfg, logger) {
if (message.author.bot || !botId) return false; if (message.author.bot || !botId) return false;
const enableMentions = cfg.enableMentions ?? true; const enableMentions = cfg.enableMentions ?? true;
const enableReplies = cfg.enableReplies ?? true; const enableReplies = cfg.enableReplies ?? true;
const isMention = enableMentions && message.mentions.users.has(botId); const isMention = enableMentions && message.mentions.users.has(botId);
let isReply = false; let isReply = false;
if (enableReplies && message.reference?.messageId) { if (enableReplies && message.reference?.messageId) {
try { try {
const ref = await message.channel.messages.fetch(message.reference.messageId); const ref = await message.channel.messages.fetch(message.reference.messageId);
isReply = ref.author.id === botId; isReply = ref.author.id === botId;
} catch {} } catch {}
} }
logger.debug(`Trigger? mention=${isMention} reply=${isReply}`); logger.debug(`Trigger? mention=${isMention} reply=${isReply}`);
return isMention || isReply; return isMention || isReply;
} }
/** /**
@ -101,7 +102,7 @@ async function shouldRespond(message, botId, cfg, logger) {
* @param {number} ttlSeconds - Time-to-live for the cache entry in seconds. * @param {number} ttlSeconds - Time-to-live for the cache entry in seconds.
*/ */
function cacheResponse(client, key, id, ttlSeconds) { function cacheResponse(client, key, id, ttlSeconds) {
client.pb?.cache?.set(key, id, ttlSeconds); client.pb?.cache?.set(key, id, ttlSeconds);
} }
/** /**
@ -112,10 +113,10 @@ function cacheResponse(client, key, id, ttlSeconds) {
* @param {number} amount - Number of tokens to award. * @param {number} amount - Number of tokens to award.
*/ */
function awardOutput(client, guildId, userId, amount) { function awardOutput(client, guildId, userId, amount) {
if (client.scorekeeper && amount > 0) { if (client.scorekeeper && amount > 0) {
client.scorekeeper.addOutput(guildId, userId, amount, 'AI_response') client.scorekeeper.addOutput(guildId, userId, amount, 'AI_response')
.catch(err => client.logger.error(`Scorekeeper error: ${err.message}`)); .catch(err => client.logger.error(`Scorekeeper error: ${err.message}`));
} }
} }
/** /**
@ -128,112 +129,112 @@ function awardOutput(client, guildId, userId, amount) {
* @returns {Promise<boolean>} True if the function call was handled. * @returns {Promise<boolean>} True if the function call was handled.
*/ */
async function handleImage(client, message, resp, cfg) { async function handleImage(client, message, resp, cfg) {
const calls = Array.isArray(resp.output) ? resp.output : []; const calls = Array.isArray(resp.output) ? resp.output : [];
const fn = calls.find(o => o.type === 'function_call' && o.name === 'generate_image'); const fn = calls.find(o => o.type === 'function_call' && o.name === 'generate_image');
if (!fn?.arguments) return false; if (!fn?.arguments) return false;
client.logger.debug(`Image function args: ${fn.arguments}`); client.logger.debug(`Image function args: ${fn.arguments}`);
let args; let args;
try { args = JSON.parse(fn.arguments); } catch (e) { return false; } try { args = JSON.parse(fn.arguments); } catch (e) { return false; }
if (!args.prompt?.trim()) { if (!args.prompt?.trim()) {
await message.reply('Cannot generate image: empty prompt.'); await message.reply('Cannot generate image: empty prompt.');
return true; return true;
}
// Use image model defined in config
const model = cfg.imageGeneration.defaultModel;
const promptText = args.prompt;
// Determine number of images (1-10); DALL·E-3 only supports 1
let count = 1;
if (args.n != null) {
const nVal = typeof args.n === 'number' ? args.n : parseInt(args.n, 10);
if (!Number.isNaN(nVal)) count = nVal;
}
// clamp between 1 and 10
count = Math.max(1, Math.min(10, count));
if (model === 'dall-e-3') count = 1;
const size = args.size || 'auto';
// Determine quality based on config and model constraints
let quality = args.quality || cfg.imageGeneration.defaultQuality;
if (model === 'gpt-image-1') {
if (!['low', 'medium', 'high', 'auto'].includes(quality)) quality = 'auto';
} else if (model === 'dall-e-2') {
quality = 'standard';
} else if (model === 'dall-e-3') {
if (!['standard', 'hd', 'auto'].includes(quality)) quality = 'standard';
}
const background = args.background;
const moderation = args.moderation;
const outputFormat = args.output_format;
const compression = args.output_compression;
const style = args.style;
const user = args.user || message.author.id;
try {
// Build generate parameters
const genParams = { model, prompt: promptText, n: count, size, quality, user };
// response_format supported for DALL·E models (not gpt-image-1)
if (model !== 'gpt-image-1' && args.response_format) {
genParams['response_format'] = args.response_format;
} }
// gpt-image-1 supports background, moderation, output_format, and output_compression // Use image model defined in config
const model = cfg.imageGeneration.defaultModel;
const promptText = args.prompt;
// Determine number of images (1-10); DALL·E-3 only supports 1
let count = 1;
if (args.n !== null) {
const nVal = typeof args.n === 'number' ? args.n : parseInt(args.n, 10);
if (!Number.isNaN(nVal)) count = nVal;
}
// clamp between 1 and 10
count = Math.max(1, Math.min(10, count));
if (model === 'dall-e-3') count = 1;
const size = args.size || 'auto';
// Determine quality based on config and model constraints
let quality = args.quality || cfg.imageGeneration.defaultQuality;
if (model === 'gpt-image-1') { if (model === 'gpt-image-1') {
if (background) genParams['background'] = background; if (!['low', 'medium', 'high', 'auto'].includes(quality)) quality = 'auto';
if (moderation) genParams['moderation'] = moderation; } else if (model === 'dall-e-2') {
if (outputFormat) { quality = 'standard';
genParams['output_format'] = outputFormat; } else if (model === 'dall-e-3') {
// only support compression for JPEG or WEBP formats if (!['standard', 'hd', 'auto'].includes(quality)) quality = 'standard';
if (['jpeg','webp'].includes(outputFormat) && typeof compression === 'number') { }
genParams['output_compression'] = compression; const background = args.background;
const moderation = args.moderation;
const outputFormat = args.output_format;
const compression = args.output_compression;
const style = args.style;
const user = args.user || message.author.id;
try {
// Build generate parameters
const genParams = { model, prompt: promptText, n: count, size, quality, user };
// response_format supported for DALL·E models (not gpt-image-1)
if (model !== 'gpt-image-1' && args.response_format) {
genParams['response_format'] = args.response_format;
} }
} // gpt-image-1 supports background, moderation, output_format, and output_compression
if (model === 'gpt-image-1') {
if (background) genParams['background'] = background;
if (moderation) genParams['moderation'] = moderation;
if (outputFormat) {
genParams['output_format'] = outputFormat;
// only support compression for JPEG or WEBP formats
if (['jpeg','webp'].includes(outputFormat) && typeof compression === 'number') {
genParams['output_compression'] = compression;
}
}
}
// dall-e-3 supports style
if (model === 'dall-e-3' && style) {
genParams['style'] = style;
}
// Generate images via OpenAI Images API
const imgRes = await client.openai.images.generate(genParams);
const images = imgRes.data || [];
if (!images.length) throw new Error('No images generated');
// Ensure save directory exists
const dir = cfg.imageGeneration?.imageSavePath || './images';
await fs.mkdir(dir, { recursive: true });
const attachments = [];
const outputs = [];
// Process each generated image
for (let i = 0; i < images.length; i++) {
const img = images[i];
let buffer, ext = outputFormat || 'png';
if (img.b64_json) {
buffer = Buffer.from(img.b64_json, 'base64');
outputs.push({ b64_json: img.b64_json });
} else if (img.url) {
const dl = await axios.get(img.url, { responseType: 'arraybuffer' });
buffer = Buffer.from(dl.data);
// derive extension from URL if possible
const parsed = path.extname(img.url.split('?')[0]).replace(/^[.]/, '');
if (parsed) ext = parsed;
outputs.push({ url: img.url });
} else {
throw new Error('No image data');
}
const filename = `${message.author.id}-${Date.now()}-${i}.${ext}`;
const filePath = path.join(dir, filename);
await fs.writeFile(filePath, buffer);
client.logger.info(`Saved image: ${filePath}`);
attachments.push(new AttachmentBuilder(buffer, { name: filename }));
}
// Award output points based on token usage for image generation
const tokens = imgRes.usage?.total_tokens ?? count;
if (client.scorekeeper && tokens > 0) {
client.scorekeeper.addOutput(message.guild.id, message.author.id, tokens, 'image_generation')
.catch(err => client.logger.error(`Scorekeeper error: ${err.message}`));
}
// Reply with attachments
await message.reply({ content: promptText, files: attachments });
} catch (err) {
client.logger.error(`Image error: ${err.message}`);
await message.reply(`Image generation error: ${err.message}`);
} }
// dall-e-3 supports style return true;
if (model === 'dall-e-3' && style) {
genParams['style'] = style;
}
// Generate images via OpenAI Images API
const imgRes = await client.openai.images.generate(genParams);
const images = imgRes.data || [];
if (!images.length) throw new Error('No images generated');
// Ensure save directory exists
const dir = cfg.imageGeneration?.imageSavePath || './images';
await fs.mkdir(dir, { recursive: true });
const attachments = [];
const outputs = [];
// Process each generated image
for (let i = 0; i < images.length; i++) {
const img = images[i];
let buffer, ext = outputFormat || 'png';
if (img.b64_json) {
buffer = Buffer.from(img.b64_json, 'base64');
outputs.push({ b64_json: img.b64_json });
} else if (img.url) {
const dl = await axios.get(img.url, { responseType: 'arraybuffer' });
buffer = Buffer.from(dl.data);
// derive extension from URL if possible
const parsed = path.extname(img.url.split('?')[0]).replace(/^[.]/, '');
if (parsed) ext = parsed;
outputs.push({ url: img.url });
} else {
throw new Error('No image data');
}
const filename = `${message.author.id}-${Date.now()}-${i}.${ext}`;
const filePath = path.join(dir, filename);
await fs.writeFile(filePath, buffer);
client.logger.info(`Saved image: ${filePath}`);
attachments.push(new AttachmentBuilder(buffer, { name: filename }));
}
// Award output points based on token usage for image generation
const tokens = imgRes.usage?.total_tokens ?? count;
if (client.scorekeeper && tokens > 0) {
client.scorekeeper.addOutput(message.guild.id, message.author.id, tokens, 'image_generation')
.catch(err => client.logger.error(`Scorekeeper error: ${err.message}`));
}
// Reply with attachments
await message.reply({ content: promptText, files: attachments });
} catch (err) {
client.logger.error(`Image error: ${err.message}`);
await message.reply(`Image generation error: ${err.message}`);
}
return true;
} }
/** /**
@ -244,213 +245,213 @@ async function handleImage(client, message, resp, cfg) {
* @param {Message} message - Incoming Discord message. * @param {Message} message - Incoming Discord message.
*/ */
async function onMessage(client, cfg, message) { async function onMessage(client, cfg, message) {
const logger = client.logger; const logger = client.logger;
const botId = client.user?.id; const botId = client.user?.id;
client.logger.debug(`[onMessage] Received message ${message.id} from ${message.author.id}`); client.logger.debug(`[onMessage] Received message ${message.id} from ${message.author.id}`);
// Check if bot should respond, based on config (mentions/replies) // Check if bot should respond, based on config (mentions/replies)
if (!(await shouldRespond(message, botId, cfg, logger))) return; if (!(await shouldRespond(message, botId, cfg, logger))) return;
// Determine channel/thread key for context // Determine channel/thread key for context
const key = message.thread?.id || message.channel.id; const key = message.thread?.id || message.channel.id;
// Initialize per-channel lock map // Initialize per-channel lock map
const lockMap = client._responseLockMap || (client._responseLockMap = new Map()); const lockMap = client._responseLockMap || (client._responseLockMap = new Map());
// Get last pending promise for this key // Get last pending promise for this key
const last = lockMap.get(key) || Promise.resolve(); const last = lockMap.get(key) || Promise.resolve();
// Handler to run in sequence // Handler to run in sequence
const handler = async () => { const handler = async () => {
// Start typing indicator loop every 9 seconds // Start typing indicator loop every 9 seconds
const typingInterval = setInterval(() => { const typingInterval = setInterval(() => {
message.channel.sendTyping().catch(() => {}); message.channel.sendTyping().catch(() => {});
}, 9000); }, 9000);
// Initial typing // Initial typing
message.channel.sendTyping().catch(() => {}); message.channel.sendTyping().catch(() => {});
try {
// Previous response ID for context continuity
const prev = client.pb?.cache?.get(key);
// Enforce minimum score to use AI responses
// Enforce minimum score to use AI responses if scorekeeper is enabled
if (client.scorekeeper) {
try { try {
const isAdmin = message.member?.permissions?.has(PermissionFlagsBits.Administrator); // Previous response ID for context continuity
const scoreData = await client.scorekeeper.getScore(message.guild.id, message.author.id); const prev = client.pb?.cache?.get(key);
if (!isAdmin && scoreData.totalScore < cfg.minScore) { // Enforce minimum score to use AI responses
await message.reply( // Enforce minimum score to use AI responses if scorekeeper is enabled
`You need an I/O score of at least ${cfg.minScore} to use AI responses. Your current I/O score is ${scoreData.totalScore.toFixed(2)}.` if (client.scorekeeper) {
); try {
return; const isAdmin = message.member?.permissions?.has(PermissionFlagsBits.Administrator);
} const scoreData = await client.scorekeeper.getScore(message.guild.id, message.author.id);
if (!isAdmin && scoreData.totalScore < cfg.minScore) {
await message.reply(
`You need an I/O score of at least ${cfg.minScore} to use AI responses. Your current I/O score is ${scoreData.totalScore.toFixed(2)}.`
);
return;
}
} catch (err) {
client.logger.error(`Error checking score: ${err.message}`);
}
}
// Build request body, including replied-to message context and mention of who spoke
let referencePrefix = '';
let referenceMessage = null;
if (message.reference?.messageId) {
try {
const ref = await message.channel.messages.fetch(message.reference.messageId);
referenceMessage = ref;
const refContent = ref.content || '';
if (ref.author.id === botId) {
referencePrefix = `You said: ${refContent}`;
} else {
referencePrefix = `<@${ref.author.id}> said: ${refContent}`;
}
} catch {
// ignore fetch errors
}
}
const speakerMention = `<@${message.author.id}>`;
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,
input: userInput,
previous_response_id: prev,
max_output_tokens: cfg.defaultMaxTokens,
temperature: cfg.defaultTemperature
};
// Assemble any enabled tools
const tools = [];
if (cfg.tools?.imageGeneration) {
const model = cfg.imageGeneration.defaultModel;
// Configure allowed sizes per model
let sizeEnum;
switch (model) {
case 'gpt-image-1': sizeEnum = ['auto','1024x1024','1536x1024','1024x1536']; break;
case 'dall-e-2': sizeEnum = ['256x256','512x512','1024x1024']; break;
case 'dall-e-3': sizeEnum = ['auto','1024x1024','1792x1024','1024x1792']; break;
default: sizeEnum = ['auto','1024x1024'];
}
// Configure quality options per model
let qualityEnum;
switch (model) {
case 'gpt-image-1': qualityEnum = ['auto','low','medium','high']; break;
case 'dall-e-2': qualityEnum = ['standard']; break;
case 'dall-e-3': qualityEnum = ['auto','standard','hd']; break;
default: qualityEnum = ['auto','standard'];
}
// Build schema properties dynamically
const properties = {
prompt: { type: 'string', description: 'Text description of desired image(s).' },
n: { type: 'number', description: 'Number of images to generate.' },
size: { type: 'string', enum: sizeEnum, description: 'Image size.' },
quality: { type: 'string', enum: qualityEnum, description: 'Image quality.' },
user: { type: 'string', description: 'Unique end-user identifier.' }
};
if (model !== 'gpt-image-1') {
properties.response_format = { type: 'string', enum: ['url','b64_json'], description: 'Format of returned images.' };
}
if (model === 'gpt-image-1') {
properties.background = { type: 'string', enum: ['transparent','opaque','auto'], description: 'Background transparency.' };
properties.moderation = { type: 'string', enum: ['low','auto'], description: 'Content moderation level.' };
properties.output_format = { type: 'string', enum: ['png','jpeg','webp'], description: 'Output image format.' };
properties.output_compression = { type: 'number', description: 'Compression level (0-100).' };
}
if (model === 'dall-e-3') {
properties.style = { type: 'string', enum: ['vivid','natural'], description: 'Style option for dall-e-3.' };
}
// Determine required fields
const required = ['prompt','n','size','quality','user'];
if (model !== 'gpt-image-1') required.push('response_format');
if (model === 'gpt-image-1') required.push('background','moderation','output_format','output_compression');
if (model === 'dall-e-3') required.push('style');
// Register the function tool
tools.push({
type: 'function',
name: 'generate_image',
description: `Generate images using model ${model} with requested parameters.`,
parameters: {
type: 'object',
properties,
required,
additionalProperties: false
},
strict: true
});
}
if (cfg.tools?.webSearch) {
tools.push({ type: 'web_search_preview' });
}
if (tools.length) {
body.tools = tools;
}
// If there are image attachments in the referenced or current message, wrap text and images into a multimodal message
const refImages = referenceMessage
? referenceMessage.attachments.filter(att => /\.(png|jpe?g|gif|webp)$/i.test(att.name || att.url))
: new Map();
const currImages = message.attachments.filter(att => /\.(png|jpe?g|gif|webp)$/i.test(att.name || att.url));
if (refImages.size > 0 || currImages.size > 0) {
// build ordered content items: text first, then referenced images, then current images
const contentItems = [{ type: 'input_text', text: userInput }];
for (const att of refImages.values()) {
contentItems.push({ type: 'input_image', detail: 'auto', image_url: att.url });
}
for (const att of currImages.values()) {
contentItems.push({ type: 'input_image', detail: 'auto', image_url: att.url });
}
body.input = [{ type: 'message', role: 'user', content: contentItems }];
}
// 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) { } catch (err) {
client.logger.error(`Error checking score: ${err.message}`); logger.error(`Queued onMessage error for ${key}: ${err.message}`);
} finally {
clearInterval(typingInterval);
} }
} };
// Build request body, including replied-to message context and mention of who spoke // Chain the handler to the last promise
let referencePrefix = ''; const next = last.then(handler).catch(err => logger.error(`[onMessage] Handler error: ${err.message}`));
let referenceMessage = null; lockMap.set(key, next);
if (message.reference?.messageId) { // Queue enqueued; handler will send response when its turn arrives
try { return;
const ref = await message.channel.messages.fetch(message.reference.messageId);
referenceMessage = ref;
const refContent = ref.content || '';
if (ref.author.id === botId) {
referencePrefix = `You said: ${refContent}`;
} else {
referencePrefix = `<@${ref.author.id}> said: ${refContent}`;
}
} catch {
// ignore fetch errors
}
}
const speakerMention = `<@${message.author.id}>`;
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,
input: userInput,
previous_response_id: prev,
max_output_tokens: cfg.defaultMaxTokens,
temperature: cfg.defaultTemperature
};
// Assemble any enabled tools
const tools = [];
if (cfg.tools?.imageGeneration) {
const model = cfg.imageGeneration.defaultModel;
// Configure allowed sizes per model
let sizeEnum;
switch (model) {
case 'gpt-image-1': sizeEnum = ['auto','1024x1024','1536x1024','1024x1536']; break;
case 'dall-e-2': sizeEnum = ['256x256','512x512','1024x1024']; break;
case 'dall-e-3': sizeEnum = ['auto','1024x1024','1792x1024','1024x1792']; break;
default: sizeEnum = ['auto','1024x1024'];
}
// Configure quality options per model
let qualityEnum;
switch (model) {
case 'gpt-image-1': qualityEnum = ['auto','low','medium','high']; break;
case 'dall-e-2': qualityEnum = ['standard']; break;
case 'dall-e-3': qualityEnum = ['auto','standard','hd']; break;
default: qualityEnum = ['auto','standard'];
}
// Build schema properties dynamically
const properties = {
prompt: { type: 'string', description: 'Text description of desired image(s).' },
n: { type: 'number', description: 'Number of images to generate.' },
size: { type: 'string', enum: sizeEnum, description: 'Image size.' },
quality: { type: 'string', enum: qualityEnum, description: 'Image quality.' },
user: { type: 'string', description: 'Unique end-user identifier.' }
};
if (model !== 'gpt-image-1') {
properties.response_format = { type: 'string', enum: ['url','b64_json'], description: 'Format of returned images.' };
}
if (model === 'gpt-image-1') {
properties.background = { type: 'string', enum: ['transparent','opaque','auto'], description: 'Background transparency.' };
properties.moderation = { type: 'string', enum: ['low','auto'], description: 'Content moderation level.' };
properties.output_format = { type: 'string', enum: ['png','jpeg','webp'], description: 'Output image format.' };
properties.output_compression = { type: 'number', description: 'Compression level (0-100).' };
}
if (model === 'dall-e-3') {
properties.style = { type: 'string', enum: ['vivid','natural'], description: 'Style option for dall-e-3.' };
}
// Determine required fields
const required = ['prompt','n','size','quality','user'];
if (model !== 'gpt-image-1') required.push('response_format');
if (model === 'gpt-image-1') required.push('background','moderation','output_format','output_compression');
if (model === 'dall-e-3') required.push('style');
// Register the function tool
tools.push({
type: 'function',
name: 'generate_image',
description: `Generate images using model ${model} with requested parameters.`,
parameters: {
type: 'object',
properties,
required,
additionalProperties: false
},
strict: true
});
}
if (cfg.tools?.webSearch) {
tools.push({ type: 'web_search_preview' });
}
if (tools.length) {
body.tools = tools;
}
// If there are image attachments in the referenced or current message, wrap text and images into a multimodal message
const refImages = referenceMessage
? referenceMessage.attachments.filter(att => /\.(png|jpe?g|gif|webp)$/i.test(att.name || att.url))
: new Map();
const currImages = message.attachments.filter(att => /\.(png|jpe?g|gif|webp)$/i.test(att.name || att.url));
if (refImages.size > 0 || currImages.size > 0) {
// build ordered content items: text first, then referenced images, then current images
const contentItems = [{ type: 'input_text', text: userInput }];
for (const att of refImages.values()) {
contentItems.push({ type: 'input_image', detail: 'auto', image_url: att.url });
}
for (const att of currImages.values()) {
contentItems.push({ type: 'input_image', detail: 'auto', image_url: att.url });
}
body.input = [{ type: 'message', role: 'user', content: contentItems }];
}
// 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}`);
} finally {
clearInterval(typingInterval);
}
};
// Chain the handler to the last promise
const next = last.then(handler).catch(err => logger.error(`[onMessage] Handler error: ${err.message}`));
lockMap.set(key, next);
// Queue enqueued; handler will send response when its turn arrives
return;
} }
/** /**
@ -461,52 +462,52 @@ async function onMessage(client, cfg, message) {
* @param {string} text - Narrative prompt text. * @param {string} text - Narrative prompt text.
*/ */
export async function sendNarrative(client, cfg, channelId, text) { export async function sendNarrative(client, cfg, channelId, text) {
const logger = client.logger; const logger = client.logger;
try { try {
// Build the narrative instructions // Build the narrative instructions
// Expand template for sendNarrative // Expand template for sendNarrative
const now = new Date(); const now = new Date();
const date = now.toISOString().split('T')[0]; const date = now.toISOString().split('T')[0];
const time = now.toTimeString().split(' ')[0]; const time = now.toTimeString().split(' ')[0];
const datetime = now.toISOString().replace('T',' ').replace(/\..+$/,''); const datetime = now.toISOString().replace('T',' ').replace(/\..+$/,'');
const ctx = { const ctx = {
clientId: client.config.id, clientId: client.config.id,
userName: client.user.username, userName: client.user.username,
userId: client.user.id, userId: client.user.id,
input: text, input: text,
locationName: channel.name, locationName: channel.name,
locationId: channel.id, locationId: channel.id,
date, time, datetime date, time, datetime
}; };
const raw = `${client.responsesPrompt}\n\nGenerate the following as an engaging narrative:`; const raw = `${client.responsesPrompt}\n\nGenerate the following as an engaging narrative:`;
const instructions = expandTemplate(raw, ctx); const instructions = expandTemplate(raw, ctx);
const body = { const body = {
model: cfg.defaultModel, model: cfg.defaultModel,
instructions, instructions,
input: text, input: text,
max_output_tokens: cfg.defaultMaxTokens, max_output_tokens: cfg.defaultMaxTokens,
temperature: cfg.defaultTemperature temperature: cfg.defaultTemperature
}; };
logger.debug(`[sendNarrative] Calling AI with body: ${JSON.stringify(body).slice(0,1000)}`); logger.debug(`[sendNarrative] Calling AI with body: ${JSON.stringify(body).slice(0,1000)}`);
const resp = await client.openai.responses.create(body); const resp = await client.openai.responses.create(body);
logger.info(`[sendNarrative] Received AI response id=${resp.id}`); logger.info(`[sendNarrative] Received AI response id=${resp.id}`);
// Fetch the target channel or thread // Fetch the target channel or thread
const channel = await client.channels.fetch(channelId); const channel = await client.channels.fetch(channelId);
if (!channel || typeof channel.send !== 'function') { if (!channel || typeof channel.send !== 'function') {
logger.error(`[sendNarrative] Cannot send to channel ID ${channelId}`); logger.error(`[sendNarrative] Cannot send to channel ID ${channelId}`);
return; 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}`);
} }
// 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}`);
}
} }
/** /**
@ -518,11 +519,11 @@ export async function sendNarrative(client, cfg, channelId, text) {
* @param {object} clientConfig - Full client configuration object. * @param {object} clientConfig - Full client configuration object.
*/ */
export async function init(client, clientConfig) { export async function init(client, clientConfig) {
const cfg = clientConfig.responses; const cfg = clientConfig.responses;
client.logger.info('[module:responses] Initializing Responses module'); client.logger.info('[module:responses] Initializing Responses module');
// Initialize prompt from responsesPrompt module (must be loaded before this) // Initialize prompt from responsesPrompt module (must be loaded before this)
client.responsesPrompt = client.responsesPrompt ?? ''; client.responsesPrompt = client.responsesPrompt ?? '';
client.openai = new OpenAI({ apiKey: cfg.apiKey }); client.openai = new OpenAI({ apiKey: cfg.apiKey });
client.on('messageCreate', m => onMessage(client, cfg, m)); client.on('messageCreate', m => onMessage(client, cfg, m));
client.logger.info('[module:responses] Responses module ready'); client.logger.info('[module:responses] Responses module ready');
} }

View File

@ -1,6 +1,8 @@
import { _fs } from 'fs';
import { _path } from 'path';
import { _MessageFlags } from 'discord-api-types/v10';
import { SlashCommandBuilder, PermissionFlagsBits, ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder } from 'discord.js'; import { SlashCommandBuilder, PermissionFlagsBits, ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder } from 'discord.js';
import fs from 'fs';
import path from 'path';
// Placeholder info for template variables // Placeholder info for template variables
const TEMPLATE_KEYS_INFO = 'Available keys: userName, userId, locationName, locationId, date, time, datetime, clientId'; const TEMPLATE_KEYS_INFO = 'Available keys: userName, userId, locationName, locationId, date, time, datetime, clientId';
@ -14,140 +16,140 @@ const MAX_FIELDS = 5;
* responses_prompts collection holds all versions; newest record per client is the live prompt. * responses_prompts collection holds all versions; newest record per client is the live prompt.
*/ */
export const commands = [ export const commands = [
{ {
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName('prompt') .setName('prompt')
.setDescription('Edit the AI response prompt (current or past version)') .setDescription('Edit the AI response prompt (current or past version)')
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator) .setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
.setDMPermission(false) .setDMPermission(false)
.addStringOption(opt => .addStringOption(opt =>
opt.setName('version') opt.setName('version')
.setDescription('ID of a past prompt version to load') .setDescription('ID of a past prompt version to load')
.setRequired(false) .setRequired(false)
.setAutocomplete(true) .setAutocomplete(true)
), ),
async execute(interaction, client) { async execute(interaction, client) {
const clientId = client.config.id; const _clientId = client.config.id;
const versionId = interaction.options.getString('version'); const versionId = interaction.options.getString('version');
// Fetch prompt: live latest or selected historic // Fetch prompt: live latest or selected historic
let promptText = client.responsesPrompt || ''; let promptText = client.responsesPrompt || '';
if (versionId) { if (versionId) {
try { try {
const rec = await client.pb.getOne('responses_prompts', versionId); const rec = await client.pb.getOne('responses_prompts', versionId);
if (rec?.prompt) promptText = rec.prompt; if (rec?.prompt) promptText = rec.prompt;
} catch (err) { } catch (err) {
client.logger.error(`Failed to load prompt version ${versionId}: ${err.message}`); client.logger.error(`Failed to load prompt version ${versionId}: ${err.message}`);
}
}
// 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-${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(text);
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)
));
}
await interaction.showModal(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-${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(text);
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)
));
}
await interaction.showModal(modal);
} }
}
]; ];
// Store clients for event hooks // Store clients for event hooks
const _clients = []; const _clients = [];
export async function init(client, clientConfig) { export async function init(client, clientConfig) {
const clientId = clientConfig.id; const _clientId = client.config.id;
client.logger.info('[module:responsesPrompt] initialized'); client.logger.info('[module:responsesPrompt] initialized');
// Load live prompt (latest version) // Load live prompt (latest version)
try { try {
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(`Error loading current prompt: ${err.message}`);
client.responsesPrompt = '';
}
_clients.push({ client, clientConfig });
// 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') const { items } = await client.pb.collection('responses_prompts')
.getList(1, 25, { filter: `clientId="${clientId}"`, sort: '-created' }); .getList(1, 1, { filter: `clientId="${_clientId}"`, sort: '-created' });
const choices = items.map(r => ({ name: new Date(r.created).toLocaleString(), value: r.id })); client.responsesPrompt = items[0]?.prompt || '';
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;
const id = interaction.customId;
if (!id.startsWith('promptModal-')) return;
const parts = [];
for (let i = 0; i < MAX_FIELDS; i++) {
try {
const v = interaction.fields.getTextInputValue(`prompt_${i}`) || '';
if (v.trim()) parts.push(v);
} catch {}
}
const newPrompt = parts.join('\n');
// Persist new version
let newRec;
try {
newRec = await client.pb.createOne('responses_prompts', { clientId, prompt: newPrompt, updatedBy: interaction.user.id });
client.responsesPrompt = newPrompt;
} catch (err) { } catch (err) {
client.logger.error(`Failed to save prompt: ${err.message}`); client.logger.error(`Error loading current prompt: ${err.message}`);
return interaction.reply({ content: `Error saving prompt: ${err.message}`, ephemeral: true }); client.responsesPrompt = '';
} }
// Prune older versions beyond the 10 most recent _clients.push({ client, clientConfig });
try { // Autocomplete versions
const { items } = await client.pb.collection('responses_prompts') client.on('interactionCreate', async interaction => {
.getList(1, 100, { filter: `clientId="${clientId}"`, sort: '-created' }); if (!interaction.isAutocomplete() || interaction.commandName !== 'prompt') return;
const toDelete = items.map(r => r.id).slice(10); const focused = interaction.options.getFocused(true);
for (const id of toDelete) { if (focused.name === 'version') {
await client.pb.deleteOne('responses_prompts', id); try {
} const { items } = await client.pb.collection('responses_prompts')
} catch (err) { .getList(1, 25, { filter: `clientId="${_clientId}"`, sort: '-created' });
client.logger.error(`Failed to prune old prompts: ${err.message}`); const choices = items.map(r => ({ name: new Date(r.created).toLocaleString(), value: r.id }));
} await interaction.respond(choices);
await interaction.reply({ content: 'Prompt saved!', ephemeral: true }); } 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;
const id = interaction.customId;
if (!id.startsWith('promptModal-')) return;
const parts = [];
for (let i = 0; i < MAX_FIELDS; i++) {
try {
const v = interaction.fields.getTextInputValue(`prompt_${i}`) || '';
if (v.trim()) parts.push(v);
} catch {}
}
const newPrompt = parts.join('\n');
// Persist new version
let _newRec;
try {
_newRec = await client.pb.createOne('responses_prompts', { clientId: _clientId, prompt: newPrompt, updatedBy: interaction.user.id });
client.responsesPrompt = newPrompt;
} catch (err) {
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 });
});
} }

View File

@ -1,3 +1,7 @@
import fs from 'fs/promises';
import path from 'path';
import axios from 'axios';
import { MessageFlags } from 'discord-api-types/v10'; import { MessageFlags } from 'discord-api-types/v10';
/** /**
* Slash command module for '/query'. * Slash command module for '/query'.
@ -5,10 +9,8 @@ import { MessageFlags } from 'discord-api-types/v10';
* including optional image generation function calls. * including optional image generation function calls.
*/ */
import { SlashCommandBuilder, AttachmentBuilder, PermissionFlagsBits } from 'discord.js'; import { SlashCommandBuilder, AttachmentBuilder, PermissionFlagsBits } from 'discord.js';
import { expandTemplate } from '../_src/template.js'; import { expandTemplate } from '../_src/template.js';
import fs from 'fs/promises';
import path from 'path';
import axios from 'axios';
/** /**
* Split long text into chunks safe for Discord messaging. * Split long text into chunks safe for Discord messaging.
@ -17,19 +19,19 @@ import axios from 'axios';
* @returns {string[]} Array of message chunks. * @returns {string[]} Array of message chunks.
*/ */
function splitLongMessage(text, max = 2000) { function splitLongMessage(text, max = 2000) {
const lines = text.split('\n'); const lines = text.split('\n');
const chunks = []; const chunks = [];
let chunk = ''; let chunk = '';
for (const line of lines) { for (const line of lines) {
const next = line + '\n'; const next = line + '\n';
if (chunk.length + next.length > max) { if (chunk.length + next.length > max) {
chunks.push(chunk); chunks.push(chunk);
chunk = ''; chunk = '';
}
chunk += next;
} }
chunk += next; if (chunk) chunks.push(chunk);
} return chunks;
if (chunk) chunks.push(chunk);
return chunks;
} }
/** /**
@ -43,112 +45,112 @@ function splitLongMessage(text, max = 2000) {
* @returns {Promise<boolean>} True if a function call was handled. * @returns {Promise<boolean>} True if a function call was handled.
*/ */
async function handleImageInteraction(client, interaction, resp, cfg, ephemeral) { async function handleImageInteraction(client, interaction, resp, cfg, ephemeral) {
const calls = Array.isArray(resp.output) ? resp.output : []; const calls = Array.isArray(resp.output) ? resp.output : [];
const fn = calls.find(o => o.type === 'function_call' && o.name === 'generate_image'); const fn = calls.find(o => o.type === 'function_call' && o.name === 'generate_image');
if (!fn?.arguments) return false; if (!fn?.arguments) return false;
client.logger.debug(`Image function args: ${fn.arguments}`); client.logger.debug(`Image function args: ${fn.arguments}`);
let args; let args;
try { args = JSON.parse(fn.arguments); } catch (e) { return false; } try { args = JSON.parse(fn.arguments); } catch (e) { return false; }
if (!args.prompt?.trim()) { if (!args.prompt?.trim()) {
await interaction.editReply({ content: 'Cannot generate image: empty prompt.', ephemeral }); await interaction.editReply({ content: 'Cannot generate image: empty prompt.', ephemeral });
return true; return true;
}
// Always use image model defined in config
const model = cfg.imageGeneration.defaultModel;
const promptText = args.prompt;
// Determine number of images (1-10); DALL·E-3 only supports 1
let count = 1;
if (args.n != null) {
const nVal = typeof args.n === 'number' ? args.n : parseInt(args.n, 10);
if (!Number.isNaN(nVal)) count = nVal;
}
// clamp
count = Math.max(1, Math.min(10, count));
if (model === 'dall-e-3') count = 1;
const size = args.size || 'auto';
// Determine quality based on config and model constraints
let quality = args.quality || cfg.imageGeneration.defaultQuality;
if (model === 'gpt-image-1') {
if (!['low', 'medium', 'high', 'auto'].includes(quality)) quality = 'auto';
} else if (model === 'dall-e-2') {
quality = 'standard';
} else if (model === 'dall-e-3') {
if (!['standard', 'hd', 'auto'].includes(quality)) quality = 'standard';
}
const background = args.background;
const moderation = args.moderation;
const outputFormat = args.output_format;
const compression = args.output_compression;
const style = args.style;
const user = args.user || interaction.user.id;
try {
// Build generate parameters
const genParams = { model, prompt: promptText, n: count, size, quality, user };
// response_format supported for DALL·E models (not gpt-image-1)
if (model !== 'gpt-image-1' && args.response_format) {
genParams['response_format'] = args.response_format;
} }
// gpt-image-1 supports background, moderation, output_format, and output_compression // Always use image model defined in config
const model = cfg.imageGeneration.defaultModel;
const promptText = args.prompt;
// Determine number of images (1-10); DALL·E-3 only supports 1
let count = 1;
if (args.n !== null) {
const nVal = typeof args.n === 'number' ? args.n : parseInt(args.n, 10);
if (!Number.isNaN(nVal)) count = nVal;
}
// clamp
count = Math.max(1, Math.min(10, count));
if (model === 'dall-e-3') count = 1;
const size = args.size || 'auto';
// Determine quality based on config and model constraints
let quality = args.quality || cfg.imageGeneration.defaultQuality;
if (model === 'gpt-image-1') { if (model === 'gpt-image-1') {
if (background) genParams['background'] = background; if (!['low', 'medium', 'high', 'auto'].includes(quality)) quality = 'auto';
if (moderation) genParams['moderation'] = moderation; } else if (model === 'dall-e-2') {
if (outputFormat) { quality = 'standard';
genParams['output_format'] = outputFormat; } else if (model === 'dall-e-3') {
// only support compression for JPEG or WEBP formats if (!['standard', 'hd', 'auto'].includes(quality)) quality = 'standard';
if (['jpeg','webp'].includes(outputFormat) && typeof compression === 'number') { }
genParams['output_compression'] = compression; const background = args.background;
const moderation = args.moderation;
const outputFormat = args.output_format;
const compression = args.output_compression;
const style = args.style;
const user = args.user || interaction.user.id;
try {
// Build generate parameters
const genParams = { model, prompt: promptText, n: count, size, quality, user };
// response_format supported for DALL·E models (not gpt-image-1)
if (model !== 'gpt-image-1' && args.response_format) {
genParams['response_format'] = args.response_format;
} }
} // gpt-image-1 supports background, moderation, output_format, and output_compression
if (model === 'gpt-image-1') {
if (background) genParams['background'] = background;
if (moderation) genParams['moderation'] = moderation;
if (outputFormat) {
genParams['output_format'] = outputFormat;
// only support compression for JPEG or WEBP formats
if (['jpeg','webp'].includes(outputFormat) && typeof compression === 'number') {
genParams['output_compression'] = compression;
}
}
}
// dall-e-3 supports style
if (model === 'dall-e-3' && style) {
genParams['style'] = style;
}
// Generate images via OpenAI Images API
const imgRes = await client.openai.images.generate(genParams);
const images = imgRes.data || [];
if (!images.length) throw new Error('No images generated');
// Ensure save directory exists
const dir = cfg.imageGeneration?.imageSavePath || './images';
await fs.mkdir(dir, { recursive: true });
const attachments = [];
const outputs = [];
// Process each generated image
for (let i = 0; i < images.length; i++) {
const img = images[i];
let buffer, ext = outputFormat || 'png';
if (img.b64_json) {
buffer = Buffer.from(img.b64_json, 'base64');
outputs.push({ b64_json: img.b64_json });
} else if (img.url) {
const dl = await axios.get(img.url, { responseType: 'arraybuffer' });
buffer = Buffer.from(dl.data);
const parsed = path.extname(img.url.split('?')[0]).replace(/^[.]/, '');
if (parsed) ext = parsed;
outputs.push({ url: img.url });
} else {
throw new Error('No image data');
}
const filename = `${interaction.user.id}-${Date.now()}-${i}.${ext}`;
const filePath = path.join(dir, filename);
await fs.writeFile(filePath, buffer);
client.logger.info(`Saved image: ${filePath}`);
attachments.push(new AttachmentBuilder(buffer, { name: filename }));
}
// Award output points based on token usage for image generation
const tokens = imgRes.usage?.total_tokens ?? count;
if (client.scorekeeper && tokens > 0) {
client.scorekeeper.addOutput(interaction.guildId, interaction.user.id, tokens, 'image_generation')
.catch(err => client.logger.error(`Scorekeeper error: ${err.message}`));
}
// Reply with attachments
await interaction.editReply({ content: promptText, files: attachments });
return true;
} catch (err) {
client.logger.error(`Image generation error: ${err.message}`);
await interaction.editReply({ content: `Image generation error: ${err.message}`, ephemeral });
return true;
} }
// dall-e-3 supports style
if (model === 'dall-e-3' && style) {
genParams['style'] = style;
}
// Generate images via OpenAI Images API
const imgRes = await client.openai.images.generate(genParams);
const images = imgRes.data || [];
if (!images.length) throw new Error('No images generated');
// Ensure save directory exists
const dir = cfg.imageGeneration?.imageSavePath || './images';
await fs.mkdir(dir, { recursive: true });
const attachments = [];
const outputs = [];
// Process each generated image
for (let i = 0; i < images.length; i++) {
const img = images[i];
let buffer, ext = outputFormat || 'png';
if (img.b64_json) {
buffer = Buffer.from(img.b64_json, 'base64');
outputs.push({ b64_json: img.b64_json });
} else if (img.url) {
const dl = await axios.get(img.url, { responseType: 'arraybuffer' });
buffer = Buffer.from(dl.data);
const parsed = path.extname(img.url.split('?')[0]).replace(/^[.]/, '');
if (parsed) ext = parsed;
outputs.push({ url: img.url });
} else {
throw new Error('No image data');
}
const filename = `${interaction.user.id}-${Date.now()}-${i}.${ext}`;
const filePath = path.join(dir, filename);
await fs.writeFile(filePath, buffer);
client.logger.info(`Saved image: ${filePath}`);
attachments.push(new AttachmentBuilder(buffer, { name: filename }));
}
// Award output points based on token usage for image generation
const tokens = imgRes.usage?.total_tokens ?? count;
if (client.scorekeeper && tokens > 0) {
client.scorekeeper.addOutput(interaction.guildId, interaction.user.id, tokens, 'image_generation')
.catch(err => client.logger.error(`Scorekeeper error: ${err.message}`));
}
// Reply with attachments
await interaction.editReply({ content: promptText, files: attachments });
return true;
} catch (err) {
client.logger.error(`Image generation error: ${err.message}`);
await interaction.editReply({ content: `Image generation error: ${err.message}`, ephemeral });
return true;
}
} }
/** /**
@ -161,198 +163,198 @@ async function handleImageInteraction(client, interaction, resp, cfg, ephemeral)
* Slash command definitions and handlers for the '/query' command. * Slash command definitions and handlers for the '/query' command.
*/ */
export const commands = [ export const commands = [
{ {
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName('query') .setName('query')
.setDescription('Send a custom AI query') .setDescription('Send a custom AI query')
.addStringOption(opt => .addStringOption(opt =>
opt.setName('prompt') opt.setName('prompt')
.setDescription('Your query text') .setDescription('Your query text')
.setRequired(true) .setRequired(true)
) )
.addBooleanOption(opt => .addBooleanOption(opt =>
opt.setName('ephemeral') opt.setName('ephemeral')
.setDescription('Receive an ephemeral response') .setDescription('Receive an ephemeral response')
.setRequired(false) .setRequired(false)
), ),
async execute(interaction, client) { async execute(interaction, client) {
const cfg = client.config.responses; const cfg = client.config.responses;
// Enforce minimum score to use /query if scorekeeper is enabled // Enforce minimum score to use /query if scorekeeper is enabled
if (client.scorekeeper) { if (client.scorekeeper) {
try { try {
const isAdmin = interaction.member?.permissions?.has(PermissionFlagsBits.Administrator); const isAdmin = interaction.member?.permissions?.has(PermissionFlagsBits.Administrator);
const scoreData = await client.scorekeeper.getScore(interaction.guildId, interaction.user.id); const scoreData = await client.scorekeeper.getScore(interaction.guildId, interaction.user.id);
if (!isAdmin && scoreData.totalScore < cfg.minScore) { if (!isAdmin && scoreData.totalScore < cfg.minScore) {
return interaction.reply({ return interaction.reply({
content: `You need an I/O score of at least ${cfg.minScore} to use /query. Your current I/O score is ${scoreData.totalScore.toFixed(2)}.`, content: `You need an I/O score of at least ${cfg.minScore} to use /query. Your current I/O score is ${scoreData.totalScore.toFixed(2)}.`,
ephemeral: true ephemeral: true
}); });
} }
} catch (err) { } catch (err) {
client.logger.error(`[cmd:query] Error checking score: ${err.message}`); client.logger.error(`[cmd:query] Error checking score: ${err.message}`);
return interaction.reply({ content: 'Error verifying your score. Please try again later.', flags: MessageFlags.Ephemeral}); return interaction.reply({ content: 'Error verifying your score. Please try again later.', flags: MessageFlags.Ephemeral });
} }
} }
const prompt = interaction.options.getString('prompt'); const prompt = interaction.options.getString('prompt');
const flag = interaction.options.getBoolean('ephemeral'); const flag = interaction.options.getBoolean('ephemeral');
client.logger.info(`[cmd:query] Prompt received from ${interaction.user.id}, length=${prompt.length}`); client.logger.info(`[cmd:query] Prompt received from ${interaction.user.id}, length=${prompt.length}`);
const ephemeral = flag !== null ? flag : true; const ephemeral = flag !== null ? flag : true;
await interaction.deferReply({ ephemeral }); await interaction.deferReply({ ephemeral });
// Determine channel/thread key for context // Determine channel/thread key for context
const key = interaction.channelId; const key = interaction.channelId;
// Initialize per-channel lock map // Initialize per-channel lock map
const lockMap = client._responseLockMap || (client._responseLockMap = new Map()); const lockMap = client._responseLockMap || (client._responseLockMap = new Map());
// Get last pending promise for this key // Get last pending promise for this key
const last = lockMap.get(key) || Promise.resolve(); const last = lockMap.get(key) || Promise.resolve();
// Handler to run in sequence // Handler to run in sequence
const handler = async () => { const handler = async () => {
// Kick off a repeated typing indicator during processing // Kick off a repeated typing indicator during processing
const typingInterval = setInterval(() => interaction.channel.sendTyping().catch(() => {}), 9000); const typingInterval = setInterval(() => interaction.channel.sendTyping().catch(() => {}), 9000);
// initial typing // initial typing
interaction.channel.sendTyping().catch(() => {}); interaction.channel.sendTyping().catch(() => {});
// Read previous response ID // Read previous response ID
const previous = client.pb?.cache?.get(key); const previous = client.pb?.cache?.get(key);
// Build request body // Build request body
// Expand template for query // Expand template for query
const now = new Date(); const now = new Date();
const date = now.toISOString().split('T')[0]; const date = now.toISOString().split('T')[0];
const time = now.toTimeString().split(' ')[0]; const time = now.toTimeString().split(' ')[0];
const datetime = now.toISOString().replace('T',' ').replace(/\..+$/,''); const datetime = now.toISOString().replace('T',' ').replace(/\..+$/,'');
const channel = await client.channels.fetch(interaction.channelId); const channel = await client.channels.fetch(interaction.channelId);
const locationName = channel.name; const locationName = channel.name;
const locationId = channel.id; const locationId = channel.id;
const ctx = { const ctx = {
clientId: client.config.id, clientId: client.config.id,
userName: interaction.user.username, userName: interaction.user.username,
userId: interaction.user.id, userId: interaction.user.id,
userTag: interaction.user.tag, userTag: interaction.user.tag,
// add guild context // add guild context
guildName: interaction.guild?.name || '', guildName: interaction.guild?.name || '',
guildId: interaction.guild?.id || '', guildId: interaction.guild?.id || '',
input: prompt, input: prompt,
locationName, locationId, locationName, locationId,
date, time, datetime date, time, datetime
}; };
const instructions = expandTemplate(client.responsesPrompt, ctx); const instructions = expandTemplate(client.responsesPrompt, ctx);
const body = { const body = {
model: cfg.defaultModel, model: cfg.defaultModel,
instructions, instructions,
input: prompt, input: prompt,
previous_response_id: previous, previous_response_id: previous,
max_output_tokens: cfg.defaultMaxTokens, max_output_tokens: cfg.defaultMaxTokens,
temperature: cfg.defaultTemperature temperature: cfg.defaultTemperature
}; };
// Assemble enabled tools // Assemble enabled tools
const tools = []; const tools = [];
if (cfg.tools?.imageGeneration) { if (cfg.tools?.imageGeneration) {
const model = cfg.imageGeneration.defaultModel; const model = cfg.imageGeneration.defaultModel;
// Configure allowed sizes per model // Configure allowed sizes per model
let sizeEnum; let sizeEnum;
switch (model) { switch (model) {
case 'gpt-image-1': sizeEnum = ['auto','1024x1024','1536x1024','1024x1536']; break; case 'gpt-image-1': sizeEnum = ['auto','1024x1024','1536x1024','1024x1536']; break;
case 'dall-e-2': sizeEnum = ['256x256','512x512','1024x1024']; break; case 'dall-e-2': sizeEnum = ['256x256','512x512','1024x1024']; break;
case 'dall-e-3': sizeEnum = ['auto','1024x1024','1792x1024','1024x1792']; break; case 'dall-e-3': sizeEnum = ['auto','1024x1024','1792x1024','1024x1792']; break;
default: sizeEnum = ['auto','1024x1024']; default: sizeEnum = ['auto','1024x1024'];
} }
// Configure quality options per model // Configure quality options per model
let qualityEnum; let qualityEnum;
switch (model) { switch (model) {
case 'gpt-image-1': qualityEnum = ['auto','low','medium','high']; break; case 'gpt-image-1': qualityEnum = ['auto','low','medium','high']; break;
case 'dall-e-2': qualityEnum = ['standard']; break; case 'dall-e-2': qualityEnum = ['standard']; break;
case 'dall-e-3': qualityEnum = ['auto','standard','hd']; break; case 'dall-e-3': qualityEnum = ['auto','standard','hd']; break;
default: qualityEnum = ['auto','standard']; default: qualityEnum = ['auto','standard'];
} }
// Build schema properties dynamically // Build schema properties dynamically
const properties = { const properties = {
prompt: { type: 'string', description: 'Text description of desired image(s).' }, prompt: { type: 'string', description: 'Text description of desired image(s).' },
n: { type: 'number', description: 'Number of images to generate.' }, n: { type: 'number', description: 'Number of images to generate.' },
size: { type: 'string', enum: sizeEnum, description: 'Image size.' }, size: { type: 'string', enum: sizeEnum, description: 'Image size.' },
quality: { type: 'string', enum: qualityEnum, description: 'Image quality.' }, quality: { type: 'string', enum: qualityEnum, description: 'Image quality.' },
user: { type: 'string', description: 'Unique end-user identifier.' } user: { type: 'string', description: 'Unique end-user identifier.' }
}; };
if (model !== 'gpt-image-1') { if (model !== 'gpt-image-1') {
properties.response_format = { type: 'string', enum: ['url','b64_json'], description: 'Format of returned images.' }; properties.response_format = { type: 'string', enum: ['url','b64_json'], description: 'Format of returned images.' };
} }
if (model === 'gpt-image-1') { if (model === 'gpt-image-1') {
properties.background = { type: 'string', enum: ['transparent','opaque','auto'], description: 'Background transparency.' }; properties.background = { type: 'string', enum: ['transparent','opaque','auto'], description: 'Background transparency.' };
properties.moderation = { type: 'string', enum: ['low','auto'], description: 'Content moderation level.' }; properties.moderation = { type: 'string', enum: ['low','auto'], description: 'Content moderation level.' };
properties.output_format = { type: 'string', enum: ['png','jpeg','webp'], description: 'Output image format.' }; properties.output_format = { type: 'string', enum: ['png','jpeg','webp'], description: 'Output image format.' };
properties.output_compression = { type: 'number', description: 'Compression level (0-100).' }; properties.output_compression = { type: 'number', description: 'Compression level (0-100).' };
} }
if (model === 'dall-e-3') { if (model === 'dall-e-3') {
properties.style = { type: 'string', enum: ['vivid','natural'], description: 'Style option for dall-e-3.' }; properties.style = { type: 'string', enum: ['vivid','natural'], description: 'Style option for dall-e-3.' };
} }
// Determine required fields // Determine required fields
const required = ['prompt','n','size','quality','user']; const required = ['prompt','n','size','quality','user'];
if (model !== 'gpt-image-1') required.push('response_format'); if (model !== 'gpt-image-1') required.push('response_format');
if (model === 'gpt-image-1') required.push('background','moderation','output_format','output_compression'); if (model === 'gpt-image-1') required.push('background','moderation','output_format','output_compression');
if (model === 'dall-e-3') required.push('style'); if (model === 'dall-e-3') required.push('style');
tools.push({ tools.push({
type: 'function', type: 'function',
name: 'generate_image', name: 'generate_image',
description: `Generate images using model ${model} with requested parameters.`, description: `Generate images using model ${model} with requested parameters.`,
parameters: { parameters: {
type: 'object', type: 'object',
properties, properties,
required, required,
additionalProperties: false additionalProperties: false
}, },
strict: true, strict: true
}); });
} }
if (cfg.tools?.webSearch) { if (cfg.tools?.webSearch) {
tools.push({ type: 'web_search_preview' }); tools.push({ type: 'web_search_preview' });
} }
if (tools.length) body.tools = tools; if (tools.length) body.tools = tools;
// Call AI // Call AI
let resp; let resp;
try { try {
resp = await client.openai.responses.create(body); resp = await client.openai.responses.create(body);
// Award output tokens // Award output tokens
const tokens = resp.usage?.total_tokens ?? resp.usage?.completion_tokens ?? 0; const tokens = resp.usage?.total_tokens ?? resp.usage?.completion_tokens ?? 0;
if (client.scorekeeper && tokens > 0) { if (client.scorekeeper && tokens > 0) {
client.scorekeeper.addOutput(interaction.guildId, interaction.user.id, tokens, 'AI_query') client.scorekeeper.addOutput(interaction.guildId, interaction.user.id, tokens, 'AI_query')
.catch(e => client.logger.error(`Scorekeeper error: ${e.message}`)); .catch(e => client.logger.error(`Scorekeeper error: ${e.message}`));
} }
} catch (err) { } catch (err) {
client.logger.error(`AI error in /query: ${err.message}`); client.logger.error(`AI error in /query: ${err.message}`);
clearInterval(typingInterval); clearInterval(typingInterval);
return interaction.editReply({ content: 'Error generating response.', ephemeral }); return interaction.editReply({ content: 'Error generating response.', ephemeral });
} }
// Cache response ID if not a function call // Cache response ID if not a function call
const isFuncCall = Array.isArray(resp.output) && resp.output.some(o => o.type === 'function_call'); const isFuncCall = Array.isArray(resp.output) && resp.output.some(o => o.type === 'function_call');
if (!isFuncCall && resp.id && cfg.conversationExpiry) { if (!isFuncCall && resp.id && cfg.conversationExpiry) {
client.pb?.cache?.set(key, resp.id, Math.floor(cfg.conversationExpiry / 1000)); client.pb?.cache?.set(key, resp.id, Math.floor(cfg.conversationExpiry / 1000));
} }
// Handle image function call if present // Handle image function call if present
if (await handleImageInteraction(client, interaction, resp, cfg, ephemeral)) { if (await handleImageInteraction(client, interaction, resp, cfg, ephemeral)) {
clearInterval(typingInterval); clearInterval(typingInterval);
return; 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);
for (let i = 0; i < chunks.length; i++) {
if (i === 0) {
await interaction.editReply({ content: chunks[i] });
} else {
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}`));
lockMap.set(key, next);
await next;
} }
// 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);
for (let i = 0; i < chunks.length; i++) {
if (i === 0) {
await interaction.editReply({ content: chunks[i] });
} else {
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}`));
lockMap.set(key, next);
await next;
} }
}
]; ];

View File

@ -12,26 +12,26 @@ import { sendNarrative } from './responses.js';
* @param {object} clientConfig - Full client configuration object. * @param {object} clientConfig - Full client configuration object.
*/ */
export async function init(client, clientConfig) { export async function init(client, clientConfig) {
const cfg = clientConfig.responsesRandomizer; const cfg = clientConfig.responsesRandomizer;
const chance = Number(cfg.chance); const chance = Number(cfg.chance);
if (isNaN(chance) || chance <= 0) { if (isNaN(chance) || chance <= 0) {
client.logger.warn(`[module:responsesRandomizer] Invalid chance value: ${cfg.chance}. Module disabled.`); client.logger.warn(`[module:responsesRandomizer] Invalid chance value: ${cfg.chance}. Module disabled.`);
return; return;
}
client.logger.info(`[module:responsesRandomizer] Enabled with chance=${chance}`);
client.on('messageCreate', async (message) => {
try {
// Skip bot messages or non-guild messages
if (message.author.bot || !message.guild) return;
const content = message.content?.trim();
if (!content) return;
// Roll the dice
if (Math.random() > chance) return;
// Generate and send narrative
await sendNarrative(client, clientConfig.responses, message.channel.id, content);
} catch (err) {
client.logger.error(`[module:responsesRandomizer] Error processing message: ${err.message}`);
} }
}); client.logger.info(`[module:responsesRandomizer] Enabled with chance=${chance}`);
client.on('messageCreate', async (message) => {
try {
// Skip bot messages or non-guild messages
if (message.author.bot || !message.guild) return;
const content = message.content?.trim();
if (!content) return;
// Roll the dice
if (Math.random() > chance) return;
// Generate and send narrative
await sendNarrative(client, clientConfig.responses, message.channel.id, content);
} catch (err) {
client.logger.error(`[module:responsesRandomizer] Error processing message: ${err.message}`);
}
});
} }

View File

@ -1,364 +1,364 @@
import { MessageFlags } from 'discord-api-types/v10'; import { _MessageFlags } from 'discord-api-types/v10';
// _opt/schangar.js // _opt/schangar.js
import { SlashCommandBuilder } from 'discord.js'; import { SlashCommandBuilder } from 'discord.js';
// Export commands array for the centralized handler // Export commands array for the centralized handler
export const commands = [ export const commands = [
{ {
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName('hangarsync') .setName('hangarsync')
.setDescription('Mark the moment all five lights turn green, for use with hangarstatus') .setDescription('Mark the moment all five lights turn green, for use with hangarstatus')
.addStringOption(option => .addStringOption(option =>
option.setName('timestamp') option.setName('timestamp')
.setDescription('Custom timestamp (Unix time in seconds or ISO 8601 format). Leave empty for current time.') .setDescription('Custom timestamp (Unix time in seconds or ISO 8601 format). Leave empty for current time.')
.setRequired(false)), .setRequired(false)),
execute: async (interaction, client) => { execute: async (interaction, client) => {
const customTimestamp = interaction.options.getString('timestamp'); const customTimestamp = interaction.options.getString('timestamp');
let syncEpoch; let syncEpoch;
// Attempt to validate custom timestamp // Attempt to validate custom timestamp
if (customTimestamp) { if (customTimestamp) {
try { try {
if (/^\d+$/.test(customTimestamp)) { if (/^\d+$/.test(customTimestamp)) {
const timestampInSeconds = parseInt(customTimestamp); const timestampInSeconds = parseInt(customTimestamp);
if (timestampInSeconds < 0 || timestampInSeconds > Math.floor(Date.now() / 1000)) { if (timestampInSeconds < 0 || timestampInSeconds > Math.floor(Date.now() / 1000)) {
return interaction.reply({ return interaction.reply({
content: 'Invalid timestamp. Please provide a Unix time in seconds that is not in the future.', content: 'Invalid timestamp. Please provide a Unix time in seconds that is not in the future.',
ephemeral: true ephemeral: true
}); });
} }
syncEpoch = timestampInSeconds * 1000; syncEpoch = timestampInSeconds * 1000;
} else { } else {
const date = new Date(customTimestamp); const date = new Date(customTimestamp);
syncEpoch = date.getTime(); syncEpoch = date.getTime();
if (isNaN(syncEpoch) || syncEpoch < 0) { if (isNaN(syncEpoch) || syncEpoch < 0) {
return interaction.reply({ return interaction.reply({
content: 'Invalid timestamp format. Please use Unix time in seconds or a valid ISO 8601 string.', content: 'Invalid timestamp format. Please use Unix time in seconds or a valid ISO 8601 string.',
ephemeral: true ephemeral: true
}); });
} }
} }
} catch (error) { } catch (error) {
client.logger.error(`[cmd:hangarsync] Failed to parse timestamp: ${error.message}`); client.logger.error(`[cmd:hangarsync] Failed to parse timestamp: ${error.message}`);
return interaction.reply({ return interaction.reply({
content: 'Failed to parse timestamp. Please use Unix time in seconds or a valid ISO 8601 string.', content: 'Failed to parse timestamp. Please use Unix time in seconds or a valid ISO 8601 string.',
ephemeral: true ephemeral: true
}); });
} }
} else { } else {
syncEpoch = Date.now(); syncEpoch = Date.now();
} }
// Check PocketBase connection status // Check PocketBase connection status
if (!isPocketBaseConnected(client)) { if (!isPocketBaseConnected(client)) {
client.logger.error('[cmd:hangarsync] PocketBase not connected'); client.logger.error('[cmd:hangarsync] PocketBase not connected');
// Try to reconnect if available // Try to reconnect if available
if (typeof client.pb.ensureConnection === 'function') { if (typeof client.pb.ensureConnection === 'function') {
await client.pb.ensureConnection(); await client.pb.ensureConnection();
// Check if reconnection worked // Check if reconnection worked
if (!isPocketBaseConnected(client)) { if (!isPocketBaseConnected(client)) {
return interaction.reply({ return interaction.reply({
content: 'Database connection unavailable. Please try again later.', content: 'Database connection unavailable. Please try again later.',
ephemeral: true ephemeral: true
}); });
} }
} else { } else {
return interaction.reply({ return interaction.reply({
content: 'Database connection unavailable. Please try again later.', content: 'Database connection unavailable. Please try again later.',
ephemeral: true ephemeral: true
}); });
} }
} }
// Create or update timestamp for guild // Create or update timestamp for guild
try { try {
let record = null; let record = null;
try { try {
// First try the enhanced method if available // First try the enhanced method if available
if (typeof client.pb.getFirst === 'function') { if (typeof client.pb.getFirst === 'function') {
record = await client.pb.getFirst('command_hangarsync', `guildId = "${interaction.guildId}"`); record = await client.pb.getFirst('command_hangarsync', `guildId = "${interaction.guildId}"`);
} else { } else {
// Fall back to standard PocketBase method // Fall back to standard PocketBase method
const records = await client.pb.collection('command_hangarsync').getList(1, 1, { const records = await client.pb.collection('command_hangarsync').getList(1, 1, {
filter: `guildId = "${interaction.guildId}"` filter: `guildId = "${interaction.guildId}"`
}); });
if (records.items.length > 0) { if (records.items.length > 0) {
record = records.items[0]; record = records.items[0];
} }
} }
} catch (error) { } catch (error) {
// Handle case where collection might not exist // Handle case where collection might not exist
client.logger.warn(`Error retrieving hangarsync record: ${error.message}`); client.logger.warn(`Error retrieving hangarsync record: ${error.message}`);
} }
if (record) { if (record) {
// Update existing record // Update existing record
if (typeof client.pb.updateOne === 'function') { if (typeof client.pb.updateOne === 'function') {
await client.pb.updateOne('command_hangarsync', record.id, { await client.pb.updateOne('command_hangarsync', record.id, {
userId: `${interaction.user.id}`, userId: `${interaction.user.id}`,
epoch: `${syncEpoch}`, epoch: `${syncEpoch}`
}); });
} else { } else {
await client.pb.collection('command_hangarsync').update(record.id, { await client.pb.collection('command_hangarsync').update(record.id, {
userId: `${interaction.user.id}`, userId: `${interaction.user.id}`,
epoch: `${syncEpoch}`, epoch: `${syncEpoch}`
}); });
} }
client.logger.info(`[cmd:hangarsync] Updated hangarsync for guild ${interaction.guildId} by user ${interaction.user.id}`); client.logger.info(`[cmd:hangarsync] Updated hangarsync for guild ${interaction.guildId} by user ${interaction.user.id}`);
} else { } else {
// Create new record // Create new record
if (typeof client.pb.createOne === 'function') { if (typeof client.pb.createOne === 'function') {
await client.pb.createOne('command_hangarsync', { await client.pb.createOne('command_hangarsync', {
guildId: `${interaction.guildId}`, guildId: `${interaction.guildId}`,
userId: `${interaction.user.id}`, userId: `${interaction.user.id}`,
epoch: `${syncEpoch}`, epoch: `${syncEpoch}`
}); });
} else { } else {
await client.pb.collection('command_hangarsync').create({ await client.pb.collection('command_hangarsync').create({
guildId: `${interaction.guildId}`, guildId: `${interaction.guildId}`,
userId: `${interaction.user.id}`, userId: `${interaction.user.id}`,
epoch: `${syncEpoch}`, epoch: `${syncEpoch}`
}); });
} }
client.logger.info(`[cmd:hangarsync] Created new hangarsync for guild ${interaction.guildId} by user ${interaction.user.id}`); client.logger.info(`[cmd:hangarsync] Created new hangarsync for guild ${interaction.guildId} by user ${interaction.user.id}`);
} }
await interaction.reply(`Executive hangar status has been synced: <t:${Math.ceil(syncEpoch / 1000)}>`); await interaction.reply(`Executive hangar status has been synced: <t:${Math.ceil(syncEpoch / 1000)}>`);
} catch (error) { } catch (error) {
client.logger.error(`[cmd:hangarsync] Error: ${error.message}`); client.logger.error(`[cmd:hangarsync] Error: ${error.message}`);
await interaction.reply({ await interaction.reply({
content: `Error syncing hangar status. Please try again later.`, content: 'Error syncing hangar status. Please try again later.',
ephemeral: true ephemeral: true
}); });
} }
} }
}, },
{ {
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName('hangarstatus') .setName('hangarstatus')
.setDescription('Check the status of contested zone executive hangars') .setDescription('Check the status of contested zone executive hangars')
.addBooleanOption(option => .addBooleanOption(option =>
option.setName('verbose') option.setName('verbose')
.setDescription('Extra output, mainly for debugging.') .setDescription('Extra output, mainly for debugging.')
.setRequired(false)), .setRequired(false)),
execute: async (interaction, client) => { execute: async (interaction, client) => {
const verbose = interaction.options.getBoolean('verbose'); const verbose = interaction.options.getBoolean('verbose');
// Check PocketBase connection status // Check PocketBase connection status
if (!isPocketBaseConnected(client)) { if (!isPocketBaseConnected(client)) {
client.logger.error('[cmd:hangarstatus] PocketBase not connected'); client.logger.error('[cmd:hangarstatus] PocketBase not connected');
// Try to reconnect if available // Try to reconnect if available
if (typeof client.pb.ensureConnection === 'function') { if (typeof client.pb.ensureConnection === 'function') {
await client.pb.ensureConnection(); await client.pb.ensureConnection();
// Check if reconnection worked // Check if reconnection worked
if (!isPocketBaseConnected(client)) { if (!isPocketBaseConnected(client)) {
return interaction.reply({ return interaction.reply({
content: 'Database connection unavailable. Please try again later.', content: 'Database connection unavailable. Please try again later.',
ephemeral: true ephemeral: true
}); });
} }
} else { } else {
return interaction.reply({ return interaction.reply({
content: 'Database connection unavailable. Please try again later.', content: 'Database connection unavailable. Please try again later.',
ephemeral: true ephemeral: true
}); });
} }
} }
try { try {
// Get hangarsync data for guild // Get hangarsync data for guild
let hangarSync = null; let hangarSync = null;
try { try {
// First try the enhanced method if available // First try the enhanced method if available
if (typeof client.pb.getFirst === 'function') { if (typeof client.pb.getFirst === 'function') {
hangarSync = await client.pb.getFirst('command_hangarsync', `guildId = "${interaction.guildId}"`); hangarSync = await client.pb.getFirst('command_hangarsync', `guildId = "${interaction.guildId}"`);
} else { } else {
// Fall back to standard PocketBase methods // Fall back to standard PocketBase methods
try { try {
hangarSync = await client.pb.collection('command_hangarsync').getFirstListItem(`guildId = "${interaction.guildId}"`); hangarSync = await client.pb.collection('command_hangarsync').getFirstListItem(`guildId = "${interaction.guildId}"`);
} catch (error) { } catch (error) {
// getFirstListItem throws if no items found // getFirstListItem throws if no items found
if (error.status !== 404) throw error; if (error.status !== 404) throw error;
} }
} }
if (!hangarSync) { if (!hangarSync) {
client.logger.info(`[cmd:hangarstatus] No sync data found for guild ${interaction.guildId}`); client.logger.info(`[cmd:hangarstatus] No sync data found for guild ${interaction.guildId}`);
return interaction.reply({ return interaction.reply({
content: 'No sync data found. Please use `/hangarsync` first to establish a reference point.', content: 'No sync data found. Please use `/hangarsync` first to establish a reference point.',
ephemeral: true ephemeral: true
}); });
} }
} catch (error) { } catch (error) {
client.logger.error(`[cmd:hangarstatus] Error retrieving sync data for guild ${interaction.guildId}: ${error.message}`); client.logger.error(`[cmd:hangarstatus] Error retrieving sync data for guild ${interaction.guildId}: ${error.message}`);
return interaction.reply({ return interaction.reply({
content: 'No sync data found. Please use `/hangarsync` first to establish a reference point.', content: 'No sync data found. Please use `/hangarsync` first to establish a reference point.',
ephemeral: true ephemeral: true
}); });
} }
const currentTime = Date.now(); const currentTime = Date.now();
// 5 minutes (all off) + 5*24 minutes (turning green) + 5*12 minutes (turning off) = 185 minutes // 5 minutes (all off) + 5*24 minutes (turning green) + 5*12 minutes (turning off) = 185 minutes
const cycleDuration = 5 + (5 * 24) + (5 * 12); const cycleDuration = 5 + (5 * 24) + (5 * 12);
// Key positions in the cycle // Key positions in the cycle
const allOffDuration = 5; const allOffDuration = 5;
const turningGreenDuration = 5 * 24; const _turningGreenDuration = 5 * 24 * 1000;
const turningOffDuration = 5 * 12; const turningOffDuration = 5 * 12 * 1000;
// Calculate how much time has passed since the epoch // Calculate how much time has passed since the epoch
const timeSinceEpoch = (currentTime - hangarSync.epoch) / (60 * 1000); const timeSinceEpoch = (currentTime - hangarSync.epoch) / (60 * 1000);
// Calculate where we are in the full-cycle relative to the epoch // Calculate where we are in the full-cycle relative to the epoch
const cyclePosition = ((timeSinceEpoch % cycleDuration) + cycleDuration) % cycleDuration; const cyclePosition = ((timeSinceEpoch % cycleDuration) + cycleDuration) % cycleDuration;
// Initialize stuff and things // Initialize stuff and things
const lights = [":black_circle:", ":black_circle:", ":black_circle:", ":black_circle:", ":black_circle:"]; const lights = [':black_circle:', ':black_circle:', ':black_circle:', ':black_circle:', ':black_circle:'];
let minutesUntilNextPhase = 0; let minutesUntilNextPhase = 0;
let currentPhase = ""; let currentPhase = '';
// If the epoch is now, we should be at the all-green position. // If the epoch is now, we should be at the all-green position.
// From there, we need to determine where we are in the cycle. // From there, we need to determine where we are in the cycle.
// Case 1: We're in the unlocked phase, right after epoch // Case 1: We're in the unlocked phase, right after epoch
if (cyclePosition < turningOffDuration) { if (cyclePosition < turningOffDuration) {
currentPhase = "Unlocked"; currentPhase = 'Unlocked';
// All lights start as green // All lights start as green
lights.fill(":green_circle:"); lights.fill(':green_circle:');
// Calculate how many lights have turned off // Calculate how many lights have turned off
const offLights = Math.floor(cyclePosition / 12); const offLights = Math.floor(cyclePosition / 12);
// Set the appropriate number of lights to off // Set the appropriate number of lights to off
for (let i = 0; i < offLights; i++) { for (let i = 0; i < offLights; i++) {
lights[i] = ":black_circle:"; lights[i] = ':black_circle:';
} }
// Calculate time until next light turns off // Calculate time until next light turns off
const timeUntilNextLight = 12 - (cyclePosition % 12); const timeUntilNextLight = 12 - (cyclePosition % 12);
minutesUntilNextPhase = timeUntilNextLight; minutesUntilNextPhase = timeUntilNextLight;
} }
// Case 2: We're in the reset phase // Case 2: We're in the reset phase
else if (cyclePosition < turningOffDuration + allOffDuration) { else if (cyclePosition < turningOffDuration + allOffDuration) {
currentPhase = "Resetting"; currentPhase = 'Resetting';
// Lights are initialized "off", so do nothing with them // Lights are initialized "off", so do nothing with them
// Calculate time until all lights turn red // Calculate time until all lights turn red
const timeIntoPhase = cyclePosition - turningOffDuration; const timeIntoPhase = cyclePosition - turningOffDuration;
minutesUntilNextPhase = allOffDuration - timeIntoPhase; minutesUntilNextPhase = allOffDuration - timeIntoPhase;
} }
// Case 3: We're in the locked phase // Case 3: We're in the locked phase
else { else {
currentPhase = "Locked"; currentPhase = 'Locked';
// All lights start as red // All lights start as red
lights.fill(":red_circle:"); lights.fill(':red_circle:');
// Calculate how many lights have turned green // Calculate how many lights have turned green
const timeIntoPhase = cyclePosition - (turningOffDuration + allOffDuration); const timeIntoPhase = cyclePosition - (turningOffDuration + allOffDuration);
const greenLights = Math.floor(timeIntoPhase / 24); const greenLights = Math.floor(timeIntoPhase / 24);
// Set the appropriate number of lights to green // Set the appropriate number of lights to green
for (let i = 0; i < greenLights; i++) { for (let i = 0; i < greenLights; i++) {
lights[i] = ":green_circle:"; lights[i] = ':green_circle:';
} }
// Calculate time until next light turns green // Calculate time until next light turns green
const timeUntilNextLight = 24 - (timeIntoPhase % 24); const timeUntilNextLight = 24 - (timeIntoPhase % 24);
minutesUntilNextPhase = timeUntilNextLight; minutesUntilNextPhase = timeUntilNextLight;
} }
// Calculate a timestamp for Discord's formatting and reply // Calculate a timestamp for Discord's formatting and reply
const expiration = Math.ceil((Date.now() / 1000) + (minutesUntilNextPhase * 60)); const expiration = Math.ceil((Date.now() / 1000) + (minutesUntilNextPhase * 60));
// Determine time to next Lock/Unlock phase for inline display // Determine time to next Lock/Unlock phase for inline display
const isUnlocked = currentPhase === 'Unlocked'; const isUnlocked = currentPhase === 'Unlocked';
const label = isUnlocked ? 'Lock' : 'Unlock'; const label = isUnlocked ? 'Lock' : 'Unlock';
const minutesToPhase = isUnlocked const minutesToPhase = isUnlocked
? (turningOffDuration + allOffDuration) - cyclePosition ? (turningOffDuration + allOffDuration) - cyclePosition
: cycleDuration - cyclePosition; : cycleDuration - cyclePosition;
const phaseEpoch = Math.ceil(Date.now() / 1000 + (minutesToPhase * 60)); const phaseEpoch = Math.ceil(Date.now() / 1000 + (minutesToPhase * 60));
// Reply with lights and inline time to phase // Reply with lights and inline time to phase
await interaction.reply( await interaction.reply(
`### ${lights[0]} ${lights[1]} ${lights[2]} ${lights[3]} ${lights[4]} — Time to ${label}: <t:${phaseEpoch}:R>` `### ${lights[0]} ${lights[1]} ${lights[2]} ${lights[3]} ${lights[4]} — Time to ${label}: <t:${phaseEpoch}:R>`
); );
if (verbose) { if (verbose) {
// Replace user mention with displayName for last sync // Replace user mention with displayName for last sync
const syncMember = await interaction.guild.members.fetch(hangarSync.userId).catch(() => null); const syncMember = await interaction.guild.members.fetch(hangarSync.userId).catch(() => null);
const syncName = syncMember ? syncMember.displayName : `<@${hangarSync.userId}>`; const syncName = syncMember ? syncMember.displayName : `<@${hangarSync.userId}>`;
// Calculate time until next Lock/Unlock phase // Calculate time until next Lock/Unlock phase
const isUnlocked = currentPhase === 'Unlocked'; const isUnlocked = currentPhase === 'Unlocked';
const label = isUnlocked ? 'Lock' : 'Unlock'; const label = isUnlocked ? 'Lock' : 'Unlock';
const minutesToPhase = isUnlocked const minutesToPhase = isUnlocked
? (turningOffDuration + allOffDuration) - cyclePosition ? (turningOffDuration + allOffDuration) - cyclePosition
: cycleDuration - cyclePosition; : cycleDuration - cyclePosition;
const phaseEpoch = Math.ceil(Date.now() / 1000 + (minutesToPhase * 60)); const phaseEpoch = Math.ceil(Date.now() / 1000 + (minutesToPhase * 60));
await interaction.followUp( await interaction.followUp(
`- **Phase**: ${currentPhase}\n` + `- **Phase**: ${currentPhase}\n` +
`- **Time to ${label}**: <t:${phaseEpoch}:R>\n` + `- **Time to ${label}**: <t:${phaseEpoch}:R>\n` +
`- **Status Expiration**: <t:${expiration}:R>\n` + `- **Status Expiration**: <t:${expiration}:R>\n` +
`- **Epoch**: <t:${Math.ceil(hangarSync.epoch / 1000)}:R>\n` + `- **Epoch**: <t:${Math.ceil(hangarSync.epoch / 1000)}:R>\n` +
`- **Sync**: <t:${Math.floor(new Date(hangarSync.updated).getTime() / 1000)}:R> by ${syncName}` `- **Sync**: <t:${Math.floor(new Date(hangarSync.updated).getTime() / 1000)}:R> by ${syncName}`
); );
// Add additional debug info to logs // Add additional debug info to logs
client.logger.debug(`Hangarstatus for guild ${interaction.guildId}: Phase=${currentPhase}, CyclePosition=${cyclePosition}, TimeSinceEpoch=${timeSinceEpoch}`); client.logger.debug(`Hangarstatus for guild ${interaction.guildId}: Phase=${currentPhase}, CyclePosition=${cyclePosition}, TimeSinceEpoch=${timeSinceEpoch}`);
} }
} catch (error) { } catch (error) {
client.logger.error(`Error in hangarstatus command: ${error.message}`); client.logger.error(`Error in hangarstatus command: ${error.message}`);
await interaction.reply({ await interaction.reply({
content: `Error retrieving hangar status. Please try again later.`, content: 'Error retrieving hangar status. Please try again later.',
ephemeral: true ephemeral: true
}); });
} }
} }
} }
]; ];
// Function to check PocketBase connection status // Function to check PocketBase connection status
function isPocketBaseConnected(client) { function isPocketBaseConnected(client) {
// Check multiple possible status indicators to be safe // Check multiple possible status indicators to be safe
return client.pb && ( return client.pb && (
// Check status object (original code style) // Check status object (original code style)
(client.pb.status && client.pb.status.connected) || (client.pb.status && client.pb.status.connected) ||
// Check isConnected property (pbutils module style) // Check isConnected property (pbutils module style)
client.pb.isConnected === true || client.pb.isConnected === true ||
// Last resort: check if authStore is valid // Last resort: check if authStore is valid
client.pb.authStore?.isValid === true client.pb.authStore?.isValid === true
); );
} }
// Initialize module // Initialize module
export const init = async (client, config) => { export async function init(client, _config) {
client.logger.info('Initializing Star Citizen Hangar Status module'); client.logger.info('Initializing Star Citizen Hangar Status module');
// Check PocketBase connection // Check PocketBase connection
if (!isPocketBaseConnected(client)) { if (!isPocketBaseConnected(client)) {
client.logger.warn('PocketBase not connected at initialization'); client.logger.warn('PocketBase not connected at initialization');
// Try to reconnect if available // Try to reconnect if available
if (typeof client.pb.ensureConnection === 'function') { if (typeof client.pb.ensureConnection === 'function') {
await client.pb.ensureConnection(); await client.pb.ensureConnection();
} }
} else { } else {
client.logger.info('PocketBase connection confirmed'); client.logger.info('PocketBase connection confirmed');
} }
client.logger.info('Star Citizen Hangar Status module initialized'); client.logger.info('Star Citizen Hangar Status module initialized');
}; }

View File

@ -1,150 +1,150 @@
// Example of another module using scorekeeper // Example of another module using scorekeeper
export const init = async (client, config) => { export async function init(client, _config) {
// Set up message listener that adds input points when users chat // Set up message listener that adds input points when users chat
client.on('messageCreate', async (message) => { client.on('messageCreate', async (message) => {
if (message.author.bot) return; if (message.author.bot) return;
// Skip if not in a guild // Skip if not in a guild
if (!message.guild) return; if (!message.guild) return;
// Calculate input points: 1 point per character, plus 10 points per attachment // Calculate input points: 1 point per character, plus 10 points per attachment
const textPoints = message.content.length; const textPoints = message.content.length;
const attachmentPoints = message.attachments.size * 10; const attachmentPoints = message.attachments.size * 10;
const points = textPoints + attachmentPoints; const points = textPoints + attachmentPoints;
// Do not award zero or negative points // Do not award zero or negative points
if (points <= 0) return; if (points <= 0) return;
try { try {
await client.scorekeeper.addInput(message.guild.id, message.author.id, points, 'message'); await client.scorekeeper.addInput(message.guild.id, message.author.id, points, 'message');
} catch (error) { } catch (error) {
client.logger.error(`Error adding input points: ${error.message}`); client.logger.error(`Error adding input points: ${error.message}`);
} }
}); });
// Initialize voice tracking state // Initialize voice tracking state
client.voiceTracker = { client.voiceTracker = {
joinTimes: new Map(), // Tracks when users joined voice joinTimes: new Map(), // Tracks when users joined voice
activeUsers: new Map() // Tracks users currently earning points activeUsers: new Map() // Tracks users currently earning points
}; };
// Set up a voice state listener that adds input for voice activity // Set up a voice state listener that adds input for voice activity
client.on('voiceStateUpdate', async (oldState, newState) => { client.on('voiceStateUpdate', async (oldState, newState) => {
// Skip if not in a guild // Skip if not in a guild
if (!oldState.guild && !newState.guild) return; if (!oldState.guild && !newState.guild) return;
const guild = oldState.guild || newState.guild; const guild = oldState.guild || newState.guild;
const member = oldState.member || newState.member; const member = oldState.member || newState.member;
// User joined a voice channel // User joined a voice channel
if (!oldState.channelId && newState.channelId) { if (!oldState.channelId && newState.channelId) {
// Check if the channel has other non-bot users // Check if the channel has other non-bot users
const channel = newState.channel; const channel = newState.channel;
const otherUsers = channel.members.filter(m => const otherUsers = channel.members.filter(m =>
m.id !== member.id && !m.user.bot m.id !== member.id && !m.user.bot
); );
// Store join time if there's at least one other non-bot user // Store join time if there's at least one other non-bot user
if (otherUsers.size > 0) { if (otherUsers.size > 0) {
client.voiceTracker.joinTimes.set(member.id, Date.now()); client.voiceTracker.joinTimes.set(member.id, Date.now());
client.voiceTracker.activeUsers.set(member.id, newState.channelId); client.voiceTracker.activeUsers.set(member.id, newState.channelId);
client.logger.debug(`${member.user.tag} joined voice with others - tracking time`); client.logger.debug(`${member.user.tag} joined voice with others - tracking time`);
} else { } else {
client.logger.debug(`${member.user.tag} joined voice alone or with bots - not tracking time`); client.logger.debug(`${member.user.tag} joined voice alone or with bots - not tracking time`);
} }
} }
// User left a voice channel // User left a voice channel
else if (oldState.channelId && !newState.channelId) { else if (oldState.channelId && !newState.channelId) {
processVoiceLeave(client, guild, member, oldState.channelId); processVoiceLeave(client, guild, member, oldState.channelId);
} }
// User switched voice channels // User switched voice channels
else if (oldState.channelId && newState.channelId && oldState.channelId !== newState.channelId) { else if (oldState.channelId && newState.channelId && oldState.channelId !== newState.channelId) {
// Process leaving the old channel // Process leaving the old channel
processVoiceLeave(client, guild, member, oldState.channelId); processVoiceLeave(client, guild, member, oldState.channelId);
// Check if the new channel has other non-bot users // Check if the new channel has other non-bot users
const channel = newState.channel; const channel = newState.channel;
const otherUsers = channel.members.filter(m => const otherUsers = channel.members.filter(m =>
m.id !== member.id && !m.user.bot m.id !== member.id && !m.user.bot
); );
// Start tracking in the new channel if there are other non-bot users // Start tracking in the new channel if there are other non-bot users
if (otherUsers.size > 0) { if (otherUsers.size > 0) {
client.voiceTracker.joinTimes.set(member.id, Date.now()); client.voiceTracker.joinTimes.set(member.id, Date.now());
client.voiceTracker.activeUsers.set(member.id, newState.channelId); client.voiceTracker.activeUsers.set(member.id, newState.channelId);
} }
} }
// If someone joined or left a channel, update tracking for everyone in that channel // If someone joined or left a channel, update tracking for everyone in that channel
updateChannelUserTracking(client, oldState, newState); updateChannelUserTracking(client, oldState, newState);
}); });
}; }
/** /**
* Process when a user leaves a voice channel * Process when a user leaves a voice channel
*/ */
function processVoiceLeave(client, guild, member, channelId) { function processVoiceLeave(client, guild, member, channelId) {
if (client.voiceTracker.activeUsers.get(member.id) === channelId) { if (client.voiceTracker.activeUsers.get(member.id) === channelId) {
const joinTime = client.voiceTracker.joinTimes.get(member.id); const joinTime = client.voiceTracker.joinTimes.get(member.id);
if (joinTime) { if (joinTime) {
const duration = (Date.now() - joinTime) / 1000 / 60; // Duration in minutes const duration = (Date.now() - joinTime) / 1000 / 60; // Duration in minutes
// Award 1 point per minute, up to 30 per session // Award 1 point per minute, up to 30 per session
const points = Math.min(Math.floor(duration), 30); const points = Math.min(Math.floor(duration), 30);
if (points > 0) { if (points > 0) {
try { try {
client.scorekeeper.addInput(guild.id, member.id, points, 'voice_activity') client.scorekeeper.addInput(guild.id, member.id, points, 'voice_activity')
.then(() => { .then(() => {
client.logger.debug(`Added ${points} voice activity points for ${member.user.tag}`); client.logger.debug(`Added ${points} voice activity points for ${member.user.tag}`);
}) })
.catch(error => { .catch(error => {
client.logger.error(`Error adding voice points: ${error.message}`); client.logger.error(`Error adding voice points: ${error.message}`);
}); });
} catch (error) { } catch (error) {
client.logger.error(`Error adding voice points: ${error.message}`); client.logger.error(`Error adding voice points: ${error.message}`);
} }
} }
} }
client.voiceTracker.joinTimes.delete(member.id); client.voiceTracker.joinTimes.delete(member.id);
client.voiceTracker.activeUsers.delete(member.id); client.voiceTracker.activeUsers.delete(member.id);
} }
} }
/** /**
* Updates tracking for all users in affected channels * Updates tracking for all users in affected channels
*/ */
function updateChannelUserTracking(client, oldState, newState) { function updateChannelUserTracking(client, oldState, newState) {
// Get the affected channels // Get the affected channels
const affectedChannels = new Set(); const affectedChannels = new Set();
if (oldState.channelId) affectedChannels.add(oldState.channelId); if (oldState.channelId) affectedChannels.add(oldState.channelId);
if (newState.channelId) affectedChannels.add(newState.channelId); if (newState.channelId) affectedChannels.add(newState.channelId);
for (const channelId of affectedChannels) { for (const channelId of affectedChannels) {
const channel = oldState.guild.channels.cache.get(channelId); const channel = oldState.guild.channels.cache.get(channelId);
if (!channel) continue; if (!channel) continue;
// Check if the channel has at least 2 non-bot users // Check if the channel has at least 2 non-bot users
const nonBotMembers = channel.members.filter(m => !m.user.bot); const nonBotMembers = channel.members.filter(m => !m.user.bot);
const hasMultipleUsers = nonBotMembers.size >= 2; const hasMultipleUsers = nonBotMembers.size >= 2;
// For each user in the channel // For each user in the channel
channel.members.forEach(channelMember => { channel.members.forEach(channelMember => {
if (channelMember.user.bot) return; // Skip bots if (channelMember.user.bot) return; // Skip bots
const userId = channelMember.id; const userId = channelMember.id;
const isActive = client.voiceTracker.activeUsers.get(userId) === channelId; const isActive = client.voiceTracker.activeUsers.get(userId) === channelId;
// Should be active but isn't yet // Should be active but isn't yet
if (hasMultipleUsers && !isActive) { if (hasMultipleUsers && !isActive) {
client.voiceTracker.joinTimes.set(userId, Date.now()); client.voiceTracker.joinTimes.set(userId, Date.now());
client.voiceTracker.activeUsers.set(userId, channelId); client.voiceTracker.activeUsers.set(userId, channelId);
client.logger.debug(`Starting tracking for ${channelMember.user.tag} in ${channel.name}`); client.logger.debug(`Starting tracking for ${channelMember.user.tag} in ${channel.name}`);
} }
// Should not be active but is // Should not be active but is
else if (!hasMultipleUsers && isActive) { else if (!hasMultipleUsers && isActive) {
processVoiceLeave(client, oldState.guild, channelMember, channelId); processVoiceLeave(client, oldState.guild, channelMember, channelId);
client.logger.debug(`Stopping tracking for ${channelMember.user.tag} - not enough users`); client.logger.debug(`Stopping tracking for ${channelMember.user.tag} - not enough users`);
} }
}); });
} }
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -2,70 +2,70 @@
// ANSI color/style codes // ANSI color/style codes
const CODES = { const CODES = {
// text colors // text colors
gray: 30, red: 31, green: 32, yellow: 33, gray: 30, red: 31, green: 32, yellow: 33,
blue: 34, pink: 35, cyan: 36, white: 37, blue: 34, pink: 35, cyan: 36, white: 37,
// background colors // background colors
bgGray: 40, bgOrange: 41, bgBlue: 42, bgGray: 40, bgOrange: 41, bgBlue: 42,
bgTurquoise: 43, bgFirefly: 44, bgIndigo: 45, bgTurquoise: 43, bgFirefly: 44, bgIndigo: 45,
bgLightGray: 46, bgWhite: 47, bgLightGray: 46, bgWhite: 47,
// styles // styles
bold: 1, underline: 4, bold: 1, underline: 4,
// reset // reset
reset: 0 reset: 0
}; };
/** /**
* Escape literal brackets so users can write \[ and \] without triggering tags. * Escape literal brackets so users can write \[ and \] without triggering tags.
*/ */
export function escapeBrackets(str) { export function escapeBrackets(str) {
return str return str
.replace(/\\\[/g, '__ESC_LB__') .replace(/\\\[/g, '__ESC_LB__')
.replace(/\\\]/g, '__ESC_RB__'); .replace(/\\\]/g, '__ESC_RB__');
} }
/** Restore any escaped brackets after formatting. */ /** Restore any escaped brackets after formatting. */
export function restoreBrackets(str) { export function restoreBrackets(str) {
return str return str
.replace(/__ESC_LB__/g, '[') .replace(/__ESC_LB__/g, '[')
.replace(/__ESC_RB__/g, ']'); .replace(/__ESC_RB__/g, ']');
} }
/** /**
* Parse nested [tag1,tag2][/] patterns into ANSI codes (stack-based). * Parse nested [tag1,tag2][/] patterns into ANSI codes (stack-based).
*/ */
export function formatAnsi(input) { export function formatAnsi(input) {
const stack = []; const stack = [];
let output = ''; let output = '';
const pattern = /\[\/\]|\[([^\]]+)\]/g; const pattern = /\[\/\]|\[([^\]]+)\]/g;
let lastIndex = 0; let lastIndex = 0;
let match; let match;
while ((match = pattern.exec(input)) !== null) { while ((match = pattern.exec(input)) !== null) {
output += input.slice(lastIndex, match.index); output += input.slice(lastIndex, match.index);
if (match[0] === '[/]') { if (match[0] === '[/]') {
if (stack.length) stack.pop(); if (stack.length) stack.pop();
output += `\u001b[${CODES.reset}m`; output += `\u001b[${CODES.reset}m`;
for (const tag of stack) { for (const tag of stack) {
const code = CODES[tag] ?? CODES.gray; const code = CODES[tag] ?? CODES.gray;
output += `\u001b[${code}m`; output += `\u001b[${code}m`;
} }
} else { } else {
const tags = match[1].split(/[,;\s]+/).filter(Boolean); const tags = match[1].split(/[,;\s]+/).filter(Boolean);
for (const tag of tags) { for (const tag of tags) {
stack.push(tag); stack.push(tag);
const code = CODES[tag] ?? CODES.gray; const code = CODES[tag] ?? CODES.gray;
output += `\u001b[${code}m`; output += `\u001b[${code}m`;
} }
}
lastIndex = pattern.lastIndex;
} }
lastIndex = pattern.lastIndex; output += input.slice(lastIndex);
} if (stack.length) output += `\u001b[${CODES.reset}m`;
return output;
output += input.slice(lastIndex);
if (stack.length) output += `\u001b[${CODES.reset}m`;
return output;
} }
/** /**
@ -73,17 +73,17 @@ export function formatAnsi(input) {
* Escapes brackets, parses ANSI, and restores literals. * Escapes brackets, parses ANSI, and restores literals.
*/ */
export function ansi(strings, ...values) { export function ansi(strings, ...values) {
let built = ''; let built = '';
for (let i = 0; i < strings.length; i++) { for (let i = 0; i < strings.length; i++) {
built += strings[i]; built += strings[i];
if (i < values.length) built += values[i]; if (i < values.length) built += values[i];
} }
return restoreBrackets(formatAnsi(escapeBrackets(built))); return restoreBrackets(formatAnsi(escapeBrackets(built)));
} }
/** Wrap text in a ```ansi code block for Discord. */ /** Wrap text in a ```ansi code block for Discord. */
export function wrapAnsi(text) { export function wrapAnsi(text) {
return '```ansi\n' + text + '\n```'; return '```ansi\n' + text + '\n```';
} }
// Export raw codes for advanced use (e.g., ansitheme module) // Export raw codes for advanced use (e.g., ansitheme module)

View File

@ -8,69 +8,69 @@ const rootDir = path.dirname(__dirname);
// Load modules function - hot reload functionality removed // Load modules function - hot reload functionality removed
export const loadModules = async (clientConfig, client) => { export const loadModules = async (clientConfig, client) => {
const modules = clientConfig.modules || []; const modules = clientConfig.modules || [];
const modulesDir = path.join(rootDir, '_opt'); const modulesDir = path.join(rootDir, '_opt');
// Create opt directory if it doesn't exist // Create opt directory if it doesn't exist
if (!fs.existsSync(modulesDir)) { if (!fs.existsSync(modulesDir)) {
fs.mkdirSync(modulesDir, { recursive: true }); fs.mkdirSync(modulesDir, { recursive: true });
} }
client.logger.info(`[module:loader] Loading modules: ${modules.join(', ')}`); client.logger.info(`[module:loader] Loading modules: ${modules.join(', ')}`);
// Load each module // Load each module
for (const moduleName of modules) { for (const moduleName of modules) {
try { try {
// Try _opt first, then fallback to core _src modules // Try _opt first, then fallback to core _src modules
let modulePath = path.join(modulesDir, `${moduleName}.js`); let modulePath = path.join(modulesDir, `${moduleName}.js`);
if (!fs.existsSync(modulePath)) { if (!fs.existsSync(modulePath)) {
// Fallback to core source directory // Fallback to core source directory
modulePath = path.join(rootDir, '_src', `${moduleName}.js`); modulePath = path.join(rootDir, '_src', `${moduleName}.js`);
if (!fs.existsSync(modulePath)) { if (!fs.existsSync(modulePath)) {
client.logger.warn(`[module:loader] Module not found: ${moduleName}.js`); client.logger.warn(`[module:loader] Module not found: ${moduleName}.js`);
continue; continue;
}
}
// Import module (using dynamic import for ES modules)
// Import module
const moduleUrl = `file://${modulePath}`;
const module = await import(moduleUrl);
// Register commands if the module has them
if (module.commands) {
if (Array.isArray(module.commands)) {
// Handle array of commands
for (const command of module.commands) {
if (command.data && typeof command.execute === 'function') {
const commandName = command.data.name || command.name;
client.commands.set(commandName, command);
client.logger.info(`[module:loader] Registered command: ${commandName}`);
}
}
} else if (typeof module.commands === 'object') {
// Handle map/object of commands
for (const [commandName, command] of Object.entries(module.commands)) {
if (command.execute && typeof command.execute === 'function') {
client.commands.set(commandName, command);
client.logger.info(`Registered command: ${commandName}`);
}
}
}
}
// Call init function if it exists
if (typeof module.init === 'function') {
await module.init(client, clientConfig);
client.logger.info(`[module:loader] Module initialized: ${moduleName}`);
} else {
client.logger.info(`[module:loader] Module loaded (no init): ${moduleName}`);
}
// Store the module reference (this isn't used for hot reloading anymore)
client.modules = client.modules || new Map();
client.modules.set(moduleName, module);
} catch (error) {
client.logger.error(`[module:loader] Failed to load module ${moduleName}: ${error.message}`);
} }
} }
// Import module (using dynamic import for ES modules)
// Import module
const moduleUrl = `file://${modulePath}`;
const module = await import(moduleUrl);
// Register commands if the module has them
if (module.commands) {
if (Array.isArray(module.commands)) {
// Handle array of commands
for (const command of module.commands) {
if (command.data && typeof command.execute === 'function') {
const commandName = command.data.name || command.name;
client.commands.set(commandName, command);
client.logger.info(`[module:loader] Registered command: ${commandName}`);
}
}
} else if (typeof module.commands === 'object') {
// Handle map/object of commands
for (const [commandName, command] of Object.entries(module.commands)) {
if (command.execute && typeof command.execute === 'function') {
client.commands.set(commandName, command);
client.logger.info(`Registered command: ${commandName}`);
}
}
}
}
// Call init function if it exists
if (typeof module.init === 'function') {
await module.init(client, clientConfig);
client.logger.info(`[module:loader] Module initialized: ${moduleName}`);
} else {
client.logger.info(`[module:loader] Module loaded (no init): ${moduleName}`);
}
// Store the module reference (this isn't used for hot reloading anymore)
client.modules = client.modules || new Map();
client.modules.set(moduleName, module);
} catch (error) {
client.logger.error(`[module:loader] Failed to load module ${moduleName}: ${error.message}`);
}
}
}; };

View File

@ -1,86 +1,87 @@
import winston from 'winston';
import 'winston-daily-rotate-file';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import winston from 'winston';
import 'winston-daily-rotate-file';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
const rootDir = path.dirname(__dirname); const rootDir = path.dirname(__dirname);
// Create Winston logger // Create Winston logger
export const createLogger = (clientConfig) => { export const createLogger = (clientConfig) => {
const { logging } = clientConfig; const { logging } = clientConfig;
const transports = []; const transports = [];
// Console transport // Console transport
if (logging.console.enabled) { if (logging.console.enabled) {
transports.push(new winston.transports.Console({ transports.push(new winston.transports.Console({
level: logging.console.level, level: logging.console.level,
format: winston.format.combine( format: winston.format.combine(
winston.format.timestamp({ winston.format.timestamp({
format: logging.file.timestampFormat format: logging.file.timestampFormat
}), }),
logging.console.colorize ? winston.format.colorize() : winston.format.uncolorize(), logging.console.colorize ? winston.format.colorize() : winston.format.uncolorize(),
winston.format.printf(info => `[${info.timestamp}] [${clientConfig.id}] [${info.level}] ${info.message}`) winston.format.printf(info => `[${info.timestamp}] [${clientConfig.id}] [${info.level}] ${info.message}`)
) )
})); }));
} }
// Combined file transport with rotation // Combined file transport with rotation
if (logging.file.combined.enabled) { if (logging.file.combined.enabled) {
const logDir = path.join(rootDir, logging.file.combined.location); const logDir = path.join(rootDir, logging.file.combined.location);
// Create log directory if it doesn't exist // Create log directory if it doesn't exist
if (!fs.existsSync(logDir)) { if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir, { recursive: true }); fs.mkdirSync(logDir, { recursive: true });
} }
const combinedTransport = new winston.transports.DailyRotateFile({ const combinedTransport = new winston.transports.DailyRotateFile({
filename: path.join(logDir, `${clientConfig.id}-combined-%DATE%.log`), filename: path.join(logDir, `${clientConfig.id}-combined-%DATE%.log`),
datePattern: logging.file.dateFormat, datePattern: logging.file.dateFormat,
level: logging.file.combined.level, level: logging.file.combined.level,
maxSize: logging.file.combined.maxSize, maxSize: logging.file.combined.maxSize,
maxFiles: logging.file.combined.maxFiles, maxFiles: logging.file.combined.maxFiles,
format: winston.format.combine( format: winston.format.combine(
winston.format.timestamp({ winston.format.timestamp({
format: logging.file.timestampFormat format: logging.file.timestampFormat
}), }),
winston.format.printf(info => `[${info.timestamp}] [${info.level}] ${info.message}`) winston.format.printf(info => `[${info.timestamp}] [${info.level}] ${info.message}`)
) )
}); });
transports.push(combinedTransport); transports.push(combinedTransport);
} }
// Error file transport with rotation // Error file transport with rotation
if (logging.file.error.enabled) { if (logging.file.error.enabled) {
const logDir = path.join(rootDir, logging.file.error.location); const logDir = path.join(rootDir, logging.file.error.location);
// Create log directory if it doesn't exist // Create log directory if it doesn't exist
if (!fs.existsSync(logDir)) { if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir, { recursive: true }); fs.mkdirSync(logDir, { recursive: true });
} }
const errorTransport = new winston.transports.DailyRotateFile({ const errorTransport = new winston.transports.DailyRotateFile({
filename: path.join(logDir, `${clientConfig.id}-error-%DATE%.log`), filename: path.join(logDir, `${clientConfig.id}-error-%DATE%.log`),
datePattern: logging.file.dateFormat, datePattern: logging.file.dateFormat,
level: logging.file.error.level, level: logging.file.error.level,
maxSize: logging.file.error.maxSize, maxSize: logging.file.error.maxSize,
maxFiles: logging.file.error.maxFiles, maxFiles: logging.file.error.maxFiles,
format: winston.format.combine( format: winston.format.combine(
winston.format.timestamp({ winston.format.timestamp({
format: logging.file.timestampFormat format: logging.file.timestampFormat
}), }),
winston.format.printf(info => `[${info.timestamp}] [${info.level}] ${info.message}`) winston.format.printf(info => `[${info.timestamp}] [${info.level}] ${info.message}`)
) )
}); });
transports.push(errorTransport); transports.push(errorTransport);
} }
return winston.createLogger({ return winston.createLogger({
levels: winston.config.npm.levels, levels: winston.config.npm.levels,
transports transports
}); });
}; };

View File

@ -2,19 +2,19 @@ import PocketBase from 'pocketbase';
// Initialize Pocketbase // Initialize Pocketbase
export const initializePocketbase = async (clientConfig, logger) => { export const initializePocketbase = async (clientConfig, logger) => {
try { try {
const pb = new PocketBase(clientConfig.pocketbase.url); const pb = new PocketBase(clientConfig.pocketbase.url);
// Authenticate with admin credentials // Authenticate with admin credentials
await pb.collection('_users').authWithPassword( await pb.collection('_users').authWithPassword(
clientConfig.pocketbase.username, clientConfig.pocketbase.username,
clientConfig.pocketbase.password clientConfig.pocketbase.password
); );
logger.info('PocketBase initialized and authenticated'); logger.info('PocketBase initialized and authenticated');
return pb; return pb;
} catch (error) { } catch (error) {
logger.error(`PocketBase initialization failed: ${error.message}`); logger.error(`PocketBase initialization failed: ${error.message}`);
return new PocketBase(clientConfig.pocketbase.url); return new PocketBase(clientConfig.pocketbase.url);
} }
}; };

View File

@ -1,4 +1,4 @@
"use strict"; 'use strict';
/** /**
* expandTemplate: simple variable substitution in {{key}} placeholders. * expandTemplate: simple variable substitution in {{key}} placeholders.
* @param {string} template - The template string with {{key}} tokens. * @param {string} template - The template string with {{key}} tokens.
@ -6,10 +6,10 @@
* @returns {string} - The template with keys replaced by context values. * @returns {string} - The template with keys replaced by context values.
*/ */
export function expandTemplate(template, context) { export function expandTemplate(template, context) {
if (typeof template !== 'string') return ''; if (typeof template !== 'string') return '';
return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (_match, key) => { return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (_match, key) => {
return Object.prototype.hasOwnProperty.call(context, key) return Object.prototype.hasOwnProperty.call(context, key)
? String(context[key]) ? String(context[key])
: ''; : '';
}); });
} }

690
config.js
View File

@ -1,440 +1,332 @@
import dotenv from 'dotenv'; import dotenv from 'dotenv';
dotenv.config(); dotenv.config();
const logging = {
console: {
enabled: true,
colorize: true,
level: 'silly'
},
file: {
dateFormat: 'YYYY-MM-DD',
timestampFormat: 'YYYY-MM-DD HH:mm:ss',
combined: {
enabled: true,
level: 'silly',
location: 'logs',
maxSize: '12m',
maxFiles: '30d'
},
error: {
enabled: true,
level: 'error',
location: 'logs',
maxSize: '12m',
maxFiles: '365d'
}
}
};
const pocketbase = {
url: process.env.SHARED_POCKETBASE_URL,
username: process.env.SHARED_POCKETBASE_USERNAME,
password: process.env.SHARED_POCKETBASE_PASSWORD
};
export default { export default {
clients: [ clients: [
{ {
id: 'SysAI', id: 'SysAI',
enabled: true, enabled: true,
owner: process.env.OWNER_ID, owner: process.env.OWNER_ID,
discord: { discord: {
appId: process.env.SYSAI_DISCORD_APPID, appId: process.env.SYSAI_DISCORD_APPID,
token: process.env.SYSAI_DISCORD_TOKEN token: process.env.SYSAI_DISCORD_TOKEN
}, },
logging: { logging: { ...logging },
console: {
enabled: true,
colorize: true,
level: 'silly',
},
file: {
dateFormat: 'YYYY-MM-DD',
timestampFormat: 'YYYY-MM-DD HH:mm:ss',
combined: {
enabled: true,
level: 'silly',
location: 'logs',
maxSize: '12m',
maxFiles: '30d',
},
error: {
enabled: true,
level: 'error',
location: 'logs',
maxSize: '12m',
maxFiles: '365d',
}
}
},
pocketbase: { pocketbase: { ...pocketbase },
url: process.env.SHARED_POCKETBASE_URL,
username: process.env.SHARED_POCKETBASE_USERNAME,
password: process.env.SHARED_POCKETBASE_PASSWORD
},
responses: { responses: {
apiKey: process.env.SHARED_OPENAI_API_KEY, apiKey: process.env.SHARED_OPENAI_API_KEY,
defaultModel: 'gpt-4.1', defaultModel: 'gpt-4.1',
defaultMaxTokens: 1000, defaultMaxTokens: 1000,
defaultTemperature: 0.7, defaultTemperature: 0.7,
conversationExpiry: 30 * 60 * 1000, conversationExpiry: 30 * 60 * 1000,
minScore: 1.0, minScore: 1.0,
enableMentions: true, enableMentions: true,
enableReplies: true, enableReplies: true,
tools: { tools: {
webSearch: true, webSearch: true,
fileSearch: false, fileSearch: false,
imageGeneration: true, imageGeneration: true
}, },
imageGeneration: { imageGeneration: {
defaultModel: 'gpt-image-1', defaultModel: 'gpt-image-1',
defaultQuality: 'standard', defaultQuality: 'standard',
imageSavePath: './images' imageSavePath: './images'
} }
}, },
modules: [ modules: [
'ansi', 'ansi',
'botUtils', 'botUtils',
'pbUtils', 'pbUtils',
'gitUtils', 'gitUtils',
'responses', 'responses',
'responsesPrompt', 'responsesPrompt',
'responsesQuery', 'responsesQuery',
'tempvc' 'tempvc'
] ]
}, },
{ {
id: 'ASOP', id: 'ASOP',
enabled: true, enabled: true,
owner: process.env.OWNER_ID, owner: process.env.OWNER_ID,
discord: { discord: {
appId: process.env.ASOP_DISCORD_APPID, appId: process.env.ASOP_DISCORD_APPID,
token: process.env.ASOP_DISCORD_TOKEN token: process.env.ASOP_DISCORD_TOKEN
}, },
logging: { logging: { ...logging },
console: {
enabled: true,
colorize: true,
level: 'silly',
},
file: {
dateFormat: 'YYYY-MM-DD',
timestampFormat: 'YYYY-MM-DD HH:mm:ss',
combined: {
enabled: true,
level: 'silly',
location: 'logs',
maxSize: '12m',
maxFiles: '30d',
},
error: {
enabled: true,
level: 'error',
location: 'logs',
maxSize: '12m',
maxFiles: '365d',
}
}
},
condimentX: { condimentX: {
dryRun: false, dryRun: false,
guildID: '983057544849272883', guildID: '983057544849272883',
debugChannel: '1247179154869325865', debugChannel: '1247179154869325865',
blacklistUsers: [ blacklistUsers: [
'1162531805006680064' // Crow '1162531805006680064' // Crow
], ],
blacklistRoles: [ blacklistRoles: [
'1173012816228274256', // @Bots '1173012816228274256', // @Bots
'1209570635085520977', // @Kevin Arby '1209570635085520977', // @Kevin Arby
'1226903935344971786', // @Werebeef '1226903935344971786', // @Werebeef
'1250141348040933407' // @RIP '1250141348040933407' // @RIP
], ],
graylistRoles: [ graylistRoles: [
'1246749335866310656' // @Most Active '1246749335866310656' // @Most Active
], ],
whitelistRoles: [ whitelistRoles: [
'1256082910163767378' // @"Crow" '1256082910163767378' // @"Crow"
], ],
indexRoleID: '1209570635085520977', // Kevin's Vessel indexRoleID: '1209570635085520977', // Kevin's Vessel
viralRoleID: '1226903935344971786', // Werebeef viralRoleID: '1226903935344971786', // Werebeef
antiIndexRoleID: '1241228932037214358', // Exorcised antiIndexRoleID: '1241228932037214358', // Exorcised
antiViralRoleID: '1241230334079795330', // Immunized antiViralRoleID: '1241230334079795330', // Immunized
firstCycleInterval: 30000, firstCycleInterval: 30000,
cycleInterval: 3600000, cycleInterval: 3600000,
cycleIntervalRange: 900000, cycleIntervalRange: 900000,
incidenceDenominator: 40, incidenceDenominator: 40,
cessationDenominator: 20, cessationDenominator: 20,
probabilityLimit: 20, probabilityLimit: 20,
antiViralEffectiveness: 90, antiViralEffectiveness: 90,
proximityWindow: 120000, proximityWindow: 120000,
messageHistoryLimit: 50, messageHistoryLimit: 50,
ephemeralDelay: 60000, ephemeralDelay: 60000,
openAI: true, openAI: true,
openAITriggerOnlyDuringIncident: true, openAITriggerOnlyDuringIncident: true,
openAIResponseDenominator: 1, openAIResponseDenominator: 1,
openAIInstructionsFile: './assets/kevinarby.txt', openAIInstructionsFile: './assets/kevinarby.txt',
openAITriggers: [ openAITriggers: [
'kevin', 'kevin',
'arby', 'arby',
'werebeef' 'werebeef'
], ],
openAIWebhookID: '1251666161075097640', openAIWebhookID: '1251666161075097640',
openAIWebhookToken: process.env.SYSAI_CONDIMENTX_WEBHOOK_TOKEN, openAIWebhookToken: process.env.SYSAI_CONDIMENTX_WEBHOOK_TOKEN,
openAIToken: process.env.SHARED_OPENAI_API_KEY openAIToken: process.env.SHARED_OPENAI_API_KEY
}, },
pocketbase: { pocketbase: { ...pocketbase },
url: process.env.SHARED_POCKETBASE_URL,
username: process.env.SHARED_POCKETBASE_USERNAME,
password: process.env.SHARED_POCKETBASE_PASSWORD
},
responses: { responses: {
apiKey: process.env.SHARED_OPENAI_API_KEY, apiKey: process.env.SHARED_OPENAI_API_KEY,
defaultModel: 'gpt-4.1-mini', defaultModel: 'gpt-4.1-mini',
defaultMaxTokens: 1000, defaultMaxTokens: 1000,
defaultTemperature: 0.7, defaultTemperature: 0.7,
conversationExpiry: 30 * 60 * 1000, conversationExpiry: 30 * 60 * 1000,
minScore: 0.5, minScore: 0.5,
enableMentions: true, enableMentions: true,
enableReplies: true, enableReplies: true,
tools: { tools: {
webSearch: false, webSearch: false,
fileSearch: false, fileSearch: false,
imageGeneration: true, imageGeneration: true
}, },
imageGeneration: { imageGeneration: {
defaultModel: 'gpt-image-1', defaultModel: 'gpt-image-1',
defaultQuality: 'standard', defaultQuality: 'standard',
imageSavePath: './images' imageSavePath: './images'
} }
}, },
scorekeeper: { scorekeeper: {
baseOutput: 1000, baseOutput: 1000,
commendationValue: 0.25, commendationValue: 0.25,
citationValue: 0.35, citationValue: 0.35,
cooldown: 43200000, cooldown: 43200000,
decay: 80, decay: 80,
schedule: '0 0 * * 0' schedule: '0 0 * * 0'
}, },
modules: [ modules: [
'ansi', 'ansi',
'botUtils', 'botUtils',
'pbUtils', 'pbUtils',
'gitUtils', 'gitUtils',
'condimentX', 'condimentX',
'responses', 'responses',
'responsesPrompt', 'responsesPrompt',
'responsesQuery', 'responsesQuery',
'scorekeeper', 'scorekeeper',
'scorekeeper-example', 'scorekeeper-example',
'scExecHangarStatus' 'scExecHangarStatus'
] ]
}, },
{ {
id: 'Crowley', id: 'Crowley',
enabled: true, enabled: true,
owner: process.env.OWNER_ID, owner: process.env.OWNER_ID,
discord: { discord: {
appId: process.env.CROWLEY_DISCORD_APPID, appId: process.env.CROWLEY_DISCORD_APPID,
token: process.env.CROWLEY_DISCORD_TOKEN token: process.env.CROWLEY_DISCORD_TOKEN
}, },
logging: { logging: { ...logging },
console: {
enabled: true,
colorize: true,
level: 'silly',
},
file: {
dateFormat: 'YYYY-MM-DD',
timestampFormat: 'YYYY-MM-DD HH:mm:ss',
combined: {
enabled: true,
level: 'silly',
location: 'logs',
maxSize: '12m',
maxFiles: '30d',
},
error: {
enabled: true,
level: 'error',
location: 'logs',
maxSize: '12m',
maxFiles: '365d',
}
}
},
pocketbase: { pocketbase: { ...pocketbase },
url: process.env.SHARED_POCKETBASE_URL,
username: process.env.SHARED_POCKETBASE_USERNAME,
password: process.env.SHARED_POCKETBASE_PASSWORD
},
responses: { responses: {
apiKey: process.env.SHARED_OPENAI_API_KEY, apiKey: process.env.SHARED_OPENAI_API_KEY,
defaultModel: 'gpt-4.1', defaultModel: 'gpt-4.1',
defaultMaxTokens: 1000, defaultMaxTokens: 1000,
defaultTemperature: 0.7, defaultTemperature: 0.7,
conversationExpiry: 30 * 60 * 1000, conversationExpiry: 30 * 60 * 1000,
minScore: 0, minScore: 0,
enableMentions: true, enableMentions: true,
enableReplies: true, enableReplies: true,
tools: { tools: {
webSearch: false, webSearch: false,
fileSearch: false, fileSearch: false,
imageGeneration: false, imageGeneration: false
}, },
imageGeneration: { imageGeneration: {
defaultModel: 'gpt-image-1', defaultModel: 'gpt-image-1',
defaultQuality: 'standard', defaultQuality: 'standard',
imageSavePath: './images' imageSavePath: './images'
} }
}, },
modules: [ modules: [
'botUtils', 'botUtils',
'pbUtils', 'pbUtils',
'responses', 'responses',
'responsesPrompt', 'responsesPrompt',
'responsesQuery' 'responsesQuery'
] ]
}, },
{ {
id: 'GRANDPA', id: 'GRANDPA',
enabled: true, enabled: true,
owner: process.env.OWNER_ID, owner: process.env.OWNER_ID,
discord: { discord: {
appId: process.env.GRANDPA_DISCORD_APPID, appId: process.env.GRANDPA_DISCORD_APPID,
token: process.env.GRANDPA_DISCORD_TOKEN token: process.env.GRANDPA_DISCORD_TOKEN
}, },
logging: { logging: { ...logging },
console: {
enabled: true,
colorize: true,
level: 'silly',
},
file: {
dateFormat: 'YYYY-MM-DD',
timestampFormat: 'YYYY-MM-DD HH:mm:ss',
combined: {
enabled: true,
level: 'silly',
location: 'logs',
maxSize: '12m',
maxFiles: '30d',
},
error: {
enabled: true,
level: 'error',
location: 'logs',
maxSize: '12m',
maxFiles: '365d',
}
}
},
pocketbase: { pocketbase: { ...pocketbase },
url: process.env.SHARED_POCKETBASE_URL,
username: process.env.SHARED_POCKETBASE_USERNAME,
password: process.env.SHARED_POCKETBASE_PASSWORD
},
responses: { responses: {
apiKey: process.env.SHARED_OPENAI_API_KEY, apiKey: process.env.SHARED_OPENAI_API_KEY,
defaultModel: 'gpt-4.1', defaultModel: 'gpt-4.1',
defaultMaxTokens: 200, defaultMaxTokens: 200,
defaultTemperature: 0.7, defaultTemperature: 0.7,
conversationExpiry: 30 * 60 * 1000, conversationExpiry: 30 * 60 * 1000,
minScore: 0, minScore: 0,
enableMentions: false, enableMentions: false,
enableReplies: true, enableReplies: true,
tools: { tools: {
webSearch: false, webSearch: false,
fileSearch: false, fileSearch: false,
imageGeneration: false, imageGeneration: false
}, },
imageGeneration: { imageGeneration: {
defaultModel: 'gpt-image-1', defaultModel: 'gpt-image-1',
defaultQuality: 'standard', defaultQuality: 'standard',
imageSavePath: './images' imageSavePath: './images'
} }
}, },
responsesRandomizer: { responsesRandomizer: {
chance: 0.01, chance: 0.01
}, },
modules: [ modules: [
'botUtils', 'botUtils',
'pbUtils', 'pbUtils',
'responses', 'responses',
'responsesPrompt', 'responsesPrompt',
'responsesRandomizer' 'responsesRandomizer'
] ]
}, },
{ {
id: 'Smuuush', id: 'Smuuush',
enabled: true, enabled: true,
owner: process.env.OWNER_ID, owner: process.env.OWNER_ID,
discord: { discord: {
appId: process.env.SMUUUSH_DISCORD_APPID, appId: process.env.SMUUUSH_DISCORD_APPID,
token: process.env.SMUUUSH_DISCORD_TOKEN token: process.env.SMUUUSH_DISCORD_TOKEN
}, },
logging: { logging: { ...logging },
console: {
enabled: true,
colorize: true,
level: 'silly',
},
file: {
dateFormat: 'YYYY-MM-DD',
timestampFormat: 'YYYY-MM-DD HH:mm:ss',
combined: {
enabled: true,
level: 'silly',
location: 'logs',
maxSize: '12m',
maxFiles: '30d',
},
error: {
enabled: true,
level: 'error',
location: 'logs',
maxSize: '12m',
maxFiles: '365d',
}
}
},
pocketbase: { pocketbase: { ...pocketbase },
url: process.env.SHARED_POCKETBASE_URL,
username: process.env.SHARED_POCKETBASE_USERNAME,
password: process.env.SHARED_POCKETBASE_PASSWORD
},
responses: { responses: {
apiKey: process.env.SHARED_OPENAI_API_KEY, apiKey: process.env.SHARED_OPENAI_API_KEY,
defaultModel: 'gpt-4.1-mini', defaultModel: 'gpt-4.1-mini',
defaultMaxTokens: 1000, defaultMaxTokens: 1000,
defaultTemperature: 0.7, defaultTemperature: 0.7,
conversationExpiry: 30 * 60 * 1000, conversationExpiry: 30 * 60 * 1000,
minScore: 0, minScore: 0,
enableMentions: true, enableMentions: true,
enableReplies: true, enableReplies: true,
tools: { tools: {
webSearch: false, webSearch: false,
fileSearch: false, fileSearch: false,
imageGeneration: true, imageGeneration: true
}, },
imageGeneration: { imageGeneration: {
defaultModel: 'gpt-image-1', defaultModel: 'gpt-image-1',
defaultQuality: 'standard', defaultQuality: 'standard',
imageSavePath: './images' imageSavePath: './images'
} }
}, },
modules: [ modules: [
'botUtils', 'botUtils',
'pbUtils', 'pbUtils',
'responses', 'responses',
'responsesPrompt', 'responsesPrompt',
'responsesQuery' 'responsesQuery'
], ]
} }
] ]
} };

186
index.js
View File

@ -1,130 +1,130 @@
import { Client, Collection, GatewayIntentBits } from 'discord.js'; import { Client, Collection, GatewayIntentBits } from 'discord.js';
import { ansi, wrapAnsi } from './_src/ansiColors.js';
import { loadModules } from './_src/loader.js';
import { createLogger } from './_src/logger.js'; import { createLogger } from './_src/logger.js';
import { initializePocketbase } from './_src/pocketbase.js'; import { initializePocketbase } from './_src/pocketbase.js';
import { loadModules } from './_src/loader.js';
import config from './config.js'; import config from './config.js';
// For deprecated ephemeral option: convert to flags // For deprecated ephemeral option: convert to flags
import { ansi, wrapAnsi } from './_src/ansiColors.js';
// Initialize Discord client // Initialize Discord client
const initializeClient = async (clientConfig) => { const initializeClient = async (clientConfig) => {
// Create Discord client with intents // Create Discord client with intents
const client = new Client({ const client = new Client({
// Include GuildVoiceStates and GuildMembers intents to track voice channel events // Include GuildVoiceStates and GuildMembers intents to track voice channel events
intents: [ intents: [
GatewayIntentBits.Guilds, GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent, GatewayIntentBits.MessageContent,
GatewayIntentBits.GuildMembers, GatewayIntentBits.GuildMembers,
GatewayIntentBits.GuildVoiceStates GatewayIntentBits.GuildVoiceStates
] ]
}); });
// Attach config to client // Attach config to client
client.config = clientConfig; client.config = clientConfig;
// Set up Winston logger // Set up Winston logger
client.logger = createLogger(clientConfig); client.logger = createLogger(clientConfig);
client.logger.info(`Initializing client: ${clientConfig.id}`); client.logger.info(`Initializing client: ${clientConfig.id}`);
// Set up Pocketbase // Set up Pocketbase
client.pb = await initializePocketbase(clientConfig, client.logger); client.pb = await initializePocketbase(clientConfig, client.logger);
// Commands collection // Commands collection
client.commands = new Collection(); client.commands = new Collection();
// ANSI helper attached to client // ANSI helper attached to client
client.ansi = ansi; client.ansi = ansi;
client.wrapAnsi = wrapAnsi; client.wrapAnsi = wrapAnsi;
// Load optional modules // Load optional modules
await loadModules(clientConfig, client); await loadModules(clientConfig, client);
// TODO: If the logger level is debug, create event binds to raw and debug. // TODO: If the logger level is debug, create event binds to raw and debug.
// Discord client events // Discord client events
client.on('interactionCreate', async (interaction) => { client.on('interactionCreate', async (interaction) => {
if (!interaction.isChatInputCommand()) return; if (!interaction.isChatInputCommand()) return;
const commandName = interaction.commandName;
const commandName = interaction.commandName; try {
// Find command in collection
const command = client.commands.get(commandName);
try { if (!command) {
// Find command in collection client.logger.warn(`Command not found: ${commandName}`);
const command = client.commands.get(commandName); await interaction.reply({
content: 'Sorry, this command is not properly registered.',
ephemeral: true
});
return;
}
if (!command) { // Execute the command
client.logger.warn(`Command not found: ${commandName}`); client.logger.debug(`Executing command: ${commandName}`);
await interaction.reply({ await command.execute(interaction, client);
content: 'Sorry, this command is not properly registered.',
ephemeral: true
});
return;
}
// Execute the command } catch (error) {
client.logger.debug(`Executing command: ${commandName}`); client.logger.error(`Error executing command ${commandName}: ${error.message}`);
await command.execute(interaction, client);
} catch (error) { // Handle already replied interactions
client.logger.error(`Error executing command ${commandName}: ${error.message}`); const replyContent = {
content: 'There was an error while executing this command.',
ephemeral: true
};
// Handle already replied interactions if (interaction.replied || interaction.deferred) {
const replyContent = { await interaction.followUp(replyContent).catch(err => {
content: 'There was an error while executing this command.', client.logger.error(`Failed to send followUp: ${err.message}`);
ephemeral: true });
}; } else {
await interaction.reply(replyContent).catch(err => {
client.logger.error(`Failed to reply: ${err.message}`);
});
}
}
});
if (interaction.replied || interaction.deferred) { client.on('ready', () => {
await interaction.followUp(replyContent).catch(err => { client.logger.info(`Logged in as ${client.user.tag}`);
client.logger.error(`Failed to send followUp: ${err.message}`); });
});
} else {
await interaction.reply(replyContent).catch(err => {
client.logger.error(`Failed to reply: ${err.message}`);
});
}
}
});
client.on('ready', () => { client.on('error', (error) => {
client.logger.info(`Logged in as ${client.user.tag}`); client.logger.error(`Client error: ${error.message}`);
}); });
client.on('error', (error) => { // Login to Discord
client.logger.error(`Client error: ${error.message}`); try {
}); await client.login(clientConfig.discord.token);
return client;
// Login to Discord } catch (error) {
try { client.logger.error(`Failed to login: ${error.message}`);
await client.login(clientConfig.discord.token); throw error;
return client; }
} catch (error) {
client.logger.error(`Failed to login: ${error.message}`);
throw error;
}
}; };
// Main function to start bot // Main function to start bot
const startBot = async () => { const startBot = async () => {
const clients = []; const clients = [];
// Initialize each client from config // Initialize each client from config
for (const clientConfig of config.clients) { for (const clientConfig of config.clients) {
try { try {
const client = await initializeClient(clientConfig); const client = await initializeClient(clientConfig);
clients.push(client); clients.push(client);
} catch (error) { } catch (error) {
console.error(`Failed to initialize client ${clientConfig.id}:`, error); console.error(`Failed to initialize client ${clientConfig.id}:`, error);
} }
} }
return clients; return clients;
}; };
// Launch the bot // Launch the bot
startBot().then(clients => { startBot().then(clients => {
console.log(`[main] Successfully initialized ${clients.length} Discord clients`); console.log(`[main] Successfully initialized ${clients.length} Discord clients`);
}).catch(error => { }).catch(error => {
console.error(`[main] Failed to start bot: ${error.message}`); console.error(`[main] Failed to start bot: ${error.message}`);
process.exit(1); process.exit(1);
}); });

2883
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -11,9 +11,12 @@
}, },
"scripts": { "scripts": {
"start": "node index.js", "start": "node index.js",
"registry": "node registry.js" "registry": "node registry.js",
"lint": "eslint .",
"lint:fix": "eslint . --fix"
}, },
"dependencies": { "dependencies": {
"@discordjs/rest": "^2.2.0",
"axios": "^1.8.4", "axios": "^1.8.4",
"discord-api-types": "^0.37.120", "discord-api-types": "^0.37.120",
"discord.js": "^14.18.0", "discord.js": "^14.18.0",
@ -24,5 +27,9 @@
"pocketbase": "^0.25.2", "pocketbase": "^0.25.2",
"winston": "^3.17.0", "winston": "^3.17.0",
"winston-daily-rotate-file": "^5.0.0" "winston-daily-rotate-file": "^5.0.0"
},
"devDependencies": {
"eslint": "^8.57.0",
"eslint-plugin-import": "^2.29.1"
} }
} }

View File

@ -1,9 +1,11 @@
// registry.js // registry.js
import { REST } from '@discordjs/rest';
import { Routes } from 'discord-api-types/v10';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { REST } from '@discordjs/rest'; // eslint-disable-line import/no-unresolved
import { Routes } from 'discord-api-types/v10';
import config from './config.js'; import config from './config.js';
// Get directory name in ES module // Get directory name in ES module
@ -19,7 +21,7 @@ const dryRun = args.includes('--dryrun');
// Validate required parameters // Validate required parameters
if (args.includes('--help') || args.includes('-h') || !actionArg || !guildArg || !clientArg) { if (args.includes('--help') || args.includes('-h') || !actionArg || !guildArg || !clientArg) {
console.log(` console.log(`
[registry] [registry]
Discord Command Registry Tool Discord Command Registry Tool
@ -40,14 +42,14 @@ Examples:
node registry.js --action=register --guild=all --client=ASOP node registry.js --action=register --guild=all --client=ASOP
node registry.js --action=unregister --guild=123456789012345678 --client=all --dryrun node registry.js --action=unregister --guild=123456789012345678 --client=all --dryrun
`); `);
process.exit(1); process.exit(1);
} }
// Validate action parameter // Validate action parameter
const validActions = ['register', 'unregister', 'list']; const validActions = ['register', 'unregister', 'list'];
if (!validActions.includes(actionArg.toLowerCase())) { if (!validActions.includes(actionArg.toLowerCase())) {
console.error(`[registry] Error: Invalid action "${actionArg}". Must be one of: ${validActions.join(', ')}`); console.error(`[registry] Error: Invalid action "${actionArg}". Must be one of: ${validActions.join(', ')}`);
process.exit(1); process.exit(1);
} }
const action = actionArg.toLowerCase(); const action = actionArg.toLowerCase();
@ -58,16 +60,16 @@ const targetGuildId = isGuildAll ? null : guildArg;
// Validate client parameter - must be "all" or match a client in config // Validate client parameter - must be "all" or match a client in config
const isClientAll = clientArg.toLowerCase() === 'all'; const isClientAll = clientArg.toLowerCase() === 'all';
const targetClients = isClientAll const targetClients = isClientAll
? config.clients.filter(client => client.enabled !== false) ? config.clients.filter(client => client.enabled !== false)
: config.clients.filter(client => client.id === clientArg && client.enabled !== false); : config.clients.filter(client => client.id === clientArg && client.enabled !== false);
if (targetClients.length === 0) { if (targetClients.length === 0) {
console.error(`[registry] Error: No matching clients found for "${clientArg}"`); console.error(`[registry] Error: No matching clients found for "${clientArg}"`);
console.log('Available clients:'); console.log('Available clients:');
config.clients config.clients
.filter(client => client.enabled !== false) .filter(client => client.enabled !== false)
.forEach(client => console.log(` - ${client.id}`)); .forEach(client => console.log(` - ${client.id}`));
process.exit(1); process.exit(1);
} }
/** /**
@ -76,36 +78,36 @@ if (targetClients.length === 0) {
* @returns {Promise<Array>} - Array of command data objects * @returns {Promise<Array>} - Array of command data objects
*/ */
async function extractCommandsFromModule(modulePath) { async function extractCommandsFromModule(modulePath) {
try { try {
// Import the module // Import the module
const moduleUrl = `file://${modulePath}`; const moduleUrl = `file://${modulePath}`;
const module = await import(moduleUrl); const module = await import(moduleUrl);
// Check for commands array // Check for commands array
if (Array.isArray(module.commands)) { if (Array.isArray(module.commands)) {
// Extract command data // Extract command data
const extractedCommands = module.commands.map(cmd => { const extractedCommands = module.commands.map(cmd => {
if (cmd && cmd.data && typeof cmd.data.toJSON === 'function') { if (cmd && cmd.data && typeof cmd.data.toJSON === 'function') {
try { try {
return cmd.data.toJSON(); return cmd.data.toJSON();
} catch (error) { } catch (error) {
console.warn(`Error converting command to JSON in ${path.basename(modulePath)}: ${error.message}`); console.warn(`Error converting command to JSON in ${path.basename(modulePath)}: ${error.message}`);
return null; return null;
} }
} }
return null; return null;
}).filter(Boolean); // Remove null entries }).filter(Boolean); // Remove null entries
console.log(` - Extracted ${extractedCommands.length} commands from ${path.basename(modulePath)}`); console.log(` - Extracted ${extractedCommands.length} commands from ${path.basename(modulePath)}`);
return extractedCommands; return extractedCommands;
} else { } else {
console.log(` - No commands found in ${path.basename(modulePath)}`); console.log(` - No commands found in ${path.basename(modulePath)}`);
return []; return [];
} }
} catch (error) { } catch (error) {
console.error(`Error loading module ${modulePath}: ${error.message}`); console.error(`Error loading module ${modulePath}: ${error.message}`);
return []; return [];
} }
} }
/** /**
@ -114,27 +116,27 @@ async function extractCommandsFromModule(modulePath) {
* @returns {Promise<Array>} - Array of command data objects * @returns {Promise<Array>} - Array of command data objects
*/ */
async function processClientModules(clientConfig) { async function processClientModules(clientConfig) {
console.log(`\nExtracting commands from modules for client: ${clientConfig.id}`); console.log(`\nExtracting commands from modules for client: ${clientConfig.id}`);
const commands = []; const commands = [];
const optDir = path.join(__dirname, '_opt'); const optDir = path.join(__dirname, '_opt');
// Process each module // Process each module
for (const moduleName of clientConfig.modules || []) { for (const moduleName of clientConfig.modules || []) {
console.log(`Processing module: ${moduleName}`); console.log(`Processing module: ${moduleName}`);
const modulePath = path.join(optDir, `${moduleName}.js`); const modulePath = path.join(optDir, `${moduleName}.js`);
if (!fs.existsSync(modulePath)) { if (!fs.existsSync(modulePath)) {
console.warn(` - Module not found: ${moduleName}`); console.warn(` - Module not found: ${moduleName}`);
continue; continue;
} }
const moduleCommands = await extractCommandsFromModule(modulePath); const moduleCommands = await extractCommandsFromModule(modulePath);
commands.push(...moduleCommands); commands.push(...moduleCommands);
} }
console.log(`Total commands extracted for ${clientConfig.id}: ${commands.length}`); console.log(`Total commands extracted for ${clientConfig.id}: ${commands.length}`);
return commands; return commands;
} }
/** /**
@ -144,12 +146,12 @@ async function processClientModules(clientConfig) {
* @returns {Promise<Object>} - Guild information * @returns {Promise<Object>} - Guild information
*/ */
async function getGuildInfo(rest, guildId) { async function getGuildInfo(rest, guildId) {
try { try {
return await rest.get(Routes.guild(guildId)); return await rest.get(Routes.guild(guildId));
} catch (error) { } catch (error) {
console.error(`Error fetching guild info: ${error.message}`); console.error(`Error fetching guild info: ${error.message}`);
return { name: `Unknown Guild (${guildId})` }; return { name: `Unknown Guild (${guildId})` };
} }
} }
/** /**
@ -158,24 +160,24 @@ async function getGuildInfo(rest, guildId) {
* @param {string|null} guildId - Guild ID or null for global * @param {string|null} guildId - Guild ID or null for global
*/ */
async function listCommands(clientConfig, guildId) { async function listCommands(clientConfig, guildId) {
const { id, discord } = clientConfig; const { id, discord } = clientConfig;
if (!discord || !discord.token || !discord.appId) { if (!discord || !discord.token || !discord.appId) {
console.error(`Invalid client configuration for ${id}`); console.error(`Invalid client configuration for ${id}`);
return; return;
} }
// Set up REST client // Set up REST client
const rest = new REST({ version: '10' }).setToken(discord.token); const rest = new REST({ version: '10' }).setToken(discord.token);
// Handle global or guild-specific commands // Handle global or guild-specific commands
if (guildId === null) { if (guildId === null) {
// Global commands // Global commands
await listGlobalCommands(clientConfig, rest); await listGlobalCommands(clientConfig, rest);
} else { } else {
// Guild-specific commands // Guild-specific commands
await listGuildCommands(clientConfig, rest, guildId); await listGuildCommands(clientConfig, rest, guildId);
} }
} }
/** /**
@ -184,32 +186,32 @@ async function listCommands(clientConfig, guildId) {
* @param {REST} rest - Discord REST client * @param {REST} rest - Discord REST client
*/ */
async function listGlobalCommands(clientConfig, rest) { async function listGlobalCommands(clientConfig, rest) {
console.log(`\nListing global commands for client: ${clientConfig.id}`); console.log(`\nListing global commands for client: ${clientConfig.id}`);
try { try {
const route = Routes.applicationCommands(clientConfig.discord.appId); const route = Routes.applicationCommands(clientConfig.discord.appId);
const commands = await rest.get(route); const commands = await rest.get(route);
if (commands.length === 0) { if (commands.length === 0) {
console.log(`No global commands registered for client ${clientConfig.id}`); console.log(`No global commands registered for client ${clientConfig.id}`);
return; return;
} }
console.log(`Found ${commands.length} global commands:`); console.log(`Found ${commands.length} global commands:`);
// Display commands in a formatted table // Display commands in a formatted table
console.log(''); console.log('');
console.log('ID'.padEnd(20) + 'NAME'.padEnd(20) + 'DESCRIPTION'.padEnd(60)); console.log('ID'.padEnd(20) + 'NAME'.padEnd(20) + 'DESCRIPTION'.padEnd(60));
for (const cmd of commands) { for (const cmd of commands) {
console.log( console.log(
`${cmd.id.toString().padEnd(20)}${cmd.name.padEnd(20)}${(cmd.description || '')}` `${cmd.id.toString().padEnd(20)}${cmd.name.padEnd(20)}${(cmd.description || '')}`
); );
} }
} catch (error) { } catch (error) {
console.error(`Error listing global commands for client ${clientConfig.id}: ${error.message}`); console.error(`Error listing global commands for client ${clientConfig.id}: ${error.message}`);
} }
} }
/** /**
@ -219,38 +221,38 @@ async function listGlobalCommands(clientConfig, rest) {
* @param {string} guildId - Guild ID * @param {string} guildId - Guild ID
*/ */
async function listGuildCommands(clientConfig, rest, guildId) { async function listGuildCommands(clientConfig, rest, guildId) {
// Get guild info // Get guild info
const guildInfo = await getGuildInfo(rest, guildId); const guildInfo = await getGuildInfo(rest, guildId);
const guildName = guildInfo.name || `Unknown Guild (${guildId})`; const guildName = guildInfo.name || `Unknown Guild (${guildId})`;
console.log(`\nListing commands for client: ${clientConfig.id} in guild: ${guildName} (${guildId})`); console.log(`\nListing commands for client: ${clientConfig.id} in guild: ${guildName} (${guildId})`);
try { try {
const route = Routes.applicationGuildCommands(clientConfig.discord.appId, guildId); const route = Routes.applicationGuildCommands(clientConfig.discord.appId, guildId);
const commands = await rest.get(route); const commands = await rest.get(route);
if (commands.length === 0) { if (commands.length === 0) {
console.log(`No commands registered for client ${clientConfig.id} in guild ${guildName}`); console.log(`No commands registered for client ${clientConfig.id} in guild ${guildName}`);
return; return;
} }
console.log(`Found ${commands.length} commands:`); console.log(`Found ${commands.length} commands:`);
// Display commands in a formatted table // Display commands in a formatted table
console.log(''); console.log('');
console.log('ID'.padEnd(20) + 'NAME'.padEnd(20) + 'DESCRIPTION'.padEnd(60)); console.log('ID'.padEnd(20) + 'NAME'.padEnd(20) + 'DESCRIPTION'.padEnd(60));
for (const cmd of commands) { for (const cmd of commands) {
console.log( console.log(
`${cmd.id.toString().padEnd(20)}${cmd.name.padEnd(20)}${(cmd.description || '')}` `${cmd.id.toString().padEnd(20)}${cmd.name.padEnd(20)}${(cmd.description || '')}`
); );
} }
console.log(''); console.log('');
} catch (error) { } catch (error) {
console.error(`Error listing commands for client ${clientConfig.id} in guild ${guildName}: ${error.message}`); console.error(`Error listing commands for client ${clientConfig.id} in guild ${guildName}: ${error.message}`);
} }
} }
/** /**
@ -259,57 +261,57 @@ async function listGuildCommands(clientConfig, rest, guildId) {
* @param {string|null} guildId - Guild ID or null for global * @param {string|null} guildId - Guild ID or null for global
*/ */
async function registerCommands(clientConfig, guildId) { async function registerCommands(clientConfig, guildId) {
const { id, discord } = clientConfig; const { id, discord } = clientConfig;
if (!discord || !discord.token || !discord.appId) { if (!discord || !discord.token || !discord.appId) {
console.error(`Invalid client configuration for ${id}`); console.error(`Invalid client configuration for ${id}`);
return; return;
} }
// Extract commands from modules // Extract commands from modules
const commands = await processClientModules(clientConfig); const commands = await processClientModules(clientConfig);
if (commands.length === 0) { if (commands.length === 0) {
console.log(`No commands found for client ${id}`); console.log(`No commands found for client ${id}`);
return; return;
} }
// Set up REST client // Set up REST client
const rest = new REST({ version: '10' }).setToken(discord.token); const rest = new REST({ version: '10' }).setToken(discord.token);
// Determine route and scope description // Determine route and scope description
let route; let route;
let scopeDesc; let scopeDesc;
if (guildId === null) { if (guildId === null) {
route = Routes.applicationCommands(discord.appId); route = Routes.applicationCommands(discord.appId);
scopeDesc = 'global'; scopeDesc = 'global';
} else { } else {
route = Routes.applicationGuildCommands(discord.appId, guildId); route = Routes.applicationGuildCommands(discord.appId, guildId);
const guildInfo = await getGuildInfo(rest, guildId); const guildInfo = await getGuildInfo(rest, guildId);
const guildName = guildInfo.name || `Unknown Guild (${guildId})`; const guildName = guildInfo.name || `Unknown Guild (${guildId})`;
scopeDesc = `guild ${guildName} (${guildId})`; scopeDesc = `guild ${guildName} (${guildId})`;
} }
// Register commands // Register commands
console.log(`\nRegistering ${commands.length} commands for client ${id} in ${scopeDesc}...`); console.log(`\nRegistering ${commands.length} commands for client ${id} in ${scopeDesc}...`);
// List commands being registered // List commands being registered
console.log('\nCommands to register:'); console.log('\nCommands to register:');
for (const cmd of commands) { for (const cmd of commands) {
console.log(` - ${cmd.name}: ${cmd.description}`); console.log(` - ${cmd.name}: ${cmd.description}`);
} }
if (dryRun) { if (dryRun) {
console.log(`\n[DRY RUN] Would register ${commands.length} commands for client ${id} in ${scopeDesc}`); console.log(`\n[DRY RUN] Would register ${commands.length} commands for client ${id} in ${scopeDesc}`);
} else { } else {
try { try {
await rest.put(route, { body: commands }); await rest.put(route, { body: commands });
console.log(`\nSuccessfully registered ${commands.length} commands for client ${id} in ${scopeDesc}`); console.log(`\nSuccessfully registered ${commands.length} commands for client ${id} in ${scopeDesc}`);
} catch (error) { } catch (error) {
console.error(`Error registering commands for client ${id} in ${scopeDesc}: ${error.message}`); console.error(`Error registering commands for client ${id} in ${scopeDesc}: ${error.message}`);
} }
} }
} }
/** /**
@ -318,107 +320,107 @@ async function registerCommands(clientConfig, guildId) {
* @param {string|null} guildId - Guild ID or null for global * @param {string|null} guildId - Guild ID or null for global
*/ */
async function unregisterCommands(clientConfig, guildId) { async function unregisterCommands(clientConfig, guildId) {
const { id, discord } = clientConfig; const { id, discord } = clientConfig;
if (!discord || !discord.token || !discord.appId) { if (!discord || !discord.token || !discord.appId) {
console.error(`Invalid client configuration for ${id}`); console.error(`Invalid client configuration for ${id}`);
return; return;
} }
// Set up REST client // Set up REST client
const rest = new REST({ version: '10' }).setToken(discord.token); const rest = new REST({ version: '10' }).setToken(discord.token);
// Determine route and scope description // Determine route and scope description
let route; let route;
let scopeDesc; let scopeDesc;
if (guildId === null) { if (guildId === null) {
route = Routes.applicationCommands(discord.appId); route = Routes.applicationCommands(discord.appId);
scopeDesc = 'global'; scopeDesc = 'global';
} else { } else {
route = Routes.applicationGuildCommands(discord.appId, guildId); route = Routes.applicationGuildCommands(discord.appId, guildId);
const guildInfo = await getGuildInfo(rest, guildId); const guildInfo = await getGuildInfo(rest, guildId);
const guildName = guildInfo.name || `Unknown Guild (${guildId})`; const guildName = guildInfo.name || `Unknown Guild (${guildId})`;
scopeDesc = `guild ${guildName} (${guildId})`; scopeDesc = `guild ${guildName} (${guildId})`;
} }
// Get current commands to show what will be unregistered // Get current commands to show what will be unregistered
try { try {
const currentCommands = await rest.get(route); const currentCommands = await rest.get(route);
console.log(`\nFound ${currentCommands.length} commands for client ${id} in ${scopeDesc}`); console.log(`\nFound ${currentCommands.length} commands for client ${id} in ${scopeDesc}`);
if (currentCommands.length > 0) { if (currentCommands.length > 0) {
console.log('\nCommands to unregister:'); console.log('\nCommands to unregister:');
for (const cmd of currentCommands) { for (const cmd of currentCommands) {
console.log(` - ${cmd.name}: ${cmd.description}`); console.log(` - ${cmd.name}: ${cmd.description}`);
} }
} else { } else {
console.log(`No commands to unregister for client ${id} in ${scopeDesc}`); console.log(`No commands to unregister for client ${id} in ${scopeDesc}`);
return; return;
} }
if (dryRun) { if (dryRun) {
console.log(`\n[DRY RUN] Would unregister ${currentCommands.length} commands for client ${id} in ${scopeDesc}`); console.log(`\n[DRY RUN] Would unregister ${currentCommands.length} commands for client ${id} in ${scopeDesc}`);
} else { } else {
await rest.put(route, { body: [] }); await rest.put(route, { body: [] });
console.log(`\nSuccessfully unregistered all commands for client ${id} in ${scopeDesc}`); console.log(`\nSuccessfully unregistered all commands for client ${id} in ${scopeDesc}`);
} }
} catch (error) { } catch (error) {
console.error(`Error unregistering commands for client ${id} in ${scopeDesc}: ${error.message}`); console.error(`Error unregistering commands for client ${id} in ${scopeDesc}: ${error.message}`);
} }
} }
// Main execution // Main execution
async function main() { async function main() {
console.log(''); console.log('');
console.log('Discord Command Registry Tool'); console.log('Discord Command Registry Tool');
console.log(`\nOperation: ${action.toUpperCase()}`); console.log(`\nOperation: ${action.toUpperCase()}`);
console.log(`Target Guild: ${isGuildAll ? 'ALL (Global)' : targetGuildId}`); console.log(`Target Guild: ${isGuildAll ? 'ALL (Global)' : targetGuildId}`);
console.log(`Target Client: ${isClientAll ? 'ALL' : targetClients[0].id}`); console.log(`Target Client: ${isClientAll ? 'ALL' : targetClients[0].id}`);
if (dryRun) { if (dryRun) {
console.log('\n*** DRY RUN MODE - NO CHANGES WILL BE MADE ***'); console.log('\n*** DRY RUN MODE - NO CHANGES WILL BE MADE ***');
} }
// Process each client // Process each client
for (const clientConfig of targetClients) { for (const clientConfig of targetClients) {
// Skip disabled clients // Skip disabled clients
if (clientConfig.enabled === false) { if (clientConfig.enabled === false) {
console.log(`\nSkipping disabled client: ${clientConfig.id}`); console.log(`\nSkipping disabled client: ${clientConfig.id}`);
continue; continue;
} }
console.log(''); console.log('');
console.log(`Processing client: ${clientConfig.id}`); console.log(`Processing client: ${clientConfig.id}`);
if (isGuildAll) { if (isGuildAll) {
// Global operation // Global operation
if (action === 'list') { if (action === 'list') {
await listCommands(clientConfig, null); await listCommands(clientConfig, null);
} else if (action === 'register') { } else if (action === 'register') {
await registerCommands(clientConfig, null); await registerCommands(clientConfig, null);
} else if (action === 'unregister') { } else if (action === 'unregister') {
await unregisterCommands(clientConfig, null); await unregisterCommands(clientConfig, null);
} }
} else { } else {
// Guild-specific operation // Guild-specific operation
if (action === 'list') { if (action === 'list') {
await listCommands(clientConfig, targetGuildId); await listCommands(clientConfig, targetGuildId);
} else if (action === 'register') { } else if (action === 'register') {
await registerCommands(clientConfig, targetGuildId); await registerCommands(clientConfig, targetGuildId);
} else if (action === 'unregister') { } else if (action === 'unregister') {
await unregisterCommands(clientConfig, targetGuildId); await unregisterCommands(clientConfig, targetGuildId);
} }
} }
} }
console.log(''); console.log('');
console.log('Command registry operation complete'); console.log('Command registry operation complete');
} }
main().catch(error => { main().catch(error => {
console.error('Fatal error:', error); console.error('Fatal error:', error);
process.exit(1); process.exit(1);
}); });