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 { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js';
import { CODES } from '../_src/ansiColors.js';
/**
@ -9,111 +10,111 @@ import { CODES } from '../_src/ansiColors.js';
* Both commands are Admin-only.
*/
export const commands = [
// Preview arbitrary ANSI tags
{
data: new SlashCommandBuilder()
.setName('ansi')
.setDescription('Preview an ANSI-colored code block')
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
.setDMPermission(false)
.addStringOption(opt =>
opt
.setName('text')
.setDescription('Use [red]…[/], [bold,blue]…[/], escape \\[/]')
.setRequired(true)
)
.addBooleanOption(opt =>
opt
.setName('ephemeral')
.setDescription('Reply ephemerally?')
.setRequired(false)
),
async execute(interaction, client) {
const raw = interaction.options.getString('text', true);
const ephemeral = interaction.options.getBoolean('ephemeral') ?? true;
const colored = client.ansi`${raw}`;
const block = client.wrapAnsi(colored);
const opts = { content: block };
if (ephemeral) opts.flags = MessageFlags.Ephemeral;
await interaction.reply(opts);
}
},
// Show complete ANSI theme chart
{
data: new SlashCommandBuilder()
.setName('ansitheme')
.setDescription('Show ANSI color theme chart')
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
.setDMPermission(false)
.addBooleanOption(opt =>
opt
.setName('ephemeral')
.setDescription('Reply ephemerally?')
.setRequired(false)
),
async execute(interaction, client) {
const fgs = ['gray', 'red', 'green', 'yellow', 'blue', 'pink', 'cyan', 'white'];
const bgs = ['bgGray', 'bgOrange', 'bgBlue', 'bgTurquoise', 'bgFirefly', 'bgIndigo', 'bgLightGray', 'bgWhite'];
const pad = 8;
// Column header with padded labels (no colors) - shifted right by 1
const header = ' ' + fgs.map(f => f.padEnd(pad, ' ')).join('');
// Sample row with no background (padded cells)
let defaultRow = '';
for (const fg of fgs) {
const fgCode = CODES[fg];
const openNormal = `\u001b[${fgCode}m`;
const openBold = `\u001b[${fgCode};${CODES.bold}m`;
const openUnder = `\u001b[${fgCode};${CODES.underline}m`;
const cell = `${openNormal}sa\u001b[0m${openBold}mp\u001b[0m${openUnder}le\u001b[0m`;
defaultRow += ' ' + cell + ' ';
}
// Append default row label after one pad
defaultRow += 'default';
// Colored rows per background
const rows = [];
for (const bg of bgs) {
let row = '';
const bgCode = CODES[bg];
for (const fg of fgs) {
const fgCode = CODES[fg];
const openNormal = `\u001b[${bgCode};${fgCode}m`;
const openBold = `\u001b[${bgCode};${fgCode};${CODES.bold}m`;
const openUnder = `\u001b[${bgCode};${fgCode};${CODES.underline}m`;
const cell = `${openNormal}sa\u001b[0m${openBold}mp\u001b[0m${openUnder}le\u001b[0m`;
row += ' ' + cell + ' ';
// Preview arbitrary ANSI tags
{
data: new SlashCommandBuilder()
.setName('ansi')
.setDescription('Preview an ANSI-colored code block')
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
.setDMPermission(false)
.addStringOption(opt =>
opt
.setName('text')
.setDescription('Use [red]…[/], [bold,blue]…[/], escape \\[/]')
.setRequired(true)
)
.addBooleanOption(opt =>
opt
.setName('ephemeral')
.setDescription('Reply ephemerally?')
.setRequired(false)
),
async execute(interaction, client) {
const raw = interaction.options.getString('text', true);
const ephemeral = interaction.options.getBoolean('ephemeral') ?? true;
const colored = client.ansi`${raw}`;
const block = client.wrapAnsi(colored);
const opts = { content: block };
if (ephemeral) opts.flags = MessageFlags.Ephemeral;
await interaction.reply(opts);
}
},
// Show complete ANSI theme chart
{
data: new SlashCommandBuilder()
.setName('ansitheme')
.setDescription('Show ANSI color theme chart')
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
.setDMPermission(false)
.addBooleanOption(opt =>
opt
.setName('ephemeral')
.setDescription('Reply ephemerally?')
.setRequired(false)
),
async execute(interaction, client) {
const fgs = ['gray', 'red', 'green', 'yellow', 'blue', 'pink', 'cyan', 'white'];
const bgs = ['bgGray', 'bgOrange', 'bgBlue', 'bgTurquoise', 'bgFirefly', 'bgIndigo', 'bgLightGray', 'bgWhite'];
const pad = 8;
// Column header with padded labels (no colors) - shifted right by 1
const header = ' ' + fgs.map(f => f.padEnd(pad, ' ')).join('');
// Sample row with no background (padded cells)
let defaultRow = '';
for (const fg of fgs) {
const fgCode = CODES[fg];
const openNormal = `\u001b[${fgCode}m`;
const openBold = `\u001b[${fgCode};${CODES.bold}m`;
const openUnder = `\u001b[${fgCode};${CODES.underline}m`;
const cell = `${openNormal}sa\u001b[0m${openBold}mp\u001b[0m${openUnder}le\u001b[0m`;
defaultRow += ' ' + cell + ' ';
}
// Append default row label after one pad
defaultRow += 'default';
// Colored rows per background
const rows = [];
for (const bg of bgs) {
let row = '';
const bgCode = CODES[bg];
for (const fg of fgs) {
const fgCode = CODES[fg];
const openNormal = `\u001b[${bgCode};${fgCode}m`;
const openBold = `\u001b[${bgCode};${fgCode};${CODES.bold}m`;
const openUnder = `\u001b[${bgCode};${fgCode};${CODES.underline}m`;
const cell = `${openNormal}sa\u001b[0m${openBold}mp\u001b[0m${openUnder}le\u001b[0m`;
row += ' ' + cell + ' ';
}
// Append uncolored row label immediately after cell padding
row += bg;
rows.push(row);
}
// Determine ephemeral setting
const ephemeral = interaction.options.getBoolean('ephemeral') ?? true;
// Initial sample table (header + default row)
const sampleContent = [header, defaultRow].join('\n');
const optsSample = { content: client.wrapAnsi(sampleContent) };
if (ephemeral) optsSample.flags = MessageFlags.Ephemeral;
await interaction.reply(optsSample);
// Split colored rows into two tables
const half = Math.ceil(rows.length / 2);
const firstRows = rows.slice(0, half);
const secondRows = rows.slice(half);
// First colored table
const table1 = [header, ...firstRows].join('\n');
const opts1 = { content: client.wrapAnsi(table1) };
if (ephemeral) opts1.flags = MessageFlags.Ephemeral;
await interaction.followUp(opts1);
// Second colored table
if (secondRows.length > 0) {
const table2 = [header, ...secondRows].join('\n');
const opts2 = { content: client.wrapAnsi(table2) };
if (ephemeral) opts2.flags = MessageFlags.Ephemeral;
await interaction.followUp(opts2);
}
}
// Append uncolored row label immediately after cell padding
row += bg;
rows.push(row);
}
// Determine ephemeral setting
const ephemeral = interaction.options.getBoolean('ephemeral') ?? true;
// Initial sample table (header + default row)
const sampleContent = [header, defaultRow].join('\n');
const optsSample = { content: client.wrapAnsi(sampleContent) };
if (ephemeral) optsSample.flags = MessageFlags.Ephemeral;
await interaction.reply(optsSample);
// Split colored rows into two tables
const half = Math.ceil(rows.length / 2);
const firstRows = rows.slice(0, half);
const secondRows = rows.slice(half);
// First colored table
const table1 = [header, ...firstRows].join('\n');
const opts1 = { content: client.wrapAnsi(table1) };
if (ephemeral) opts1.flags = MessageFlags.Ephemeral;
await interaction.followUp(opts1);
// Second colored table
if (secondRows.length > 0) {
const table2 = [header, ...secondRows].join('\n');
const opts2 = { content: client.wrapAnsi(table2) };
if (ephemeral) opts2.flags = MessageFlags.Ephemeral;
await interaction.followUp(opts2);
}
}
}
];
export async function init(client) {
client.logger.info('[module:ansi] Loaded ANSI utilities');
}
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 { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder } from 'discord.js';
/**
* botUtils module - provides administrative bot control commands
@ -7,157 +7,161 @@ import { MessageFlags } from 'discord-api-types/v10';
*/
// Define slash commands
export const commands = [
{
data: new SlashCommandBuilder()
.setName('exit')
.setDescription('Gracefully shutdown the bot (Owner only)')
// Restrict to server administrators by default
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
.setDMPermission(false)
.addIntegerOption(option =>
option
.setName('code')
.setDescription('Exit code to use (default 0)')
.setRequired(false)
),
/**
{
data: new SlashCommandBuilder()
.setName('exit')
.setDescription('Gracefully shutdown the bot (Owner only)')
// Restrict to server administrators by default
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
.setDMPermission(false)
.addIntegerOption(option =>
option
.setName('code')
.setDescription('Exit code to use (default 0)')
.setRequired(false)
),
/**
* Execute the exit command: only the configured owner can invoke.
* @param {import('discord.js').CommandInteraction} interaction
* @param {import('discord.js').Client} client
*/
async execute(interaction, client) {
const ownerId = client.config.owner;
// Check invoking user is the bot owner
if (interaction.user.id !== String(ownerId)) {
return interaction.reply({ content: 'Only the bot owner can shutdown the bot.', flags: MessageFlags.Ephemeral });
}
// Determine desired exit code (default 0)
const exitCode = interaction.options.getInteger('code') ?? 0;
// Validate exit code bounds
if (exitCode < 0 || exitCode > 254) {
return interaction.reply({ content: 'Exit code must be between 0 and 254 inclusive.', flags: MessageFlags.Ephemeral });
}
// Acknowledge before shutting down
await interaction.reply({ content: `Shutting down with exit code ${exitCode}...`, flags: MessageFlags.Ephemeral });
client.logger.info(
`[cmd:exit] Shutdown initiated by owner ${interaction.user.tag} (${interaction.user.id}), exit code ${exitCode}`
);
// Destroy Discord client and exit process
try {
await client.destroy();
} catch (err) {
client.logger.error(`[cmd:exit] Error during client.destroy(): ${err}`);
}
process.exit(exitCode);
}
},
/**
async execute(interaction, client) {
const ownerId = client.config.owner;
// Check invoking user is the bot owner
if (interaction.user.id !== String(ownerId)) {
return interaction.reply({ content: 'Only the bot owner can shutdown the bot.', flags: MessageFlags.Ephemeral });
}
// Determine desired exit code (default 0)
const exitCode = interaction.options.getInteger('code') ?? 0;
// Validate exit code bounds
if (exitCode < 0 || exitCode > 254) {
return interaction.reply({ content: 'Exit code must be between 0 and 254 inclusive.', flags: MessageFlags.Ephemeral });
}
// Acknowledge before shutting down
await interaction.reply({ content: `Shutting down with exit code ${exitCode}...`, flags: MessageFlags.Ephemeral });
client.logger.info(
`[cmd:exit] Shutdown initiated by owner ${interaction.user.tag} (${interaction.user.id}), exit code ${exitCode}`
);
// Destroy Discord client and exit process
try {
await client.destroy();
} catch (err) {
client.logger.error(`[cmd:exit] Error during client.destroy(): ${err}`);
}
process.exit(exitCode);
}
},
/**
* 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
* (Git Reference and Git Status) when the gitUtils module is loaded.
* @param {import('discord.js').CommandInteraction} interaction
* @param {import('discord.js').Client} client
*/
// /status: admin-only, shows current client info
{
data: new SlashCommandBuilder()
.setName('status')
.setDescription('Show this bot client status and process info')
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
.setDMPermission(false)
.addBooleanOption(option =>
option
.setName('ephemeral')
.setDescription('Whether the response should be ephemeral')
.setRequired(false)
),
async execute(interaction, client) {
const ephemeral = interaction.options.getBoolean('ephemeral') ?? true;
await interaction.deferReply({ flags: ephemeral ? MessageFlags.Ephemeral : undefined });
// Process metrics
const uptimeSec = process.uptime();
const hours = Math.floor(uptimeSec / 3600);
const minutes = Math.floor((uptimeSec % 3600) / 60);
const seconds = Math.floor(uptimeSec % 60);
const uptime = `${hours}h ${minutes}m ${seconds}s`;
const mem = process.memoryUsage();
const toMB = bytes => (bytes / 1024 / 1024).toFixed(2);
const memoryInfo = `RSS: ${toMB(mem.rss)} MB, Heap: ${toMB(mem.heapUsed)}/${toMB(mem.heapTotal)} MB`;
const cpu = process.cpuUsage();
const cpuInfo = `User: ${(cpu.user / 1000).toFixed(2)} ms, System: ${(cpu.system / 1000).toFixed(2)} ms`;
const nodeVersion = process.version;
const platform = `${process.platform} ${process.arch}`;
// Client-specific stats
const guildCount = client.guilds.cache.size;
const userCount = client.guilds.cache.reduce((sum, g) => sum + (g.memberCount || 0), 0);
const commandCount = client.commands.size;
// List of loaded optional modules
const loadedModules = client.modules ? Array.from(client.modules.keys()) : [];
// Build embed for status
// Determine if gitUtils module is loaded
const gitLoaded = client.modules?.has('gitUtils');
let branch, build, statusRaw, statusBlock;
if (gitLoaded) {
const git = client.modules.get('gitUtils');
try {
branch = await git.getBranch();
build = await git.getShortHash();
statusRaw = await git.getStatusShort();
// Format status as fenced code block using template literals
// 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 space, and only if non-empty
if (statusRaw) {
statusBlock = '```\n ' + statusRaw + '\n```';
}
} catch {
branch = 'error';
build = 'error';
// Represent error status in code fence
statusBlock = '```\n (error)\n```';
// /status: admin-only, shows current client info
{
data: new SlashCommandBuilder()
.setName('status')
.setDescription('Show this bot client status and process info')
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
.setDMPermission(false)
.addBooleanOption(option =>
option
.setName('ephemeral')
.setDescription('Whether the response should be ephemeral')
.setRequired(false)
),
async execute(interaction, client) {
const ephemeral = interaction.options.getBoolean('ephemeral') ?? true;
await interaction.deferReply({ flags: ephemeral ? MessageFlags.Ephemeral : undefined });
// Process metrics
const uptimeSec = process.uptime();
const hours = Math.floor(uptimeSec / 3600);
const minutes = Math.floor((uptimeSec % 3600) / 60);
const seconds = Math.floor(uptimeSec % 60);
const uptime = `${hours}h ${minutes}m ${seconds}s`;
const mem = process.memoryUsage();
const toMB = bytes => (bytes / 1024 / 1024).toFixed(2);
const memoryInfo = `RSS: ${toMB(mem.rss)} MB, Heap: ${toMB(mem.heapUsed)}/${toMB(mem.heapTotal)} MB`;
const cpu = process.cpuUsage();
const cpuInfo = `User: ${(cpu.user / 1000).toFixed(2)} ms, System: ${(cpu.system / 1000).toFixed(2)} ms`;
const nodeVersion = process.version;
const platform = `${process.platform} ${process.arch}`;
// Client-specific stats
const guildCount = client.guilds.cache.size;
const userCount = client.guilds.cache.reduce((sum, g) => sum + (g.memberCount || 0), 0);
const commandCount = client.commands.size;
// List of loaded optional modules
const loadedModules = client.modules ? Array.from(client.modules.keys()) : [];
// Build embed for status
// Determine if gitUtils module is loaded
const gitLoaded = client.modules?.has('gitUtils');
let branch, build, statusRaw, statusBlock;
if (gitLoaded) {
const git = client.modules.get('gitUtils');
try {
branch = await git.getBranch();
build = await git.getShortHash();
statusRaw = await git.getStatusShort();
// Format status as fenced code block using template literals
// 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 space, and only if non-empty
if (statusRaw) {
statusBlock = '```\n ' + statusRaw + '\n```';
}
} catch {
branch = 'error';
build = 'error';
// Represent error status in code fence
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
export async function init(client, clientConfig) {
client.logger.info('[module:botUtils] Module loaded');
}
export async function init(_client, _clientConfig) {
_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.
const bullet = '>';
// === OpenAI Interaction ===
// Chat completion via OpenAI with provided instructions.
async function ai(prompt = '') {
@ -86,17 +85,17 @@ export const init = async (client, config) => {
debug(`**AI Prompt**: ${prompt}`);
// Read instructions.
let openAIInstructions = fs.readFileSync(openAIInstructionsFile, 'utf8');
const openAIInstructions = fs.readFileSync(openAIInstructionsFile, 'utf8');
const unmention = /<@(\w+)>/g;
const completion = await openai.chat.completions.create({
model: 'gpt-4o-mini',
messages: [
{role: 'user', content: `${prompt.replace(unmention, '$1')}`},
{role: 'system', content: `${openAIInstructions}`},
],
{ role: 'user', content: `${prompt.replace(unmention, '$1')}` },
{ role: 'system', content: `${openAIInstructions}` }
]
});
let chunk = completion.choices[0]?.message?.content;
if (chunk != '') {
const chunk = completion.choices[0]?.message?.content;
if (chunk !== '') {
for (const line of chunk.split(/\n\s*\n/).filter(Boolean)) {
debug(`${bullet} ${line}`);
openAIWebhookClient.send(line);
@ -142,7 +141,7 @@ export const init = async (client, config) => {
// === Message Fetching Helpers ===
// Retrieve recent messages from every text channel since a given timestamp.
async function fetchRecentMessages(since) {
async function _fetchRecentMessages(since) {
const allMessages = new Collection();
// Get all text channels in the guild
@ -153,9 +152,9 @@ export const init = async (client, config) => {
// For each channel, fetch recent messages
for (const channel of textChannels.values()) {
try {
const messages = await channel.messages.fetch({
const messages = await channel.messages.fetch({
limit: messageHistoryLimit,
after: since
after: since
});
// Add these messages to our collection
@ -181,7 +180,7 @@ export const init = async (client, config) => {
debug(`**Incident Cycle #${incidentCounter++}**`);
// 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}**`);
// Build the victimsList using whitelisted roles.
@ -206,7 +205,7 @@ export const init = async (client, config) => {
}
// 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) {
debug(`${bullet} Incidence Check: **Success**`);
const newIndex = victimsList.random();
@ -240,7 +239,7 @@ export const init = async (client, config) => {
}
// 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);
debug(`${bullet} Cycle #${incidentCounter} **<t:${Math.floor((Date.now() + interval) / 1000)}:R>** at **<t:${Math.floor((Date.now() + interval) / 1000)}:t>**`);
} catch (error) {
@ -291,17 +290,16 @@ export const init = async (client, config) => {
if (message.webhookId) return;
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.
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.
if ((openAITriggerOnlyDuringIncident === true && guild.members.cache.filter(member => member.roles.cache.has(indexRole.id)).size > 0) || openAITriggerOnlyDuringIncident === false) {
// Finally, random roll to respond.
if ((Math.floor(Math.random() * openAIResponseDenominator) + 1) === 1) {
ai(`${message.member.displayName} said: ${message.cleanContent}`);
}
}
}
// 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()))) {
// 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) {
// Finally, random roll to respond.
if ((Math.floor(Math.random() * openAIResponseDenominator) + 1) === 1) {
ai(`${message.member.displayName} said: ${message.cleanContent}`);
}
}
}
if (blacklistUsers.includes(message.author.id)) return;
if (message.member.roles.cache.some(r => blacklistRoles.includes(r.id))) return;
@ -316,7 +314,7 @@ export const init = async (client, config) => {
const msgMember = msg.member;
if (msgMember) {
// Check if author has index or viral role
const isInfected = msgMember.roles.cache.has(indexRole.id) ||
const isInfected = msgMember.roles.cache.has(indexRole.id) ||
msgMember.roles.cache.has(viralRole.id);
if (isInfected) infections++;
}
@ -334,7 +332,7 @@ export const init = async (client, config) => {
let percentage = Math.min(infections / prox.size * 100, probabilityLimit);
// 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)));
}
@ -359,14 +357,14 @@ export const init = async (client, config) => {
anomaly('messageCreate', error);
}
};
// Deferred setup on ready
const readyHandler = async () => {
client.logger.info('[module:condimentX] Initializing module');
if (openAI === true) {
openai = new OpenAI({ apiKey: openAIToken }); // credentials loaded
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;
});
if (openAIWebhook) openAIWebhookClient = new WebhookClient({ id: openAIWebhookID, token: openAIWebhookToken });
@ -374,7 +372,7 @@ export const init = async (client, config) => {
try {
guild = client.guilds.cache.get(guildID);
if (!guild) {
client.logger.error(`[module:condimentX] Guild ${guildID} not found`);
client.logger.error(`[module:condimentX] Guild ${guildID} not found`);
return;
}
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 { promisify } from 'util';
import { MessageFlags } from 'discord-api-types/v10';
import { SlashCommandBuilder } from 'discord.js';
// Use execFile to avoid shell interpretation of arguments
const execFileAsync = promisify(execFile);
// Wrap Git errors
class GitError extends Error {
constructor(message) {
super(message);
this.name = 'GitError';
}
constructor(message) {
super(message);
this.name = 'GitError';
}
}
/**
@ -20,25 +21,25 @@ class GitError extends Error {
* @throws {GitError} - When the git command exits with an error.
*/
async function runGit(args) {
// Sanitize arguments: disallow dangerous shell metacharacters
if (!Array.isArray(args)) {
throw new GitError('Invalid git arguments');
}
const dangerous = /[;&|<>`$\\]/;
for (const arg of args) {
if (dangerous.test(arg)) {
throw new GitError(`Illegal character in git argument: ${arg}`);
// Sanitize arguments: disallow dangerous shell metacharacters
if (!Array.isArray(args)) {
throw new GitError('Invalid git arguments');
}
}
try {
const dangerous = /[;&|<>`$\\]/;
for (const arg of args) {
if (dangerous.test(arg)) {
throw new GitError(`Illegal character in git argument: ${arg}`);
}
}
try {
// Exec git directly without shell
const { stdout, stderr } = await execFileAsync('git', args);
const out = (stdout || stderr || '').toString().trim();
return out || '(no output)';
} catch (err) {
const msg = err.stderr?.toString().trim() || err.message;
throw new GitError(msg);
}
const { stdout, stderr } = await execFileAsync('git', args);
const out = (stdout || stderr || '').toString().trim();
return out || '(no output)';
} catch (err) {
const msg = err.stderr?.toString().trim() || err.message;
throw new GitError(msg);
}
}
/**
@ -48,10 +49,10 @@ async function runGit(args) {
* @returns {string} - The content wrapped in triple backticks.
*/
function formatCodeBlock(content, lang = '') {
const fence = '```';
return lang
? `${fence}${lang}\n${content}\n${fence}`
: `${fence}\n${content}\n${fence}`;
const fence = '```';
return lang
? `${fence}${lang}\n${content}\n${fence}`
: `${fence}\n${content}\n${fence}`;
}
/**
@ -61,76 +62,76 @@ function formatCodeBlock(content, lang = '') {
* @returns {string[]} - An array of substring chunks.
*/
function chunkString(str, chunkSize) {
const chunks = [];
for (let i = 0; i < str.length; i += chunkSize) {
chunks.push(str.slice(i, i + chunkSize));
}
return chunks;
const chunks = [];
for (let i = 0; i < str.length; i += chunkSize) {
chunks.push(str.slice(i, i + chunkSize));
}
return chunks;
}
// Single /git command: run arbitrary git <args>
export const commands = [
{
data: new SlashCommandBuilder()
.setName('git')
.setDescription('Run an arbitrary git command (Owner only)')
.addStringOption(opt =>
opt.setName('args')
.setDescription('Arguments to pass to git')
.setRequired(true))
.addBooleanOption(opt =>
opt.setName('ephemeral')
.setDescription('Make the reply ephemeral')
.setRequired(false)),
async execute(interaction, client) {
const ownerId = client.config.owner;
if (interaction.user.id !== ownerId) {
return interaction.reply({ content: 'Only the bot owner can run git commands.', flags: MessageFlags.Ephemeral });
}
const raw = interaction.options.getString('args');
// Disallow semicolons to prevent command chaining
if (raw.includes(';')) {
return interaction.reply({ content: 'Semicolons are not allowed in git arguments.', flags: MessageFlags.Ephemeral });
}
const ephemeral = interaction.options.getBoolean('ephemeral') ?? true;
const args = raw.match(/(?:[^\s"]+|"[^"]*")+/g)
.map(s => s.replace(/^"(.+)"$/, '$1'));
{
data: new SlashCommandBuilder()
.setName('git')
.setDescription('Run an arbitrary git command (Owner only)')
.addStringOption(opt =>
opt.setName('args')
.setDescription('Arguments to pass to git')
.setRequired(true))
.addBooleanOption(opt =>
opt.setName('ephemeral')
.setDescription('Make the reply ephemeral')
.setRequired(false)),
async execute(interaction, client) {
const ownerId = client.config.owner;
if (interaction.user.id !== ownerId) {
return interaction.reply({ content: 'Only the bot owner can run git commands.', flags: MessageFlags.Ephemeral });
}
const raw = interaction.options.getString('args');
// Disallow semicolons to prevent command chaining
if (raw.includes(';')) {
return interaction.reply({ content: 'Semicolons are not allowed in git arguments.', flags: MessageFlags.Ephemeral });
}
const ephemeral = interaction.options.getBoolean('ephemeral') ?? true;
const args = raw.match(/(?:[^\s"]+|"[^"]*")+/g)
.map(s => s.replace(/^"(.+)"$/, '$1'));
try {
// Log the exact git command being executed
const cmdStr = args.join(' ');
client.logger.warn(`[cmd:git] Executing git command: git ${cmdStr}`);
const output = await runGit(args);
// Prepend the git command as a header; keep it intact when chunking
const header = `git ${cmdStr}\n`;
// Discord message limit ~2000; reserve for code fences
const maxContent = 1990;
// Calculate how much output can fit after the header in the first chunk
const firstChunkSize = Math.max(0, maxContent - header.length);
// Split the raw output into chunks
const outputChunks = chunkString(output, firstChunkSize);
// Send first block with header + first output chunk
const firstBlock = header + (outputChunks[0] || '');
const replyOpts = { content: formatCodeBlock(firstBlock) };
if (ephemeral) replyOpts.flags = MessageFlags.Ephemeral;
await interaction.reply(replyOpts);
// Send any remaining blocks without the header
for (let i = 1; i < outputChunks.length; i++) {
const fuOpts = { content: formatCodeBlock(outputChunks[i]) };
if (ephemeral) fuOpts.flags = MessageFlags.Ephemeral;
await interaction.followUp(fuOpts);
try {
// Log the exact git command being executed
const cmdStr = args.join(' ');
client.logger.warn(`[cmd:git] Executing git command: git ${cmdStr}`);
const output = await runGit(args);
// Prepend the git command as a header; keep it intact when chunking
const header = `git ${cmdStr}\n`;
// Discord message limit ~2000; reserve for code fences
const maxContent = 1990;
// Calculate how much output can fit after the header in the first chunk
const firstChunkSize = Math.max(0, maxContent - header.length);
// Split the raw output into chunks
const outputChunks = chunkString(output, firstChunkSize);
// Send first block with header + first output chunk
const firstBlock = header + (outputChunks[0] || '');
const replyOpts = { content: formatCodeBlock(firstBlock) };
if (ephemeral) replyOpts.flags = MessageFlags.Ephemeral;
await interaction.reply(replyOpts);
// Send any remaining blocks without the header
for (let i = 1; i < outputChunks.length; i++) {
const fuOpts = { content: formatCodeBlock(outputChunks[i]) };
if (ephemeral) fuOpts.flags = MessageFlags.Ephemeral;
await interaction.followUp(fuOpts);
}
} catch (err) {
const msg = err instanceof GitError ? err.message : String(err);
await interaction.reply({ content: `Error: ${msg}`, 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
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
/**
@ -138,28 +139,28 @@ export async function init(client) {
* @returns {Promise<string>}
*/
export async function getBranch() {
return runGit(['rev-parse', '--abbrev-ref', 'HEAD']);
return runGit(['rev-parse', '--abbrev-ref', 'HEAD']);
}
/**
* Get short commit hash of HEAD
* @returns {Promise<string>}
*/
export async function getShortHash() {
return runGit(['rev-parse', '--short', 'HEAD']);
return runGit(['rev-parse', '--short', 'HEAD']);
}
/**
* Get concise working tree status (git status --porcelain)
* @returns {Promise<string>}
*/
export async function getStatusShort() {
return runGit(['status', '--porcelain']);
return runGit(['status', '--porcelain']);
}
/**
* Get Git remote origin URL
* @returns {Promise<string>}
*/
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)
@ -167,12 +168,12 @@ export async function getRemoteUrl() {
* @returns {Promise<string>}
*/
export async function getLog(n = 5) {
return runGit(['log', `-n${n}`, '--oneline']);
return runGit(['log', `-n${n}`, '--oneline']);
}
/**
* Get diff summary (git diff --stat)
* @returns {Promise<string>}
*/
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.
*/
export const init = async (client, config) => {
client.logger.info('[module:messageQueueExample] Initializing Message Queue Example module');
onMessageQueueEvent(client, async (action, record) => {
export async function init(client, _config) {
client.logger.info('[module:messageQueueExample] Message Queue Example module initialized');
onMessageQueueEvent(client, async (action, record) => {
// Only process newly created records
if (action !== 'create') return;
// Only process messages meant for this client
if (record.destination !== client.config.id) return;
// Only handle test dataType
if (record.dataType !== 'test') return;
if (action !== 'create') return;
// Only process messages meant for this client
if (record.destination !== client.config.id) return;
// Only handle test dataType
if (record.dataType !== 'test') return;
// At this point we have a test message for us
client.logger.info('[module:messageQueueExample] Test message received');
// At this point we have a test message for us
client.logger.info('[module:messageQueueExample] Test message received');
// Delete the processed message from the queue
try {
await client.pb.deleteMessageQueue(record.id);
client.logger.debug(`[module:messageQueueExample] Deleted message_queue record ${record.id}`);
} catch (err) {
client.logger.error(`[module:messageQueueExample] Failed to delete message_queue record ${record.id}: ${err.message}`);
}
});
};
// Delete the processed message from the queue
try {
await client.pb.deleteMessageQueue(record.id);
client.logger.debug(`[module:messageQueueExample] Deleted message_queue record ${record.id}`);
} catch (err) {
client.logger.error(`[module:messageQueueExample] Failed to delete message_queue record ${record.id}: ${err.message}`);
}
});
}

View File

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

View File

@ -4,13 +4,15 @@
* and handles text or image (function_call) outputs.
*/
// 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 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
const MAX_DISCORD_MSG_LENGTH = 2000;
@ -21,76 +23,75 @@ const MAX_DISCORD_MSG_LENGTH = 2000;
* @returns {string[]} Array of message chunks.
*/
function splitMessage(text, maxLength = MAX_DISCORD_MSG_LENGTH) {
const lines = text.split(/\n/);
const chunks = [];
let chunk = '';
let codeBlockOpen = false;
let codeBlockFence = '```';
for (let line of lines) {
const trimmed = line.trim();
const isFenceLine = trimmed.startsWith('```');
if (isFenceLine) {
if (!codeBlockOpen) {
codeBlockOpen = true;
codeBlockFence = trimmed;
} else if (trimmed === '```') {
// closing fence
codeBlockOpen = false;
}
const lines = text.split(/\n/);
const chunks = [];
let chunk = '';
let codeBlockOpen = false;
let codeBlockFence = '```';
for (const line of lines) {
const trimmed = line.trim();
const isFenceLine = trimmed.startsWith('```');
if (isFenceLine) {
if (!codeBlockOpen) {
codeBlockOpen = true;
codeBlockFence = trimmed;
} else if (trimmed === '```') {
// closing fence
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
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 (chunk) {
// close any unclosed code block
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;
}
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);
// 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.
* Controlled by enableMentions and enableReplies in config.
*/
async function shouldRespond(message, botId, cfg, logger) {
if (message.author.bot || !botId) return false;
const enableMentions = cfg.enableMentions ?? true;
const enableReplies = cfg.enableReplies ?? true;
const isMention = enableMentions && message.mentions.users.has(botId);
let isReply = false;
if (enableReplies && message.reference?.messageId) {
try {
const ref = await message.channel.messages.fetch(message.reference.messageId);
isReply = ref.author.id === botId;
} catch {}
}
logger.debug(`Trigger? mention=${isMention} reply=${isReply}`);
return isMention || isReply;
if (message.author.bot || !botId) return false;
const enableMentions = cfg.enableMentions ?? true;
const enableReplies = cfg.enableReplies ?? true;
const isMention = enableMentions && message.mentions.users.has(botId);
let isReply = false;
if (enableReplies && message.reference?.messageId) {
try {
const ref = await message.channel.messages.fetch(message.reference.messageId);
isReply = ref.author.id === botId;
} catch {}
}
logger.debug(`Trigger? mention=${isMention} reply=${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.
*/
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.
*/
function awardOutput(client, guildId, userId, amount) {
if (client.scorekeeper && amount > 0) {
client.scorekeeper.addOutput(guildId, userId, amount, 'AI_response')
.catch(err => client.logger.error(`Scorekeeper error: ${err.message}`));
}
if (client.scorekeeper && amount > 0) {
client.scorekeeper.addOutput(guildId, userId, amount, 'AI_response')
.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.
*/
async function handleImage(client, message, resp, cfg) {
const calls = Array.isArray(resp.output) ? resp.output : [];
const fn = calls.find(o => o.type === 'function_call' && o.name === 'generate_image');
if (!fn?.arguments) return false;
client.logger.debug(`Image function args: ${fn.arguments}`);
let args;
try { args = JSON.parse(fn.arguments); } catch (e) { return false; }
if (!args.prompt?.trim()) {
await message.reply('Cannot generate image: empty prompt.');
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;
const calls = Array.isArray(resp.output) ? resp.output : [];
const fn = calls.find(o => o.type === 'function_call' && o.name === 'generate_image');
if (!fn?.arguments) return false;
client.logger.debug(`Image function args: ${fn.arguments}`);
let args;
try { args = JSON.parse(fn.arguments); } catch (e) { return false; }
if (!args.prompt?.trim()) {
await message.reply('Cannot generate image: empty prompt.');
return true;
}
// 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 (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;
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
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
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;
return true;
}
/**
@ -244,213 +245,213 @@ async function handleImage(client, message, resp, cfg) {
* @param {Message} message - Incoming Discord message.
*/
async function onMessage(client, cfg, message) {
const logger = client.logger;
const botId = client.user?.id;
client.logger.debug(`[onMessage] Received message ${message.id} from ${message.author.id}`);
// Check if bot should respond, based on config (mentions/replies)
if (!(await shouldRespond(message, botId, cfg, logger))) return;
const logger = client.logger;
const botId = client.user?.id;
client.logger.debug(`[onMessage] Received message ${message.id} from ${message.author.id}`);
// Check if bot should respond, based on config (mentions/replies)
if (!(await shouldRespond(message, botId, cfg, logger))) return;
// Determine channel/thread key for context
const key = message.thread?.id || message.channel.id;
// Initialize per-channel lock map
const lockMap = client._responseLockMap || (client._responseLockMap = new Map());
// Get last pending promise for this key
const last = lockMap.get(key) || Promise.resolve();
// Handler to run in sequence
const handler = async () => {
// Determine channel/thread key for context
const key = message.thread?.id || message.channel.id;
// Initialize per-channel lock map
const lockMap = client._responseLockMap || (client._responseLockMap = new Map());
// Get last pending promise for this key
const last = lockMap.get(key) || Promise.resolve();
// Handler to run in sequence
const handler = async () => {
// Start typing indicator loop every 9 seconds
const typingInterval = setInterval(() => {
message.channel.sendTyping().catch(() => {});
}, 9000);
// Initial typing
message.channel.sendTyping().catch(() => {});
try {
// Previous response ID for context continuity
const prev = client.pb?.cache?.get(key);
// Enforce minimum score to use AI responses
// Enforce minimum score to use AI responses if scorekeeper is enabled
if (client.scorekeeper) {
const typingInterval = setInterval(() => {
message.channel.sendTyping().catch(() => {});
}, 9000);
// Initial typing
message.channel.sendTyping().catch(() => {});
try {
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;
}
// 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 {
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) {
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
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) {
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;
};
// 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.
*/
export async function sendNarrative(client, cfg, channelId, text) {
const logger = client.logger;
try {
const logger = client.logger;
try {
// Build the narrative instructions
// Expand template for sendNarrative
const now = new Date();
const date = now.toISOString().split('T')[0];
const time = now.toTimeString().split(' ')[0];
const datetime = now.toISOString().replace('T',' ').replace(/\..+$/,'');
const ctx = {
clientId: client.config.id,
userName: client.user.username,
userId: client.user.id,
input: text,
locationName: channel.name,
locationId: channel.id,
date, time, datetime
};
const raw = `${client.responsesPrompt}\n\nGenerate the following as an engaging narrative:`;
const instructions = expandTemplate(raw, ctx);
const body = {
model: cfg.defaultModel,
instructions,
input: text,
max_output_tokens: cfg.defaultMaxTokens,
temperature: cfg.defaultTemperature
};
logger.debug(`[sendNarrative] Calling AI with body: ${JSON.stringify(body).slice(0,1000)}`);
const resp = await client.openai.responses.create(body);
logger.info(`[sendNarrative] Received AI response id=${resp.id}`);
// Fetch the target channel or thread
const channel = await client.channels.fetch(channelId);
if (!channel || typeof channel.send !== 'function') {
logger.error(`[sendNarrative] Cannot send to channel ID ${channelId}`);
return;
const now = new Date();
const date = now.toISOString().split('T')[0];
const time = now.toTimeString().split(' ')[0];
const datetime = now.toISOString().replace('T',' ').replace(/\..+$/,'');
const ctx = {
clientId: client.config.id,
userName: client.user.username,
userId: client.user.id,
input: text,
locationName: channel.name,
locationId: channel.id,
date, time, datetime
};
const raw = `${client.responsesPrompt}\n\nGenerate the following as an engaging narrative:`;
const instructions = expandTemplate(raw, ctx);
const body = {
model: cfg.defaultModel,
instructions,
input: text,
max_output_tokens: cfg.defaultMaxTokens,
temperature: cfg.defaultTemperature
};
logger.debug(`[sendNarrative] Calling AI with body: ${JSON.stringify(body).slice(0,1000)}`);
const resp = await client.openai.responses.create(body);
logger.info(`[sendNarrative] Received AI response id=${resp.id}`);
// Fetch the target channel or thread
const channel = await client.channels.fetch(channelId);
if (!channel || typeof channel.send !== 'function') {
logger.error(`[sendNarrative] Cannot send to channel ID ${channelId}`);
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.
*/
export async function init(client, clientConfig) {
const cfg = clientConfig.responses;
client.logger.info('[module:responses] Initializing Responses module');
// Initialize prompt from responsesPrompt module (must be loaded before this)
client.responsesPrompt = client.responsesPrompt ?? '';
client.openai = new OpenAI({ apiKey: cfg.apiKey });
client.on('messageCreate', m => onMessage(client, cfg, m));
client.logger.info('[module:responses] Responses module ready');
const cfg = clientConfig.responses;
client.logger.info('[module:responses] Initializing Responses module');
// Initialize prompt from responsesPrompt module (must be loaded before this)
client.responsesPrompt = client.responsesPrompt ?? '';
client.openai = new OpenAI({ apiKey: cfg.apiKey });
client.on('messageCreate', m => onMessage(client, cfg, m));
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 fs from 'fs';
import path from 'path';
// Placeholder info for template variables
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.
*/
export const commands = [
{
data: new SlashCommandBuilder()
.setName('prompt')
.setDescription('Edit the AI response prompt (current or past version)')
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
.setDMPermission(false)
.addStringOption(opt =>
opt.setName('version')
.setDescription('ID of a past prompt version to load')
.setRequired(false)
.setAutocomplete(true)
),
async execute(interaction, client) {
const clientId = client.config.id;
const versionId = interaction.options.getString('version');
// Fetch prompt: live latest or selected historic
let promptText = client.responsesPrompt || '';
if (versionId) {
try {
const rec = await client.pb.getOne('responses_prompts', versionId);
if (rec?.prompt) promptText = rec.prompt;
} catch (err) {
client.logger.error(`Failed to load prompt version ${versionId}: ${err.message}`);
{
data: new SlashCommandBuilder()
.setName('prompt')
.setDescription('Edit the AI response prompt (current or past version)')
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
.setDMPermission(false)
.addStringOption(opt =>
opt.setName('version')
.setDescription('ID of a past prompt version to load')
.setRequired(false)
.setAutocomplete(true)
),
async execute(interaction, client) {
const _clientId = client.config.id;
const versionId = interaction.options.getString('version');
// Fetch prompt: live latest or selected historic
let promptText = client.responsesPrompt || '';
if (versionId) {
try {
const rec = await client.pb.getOne('responses_prompts', versionId);
if (rec?.prompt) promptText = rec.prompt;
} catch (err) {
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
const _clients = [];
export async function init(client, clientConfig) {
const clientId = clientConfig.id;
client.logger.info('[module:responsesPrompt] initialized');
// Load live prompt (latest version)
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 _clientId = client.config.id;
client.logger.info('[module:responsesPrompt] initialized');
// Load live prompt (latest version)
try {
const { items } = await client.pb.collection('responses_prompts')
.getList(1, 25, { filter: `clientId="${clientId}"`, sort: '-created' });
const choices = items.map(r => ({ name: new Date(r.created).toLocaleString(), value: r.id }));
await interaction.respond(choices);
} catch (err) {
client.logger.error(`Prompt autocomplete error: ${err.message}`);
await interaction.respond([]);
}
}
});
// Modal submission: save new version & prune old
client.on('interactionCreate', async interaction => {
if (!interaction.isModalSubmit()) return;
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;
.getList(1, 1, { filter: `clientId="${_clientId}"`, sort: '-created' });
client.responsesPrompt = items[0]?.prompt || '';
} catch (err) {
client.logger.error(`Failed to save prompt: ${err.message}`);
return interaction.reply({ content: `Error saving prompt: ${err.message}`, ephemeral: true });
client.logger.error(`Error loading current prompt: ${err.message}`);
client.responsesPrompt = '';
}
// 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 });
});
}
_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')
.getList(1, 25, { filter: `clientId="${_clientId}"`, sort: '-created' });
const choices = items.map(r => ({ name: new Date(r.created).toLocaleString(), value: r.id }));
await interaction.respond(choices);
} catch (err) {
client.logger.error(`Prompt autocomplete error: ${err.message}`);
await interaction.respond([]);
}
}
});
// Modal submission: save new version & prune old
client.on('interactionCreate', async interaction => {
if (!interaction.isModalSubmit()) return;
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';
/**
* Slash command module for '/query'.
@ -5,10 +9,8 @@ import { MessageFlags } from 'discord-api-types/v10';
* including optional image generation function calls.
*/
import { SlashCommandBuilder, AttachmentBuilder, PermissionFlagsBits } from 'discord.js';
import { expandTemplate } from '../_src/template.js';
import fs from 'fs/promises';
import path from 'path';
import axios from 'axios';
/**
* Split long text into chunks safe for Discord messaging.
@ -17,19 +19,19 @@ import axios from 'axios';
* @returns {string[]} Array of message chunks.
*/
function splitLongMessage(text, max = 2000) {
const lines = text.split('\n');
const chunks = [];
let chunk = '';
for (const line of lines) {
const next = line + '\n';
if (chunk.length + next.length > max) {
chunks.push(chunk);
chunk = '';
const lines = text.split('\n');
const chunks = [];
let chunk = '';
for (const line of lines) {
const next = line + '\n';
if (chunk.length + next.length > max) {
chunks.push(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.
*/
async function handleImageInteraction(client, interaction, resp, cfg, ephemeral) {
const calls = Array.isArray(resp.output) ? resp.output : [];
const fn = calls.find(o => o.type === 'function_call' && o.name === 'generate_image');
if (!fn?.arguments) return false;
client.logger.debug(`Image function args: ${fn.arguments}`);
let args;
try { args = JSON.parse(fn.arguments); } catch (e) { return false; }
if (!args.prompt?.trim()) {
await interaction.editReply({ content: 'Cannot generate image: empty prompt.', ephemeral });
return true;
}
// 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;
const calls = Array.isArray(resp.output) ? resp.output : [];
const fn = calls.find(o => o.type === 'function_call' && o.name === 'generate_image');
if (!fn?.arguments) return false;
client.logger.debug(`Image function args: ${fn.arguments}`);
let args;
try { args = JSON.parse(fn.arguments); } catch (e) { return false; }
if (!args.prompt?.trim()) {
await interaction.editReply({ content: 'Cannot generate image: empty prompt.', ephemeral });
return true;
}
// 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 (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;
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
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.
*/
export const commands = [
{
data: new SlashCommandBuilder()
.setName('query')
.setDescription('Send a custom AI query')
.addStringOption(opt =>
opt.setName('prompt')
.setDescription('Your query text')
.setRequired(true)
)
.addBooleanOption(opt =>
opt.setName('ephemeral')
.setDescription('Receive an ephemeral response')
.setRequired(false)
),
async execute(interaction, client) {
const cfg = client.config.responses;
// Enforce minimum score to use /query if scorekeeper is enabled
if (client.scorekeeper) {
try {
const isAdmin = interaction.member?.permissions?.has(PermissionFlagsBits.Administrator);
const scoreData = await client.scorekeeper.getScore(interaction.guildId, interaction.user.id);
if (!isAdmin && scoreData.totalScore < cfg.minScore) {
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)}.`,
ephemeral: true
});
}
} catch (err) {
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});
}
}
const prompt = interaction.options.getString('prompt');
const flag = interaction.options.getBoolean('ephemeral');
client.logger.info(`[cmd:query] Prompt received from ${interaction.user.id}, length=${prompt.length}`);
const ephemeral = flag !== null ? flag : true;
await interaction.deferReply({ ephemeral });
{
data: new SlashCommandBuilder()
.setName('query')
.setDescription('Send a custom AI query')
.addStringOption(opt =>
opt.setName('prompt')
.setDescription('Your query text')
.setRequired(true)
)
.addBooleanOption(opt =>
opt.setName('ephemeral')
.setDescription('Receive an ephemeral response')
.setRequired(false)
),
async execute(interaction, client) {
const cfg = client.config.responses;
// Enforce minimum score to use /query if scorekeeper is enabled
if (client.scorekeeper) {
try {
const isAdmin = interaction.member?.permissions?.has(PermissionFlagsBits.Administrator);
const scoreData = await client.scorekeeper.getScore(interaction.guildId, interaction.user.id);
if (!isAdmin && scoreData.totalScore < cfg.minScore) {
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)}.`,
ephemeral: true
});
}
} catch (err) {
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 });
}
}
const prompt = interaction.options.getString('prompt');
const flag = interaction.options.getBoolean('ephemeral');
client.logger.info(`[cmd:query] Prompt received from ${interaction.user.id}, length=${prompt.length}`);
const ephemeral = flag !== null ? flag : true;
await interaction.deferReply({ ephemeral });
// Determine channel/thread key for context
const key = interaction.channelId;
// Initialize per-channel lock map
const lockMap = client._responseLockMap || (client._responseLockMap = new Map());
// Get last pending promise for this key
const last = lockMap.get(key) || Promise.resolve();
// Handler to run in sequence
const handler = async () => {
// Kick off a repeated typing indicator during processing
const typingInterval = setInterval(() => interaction.channel.sendTyping().catch(() => {}), 9000);
// initial typing
interaction.channel.sendTyping().catch(() => {});
// Read previous response ID
const previous = client.pb?.cache?.get(key);
// Build request body
// Expand template for query
const now = new Date();
const date = now.toISOString().split('T')[0];
const time = now.toTimeString().split(' ')[0];
const datetime = now.toISOString().replace('T',' ').replace(/\..+$/,'');
const channel = await client.channels.fetch(interaction.channelId);
const locationName = channel.name;
const locationId = channel.id;
const ctx = {
clientId: client.config.id,
userName: interaction.user.username,
userId: interaction.user.id,
userTag: interaction.user.tag,
// add guild context
guildName: interaction.guild?.name || '',
guildId: interaction.guild?.id || '',
input: prompt,
locationName, locationId,
date, time, datetime
};
const instructions = expandTemplate(client.responsesPrompt, ctx);
const body = {
model: cfg.defaultModel,
instructions,
input: prompt,
previous_response_id: previous,
max_output_tokens: cfg.defaultMaxTokens,
temperature: cfg.defaultTemperature
};
// Assemble enabled tools
const tools = [];
if (cfg.tools?.imageGeneration) {
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');
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;
// Determine channel/thread key for context
const key = interaction.channelId;
// Initialize per-channel lock map
const lockMap = client._responseLockMap || (client._responseLockMap = new Map());
// Get last pending promise for this key
const last = lockMap.get(key) || Promise.resolve();
// Handler to run in sequence
const handler = async () => {
// Kick off a repeated typing indicator during processing
const typingInterval = setInterval(() => interaction.channel.sendTyping().catch(() => {}), 9000);
// initial typing
interaction.channel.sendTyping().catch(() => {});
// Read previous response ID
const previous = client.pb?.cache?.get(key);
// Build request body
// Expand template for query
const now = new Date();
const date = now.toISOString().split('T')[0];
const time = now.toTimeString().split(' ')[0];
const datetime = now.toISOString().replace('T',' ').replace(/\..+$/,'');
const channel = await client.channels.fetch(interaction.channelId);
const locationName = channel.name;
const locationId = channel.id;
const ctx = {
clientId: client.config.id,
userName: interaction.user.username,
userId: interaction.user.id,
userTag: interaction.user.tag,
// add guild context
guildName: interaction.guild?.name || '',
guildId: interaction.guild?.id || '',
input: prompt,
locationName, locationId,
date, time, datetime
};
const instructions = expandTemplate(client.responsesPrompt, ctx);
const body = {
model: cfg.defaultModel,
instructions,
input: prompt,
previous_response_id: previous,
max_output_tokens: cfg.defaultMaxTokens,
temperature: cfg.defaultTemperature
};
// Assemble enabled tools
const tools = [];
if (cfg.tools?.imageGeneration) {
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');
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;
// Call AI
let resp;
try {
resp = await client.openai.responses.create(body);
// Award output tokens
const tokens = resp.usage?.total_tokens ?? resp.usage?.completion_tokens ?? 0;
if (client.scorekeeper && tokens > 0) {
client.scorekeeper.addOutput(interaction.guildId, interaction.user.id, tokens, 'AI_query')
.catch(e => client.logger.error(`Scorekeeper error: ${e.message}`));
}
} catch (err) {
client.logger.error(`AI error in /query: ${err.message}`);
clearInterval(typingInterval);
return interaction.editReply({ content: 'Error generating response.', ephemeral });
}
// Call AI
let resp;
try {
resp = await client.openai.responses.create(body);
// Award output tokens
const tokens = resp.usage?.total_tokens ?? resp.usage?.completion_tokens ?? 0;
if (client.scorekeeper && tokens > 0) {
client.scorekeeper.addOutput(interaction.guildId, interaction.user.id, tokens, 'AI_query')
.catch(e => client.logger.error(`Scorekeeper error: ${e.message}`));
}
} catch (err) {
client.logger.error(`AI error in /query: ${err.message}`);
clearInterval(typingInterval);
return interaction.editReply({ content: 'Error generating response.', ephemeral });
}
// 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) {
client.pb?.cache?.set(key, resp.id, Math.floor(cfg.conversationExpiry / 1000));
}
// 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) {
client.pb?.cache?.set(key, resp.id, Math.floor(cfg.conversationExpiry / 1000));
}
// Handle image function call if present
if (await handleImageInteraction(client, interaction, resp, cfg, ephemeral)) {
clearInterval(typingInterval);
return;
// Handle image function call if present
if (await handleImageInteraction(client, interaction, resp, cfg, ephemeral)) {
clearInterval(typingInterval);
return;
}
// Send text reply chunks
const text = resp.output_text?.trim() || '';
if (!text) {
clearInterval(typingInterval);
return interaction.editReply({ content: 'No response generated.', ephemeral });
}
const chunks = splitLongMessage(text, 2000);
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.
*/
export async function init(client, clientConfig) {
const cfg = clientConfig.responsesRandomizer;
const chance = Number(cfg.chance);
if (isNaN(chance) || chance <= 0) {
client.logger.warn(`[module:responsesRandomizer] Invalid chance value: ${cfg.chance}. Module disabled.`);
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}`);
const cfg = clientConfig.responsesRandomizer;
const chance = Number(cfg.chance);
if (isNaN(chance) || chance <= 0) {
client.logger.warn(`[module:responsesRandomizer] Invalid chance value: ${cfg.chance}. Module disabled.`);
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}`);
}
});
}

View File

@ -1,364 +1,364 @@
import { MessageFlags } from 'discord-api-types/v10';
import { _MessageFlags } from 'discord-api-types/v10';
// _opt/schangar.js
import { SlashCommandBuilder } from 'discord.js';
// Export commands array for the centralized handler
export const commands = [
{
data: new SlashCommandBuilder()
.setName('hangarsync')
.setDescription('Mark the moment all five lights turn green, for use with hangarstatus')
.addStringOption(option =>
option.setName('timestamp')
.setDescription('Custom timestamp (Unix time in seconds or ISO 8601 format). Leave empty for current time.')
.setRequired(false)),
{
data: new SlashCommandBuilder()
.setName('hangarsync')
.setDescription('Mark the moment all five lights turn green, for use with hangarstatus')
.addStringOption(option =>
option.setName('timestamp')
.setDescription('Custom timestamp (Unix time in seconds or ISO 8601 format). Leave empty for current time.')
.setRequired(false)),
execute: async (interaction, client) => {
const customTimestamp = interaction.options.getString('timestamp');
let syncEpoch;
execute: async (interaction, client) => {
const customTimestamp = interaction.options.getString('timestamp');
let syncEpoch;
// Attempt to validate custom timestamp
if (customTimestamp) {
try {
if (/^\d+$/.test(customTimestamp)) {
const timestampInSeconds = parseInt(customTimestamp);
if (timestampInSeconds < 0 || timestampInSeconds > Math.floor(Date.now() / 1000)) {
return interaction.reply({
content: 'Invalid timestamp. Please provide a Unix time in seconds that is not in the future.',
ephemeral: true
});
}
syncEpoch = timestampInSeconds * 1000;
} else {
const date = new Date(customTimestamp);
syncEpoch = date.getTime();
if (isNaN(syncEpoch) || syncEpoch < 0) {
return interaction.reply({
content: 'Invalid timestamp format. Please use Unix time in seconds or a valid ISO 8601 string.',
ephemeral: true
});
}
}
} catch (error) {
client.logger.error(`[cmd:hangarsync] Failed to parse timestamp: ${error.message}`);
return interaction.reply({
content: 'Failed to parse timestamp. Please use Unix time in seconds or a valid ISO 8601 string.',
ephemeral: true
});
}
} else {
syncEpoch = Date.now();
}
// Attempt to validate custom timestamp
if (customTimestamp) {
try {
if (/^\d+$/.test(customTimestamp)) {
const timestampInSeconds = parseInt(customTimestamp);
if (timestampInSeconds < 0 || timestampInSeconds > Math.floor(Date.now() / 1000)) {
return interaction.reply({
content: 'Invalid timestamp. Please provide a Unix time in seconds that is not in the future.',
ephemeral: true
});
}
syncEpoch = timestampInSeconds * 1000;
} else {
const date = new Date(customTimestamp);
syncEpoch = date.getTime();
if (isNaN(syncEpoch) || syncEpoch < 0) {
return interaction.reply({
content: 'Invalid timestamp format. Please use Unix time in seconds or a valid ISO 8601 string.',
ephemeral: true
});
}
}
} catch (error) {
client.logger.error(`[cmd:hangarsync] Failed to parse timestamp: ${error.message}`);
return interaction.reply({
content: 'Failed to parse timestamp. Please use Unix time in seconds or a valid ISO 8601 string.',
ephemeral: true
});
}
} else {
syncEpoch = Date.now();
}
// Check PocketBase connection status
if (!isPocketBaseConnected(client)) {
// Check PocketBase connection status
if (!isPocketBaseConnected(client)) {
client.logger.error('[cmd:hangarsync] PocketBase not connected');
// Try to reconnect if available
if (typeof client.pb.ensureConnection === 'function') {
await client.pb.ensureConnection();
// Try to reconnect if available
if (typeof client.pb.ensureConnection === 'function') {
await client.pb.ensureConnection();
// Check if reconnection worked
if (!isPocketBaseConnected(client)) {
return interaction.reply({
content: 'Database connection unavailable. Please try again later.',
ephemeral: true
});
}
} else {
return interaction.reply({
content: 'Database connection unavailable. Please try again later.',
ephemeral: true
});
}
}
// Check if reconnection worked
if (!isPocketBaseConnected(client)) {
return interaction.reply({
content: 'Database connection unavailable. Please try again later.',
ephemeral: true
});
}
} else {
return interaction.reply({
content: 'Database connection unavailable. Please try again later.',
ephemeral: true
});
}
}
// Create or update timestamp for guild
try {
let record = null;
// Create or update timestamp for guild
try {
let record = null;
try {
// First try the enhanced method if available
if (typeof client.pb.getFirst === 'function') {
record = await client.pb.getFirst('command_hangarsync', `guildId = "${interaction.guildId}"`);
} else {
// Fall back to standard PocketBase method
const records = await client.pb.collection('command_hangarsync').getList(1, 1, {
filter: `guildId = "${interaction.guildId}"`
});
if (records.items.length > 0) {
record = records.items[0];
}
}
} catch (error) {
// Handle case where collection might not exist
client.logger.warn(`Error retrieving hangarsync record: ${error.message}`);
}
try {
// First try the enhanced method if available
if (typeof client.pb.getFirst === 'function') {
record = await client.pb.getFirst('command_hangarsync', `guildId = "${interaction.guildId}"`);
} else {
// Fall back to standard PocketBase method
const records = await client.pb.collection('command_hangarsync').getList(1, 1, {
filter: `guildId = "${interaction.guildId}"`
});
if (records.items.length > 0) {
record = records.items[0];
}
}
} catch (error) {
// Handle case where collection might not exist
client.logger.warn(`Error retrieving hangarsync record: ${error.message}`);
}
if (record) {
// Update existing record
if (typeof client.pb.updateOne === 'function') {
await client.pb.updateOne('command_hangarsync', record.id, {
userId: `${interaction.user.id}`,
epoch: `${syncEpoch}`,
});
} else {
await client.pb.collection('command_hangarsync').update(record.id, {
userId: `${interaction.user.id}`,
epoch: `${syncEpoch}`,
});
}
client.logger.info(`[cmd:hangarsync] Updated hangarsync for guild ${interaction.guildId} by user ${interaction.user.id}`);
} else {
// Create new record
if (typeof client.pb.createOne === 'function') {
await client.pb.createOne('command_hangarsync', {
guildId: `${interaction.guildId}`,
userId: `${interaction.user.id}`,
epoch: `${syncEpoch}`,
});
} else {
await client.pb.collection('command_hangarsync').create({
guildId: `${interaction.guildId}`,
userId: `${interaction.user.id}`,
epoch: `${syncEpoch}`,
});
}
client.logger.info(`[cmd:hangarsync] Created new hangarsync for guild ${interaction.guildId} by user ${interaction.user.id}`);
}
if (record) {
// Update existing record
if (typeof client.pb.updateOne === 'function') {
await client.pb.updateOne('command_hangarsync', record.id, {
userId: `${interaction.user.id}`,
epoch: `${syncEpoch}`
});
} else {
await client.pb.collection('command_hangarsync').update(record.id, {
userId: `${interaction.user.id}`,
epoch: `${syncEpoch}`
});
}
client.logger.info(`[cmd:hangarsync] Updated hangarsync for guild ${interaction.guildId} by user ${interaction.user.id}`);
} else {
// Create new record
if (typeof client.pb.createOne === 'function') {
await client.pb.createOne('command_hangarsync', {
guildId: `${interaction.guildId}`,
userId: `${interaction.user.id}`,
epoch: `${syncEpoch}`
});
} else {
await client.pb.collection('command_hangarsync').create({
guildId: `${interaction.guildId}`,
userId: `${interaction.user.id}`,
epoch: `${syncEpoch}`
});
}
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) {
client.logger.error(`[cmd:hangarsync] Error: ${error.message}`);
await interaction.reply({
content: `Error syncing hangar status. Please try again later.`,
ephemeral: true
});
}
}
},
{
data: new SlashCommandBuilder()
.setName('hangarstatus')
.setDescription('Check the status of contested zone executive hangars')
.addBooleanOption(option =>
option.setName('verbose')
.setDescription('Extra output, mainly for debugging.')
.setRequired(false)),
await interaction.reply({
content: 'Error syncing hangar status. Please try again later.',
ephemeral: true
});
}
}
},
{
data: new SlashCommandBuilder()
.setName('hangarstatus')
.setDescription('Check the status of contested zone executive hangars')
.addBooleanOption(option =>
option.setName('verbose')
.setDescription('Extra output, mainly for debugging.')
.setRequired(false)),
execute: async (interaction, client) => {
const verbose = interaction.options.getBoolean('verbose');
execute: async (interaction, client) => {
const verbose = interaction.options.getBoolean('verbose');
// Check PocketBase connection status
if (!isPocketBaseConnected(client)) {
// Check PocketBase connection status
if (!isPocketBaseConnected(client)) {
client.logger.error('[cmd:hangarstatus] PocketBase not connected');
// Try to reconnect if available
if (typeof client.pb.ensureConnection === 'function') {
await client.pb.ensureConnection();
// Try to reconnect if available
if (typeof client.pb.ensureConnection === 'function') {
await client.pb.ensureConnection();
// Check if reconnection worked
if (!isPocketBaseConnected(client)) {
return interaction.reply({
content: 'Database connection unavailable. Please try again later.',
ephemeral: true
});
}
} else {
return interaction.reply({
content: 'Database connection unavailable. Please try again later.',
ephemeral: true
});
}
}
// Check if reconnection worked
if (!isPocketBaseConnected(client)) {
return interaction.reply({
content: 'Database connection unavailable. Please try again later.',
ephemeral: true
});
}
} else {
return interaction.reply({
content: 'Database connection unavailable. Please try again later.',
ephemeral: true
});
}
}
try {
// Get hangarsync data for guild
let hangarSync = null;
try {
// Get hangarsync data for guild
let hangarSync = null;
try {
// First try the enhanced method if available
if (typeof client.pb.getFirst === 'function') {
hangarSync = await client.pb.getFirst('command_hangarsync', `guildId = "${interaction.guildId}"`);
} else {
// Fall back to standard PocketBase methods
try {
hangarSync = await client.pb.collection('command_hangarsync').getFirstListItem(`guildId = "${interaction.guildId}"`);
} catch (error) {
// getFirstListItem throws if no items found
if (error.status !== 404) throw error;
}
}
try {
// First try the enhanced method if available
if (typeof client.pb.getFirst === 'function') {
hangarSync = await client.pb.getFirst('command_hangarsync', `guildId = "${interaction.guildId}"`);
} else {
// Fall back to standard PocketBase methods
try {
hangarSync = await client.pb.collection('command_hangarsync').getFirstListItem(`guildId = "${interaction.guildId}"`);
} catch (error) {
// getFirstListItem throws if no items found
if (error.status !== 404) throw error;
}
}
if (!hangarSync) {
client.logger.info(`[cmd:hangarstatus] No sync data found for guild ${interaction.guildId}`);
return interaction.reply({
content: 'No sync data found. Please use `/hangarsync` first to establish a reference point.',
ephemeral: true
});
}
} catch (error) {
client.logger.error(`[cmd:hangarstatus] Error retrieving sync data for guild ${interaction.guildId}: ${error.message}`);
return interaction.reply({
content: 'No sync data found. Please use `/hangarsync` first to establish a reference point.',
ephemeral: true
});
}
if (!hangarSync) {
client.logger.info(`[cmd:hangarstatus] No sync data found for guild ${interaction.guildId}`);
return interaction.reply({
content: 'No sync data found. Please use `/hangarsync` first to establish a reference point.',
ephemeral: true
});
}
} catch (error) {
client.logger.error(`[cmd:hangarstatus] Error retrieving sync data for guild ${interaction.guildId}: ${error.message}`);
return interaction.reply({
content: 'No sync data found. Please use `/hangarsync` first to establish a reference point.',
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
const cycleDuration = 5 + (5 * 24) + (5 * 12);
// 5 minutes (all off) + 5*24 minutes (turning green) + 5*12 minutes (turning off) = 185 minutes
const cycleDuration = 5 + (5 * 24) + (5 * 12);
// Key positions in the cycle
const allOffDuration = 5;
const turningGreenDuration = 5 * 24;
const turningOffDuration = 5 * 12;
// Key positions in the cycle
const allOffDuration = 5;
const _turningGreenDuration = 5 * 24 * 1000;
const turningOffDuration = 5 * 12 * 1000;
// Calculate how much time has passed since the epoch
const timeSinceEpoch = (currentTime - hangarSync.epoch) / (60 * 1000);
// Calculate how much time has passed since the epoch
const timeSinceEpoch = (currentTime - hangarSync.epoch) / (60 * 1000);
// Calculate where we are in the full-cycle relative to the epoch
const cyclePosition = ((timeSinceEpoch % cycleDuration) + cycleDuration) % cycleDuration;
// Calculate where we are in the full-cycle relative to the epoch
const cyclePosition = ((timeSinceEpoch % cycleDuration) + cycleDuration) % cycleDuration;
// Initialize stuff and things
const lights = [":black_circle:", ":black_circle:", ":black_circle:", ":black_circle:", ":black_circle:"];
let minutesUntilNextPhase = 0;
let currentPhase = "";
// Initialize stuff and things
const lights = [':black_circle:', ':black_circle:', ':black_circle:', ':black_circle:', ':black_circle:'];
let minutesUntilNextPhase = 0;
let currentPhase = '';
// 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.
// 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.
// Case 1: We're in the unlocked phase, right after epoch
if (cyclePosition < turningOffDuration) {
currentPhase = "Unlocked";
// Case 1: We're in the unlocked phase, right after epoch
if (cyclePosition < turningOffDuration) {
currentPhase = 'Unlocked';
// All lights start as green
lights.fill(":green_circle:");
// All lights start as green
lights.fill(':green_circle:');
// Calculate how many lights have turned off
const offLights = Math.floor(cyclePosition / 12);
// Calculate how many lights have turned off
const offLights = Math.floor(cyclePosition / 12);
// Set the appropriate number of lights to off
for (let i = 0; i < offLights; i++) {
lights[i] = ":black_circle:";
}
// Set the appropriate number of lights to off
for (let i = 0; i < offLights; i++) {
lights[i] = ':black_circle:';
}
// Calculate time until next light turns off
const timeUntilNextLight = 12 - (cyclePosition % 12);
minutesUntilNextPhase = timeUntilNextLight;
}
// Calculate time until next light turns off
const timeUntilNextLight = 12 - (cyclePosition % 12);
minutesUntilNextPhase = timeUntilNextLight;
}
// Case 2: We're in the reset phase
else if (cyclePosition < turningOffDuration + allOffDuration) {
currentPhase = "Resetting";
// Case 2: We're in the reset phase
else if (cyclePosition < turningOffDuration + allOffDuration) {
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
const timeIntoPhase = cyclePosition - turningOffDuration;
minutesUntilNextPhase = allOffDuration - timeIntoPhase;
}
// Calculate time until all lights turn red
const timeIntoPhase = cyclePosition - turningOffDuration;
minutesUntilNextPhase = allOffDuration - timeIntoPhase;
}
// Case 3: We're in the locked phase
else {
currentPhase = "Locked";
// Case 3: We're in the locked phase
else {
currentPhase = 'Locked';
// All lights start as red
lights.fill(":red_circle:");
// All lights start as red
lights.fill(':red_circle:');
// Calculate how many lights have turned green
const timeIntoPhase = cyclePosition - (turningOffDuration + allOffDuration);
const greenLights = Math.floor(timeIntoPhase / 24);
// Calculate how many lights have turned green
const timeIntoPhase = cyclePosition - (turningOffDuration + allOffDuration);
const greenLights = Math.floor(timeIntoPhase / 24);
// Set the appropriate number of lights to green
for (let i = 0; i < greenLights; i++) {
lights[i] = ":green_circle:";
}
// Set the appropriate number of lights to green
for (let i = 0; i < greenLights; i++) {
lights[i] = ':green_circle:';
}
// Calculate time until next light turns green
const timeUntilNextLight = 24 - (timeIntoPhase % 24);
minutesUntilNextPhase = timeUntilNextLight;
}
// Calculate time until next light turns green
const timeUntilNextLight = 24 - (timeIntoPhase % 24);
minutesUntilNextPhase = timeUntilNextLight;
}
// Calculate a timestamp for Discord's formatting and reply
const expiration = Math.ceil((Date.now() / 1000) + (minutesUntilNextPhase * 60));
// Determine time to next Lock/Unlock phase for inline display
const isUnlocked = currentPhase === 'Unlocked';
const label = isUnlocked ? 'Lock' : 'Unlock';
const minutesToPhase = isUnlocked
? (turningOffDuration + allOffDuration) - cyclePosition
: cycleDuration - cyclePosition;
const phaseEpoch = Math.ceil(Date.now() / 1000 + (minutesToPhase * 60));
// Reply with lights and inline time to phase
await interaction.reply(
`### ${lights[0]} ${lights[1]} ${lights[2]} ${lights[3]} ${lights[4]} — Time to ${label}: <t:${phaseEpoch}:R>`
);
// Calculate a timestamp for Discord's formatting and reply
const expiration = Math.ceil((Date.now() / 1000) + (minutesUntilNextPhase * 60));
// Determine time to next Lock/Unlock phase for inline display
const isUnlocked = currentPhase === 'Unlocked';
const label = isUnlocked ? 'Lock' : 'Unlock';
const minutesToPhase = isUnlocked
? (turningOffDuration + allOffDuration) - cyclePosition
: cycleDuration - cyclePosition;
const phaseEpoch = Math.ceil(Date.now() / 1000 + (minutesToPhase * 60));
// Reply with lights and inline time to phase
await interaction.reply(
`### ${lights[0]} ${lights[1]} ${lights[2]} ${lights[3]} ${lights[4]} — Time to ${label}: <t:${phaseEpoch}:R>`
);
if (verbose) {
// Replace user mention with displayName for last sync
const syncMember = await interaction.guild.members.fetch(hangarSync.userId).catch(() => null);
const syncName = syncMember ? syncMember.displayName : `<@${hangarSync.userId}>`;
if (verbose) {
// Replace user mention with displayName for last sync
const syncMember = await interaction.guild.members.fetch(hangarSync.userId).catch(() => null);
const syncName = syncMember ? syncMember.displayName : `<@${hangarSync.userId}>`;
// Calculate time until next Lock/Unlock phase
const isUnlocked = currentPhase === 'Unlocked';
const label = isUnlocked ? 'Lock' : 'Unlock';
const minutesToPhase = isUnlocked
? (turningOffDuration + allOffDuration) - cyclePosition
: cycleDuration - cyclePosition;
const phaseEpoch = Math.ceil(Date.now() / 1000 + (minutesToPhase * 60));
// Calculate time until next Lock/Unlock phase
const isUnlocked = currentPhase === 'Unlocked';
const label = isUnlocked ? 'Lock' : 'Unlock';
const minutesToPhase = isUnlocked
? (turningOffDuration + allOffDuration) - cyclePosition
: cycleDuration - cyclePosition;
const phaseEpoch = Math.ceil(Date.now() / 1000 + (minutesToPhase * 60));
await interaction.followUp(
`- **Phase**: ${currentPhase}\n` +
await interaction.followUp(
`- **Phase**: ${currentPhase}\n` +
`- **Time to ${label}**: <t:${phaseEpoch}:R>\n` +
`- **Status Expiration**: <t:${expiration}:R>\n` +
`- **Epoch**: <t:${Math.ceil(hangarSync.epoch / 1000)}:R>\n` +
`- **Sync**: <t:${Math.floor(new Date(hangarSync.updated).getTime() / 1000)}:R> by ${syncName}`
);
);
// Add additional debug info to logs
client.logger.debug(`Hangarstatus for guild ${interaction.guildId}: Phase=${currentPhase}, CyclePosition=${cyclePosition}, TimeSinceEpoch=${timeSinceEpoch}`);
}
// Add additional debug info to logs
client.logger.debug(`Hangarstatus for guild ${interaction.guildId}: Phase=${currentPhase}, CyclePosition=${cyclePosition}, TimeSinceEpoch=${timeSinceEpoch}`);
}
} catch (error) {
client.logger.error(`Error in hangarstatus command: ${error.message}`);
await interaction.reply({
content: `Error retrieving hangar status. Please try again later.`,
ephemeral: true
});
}
}
}
} catch (error) {
client.logger.error(`Error in hangarstatus command: ${error.message}`);
await interaction.reply({
content: 'Error retrieving hangar status. Please try again later.',
ephemeral: true
});
}
}
}
];
// Function to check PocketBase connection status
function isPocketBaseConnected(client) {
// Check multiple possible status indicators to be safe
return client.pb && (
// Check status object (original code style)
(client.pb.status && client.pb.status.connected) ||
// Check multiple possible status indicators to be safe
return client.pb && (
// Check status object (original code style)
(client.pb.status && client.pb.status.connected) ||
// Check isConnected property (pbutils module style)
client.pb.isConnected === true ||
client.pb.isConnected === true ||
// Last resort: check if authStore is valid
client.pb.authStore?.isValid === true
);
);
}
// Initialize module
export const init = async (client, config) => {
client.logger.info('Initializing Star Citizen Hangar Status module');
export async function init(client, _config) {
client.logger.info('Initializing Star Citizen Hangar Status module');
// Check PocketBase connection
if (!isPocketBaseConnected(client)) {
client.logger.warn('PocketBase not connected at initialization');
// Check PocketBase connection
if (!isPocketBaseConnected(client)) {
client.logger.warn('PocketBase not connected at initialization');
// Try to reconnect if available
if (typeof client.pb.ensureConnection === 'function') {
await client.pb.ensureConnection();
}
} else {
client.logger.info('PocketBase connection confirmed');
}
// Try to reconnect if available
if (typeof client.pb.ensureConnection === 'function') {
await client.pb.ensureConnection();
}
} else {
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
export const init = async (client, config) => {
// Set up message listener that adds input points when users chat
client.on('messageCreate', async (message) => {
if (message.author.bot) return;
export async function init(client, _config) {
// Set up message listener that adds input points when users chat
client.on('messageCreate', async (message) => {
if (message.author.bot) return;
// Skip if not in a guild
if (!message.guild) return;
// Skip if not in a guild
if (!message.guild) return;
// Calculate input points: 1 point per character, plus 10 points per attachment
const textPoints = message.content.length;
const attachmentPoints = message.attachments.size * 10;
const points = textPoints + attachmentPoints;
// Do not award zero or negative points
if (points <= 0) return;
try {
await client.scorekeeper.addInput(message.guild.id, message.author.id, points, 'message');
} catch (error) {
client.logger.error(`Error adding input points: ${error.message}`);
}
});
// Calculate input points: 1 point per character, plus 10 points per attachment
const textPoints = message.content.length;
const attachmentPoints = message.attachments.size * 10;
const points = textPoints + attachmentPoints;
// Do not award zero or negative points
if (points <= 0) return;
try {
await client.scorekeeper.addInput(message.guild.id, message.author.id, points, 'message');
} catch (error) {
client.logger.error(`Error adding input points: ${error.message}`);
}
});
// Initialize voice tracking state
client.voiceTracker = {
joinTimes: new Map(), // Tracks when users joined voice
activeUsers: new Map() // Tracks users currently earning points
};
// Initialize voice tracking state
client.voiceTracker = {
joinTimes: new Map(), // Tracks when users joined voice
activeUsers: new Map() // Tracks users currently earning points
};
// Set up a voice state listener that adds input for voice activity
client.on('voiceStateUpdate', async (oldState, newState) => {
// Skip if not in a guild
if (!oldState.guild && !newState.guild) return;
// Set up a voice state listener that adds input for voice activity
client.on('voiceStateUpdate', async (oldState, newState) => {
// Skip if not in a guild
if (!oldState.guild && !newState.guild) return;
const guild = oldState.guild || newState.guild;
const member = oldState.member || newState.member;
const guild = oldState.guild || newState.guild;
const member = oldState.member || newState.member;
// User joined a voice channel
if (!oldState.channelId && newState.channelId) {
// Check if the channel has other non-bot users
const channel = newState.channel;
const otherUsers = channel.members.filter(m =>
m.id !== member.id && !m.user.bot
);
// User joined a voice channel
if (!oldState.channelId && newState.channelId) {
// Check if the channel has other non-bot users
const channel = newState.channel;
const otherUsers = channel.members.filter(m =>
m.id !== member.id && !m.user.bot
);
// Store join time if there's at least one other non-bot user
if (otherUsers.size > 0) {
client.voiceTracker.joinTimes.set(member.id, Date.now());
client.voiceTracker.activeUsers.set(member.id, newState.channelId);
client.logger.debug(`${member.user.tag} joined voice with others - tracking time`);
} else {
client.logger.debug(`${member.user.tag} joined voice alone or with bots - not tracking time`);
}
}
// User left a voice channel
else if (oldState.channelId && !newState.channelId) {
processVoiceLeave(client, guild, member, oldState.channelId);
}
// User switched voice channels
else if (oldState.channelId && newState.channelId && oldState.channelId !== newState.channelId) {
// Process leaving the old channel
processVoiceLeave(client, guild, member, oldState.channelId);
// Store join time if there's at least one other non-bot user
if (otherUsers.size > 0) {
client.voiceTracker.joinTimes.set(member.id, Date.now());
client.voiceTracker.activeUsers.set(member.id, newState.channelId);
client.logger.debug(`${member.user.tag} joined voice with others - tracking time`);
} else {
client.logger.debug(`${member.user.tag} joined voice alone or with bots - not tracking time`);
}
}
// User left a voice channel
else if (oldState.channelId && !newState.channelId) {
processVoiceLeave(client, guild, member, oldState.channelId);
}
// User switched voice channels
else if (oldState.channelId && newState.channelId && oldState.channelId !== newState.channelId) {
// Process leaving the old channel
processVoiceLeave(client, guild, member, oldState.channelId);
// Check if the new channel has other non-bot users
const channel = newState.channel;
const otherUsers = channel.members.filter(m =>
m.id !== member.id && !m.user.bot
);
// Check if the new channel has other non-bot users
const channel = newState.channel;
const otherUsers = channel.members.filter(m =>
m.id !== member.id && !m.user.bot
);
// Start tracking in the new channel if there are other non-bot users
if (otherUsers.size > 0) {
client.voiceTracker.joinTimes.set(member.id, Date.now());
client.voiceTracker.activeUsers.set(member.id, newState.channelId);
}
}
// Start tracking in the new channel if there are other non-bot users
if (otherUsers.size > 0) {
client.voiceTracker.joinTimes.set(member.id, Date.now());
client.voiceTracker.activeUsers.set(member.id, newState.channelId);
}
}
// If someone joined or left a channel, update tracking for everyone in that channel
updateChannelUserTracking(client, oldState, newState);
});
};
// If someone joined or left a channel, update tracking for everyone in that channel
updateChannelUserTracking(client, oldState, newState);
});
}
/**
* Process when a user leaves a voice channel
*/
function processVoiceLeave(client, guild, member, channelId) {
if (client.voiceTracker.activeUsers.get(member.id) === channelId) {
const joinTime = client.voiceTracker.joinTimes.get(member.id);
if (client.voiceTracker.activeUsers.get(member.id) === channelId) {
const joinTime = client.voiceTracker.joinTimes.get(member.id);
if (joinTime) {
const duration = (Date.now() - joinTime) / 1000 / 60; // Duration in minutes
if (joinTime) {
const duration = (Date.now() - joinTime) / 1000 / 60; // Duration in minutes
// Award 1 point per minute, up to 30 per session
const points = Math.min(Math.floor(duration), 30);
if (points > 0) {
try {
client.scorekeeper.addInput(guild.id, member.id, points, 'voice_activity')
.then(() => {
client.logger.debug(`Added ${points} voice activity points for ${member.user.tag}`);
})
.catch(error => {
client.logger.error(`Error adding voice points: ${error.message}`);
});
} catch (error) {
client.logger.error(`Error adding voice points: ${error.message}`);
}
}
}
// Award 1 point per minute, up to 30 per session
const points = Math.min(Math.floor(duration), 30);
if (points > 0) {
try {
client.scorekeeper.addInput(guild.id, member.id, points, 'voice_activity')
.then(() => {
client.logger.debug(`Added ${points} voice activity points for ${member.user.tag}`);
})
.catch(error => {
client.logger.error(`Error adding voice points: ${error.message}`);
});
} catch (error) {
client.logger.error(`Error adding voice points: ${error.message}`);
}
}
}
client.voiceTracker.joinTimes.delete(member.id);
client.voiceTracker.activeUsers.delete(member.id);
}
client.voiceTracker.joinTimes.delete(member.id);
client.voiceTracker.activeUsers.delete(member.id);
}
}
/**
* Updates tracking for all users in affected channels
*/
function updateChannelUserTracking(client, oldState, newState) {
// Get the affected channels
const affectedChannels = new Set();
if (oldState.channelId) affectedChannels.add(oldState.channelId);
if (newState.channelId) affectedChannels.add(newState.channelId);
// Get the affected channels
const affectedChannels = new Set();
if (oldState.channelId) affectedChannels.add(oldState.channelId);
if (newState.channelId) affectedChannels.add(newState.channelId);
for (const channelId of affectedChannels) {
const channel = oldState.guild.channels.cache.get(channelId);
if (!channel) continue;
for (const channelId of affectedChannels) {
const channel = oldState.guild.channels.cache.get(channelId);
if (!channel) continue;
// Check if the channel has at least 2 non-bot users
const nonBotMembers = channel.members.filter(m => !m.user.bot);
const hasMultipleUsers = nonBotMembers.size >= 2;
// Check if the channel has at least 2 non-bot users
const nonBotMembers = channel.members.filter(m => !m.user.bot);
const hasMultipleUsers = nonBotMembers.size >= 2;
// For each user in the channel
channel.members.forEach(channelMember => {
if (channelMember.user.bot) return; // Skip bots
// For each user in the channel
channel.members.forEach(channelMember => {
if (channelMember.user.bot) return; // Skip bots
const userId = channelMember.id;
const isActive = client.voiceTracker.activeUsers.get(userId) === channelId;
const userId = channelMember.id;
const isActive = client.voiceTracker.activeUsers.get(userId) === channelId;
// Should be active but isn't yet
if (hasMultipleUsers && !isActive) {
client.voiceTracker.joinTimes.set(userId, Date.now());
client.voiceTracker.activeUsers.set(userId, channelId);
client.logger.debug(`Starting tracking for ${channelMember.user.tag} in ${channel.name}`);
}
// Should not be active but is
else if (!hasMultipleUsers && isActive) {
processVoiceLeave(client, oldState.guild, channelMember, channelId);
client.logger.debug(`Stopping tracking for ${channelMember.user.tag} - not enough users`);
}
});
}
// Should be active but isn't yet
if (hasMultipleUsers && !isActive) {
client.voiceTracker.joinTimes.set(userId, Date.now());
client.voiceTracker.activeUsers.set(userId, channelId);
client.logger.debug(`Starting tracking for ${channelMember.user.tag} in ${channel.name}`);
}
// Should not be active but is
else if (!hasMultipleUsers && isActive) {
processVoiceLeave(client, oldState.guild, channelMember, channelId);
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
const CODES = {
// text colors
gray: 30, red: 31, green: 32, yellow: 33,
blue: 34, pink: 35, cyan: 36, white: 37,
// background colors
bgGray: 40, bgOrange: 41, bgBlue: 42,
bgTurquoise: 43, bgFirefly: 44, bgIndigo: 45,
bgLightGray: 46, bgWhite: 47,
// styles
bold: 1, underline: 4,
// reset
reset: 0
// text colors
gray: 30, red: 31, green: 32, yellow: 33,
blue: 34, pink: 35, cyan: 36, white: 37,
// background colors
bgGray: 40, bgOrange: 41, bgBlue: 42,
bgTurquoise: 43, bgFirefly: 44, bgIndigo: 45,
bgLightGray: 46, bgWhite: 47,
// styles
bold: 1, underline: 4,
// reset
reset: 0
};
/**
* Escape literal brackets so users can write \[ and \] without triggering tags.
*/
export function escapeBrackets(str) {
return str
.replace(/\\\[/g, '__ESC_LB__')
.replace(/\\\]/g, '__ESC_RB__');
return str
.replace(/\\\[/g, '__ESC_LB__')
.replace(/\\\]/g, '__ESC_RB__');
}
/** Restore any escaped brackets after formatting. */
export function restoreBrackets(str) {
return str
.replace(/__ESC_LB__/g, '[')
.replace(/__ESC_RB__/g, ']');
return str
.replace(/__ESC_LB__/g, '[')
.replace(/__ESC_RB__/g, ']');
}
/**
* Parse nested [tag1,tag2][/] patterns into ANSI codes (stack-based).
*/
export function formatAnsi(input) {
const stack = [];
let output = '';
const pattern = /\[\/\]|\[([^\]]+)\]/g;
let lastIndex = 0;
let match;
const stack = [];
let output = '';
const pattern = /\[\/\]|\[([^\]]+)\]/g;
let lastIndex = 0;
let match;
while ((match = pattern.exec(input)) !== null) {
output += input.slice(lastIndex, match.index);
while ((match = pattern.exec(input)) !== null) {
output += input.slice(lastIndex, match.index);
if (match[0] === '[/]') {
if (stack.length) stack.pop();
output += `\u001b[${CODES.reset}m`;
for (const tag of stack) {
const code = CODES[tag] ?? CODES.gray;
output += `\u001b[${code}m`;
}
} else {
const tags = match[1].split(/[,;\s]+/).filter(Boolean);
for (const tag of tags) {
stack.push(tag);
const code = CODES[tag] ?? CODES.gray;
output += `\u001b[${code}m`;
}
if (match[0] === '[/]') {
if (stack.length) stack.pop();
output += `\u001b[${CODES.reset}m`;
for (const tag of stack) {
const code = CODES[tag] ?? CODES.gray;
output += `\u001b[${code}m`;
}
} else {
const tags = match[1].split(/[,;\s]+/).filter(Boolean);
for (const tag of tags) {
stack.push(tag);
const code = CODES[tag] ?? CODES.gray;
output += `\u001b[${code}m`;
}
}
lastIndex = pattern.lastIndex;
}
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,18 +73,18 @@ export function formatAnsi(input) {
* Escapes brackets, parses ANSI, and restores literals.
*/
export function ansi(strings, ...values) {
let built = '';
for (let i = 0; i < strings.length; i++) {
built += strings[i];
if (i < values.length) built += values[i];
}
return restoreBrackets(formatAnsi(escapeBrackets(built)));
let built = '';
for (let i = 0; i < strings.length; i++) {
built += strings[i];
if (i < values.length) built += values[i];
}
return restoreBrackets(formatAnsi(escapeBrackets(built)));
}
/** Wrap text in a ```ansi code block for Discord. */
export function wrapAnsi(text) {
return '```ansi\n' + text + '\n```';
return '```ansi\n' + text + '\n```';
}
// Export raw codes for advanced use (e.g., ansitheme module)
export { CODES };
export { CODES };

View File

@ -8,69 +8,69 @@ const rootDir = path.dirname(__dirname);
// Load modules function - hot reload functionality removed
export const loadModules = async (clientConfig, client) => {
const modules = clientConfig.modules || [];
const modulesDir = path.join(rootDir, '_opt');
const modules = clientConfig.modules || [];
const modulesDir = path.join(rootDir, '_opt');
// Create opt directory if it doesn't exist
if (!fs.existsSync(modulesDir)) {
fs.mkdirSync(modulesDir, { recursive: true });
}
// Create opt directory if it doesn't exist
if (!fs.existsSync(modulesDir)) {
fs.mkdirSync(modulesDir, { recursive: true });
}
client.logger.info(`[module:loader] Loading modules: ${modules.join(', ')}`);
// Load each module
for (const moduleName of modules) {
try {
// Try _opt first, then fallback to core _src modules
let modulePath = path.join(modulesDir, `${moduleName}.js`);
if (!fs.existsSync(modulePath)) {
// Fallback to core source directory
modulePath = path.join(rootDir, '_src', `${moduleName}.js`);
if (!fs.existsSync(modulePath)) {
client.logger.warn(`[module:loader] Module not found: ${moduleName}.js`);
continue;
client.logger.info(`[module:loader] Loading modules: ${modules.join(', ')}`);
// Load each module
for (const moduleName of modules) {
try {
// Try _opt first, then fallback to core _src modules
let modulePath = path.join(modulesDir, `${moduleName}.js`);
if (!fs.existsSync(modulePath)) {
// Fallback to core source directory
modulePath = path.join(rootDir, '_src', `${moduleName}.js`);
if (!fs.existsSync(modulePath)) {
client.logger.warn(`[module:loader] Module not found: ${moduleName}.js`);
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 path from 'path';
import { fileURLToPath } from 'url';
import winston from 'winston';
import 'winston-daily-rotate-file';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const rootDir = path.dirname(__dirname);
// Create Winston logger
export const createLogger = (clientConfig) => {
const { logging } = clientConfig;
const transports = [];
const { logging } = clientConfig;
const transports = [];
// Console transport
if (logging.console.enabled) {
transports.push(new winston.transports.Console({
level: logging.console.level,
format: winston.format.combine(
winston.format.timestamp({
format: logging.file.timestampFormat
}),
logging.console.colorize ? winston.format.colorize() : winston.format.uncolorize(),
winston.format.printf(info => `[${info.timestamp}] [${clientConfig.id}] [${info.level}] ${info.message}`)
)
}));
}
// Console transport
if (logging.console.enabled) {
transports.push(new winston.transports.Console({
level: logging.console.level,
format: winston.format.combine(
winston.format.timestamp({
format: logging.file.timestampFormat
}),
logging.console.colorize ? winston.format.colorize() : winston.format.uncolorize(),
winston.format.printf(info => `[${info.timestamp}] [${clientConfig.id}] [${info.level}] ${info.message}`)
)
}));
}
// Combined file transport with rotation
if (logging.file.combined.enabled) {
const logDir = path.join(rootDir, logging.file.combined.location);
// Combined file transport with rotation
if (logging.file.combined.enabled) {
const logDir = path.join(rootDir, logging.file.combined.location);
// Create log directory if it doesn't exist
if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir, { recursive: true });
}
// Create log directory if it doesn't exist
if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir, { recursive: true });
}
const combinedTransport = new winston.transports.DailyRotateFile({
filename: path.join(logDir, `${clientConfig.id}-combined-%DATE%.log`),
datePattern: logging.file.dateFormat,
level: logging.file.combined.level,
maxSize: logging.file.combined.maxSize,
maxFiles: logging.file.combined.maxFiles,
format: winston.format.combine(
winston.format.timestamp({
format: logging.file.timestampFormat
}),
winston.format.printf(info => `[${info.timestamp}] [${info.level}] ${info.message}`)
)
});
const combinedTransport = new winston.transports.DailyRotateFile({
filename: path.join(logDir, `${clientConfig.id}-combined-%DATE%.log`),
datePattern: logging.file.dateFormat,
level: logging.file.combined.level,
maxSize: logging.file.combined.maxSize,
maxFiles: logging.file.combined.maxFiles,
format: winston.format.combine(
winston.format.timestamp({
format: logging.file.timestampFormat
}),
winston.format.printf(info => `[${info.timestamp}] [${info.level}] ${info.message}`)
)
});
transports.push(combinedTransport);
}
transports.push(combinedTransport);
}
// Error file transport with rotation
if (logging.file.error.enabled) {
const logDir = path.join(rootDir, logging.file.error.location);
// Error file transport with rotation
if (logging.file.error.enabled) {
const logDir = path.join(rootDir, logging.file.error.location);
// Create log directory if it doesn't exist
if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir, { recursive: true });
}
// Create log directory if it doesn't exist
if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir, { recursive: true });
}
const errorTransport = new winston.transports.DailyRotateFile({
filename: path.join(logDir, `${clientConfig.id}-error-%DATE%.log`),
datePattern: logging.file.dateFormat,
level: logging.file.error.level,
maxSize: logging.file.error.maxSize,
maxFiles: logging.file.error.maxFiles,
format: winston.format.combine(
winston.format.timestamp({
format: logging.file.timestampFormat
}),
winston.format.printf(info => `[${info.timestamp}] [${info.level}] ${info.message}`)
)
});
const errorTransport = new winston.transports.DailyRotateFile({
filename: path.join(logDir, `${clientConfig.id}-error-%DATE%.log`),
datePattern: logging.file.dateFormat,
level: logging.file.error.level,
maxSize: logging.file.error.maxSize,
maxFiles: logging.file.error.maxFiles,
format: winston.format.combine(
winston.format.timestamp({
format: logging.file.timestampFormat
}),
winston.format.printf(info => `[${info.timestamp}] [${info.level}] ${info.message}`)
)
});
transports.push(errorTransport);
}
transports.push(errorTransport);
}
return winston.createLogger({
levels: winston.config.npm.levels,
transports
});
return winston.createLogger({
levels: winston.config.npm.levels,
transports
});
};

View File

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

View File

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

690
config.js
View File

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

186
index.js
View File

@ -1,130 +1,130 @@
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 { initializePocketbase } from './_src/pocketbase.js';
import { loadModules } from './_src/loader.js';
import config from './config.js';
// For deprecated ephemeral option: convert to flags
import { ansi, wrapAnsi } from './_src/ansiColors.js';
// Initialize Discord client
const initializeClient = async (clientConfig) => {
// Create Discord client with intents
const client = new Client({
// Include GuildVoiceStates and GuildMembers intents to track voice channel events
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
GatewayIntentBits.GuildMembers,
GatewayIntentBits.GuildVoiceStates
]
});
// Create Discord client with intents
const client = new Client({
// Include GuildVoiceStates and GuildMembers intents to track voice channel events
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
GatewayIntentBits.GuildMembers,
GatewayIntentBits.GuildVoiceStates
]
});
// Attach config to client
client.config = clientConfig;
// Attach config to client
client.config = clientConfig;
// Set up Winston logger
client.logger = createLogger(clientConfig);
client.logger.info(`Initializing client: ${clientConfig.id}`);
// Set up Winston logger
client.logger = createLogger(clientConfig);
client.logger.info(`Initializing client: ${clientConfig.id}`);
// Set up Pocketbase
client.pb = await initializePocketbase(clientConfig, client.logger);
// Set up Pocketbase
client.pb = await initializePocketbase(clientConfig, client.logger);
// Commands collection
client.commands = new Collection();
// ANSI helper attached to client
client.ansi = ansi;
client.wrapAnsi = wrapAnsi;
// Commands collection
client.commands = new Collection();
// ANSI helper attached to client
client.ansi = ansi;
client.wrapAnsi = wrapAnsi;
// Load optional modules
await loadModules(clientConfig, client);
// Load optional modules
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
client.on('interactionCreate', async (interaction) => {
if (!interaction.isChatInputCommand()) return;
// Discord client events
client.on('interactionCreate', async (interaction) => {
if (!interaction.isChatInputCommand()) return;
const commandName = interaction.commandName;
const commandName = interaction.commandName;
try {
// Find command in collection
const command = client.commands.get(commandName);
try {
// Find command in collection
const command = client.commands.get(commandName);
if (!command) {
client.logger.warn(`Command not found: ${commandName}`);
await interaction.reply({
content: 'Sorry, this command is not properly registered.',
ephemeral: true
});
return;
}
if (!command) {
client.logger.warn(`Command not found: ${commandName}`);
await interaction.reply({
content: 'Sorry, this command is not properly registered.',
ephemeral: true
});
return;
}
// Execute the command
client.logger.debug(`Executing command: ${commandName}`);
await command.execute(interaction, client);
// Execute the command
client.logger.debug(`Executing command: ${commandName}`);
await command.execute(interaction, client);
} catch (error) {
client.logger.error(`Error executing command ${commandName}: ${error.message}`);
} catch (error) {
client.logger.error(`Error executing command ${commandName}: ${error.message}`);
// Handle already replied interactions
const replyContent = {
content: 'There was an error while executing this command.',
ephemeral: true
};
// Handle already replied interactions
const replyContent = {
content: 'There was an error while executing this command.',
ephemeral: true
};
if (interaction.replied || interaction.deferred) {
await interaction.followUp(replyContent).catch(err => {
client.logger.error(`Failed to send followUp: ${err.message}`);
});
} else {
await interaction.reply(replyContent).catch(err => {
client.logger.error(`Failed to reply: ${err.message}`);
});
}
}
});
if (interaction.replied || interaction.deferred) {
await interaction.followUp(replyContent).catch(err => {
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.logger.info(`Logged in as ${client.user.tag}`);
});
client.on('ready', () => {
client.logger.info(`Logged in as ${client.user.tag}`);
});
client.on('error', (error) => {
client.logger.error(`Client error: ${error.message}`);
});
client.on('error', (error) => {
client.logger.error(`Client error: ${error.message}`);
});
// Login to Discord
try {
await client.login(clientConfig.discord.token);
return client;
} catch (error) {
client.logger.error(`Failed to login: ${error.message}`);
throw error;
}
// Login to Discord
try {
await client.login(clientConfig.discord.token);
return client;
} catch (error) {
client.logger.error(`Failed to login: ${error.message}`);
throw error;
}
};
// Main function to start bot
const startBot = async () => {
const clients = [];
const clients = [];
// Initialize each client from config
for (const clientConfig of config.clients) {
try {
const client = await initializeClient(clientConfig);
clients.push(client);
} catch (error) {
console.error(`Failed to initialize client ${clientConfig.id}:`, error);
}
}
// Initialize each client from config
for (const clientConfig of config.clients) {
try {
const client = await initializeClient(clientConfig);
clients.push(client);
} catch (error) {
console.error(`Failed to initialize client ${clientConfig.id}:`, error);
}
}
return clients;
return clients;
};
// Launch the bot
startBot().then(clients => {
console.log(`[main] Successfully initialized ${clients.length} Discord clients`);
console.log(`[main] Successfully initialized ${clients.length} Discord clients`);
}).catch(error => {
console.error(`[main] Failed to start bot: ${error.message}`);
process.exit(1);
console.error(`[main] Failed to start bot: ${error.message}`);
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": {
"start": "node index.js",
"registry": "node registry.js"
"registry": "node registry.js",
"lint": "eslint .",
"lint:fix": "eslint . --fix"
},
"dependencies": {
"@discordjs/rest": "^2.2.0",
"axios": "^1.8.4",
"discord-api-types": "^0.37.120",
"discord.js": "^14.18.0",
@ -24,5 +27,9 @@
"pocketbase": "^0.25.2",
"winston": "^3.17.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
import { REST } from '@discordjs/rest';
import { Routes } from 'discord-api-types/v10';
import fs from 'fs';
import path from 'path';
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';
// Get directory name in ES module
@ -19,7 +21,7 @@ const dryRun = args.includes('--dryrun');
// Validate required parameters
if (args.includes('--help') || args.includes('-h') || !actionArg || !guildArg || !clientArg) {
console.log(`
console.log(`
[registry]
Discord Command Registry Tool
@ -40,14 +42,14 @@ Examples:
node registry.js --action=register --guild=all --client=ASOP
node registry.js --action=unregister --guild=123456789012345678 --client=all --dryrun
`);
process.exit(1);
process.exit(1);
}
// Validate action parameter
const validActions = ['register', 'unregister', 'list'];
if (!validActions.includes(actionArg.toLowerCase())) {
console.error(`[registry] Error: Invalid action "${actionArg}". Must be one of: ${validActions.join(', ')}`);
process.exit(1);
console.error(`[registry] Error: Invalid action "${actionArg}". Must be one of: ${validActions.join(', ')}`);
process.exit(1);
}
const action = actionArg.toLowerCase();
@ -57,17 +59,17 @@ const targetGuildId = isGuildAll ? null : guildArg;
// Validate client parameter - must be "all" or match a client in config
const isClientAll = clientArg.toLowerCase() === 'all';
const targetClients = isClientAll
? config.clients.filter(client => client.enabled !== false)
: config.clients.filter(client => client.id === clientArg && client.enabled !== false);
const targetClients = isClientAll
? config.clients.filter(client => client.enabled !== false)
: config.clients.filter(client => client.id === clientArg && client.enabled !== false);
if (targetClients.length === 0) {
console.error(`[registry] Error: No matching clients found for "${clientArg}"`);
console.log('Available clients:');
config.clients
.filter(client => client.enabled !== false)
.forEach(client => console.log(` - ${client.id}`));
process.exit(1);
console.error(`[registry] Error: No matching clients found for "${clientArg}"`);
console.log('Available clients:');
config.clients
.filter(client => client.enabled !== false)
.forEach(client => console.log(` - ${client.id}`));
process.exit(1);
}
/**
@ -76,36 +78,36 @@ if (targetClients.length === 0) {
* @returns {Promise<Array>} - Array of command data objects
*/
async function extractCommandsFromModule(modulePath) {
try {
// Import the module
const moduleUrl = `file://${modulePath}`;
const module = await import(moduleUrl);
try {
// Import the module
const moduleUrl = `file://${modulePath}`;
const module = await import(moduleUrl);
// Check for commands array
if (Array.isArray(module.commands)) {
// Extract command data
const extractedCommands = module.commands.map(cmd => {
if (cmd && cmd.data && typeof cmd.data.toJSON === 'function') {
try {
return cmd.data.toJSON();
} catch (error) {
console.warn(`Error converting command to JSON in ${path.basename(modulePath)}: ${error.message}`);
return null;
}
}
return null;
}).filter(Boolean); // Remove null entries
// Check for commands array
if (Array.isArray(module.commands)) {
// Extract command data
const extractedCommands = module.commands.map(cmd => {
if (cmd && cmd.data && typeof cmd.data.toJSON === 'function') {
try {
return cmd.data.toJSON();
} catch (error) {
console.warn(`Error converting command to JSON in ${path.basename(modulePath)}: ${error.message}`);
return null;
}
}
return null;
}).filter(Boolean); // Remove null entries
console.log(` - Extracted ${extractedCommands.length} commands from ${path.basename(modulePath)}`);
return extractedCommands;
} else {
console.log(` - No commands found in ${path.basename(modulePath)}`);
return [];
}
} catch (error) {
console.error(`Error loading module ${modulePath}: ${error.message}`);
return [];
}
console.log(` - Extracted ${extractedCommands.length} commands from ${path.basename(modulePath)}`);
return extractedCommands;
} else {
console.log(` - No commands found in ${path.basename(modulePath)}`);
return [];
}
} catch (error) {
console.error(`Error loading module ${modulePath}: ${error.message}`);
return [];
}
}
/**
@ -114,27 +116,27 @@ async function extractCommandsFromModule(modulePath) {
* @returns {Promise<Array>} - Array of command data objects
*/
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 optDir = path.join(__dirname, '_opt');
const commands = [];
const optDir = path.join(__dirname, '_opt');
// Process each module
for (const moduleName of clientConfig.modules || []) {
console.log(`Processing module: ${moduleName}`);
const modulePath = path.join(optDir, `${moduleName}.js`);
// Process each module
for (const moduleName of clientConfig.modules || []) {
console.log(`Processing module: ${moduleName}`);
const modulePath = path.join(optDir, `${moduleName}.js`);
if (!fs.existsSync(modulePath)) {
console.warn(` - Module not found: ${moduleName}`);
continue;
}
if (!fs.existsSync(modulePath)) {
console.warn(` - Module not found: ${moduleName}`);
continue;
}
const moduleCommands = await extractCommandsFromModule(modulePath);
commands.push(...moduleCommands);
}
const moduleCommands = await extractCommandsFromModule(modulePath);
commands.push(...moduleCommands);
}
console.log(`Total commands extracted for ${clientConfig.id}: ${commands.length}`);
return commands;
console.log(`Total commands extracted for ${clientConfig.id}: ${commands.length}`);
return commands;
}
/**
@ -144,12 +146,12 @@ async function processClientModules(clientConfig) {
* @returns {Promise<Object>} - Guild information
*/
async function getGuildInfo(rest, guildId) {
try {
return await rest.get(Routes.guild(guildId));
} catch (error) {
console.error(`Error fetching guild info: ${error.message}`);
return { name: `Unknown Guild (${guildId})` };
}
try {
return await rest.get(Routes.guild(guildId));
} catch (error) {
console.error(`Error fetching guild info: ${error.message}`);
return { name: `Unknown Guild (${guildId})` };
}
}
/**
@ -158,24 +160,24 @@ async function getGuildInfo(rest, guildId) {
* @param {string|null} guildId - Guild ID or null for global
*/
async function listCommands(clientConfig, guildId) {
const { id, discord } = clientConfig;
const { id, discord } = clientConfig;
if (!discord || !discord.token || !discord.appId) {
console.error(`Invalid client configuration for ${id}`);
return;
}
if (!discord || !discord.token || !discord.appId) {
console.error(`Invalid client configuration for ${id}`);
return;
}
// Set up REST client
const rest = new REST({ version: '10' }).setToken(discord.token);
// Set up REST client
const rest = new REST({ version: '10' }).setToken(discord.token);
// Handle global or guild-specific commands
if (guildId === null) {
// Global commands
await listGlobalCommands(clientConfig, rest);
} else {
// Guild-specific commands
await listGuildCommands(clientConfig, rest, guildId);
}
// Handle global or guild-specific commands
if (guildId === null) {
// Global commands
await listGlobalCommands(clientConfig, rest);
} else {
// Guild-specific commands
await listGuildCommands(clientConfig, rest, guildId);
}
}
/**
@ -184,32 +186,32 @@ async function listCommands(clientConfig, guildId) {
* @param {REST} rest - Discord REST client
*/
async function listGlobalCommands(clientConfig, rest) {
console.log(`\nListing global commands for client: ${clientConfig.id}`);
console.log(`\nListing global commands for client: ${clientConfig.id}`);
try {
const route = Routes.applicationCommands(clientConfig.discord.appId);
const commands = await rest.get(route);
try {
const route = Routes.applicationCommands(clientConfig.discord.appId);
const commands = await rest.get(route);
if (commands.length === 0) {
console.log(`No global commands registered for client ${clientConfig.id}`);
return;
}
if (commands.length === 0) {
console.log(`No global commands registered for client ${clientConfig.id}`);
return;
}
console.log(`Found ${commands.length} global commands:`);
console.log(`Found ${commands.length} global commands:`);
// Display commands in a formatted table
console.log('');
console.log('ID'.padEnd(20) + 'NAME'.padEnd(20) + 'DESCRIPTION'.padEnd(60));
// Display commands in a formatted table
console.log('');
console.log('ID'.padEnd(20) + 'NAME'.padEnd(20) + 'DESCRIPTION'.padEnd(60));
for (const cmd of commands) {
console.log(
`${cmd.id.toString().padEnd(20)}${cmd.name.padEnd(20)}${(cmd.description || '')}`
);
}
for (const cmd of commands) {
console.log(
`${cmd.id.toString().padEnd(20)}${cmd.name.padEnd(20)}${(cmd.description || '')}`
);
}
} catch (error) {
console.error(`Error listing global commands for client ${clientConfig.id}: ${error.message}`);
}
} catch (error) {
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
*/
async function listGuildCommands(clientConfig, rest, guildId) {
// Get guild info
const guildInfo = await getGuildInfo(rest, guildId);
const guildName = guildInfo.name || `Unknown Guild (${guildId})`;
// Get guild info
const guildInfo = await getGuildInfo(rest, 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 {
const route = Routes.applicationGuildCommands(clientConfig.discord.appId, guildId);
const commands = await rest.get(route);
try {
const route = Routes.applicationGuildCommands(clientConfig.discord.appId, guildId);
const commands = await rest.get(route);
if (commands.length === 0) {
console.log(`No commands registered for client ${clientConfig.id} in guild ${guildName}`);
return;
}
if (commands.length === 0) {
console.log(`No commands registered for client ${clientConfig.id} in guild ${guildName}`);
return;
}
console.log(`Found ${commands.length} commands:`);
console.log(`Found ${commands.length} commands:`);
// Display commands in a formatted table
console.log('');
console.log('ID'.padEnd(20) + 'NAME'.padEnd(20) + 'DESCRIPTION'.padEnd(60));
// Display commands in a formatted table
console.log('');
console.log('ID'.padEnd(20) + 'NAME'.padEnd(20) + 'DESCRIPTION'.padEnd(60));
for (const cmd of commands) {
console.log(
`${cmd.id.toString().padEnd(20)}${cmd.name.padEnd(20)}${(cmd.description || '')}`
);
}
for (const cmd of commands) {
console.log(
`${cmd.id.toString().padEnd(20)}${cmd.name.padEnd(20)}${(cmd.description || '')}`
);
}
console.log('');
console.log('');
} catch (error) {
console.error(`Error listing commands for client ${clientConfig.id} in guild ${guildName}: ${error.message}`);
}
} catch (error) {
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
*/
async function registerCommands(clientConfig, guildId) {
const { id, discord } = clientConfig;
const { id, discord } = clientConfig;
if (!discord || !discord.token || !discord.appId) {
console.error(`Invalid client configuration for ${id}`);
return;
}
if (!discord || !discord.token || !discord.appId) {
console.error(`Invalid client configuration for ${id}`);
return;
}
// Extract commands from modules
const commands = await processClientModules(clientConfig);
// Extract commands from modules
const commands = await processClientModules(clientConfig);
if (commands.length === 0) {
console.log(`No commands found for client ${id}`);
return;
}
if (commands.length === 0) {
console.log(`No commands found for client ${id}`);
return;
}
// Set up REST client
const rest = new REST({ version: '10' }).setToken(discord.token);
// Set up REST client
const rest = new REST({ version: '10' }).setToken(discord.token);
// Determine route and scope description
let route;
let scopeDesc;
// Determine route and scope description
let route;
let scopeDesc;
if (guildId === null) {
route = Routes.applicationCommands(discord.appId);
scopeDesc = 'global';
} else {
route = Routes.applicationGuildCommands(discord.appId, guildId);
const guildInfo = await getGuildInfo(rest, guildId);
const guildName = guildInfo.name || `Unknown Guild (${guildId})`;
scopeDesc = `guild ${guildName} (${guildId})`;
}
if (guildId === null) {
route = Routes.applicationCommands(discord.appId);
scopeDesc = 'global';
} else {
route = Routes.applicationGuildCommands(discord.appId, guildId);
const guildInfo = await getGuildInfo(rest, guildId);
const guildName = guildInfo.name || `Unknown Guild (${guildId})`;
scopeDesc = `guild ${guildName} (${guildId})`;
}
// Register commands
console.log(`\nRegistering ${commands.length} commands for client ${id} in ${scopeDesc}...`);
// Register commands
console.log(`\nRegistering ${commands.length} commands for client ${id} in ${scopeDesc}...`);
// List commands being registered
console.log('\nCommands to register:');
for (const cmd of commands) {
console.log(` - ${cmd.name}: ${cmd.description}`);
}
// List commands being registered
console.log('\nCommands to register:');
for (const cmd of commands) {
console.log(` - ${cmd.name}: ${cmd.description}`);
}
if (dryRun) {
console.log(`\n[DRY RUN] Would register ${commands.length} commands for client ${id} in ${scopeDesc}`);
} else {
try {
await rest.put(route, { body: commands });
console.log(`\nSuccessfully registered ${commands.length} commands for client ${id} in ${scopeDesc}`);
} catch (error) {
console.error(`Error registering commands for client ${id} in ${scopeDesc}: ${error.message}`);
}
}
if (dryRun) {
console.log(`\n[DRY RUN] Would register ${commands.length} commands for client ${id} in ${scopeDesc}`);
} else {
try {
await rest.put(route, { body: commands });
console.log(`\nSuccessfully registered ${commands.length} commands for client ${id} in ${scopeDesc}`);
} catch (error) {
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
*/
async function unregisterCommands(clientConfig, guildId) {
const { id, discord } = clientConfig;
const { id, discord } = clientConfig;
if (!discord || !discord.token || !discord.appId) {
console.error(`Invalid client configuration for ${id}`);
return;
}
if (!discord || !discord.token || !discord.appId) {
console.error(`Invalid client configuration for ${id}`);
return;
}
// Set up REST client
const rest = new REST({ version: '10' }).setToken(discord.token);
// Set up REST client
const rest = new REST({ version: '10' }).setToken(discord.token);
// Determine route and scope description
let route;
let scopeDesc;
// Determine route and scope description
let route;
let scopeDesc;
if (guildId === null) {
route = Routes.applicationCommands(discord.appId);
scopeDesc = 'global';
} else {
route = Routes.applicationGuildCommands(discord.appId, guildId);
const guildInfo = await getGuildInfo(rest, guildId);
const guildName = guildInfo.name || `Unknown Guild (${guildId})`;
scopeDesc = `guild ${guildName} (${guildId})`;
}
if (guildId === null) {
route = Routes.applicationCommands(discord.appId);
scopeDesc = 'global';
} else {
route = Routes.applicationGuildCommands(discord.appId, guildId);
const guildInfo = await getGuildInfo(rest, guildId);
const guildName = guildInfo.name || `Unknown Guild (${guildId})`;
scopeDesc = `guild ${guildName} (${guildId})`;
}
// Get current commands to show what will be unregistered
try {
const currentCommands = await rest.get(route);
console.log(`\nFound ${currentCommands.length} commands for client ${id} in ${scopeDesc}`);
// Get current commands to show what will be unregistered
try {
const currentCommands = await rest.get(route);
console.log(`\nFound ${currentCommands.length} commands for client ${id} in ${scopeDesc}`);
if (currentCommands.length > 0) {
console.log('\nCommands to unregister:');
for (const cmd of currentCommands) {
console.log(` - ${cmd.name}: ${cmd.description}`);
}
} else {
console.log(`No commands to unregister for client ${id} in ${scopeDesc}`);
return;
}
if (currentCommands.length > 0) {
console.log('\nCommands to unregister:');
for (const cmd of currentCommands) {
console.log(` - ${cmd.name}: ${cmd.description}`);
}
} else {
console.log(`No commands to unregister for client ${id} in ${scopeDesc}`);
return;
}
if (dryRun) {
console.log(`\n[DRY RUN] Would unregister ${currentCommands.length} commands for client ${id} in ${scopeDesc}`);
} else {
await rest.put(route, { body: [] });
console.log(`\nSuccessfully unregistered all commands for client ${id} in ${scopeDesc}`);
}
} catch (error) {
console.error(`Error unregistering commands for client ${id} in ${scopeDesc}: ${error.message}`);
}
if (dryRun) {
console.log(`\n[DRY RUN] Would unregister ${currentCommands.length} commands for client ${id} in ${scopeDesc}`);
} else {
await rest.put(route, { body: [] });
console.log(`\nSuccessfully unregistered all commands for client ${id} in ${scopeDesc}`);
}
} catch (error) {
console.error(`Error unregistering commands for client ${id} in ${scopeDesc}: ${error.message}`);
}
}
// Main execution
async function main() {
console.log('');
console.log('Discord Command Registry Tool');
console.log('');
console.log('Discord Command Registry Tool');
console.log(`\nOperation: ${action.toUpperCase()}`);
console.log(`Target Guild: ${isGuildAll ? 'ALL (Global)' : targetGuildId}`);
console.log(`Target Client: ${isClientAll ? 'ALL' : targetClients[0].id}`);
console.log(`\nOperation: ${action.toUpperCase()}`);
console.log(`Target Guild: ${isGuildAll ? 'ALL (Global)' : targetGuildId}`);
console.log(`Target Client: ${isClientAll ? 'ALL' : targetClients[0].id}`);
if (dryRun) {
console.log('\n*** DRY RUN MODE - NO CHANGES WILL BE MADE ***');
}
if (dryRun) {
console.log('\n*** DRY RUN MODE - NO CHANGES WILL BE MADE ***');
}
// Process each client
for (const clientConfig of targetClients) {
// Skip disabled clients
if (clientConfig.enabled === false) {
console.log(`\nSkipping disabled client: ${clientConfig.id}`);
continue;
}
// Process each client
for (const clientConfig of targetClients) {
// Skip disabled clients
if (clientConfig.enabled === false) {
console.log(`\nSkipping disabled client: ${clientConfig.id}`);
continue;
}
console.log('');
console.log(`Processing client: ${clientConfig.id}`);
console.log('');
console.log(`Processing client: ${clientConfig.id}`);
if (isGuildAll) {
// Global operation
if (action === 'list') {
await listCommands(clientConfig, null);
} else if (action === 'register') {
await registerCommands(clientConfig, null);
} else if (action === 'unregister') {
await unregisterCommands(clientConfig, null);
}
} else {
// Guild-specific operation
if (action === 'list') {
await listCommands(clientConfig, targetGuildId);
} else if (action === 'register') {
await registerCommands(clientConfig, targetGuildId);
} else if (action === 'unregister') {
await unregisterCommands(clientConfig, targetGuildId);
}
}
}
if (isGuildAll) {
// Global operation
if (action === 'list') {
await listCommands(clientConfig, null);
} else if (action === 'register') {
await registerCommands(clientConfig, null);
} else if (action === 'unregister') {
await unregisterCommands(clientConfig, null);
}
} else {
// Guild-specific operation
if (action === 'list') {
await listCommands(clientConfig, targetGuildId);
} else if (action === 'register') {
await registerCommands(clientConfig, targetGuildId);
} else if (action === 'unregister') {
await unregisterCommands(clientConfig, targetGuildId);
}
}
}
console.log('');
console.log('Command registry operation complete');
console.log('');
console.log('Command registry operation complete');
}
main().catch(error => {
console.error('Fatal error:', error);
process.exit(1);
console.error('Fatal error:', error);
process.exit(1);
});