Compare commits
No commits in common. "main" and "scorekeeper-categories" have entirely different histories.
main
...
scorekeepe
@ -1,6 +0,0 @@
|
||||
node_modules/
|
||||
logs/
|
||||
images/
|
||||
dist/
|
||||
coverage/
|
||||
*.min.js
|
||||
@ -1,67 +0,0 @@
|
||||
{
|
||||
"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" }
|
||||
}]
|
||||
}
|
||||
}
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -3,7 +3,6 @@
|
||||
node_modules
|
||||
|
||||
.env
|
||||
.nvmrc
|
||||
images/*
|
||||
logs/*
|
||||
pocketbase/*
|
||||
|
||||
120
_opt/ansi.js
120
_opt/ansi.js
@ -1,120 +0,0 @@
|
||||
import { MessageFlags } from 'discord-api-types/v10';
|
||||
import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js';
|
||||
|
||||
import { CODES } from '../_src/ansiColors.js';
|
||||
|
||||
/**
|
||||
* Combined ANSI utilities module
|
||||
* - /ansi: preview nested [tag]…[/] ANSI coloring
|
||||
* - /ansitheme: display full BG×FG theme chart
|
||||
* Both commands are Admin-only.
|
||||
*/
|
||||
export const commands = [
|
||||
// Preview arbitrary ANSI tags
|
||||
{
|
||||
data: new SlashCommandBuilder()
|
||||
.setName('ansi')
|
||||
.setDescription('Preview an ANSI-colored code block')
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
|
||||
.setDMPermission(false)
|
||||
.addStringOption(opt =>
|
||||
opt
|
||||
.setName('text')
|
||||
.setDescription('Use [red]…[/], [bold,blue]…[/], escape \\[/]')
|
||||
.setRequired(true)
|
||||
)
|
||||
.addBooleanOption(opt =>
|
||||
opt
|
||||
.setName('ephemeral')
|
||||
.setDescription('Reply ephemerally?')
|
||||
.setRequired(false)
|
||||
),
|
||||
async execute(interaction, client) {
|
||||
const raw = interaction.options.getString('text', true);
|
||||
const ephemeral = interaction.options.getBoolean('ephemeral') ?? true;
|
||||
const colored = client.ansi`${raw}`;
|
||||
const block = client.wrapAnsi(colored);
|
||||
const opts = { content: block };
|
||||
if (ephemeral) opts.flags = MessageFlags.Ephemeral;
|
||||
await interaction.reply(opts);
|
||||
}
|
||||
},
|
||||
|
||||
// Show complete ANSI theme chart
|
||||
{
|
||||
data: new SlashCommandBuilder()
|
||||
.setName('ansitheme')
|
||||
.setDescription('Show ANSI color theme chart')
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
|
||||
.setDMPermission(false)
|
||||
.addBooleanOption(opt =>
|
||||
opt
|
||||
.setName('ephemeral')
|
||||
.setDescription('Reply ephemerally?')
|
||||
.setRequired(false)
|
||||
),
|
||||
async execute(interaction, client) {
|
||||
const fgs = ['gray', 'red', 'green', 'yellow', 'blue', 'pink', 'cyan', 'white'];
|
||||
const bgs = ['bgGray', 'bgOrange', 'bgBlue', 'bgTurquoise', 'bgFirefly', 'bgIndigo', 'bgLightGray', 'bgWhite'];
|
||||
const pad = 8;
|
||||
// Column header with padded labels (no colors) - shifted right by 1
|
||||
const header = ' ' + fgs.map(f => f.padEnd(pad, ' ')).join('');
|
||||
// Sample row with no background (padded cells)
|
||||
let defaultRow = '';
|
||||
for (const fg of fgs) {
|
||||
const fgCode = CODES[fg];
|
||||
const openNormal = `\u001b[${fgCode}m`;
|
||||
const openBold = `\u001b[${fgCode};${CODES.bold}m`;
|
||||
const openUnder = `\u001b[${fgCode};${CODES.underline}m`;
|
||||
const cell = `${openNormal}sa\u001b[0m${openBold}mp\u001b[0m${openUnder}le\u001b[0m`;
|
||||
defaultRow += ' ' + cell + ' ';
|
||||
}
|
||||
// Append default row label after one pad
|
||||
defaultRow += 'default';
|
||||
// Colored rows per background
|
||||
const rows = [];
|
||||
for (const bg of bgs) {
|
||||
let row = '';
|
||||
const bgCode = CODES[bg];
|
||||
for (const fg of fgs) {
|
||||
const fgCode = CODES[fg];
|
||||
const openNormal = `\u001b[${bgCode};${fgCode}m`;
|
||||
const openBold = `\u001b[${bgCode};${fgCode};${CODES.bold}m`;
|
||||
const openUnder = `\u001b[${bgCode};${fgCode};${CODES.underline}m`;
|
||||
const cell = `${openNormal}sa\u001b[0m${openBold}mp\u001b[0m${openUnder}le\u001b[0m`;
|
||||
row += ' ' + cell + ' ';
|
||||
}
|
||||
// Append uncolored row label immediately after cell padding
|
||||
row += bg;
|
||||
rows.push(row);
|
||||
}
|
||||
// Determine ephemeral setting
|
||||
const ephemeral = interaction.options.getBoolean('ephemeral') ?? true;
|
||||
// Initial sample table (header + default row)
|
||||
const sampleContent = [header, defaultRow].join('\n');
|
||||
const optsSample = { content: client.wrapAnsi(sampleContent) };
|
||||
if (ephemeral) optsSample.flags = MessageFlags.Ephemeral;
|
||||
await interaction.reply(optsSample);
|
||||
// Split colored rows into two tables
|
||||
const half = Math.ceil(rows.length / 2);
|
||||
const firstRows = rows.slice(0, half);
|
||||
const secondRows = rows.slice(half);
|
||||
// First colored table
|
||||
const table1 = [header, ...firstRows].join('\n');
|
||||
const opts1 = { content: client.wrapAnsi(table1) };
|
||||
if (ephemeral) opts1.flags = MessageFlags.Ephemeral;
|
||||
await interaction.followUp(opts1);
|
||||
// Second colored table
|
||||
if (secondRows.length > 0) {
|
||||
const table2 = [header, ...secondRows].join('\n');
|
||||
const opts2 = { content: client.wrapAnsi(table2) };
|
||||
if (ephemeral) opts2.flags = MessageFlags.Ephemeral;
|
||||
await interaction.followUp(opts2);
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
export async function init(client) {
|
||||
client.logger.info('[module:ansi] Loaded ANSI utilities');
|
||||
}
|
||||
167
_opt/botUtils.js
167
_opt/botUtils.js
@ -1,167 +0,0 @@
|
||||
import { MessageFlags } from 'discord-api-types/v10';
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder } from 'discord.js';
|
||||
|
||||
/**
|
||||
* botUtils module - provides administrative bot control commands
|
||||
* Currently implements an owner-only exit command for graceful shutdown.
|
||||
*/
|
||||
// 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)
|
||||
),
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Slash command `/status` (Administrator only):
|
||||
* 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```';
|
||||
}
|
||||
}
|
||||
// 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 handleInteractionCreate(_client, _clientConfig, _interaction) {
|
||||
// ... existing code ...
|
||||
}
|
||||
@ -78,6 +78,7 @@ 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 = '') {
|
||||
@ -85,17 +86,17 @@ export const init = async (client, config) => {
|
||||
debug(`**AI Prompt**: ${prompt}`);
|
||||
|
||||
// Read instructions.
|
||||
const openAIInstructions = fs.readFileSync(openAIInstructionsFile, 'utf8');
|
||||
let 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}`},
|
||||
],
|
||||
});
|
||||
const chunk = completion.choices[0]?.message?.content;
|
||||
if (chunk !== '') {
|
||||
let 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);
|
||||
@ -141,7 +142,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
|
||||
@ -152,9 +153,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
|
||||
@ -180,7 +181,7 @@ export const init = async (client, config) => {
|
||||
debug(`**Incident Cycle #${incidentCounter++}**`);
|
||||
|
||||
// Rebuild the list of current index cases, if any.
|
||||
const indexesList = guild.members.cache.filter(member => member.roles.cache.has(indexRole.id));
|
||||
let indexesList = guild.members.cache.filter(member => member.roles.cache.has(indexRole.id));
|
||||
debug(`${bullet} Index Cases: **${indexesList.size}**`);
|
||||
|
||||
// Build the victimsList using whitelisted roles.
|
||||
@ -205,7 +206,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();
|
||||
@ -239,7 +240,7 @@ export const init = async (client, config) => {
|
||||
}
|
||||
|
||||
// Prepare the next cycle.
|
||||
const interval = cycleInterval + Math.floor(Math.random() * (2 * cycleIntervalRange + 1)) - cycleIntervalRange;
|
||||
let 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) {
|
||||
@ -290,16 +291,17 @@ 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;
|
||||
@ -314,7 +316,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++;
|
||||
}
|
||||
@ -332,7 +334,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) && Math.random() * 100 === antiViralEffectiveness) {
|
||||
if (message.member.roles.cache.has(antiViralRole.id)) {
|
||||
percentage = Math.round(percentage - (antiViralEffectiveness * (percentage / 100)));
|
||||
}
|
||||
|
||||
@ -357,14 +359,14 @@ export const init = async (client, config) => {
|
||||
anomaly('messageCreate', error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Deferred setup on ready
|
||||
const readyHandler = async () => {
|
||||
client.logger.info('[module:condimentX] Initializing module');
|
||||
client.logger.info('Initializing CondimentX module');
|
||||
if (openAI === true) {
|
||||
openai = new OpenAI({ apiKey: openAIToken }); // credentials loaded
|
||||
openai = new OpenAI({ apiKey: openAIToken });
|
||||
openAIWebhook = await client.fetchWebhook(openAIWebhookID, openAIWebhookToken).catch(error => {
|
||||
client.logger.error(`[module:condimentX] Could not fetch webhook: ${error.message}`);
|
||||
client.logger.error(`Could not fetch webhook: ${error.message}`);
|
||||
return null;
|
||||
});
|
||||
if (openAIWebhook) openAIWebhookClient = new WebhookClient({ id: openAIWebhookID, token: openAIWebhookToken });
|
||||
@ -372,7 +374,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(`CondimentX error: Guild ${guildID} not found`);
|
||||
return;
|
||||
}
|
||||
indexRole = await guild.roles.fetch(indexRoleID);
|
||||
|
||||
179
_opt/gitUtils.js
179
_opt/gitUtils.js
@ -1,179 +0,0 @@
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a git command with given arguments and return its output.
|
||||
* @param {string[]} args - Git command arguments (e.g., ['status', '--porcelain']).
|
||||
* @returns {Promise<string>} - Trimmed stdout or stderr from the command.
|
||||
* @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}`);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap content into a Markdown code block, optionally specifying a language.
|
||||
* @param {string} content - The text to wrap in a code block.
|
||||
* @param {string} [lang] - Optional language identifier (e.g., 'js').
|
||||
* @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}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a large string into smaller chunks for message limits.
|
||||
* @param {string} str - The input string to split.
|
||||
* @param {number} chunkSize - Maximum length of each chunk.
|
||||
* @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;
|
||||
}
|
||||
|
||||
// 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'));
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// No special init logic
|
||||
export async function init(client) {
|
||||
client.logger.warn('[module:gitUtils] Git utilities module loaded - dangerous module, use with caution');
|
||||
}
|
||||
// Helper functions for external use
|
||||
/**
|
||||
* Get current Git branch name
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
export async function getBranch() {
|
||||
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']);
|
||||
}
|
||||
/**
|
||||
* Get concise working tree status (git status --porcelain)
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
export async function getStatusShort() {
|
||||
return runGit(['status', '--porcelain']);
|
||||
}
|
||||
/**
|
||||
* Get Git remote origin URL
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
export async function getRemoteUrl() {
|
||||
return runGit(['config', '--get', 'remote.origin.url']);
|
||||
}
|
||||
/**
|
||||
* Get recent commit log (n lines, one-line format)
|
||||
* @param {number} [n=5]
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
export async function getLog(n = 5) {
|
||||
return runGit(['log', `-n${n}`, '--oneline']);
|
||||
}
|
||||
/**
|
||||
* Get diff summary (git diff --stat)
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
export async function getDiffStat() {
|
||||
return runGit(['diff', '--stat']);
|
||||
}
|
||||
@ -1,28 +0,0 @@
|
||||
// _opt/messageQueue-example.js
|
||||
import { onMessageQueueEvent } from './pbUtils.js';
|
||||
|
||||
/**
|
||||
* Example module that listens for 'test' messages in the message_queue collection.
|
||||
*/
|
||||
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;
|
||||
|
||||
// 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}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
680
_opt/pbUtils.js
680
_opt/pbUtils.js
@ -1,12 +1,4 @@
|
||||
// _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;
|
||||
}
|
||||
|
||||
/**
|
||||
* PocketBase utilities module - extends PocketBase client with useful shortcuts
|
||||
@ -17,444 +9,382 @@ if (typeof global.EventSource === 'undefined') {
|
||||
* @param {Object} client - Discord client with attached PocketBase instance
|
||||
* @param {Object} config - Client configuration
|
||||
*/
|
||||
export async function init(client, _config) {
|
||||
const { pb, logger } = client;
|
||||
export const init = async (client, config) => {
|
||||
const { pb, logger } = client;
|
||||
|
||||
logger.info('[module:pbUtils] Initializing PocketBase utilities module');
|
||||
logger.info('Initializing PocketBase utilities module');
|
||||
|
||||
// Attach utility methods to the pb object
|
||||
extendPocketBase(client, pb, logger);
|
||||
// Attach utility methods to the pb object
|
||||
extendPocketBase(pb, logger);
|
||||
|
||||
// Add connection state handling
|
||||
setupConnectionHandling(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}`);
|
||||
}
|
||||
|
||||
// end of init()
|
||||
|
||||
logger.info('PocketBase utilities module initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a handler for incoming message_queue pub/sub events.
|
||||
* Other modules can import and use this to react to external messages.
|
||||
* @param {import('discord.js').Client} client - The Discord client
|
||||
* @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}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
logger.info('PocketBase utilities module initialized');
|
||||
};
|
||||
|
||||
/**
|
||||
* Extends the PocketBase instance with utility methods
|
||||
* @param {Object} pb - PocketBase instance
|
||||
* @param {Object} logger - Winston logger
|
||||
*/
|
||||
/**
|
||||
* Adds utility methods to the PocketBase client.
|
||||
* @param {import('discord.js').Client} client - The Discord client
|
||||
* @param {object} pb - The PocketBase instance
|
||||
* @param {object} logger - Logger instance
|
||||
*/
|
||||
const extendPocketBase = (client, pb, logger) => {
|
||||
// ===== COLLECTION OPERATIONS =====
|
||||
const extendPocketBase = (pb, logger) => {
|
||||
// ===== 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;
|
||||
}
|
||||
};
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Convenience: publish a message into the "message_queue" collection,
|
||||
* with source/destination validation.
|
||||
* @param {string} source - origin (client id or 'external')
|
||||
* @param {string} destination - target client id
|
||||
* @param {string} dataType - message type
|
||||
* @param {object} data - JSON-serializable payload
|
||||
* @returns {Promise<Object>} created record
|
||||
*/
|
||||
pb.publishMessage = async (source, destination, dataType, data) => {
|
||||
// Valid sources: all configured clients + 'external'
|
||||
const validSources = client.config.clients.map(c => c.id).concat('external');
|
||||
if (!validSources.includes(source)) throw new Error(`Invalid message source: ${source}`);
|
||||
// Valid destinations: all configured clients
|
||||
const validDest = client.config.clients.map(c => c.id);
|
||||
if (!validDest.includes(destination)) throw new Error(`Invalid message destination: ${destination}`);
|
||||
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;
|
||||
const isRunning = true;
|
||||
while (isRunning) {
|
||||
try {
|
||||
const result = await pb.collection(collection).getList(page, pageSize, options);
|
||||
records.push(...result.items);
|
||||
pb.getAll = async (collection, options = {}) => {
|
||||
const records = [];
|
||||
const pageSize = options.pageSize || 200;
|
||||
let page = 1;
|
||||
|
||||
if (records.length >= result.totalItems) {
|
||||
break;
|
||||
}
|
||||
try {
|
||||
while (true) {
|
||||
const result = await pb.collection(collection).getList(page, pageSize, options);
|
||||
records.push(...result.items);
|
||||
|
||||
page++;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to get all records from ${collection}: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (records.length >= result.totalItems) {
|
||||
break;
|
||||
}
|
||||
|
||||
return records;
|
||||
};
|
||||
page++;
|
||||
}
|
||||
|
||||
/**
|
||||
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 =====
|
||||
// ===== 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;
|
||||
}
|
||||
};
|
||||
/**
|
||||
* Delete a message in the "message_queue" collection by its record ID.
|
||||
* @param {string} id - Record ID to delete.
|
||||
* @returns {Promise<boolean>} True if deleted or not found, false on error.
|
||||
*/
|
||||
pb.deleteMessageQueue = async (id) => {
|
||||
return await pb.deleteOne('message_queue', id);
|
||||
};
|
||||
return results;
|
||||
} catch (error) {
|
||||
logger.error(`Failed batch delete in ${collection}: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// ===== 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 +393,80 @@ 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) {
|
||||
// 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
|
||||
// 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;
|
||||
}
|
||||
// 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) {
|
||||
await pb.admins.authRefresh();
|
||||
} else if (pb._config.username && pb._config.password) {
|
||||
// Fall back to full re-authentication if credentials available
|
||||
await pb.admins.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);
|
||||
};
|
||||
};
|
||||
|
||||
@ -3,15 +3,11 @@
|
||||
* Listens to message events, sends chat queries to the OpenAI Responses API,
|
||||
* and handles text or image (function_call) outputs.
|
||||
*/
|
||||
// Removed local file fallback; prompt now comes exclusively from PocketBase via responsesPrompt module
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
import { OpenAI } from 'openai';
|
||||
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;
|
||||
@ -23,75 +19,94 @@ 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 (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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
if (chunk) {
|
||||
// close any unclosed code block
|
||||
// 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;
|
||||
}
|
||||
// remove trailing newline from each chunk
|
||||
return chunks.map(c => c.endsWith('\n') ? c.slice(0, -1) : c);
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load AI system prompt text from a file.
|
||||
* @param {string} filePath - Path to the prompt file.
|
||||
* @param {object} logger - Logger instance for reporting.
|
||||
* @returns {Promise<string>} Promise resolving to the prompt text or empty string.
|
||||
*/
|
||||
async function loadSystemPrompt(filePath, logger) {
|
||||
try {
|
||||
const prompt = await fs.readFile(path.resolve(filePath), 'utf8');
|
||||
logger.info(`Loaded system prompt: ${filePath}`);
|
||||
return prompt;
|
||||
} catch (err) {
|
||||
logger.error(`Failed to load system prompt: ${err.message}`);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the bot should respond to a message.
|
||||
* Controlled by enableMentions and enableReplies in config.
|
||||
* Triggers when the bot is mentioned or when the message is a direct reply.
|
||||
* @param {Message} message - The incoming Discord message.
|
||||
* @param {string} botId - The bot user ID.
|
||||
* @param {object} logger - Logger for debugging.
|
||||
* @returns {Promise<boolean>} True if the bot should respond.
|
||||
*/
|
||||
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;
|
||||
async function shouldRespond(message, botId, logger) {
|
||||
if (message.author.bot || !botId) return false;
|
||||
const isMention = message.mentions.users.has(botId);
|
||||
let isReply = false;
|
||||
if (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;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -102,7 +117,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);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -113,10 +128,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)
|
||||
.catch(err => client.logger.error(`Scorekeeper error: ${err.message}`));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -129,112 +144,106 @@ 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;
|
||||
}
|
||||
// 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}`);
|
||||
}
|
||||
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 { 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;
|
||||
}
|
||||
// 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 }));
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -245,213 +254,181 @@ 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;
|
||||
if (!(await shouldRespond(message, botId, logger))) return;
|
||||
await message.channel.sendTyping();
|
||||
|
||||
// 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(() => {});
|
||||
// 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 () => {
|
||||
try {
|
||||
// Previous response ID for context continuity
|
||||
const prev = client.pb?.cache?.get(key);
|
||||
// Enforce minimum score to use AI responses
|
||||
// Enforce minimum score to use AI responses if scorekeeper is enabled
|
||||
if (client.scorekeeper) {
|
||||
try {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
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) {
|
||||
logger.error(`Queued onMessage error for ${key}: ${err.message}`);
|
||||
} finally {
|
||||
clearInterval(typingInterval);
|
||||
client.logger.error(`Error checking score: ${err.message}`);
|
||||
}
|
||||
};
|
||||
// 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;
|
||||
}
|
||||
// Build request body, prefixing with a mention of who spoke
|
||||
const speakerMention = `<@${message.author.id}>`;
|
||||
const body = {
|
||||
model: cfg.defaultModel,
|
||||
instructions: client.responsesSystemPrompt,
|
||||
input: `${speakerMention} said to you: ${message.content}`,
|
||||
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;
|
||||
}
|
||||
|
||||
// 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}`);
|
||||
}
|
||||
};
|
||||
// Chain the handler to the last promise
|
||||
const next = last.then(handler).catch(err => logger.error(err));
|
||||
lockMap.set(key, next);
|
||||
// Queue enqueued; handler will send response when its turn arrives
|
||||
return;
|
||||
|
||||
// Call OpenAI Responses
|
||||
let resp;
|
||||
try {
|
||||
logger.debug(`Calling AI with body: ${JSON.stringify(body)}`);
|
||||
resp = await client.openai.responses.create(body);
|
||||
logger.info(`AI response id=${resp.id}`);
|
||||
// Award tokens for the AI chat response immediately (captures token usage even if image follows)
|
||||
const chatTokens = resp.usage?.total_tokens ?? resp.usage?.completion_tokens ?? 0;
|
||||
awardOutput(client, message.guild.id, message.author.id, chatTokens);
|
||||
} catch (err) {
|
||||
logger.error(`AI error: ${err.message}`);
|
||||
return message.reply('Error generating response.');
|
||||
}
|
||||
|
||||
// Cache for next turn only if this was a text response
|
||||
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 (split if over Discord limit)
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
@ -462,52 +439,37 @@ 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;
|
||||
}
|
||||
// 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}`);
|
||||
const instructions = `${client.responsesSystemPrompt}\n\nGenerate the following as an engaging narrative:`;
|
||||
const body = {
|
||||
model: cfg.defaultModel,
|
||||
instructions,
|
||||
input: text,
|
||||
max_output_tokens: cfg.defaultMaxTokens,
|
||||
temperature: cfg.defaultTemperature,
|
||||
};
|
||||
logger.debug('sendNarrative: calling AI with body', body);
|
||||
const resp = await client.openai.responses.create(body);
|
||||
logger.info(`sendNarrative 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}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -519,11 +481,10 @@ 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('Initializing Responses module');
|
||||
client.responsesSystemPrompt = await loadSystemPrompt(cfg.systemPromptPath, client.logger);
|
||||
client.openai = new OpenAI({ apiKey: cfg.apiKey });
|
||||
client.on('messageCreate', m => onMessage(client, cfg, m));
|
||||
client.logger.info('Responses module ready');
|
||||
}
|
||||
|
||||
@ -1,155 +0,0 @@
|
||||
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';
|
||||
// Placeholder info for template variables
|
||||
const TEMPLATE_KEYS_INFO = 'Available keys: userName, userId, locationName, locationId, date, time, datetime, clientId';
|
||||
|
||||
// Modal text input limits
|
||||
const MAX_LEN = 4000;
|
||||
const MAX_FIELDS = 5;
|
||||
|
||||
/**
|
||||
* responsesPrompt module
|
||||
* Implements `/prompt [version]` to edit the current or historical prompt in a single PocketBase collection.
|
||||
* 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}`);
|
||||
}
|
||||
}
|
||||
// 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 = 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, 1, { filter: `clientId="${_clientId}"`, sort: '-created' });
|
||||
client.responsesPrompt = items[0]?.prompt || '';
|
||||
} catch (err) {
|
||||
client.logger.error(`Error loading current prompt: ${err.message}`);
|
||||
client.responsesPrompt = '';
|
||||
}
|
||||
_clients.push({ client, clientConfig });
|
||||
// Autocomplete versions
|
||||
client.on('interactionCreate', async interaction => {
|
||||
if (!interaction.isAutocomplete() || interaction.commandName !== 'prompt') return;
|
||||
const focused = interaction.options.getFocused(true);
|
||||
if (focused.name === 'version') {
|
||||
try {
|
||||
const { items } = await client.pb.collection('responses_prompts')
|
||||
.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 });
|
||||
});
|
||||
}
|
||||
@ -1,16 +1,12 @@
|
||||
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'.
|
||||
* Defines and handles the /query command via the OpenAI Responses API,
|
||||
* 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.
|
||||
@ -19,19 +15,19 @@ import { expandTemplate } from '../_src/template.js';
|
||||
* @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 = '';
|
||||
}
|
||||
chunk += next;
|
||||
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 = '';
|
||||
}
|
||||
if (chunk) chunks.push(chunk);
|
||||
return chunks;
|
||||
chunk += next;
|
||||
}
|
||||
if (chunk) chunks.push(chunk);
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -45,112 +41,106 @@ 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 {
|
||||
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 { 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;
|
||||
}
|
||||
// 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;
|
||||
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 }));
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -163,198 +153,168 @@ 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 });
|
||||
|
||||
// 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 });
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
// 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;
|
||||
{
|
||||
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(`Error checking score: ${err.message}`);
|
||||
return interaction.reply({ content: 'Error verifying your score. Please try again later.', ephemeral: true });
|
||||
}
|
||||
}
|
||||
const prompt = interaction.options.getString('prompt');
|
||||
const flag = interaction.options.getBoolean('ephemeral');
|
||||
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 () => {
|
||||
// Read previous response ID
|
||||
const previous = client.pb?.cache?.get(key);
|
||||
// Build request body
|
||||
const body = {
|
||||
model: cfg.defaultModel,
|
||||
instructions: client.responsesSystemPrompt,
|
||||
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)
|
||||
.catch(e => client.logger.error(`Scorekeeper error: ${e.message}`));
|
||||
}
|
||||
} catch (err) {
|
||||
client.logger.error(`AI error in /query: ${err.message}`);
|
||||
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));
|
||||
}
|
||||
|
||||
// Handle image function call if present
|
||||
if (await handleImageInteraction(client, interaction, resp, cfg, ephemeral)) {
|
||||
return;
|
||||
}
|
||||
// Send text reply chunks
|
||||
const text = resp.output_text?.trim() || '';
|
||||
if (!text) {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
};
|
||||
// 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;
|
||||
}
|
||||
];
|
||||
}
|
||||
];
|
||||
@ -1,37 +0,0 @@
|
||||
/**
|
||||
* responsesRandomizer module
|
||||
* Listens to all guild messages and randomly sends a generated narrative.
|
||||
* Uses sendNarrative from responses.js.
|
||||
*/
|
||||
|
||||
import { sendNarrative } from './responses.js';
|
||||
|
||||
/**
|
||||
* Initialize the responsesRandomizer module.
|
||||
* @param {import('discord.js').Client} client - Discord client instance.
|
||||
* @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}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -1,364 +1,363 @@
|
||||
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(`Failed to parse timestamp in hangarsync command: ${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)) {
|
||||
client.logger.error('[cmd:hangarsync] PocketBase not connected');
|
||||
// Check PocketBase connection status
|
||||
if (!isPocketBaseConnected(client)) {
|
||||
client.logger.error('PocketBase not connected when executing hangarsync command');
|
||||
|
||||
// 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(`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(`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)}>`);
|
||||
} 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(`Executive hangar status has been synced: <t:${Math.ceil(syncEpoch / 1000)}>`);
|
||||
} catch (error) {
|
||||
client.logger.error(`Error in hangarsync command: ${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)),
|
||||
|
||||
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)) {
|
||||
client.logger.error('[cmd:hangarstatus] PocketBase not connected');
|
||||
// Check PocketBase connection status
|
||||
if (!isPocketBaseConnected(client)) {
|
||||
client.logger.error('PocketBase not connected when executing hangarstatus command');
|
||||
|
||||
// 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(`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.info(`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 * 1000;
|
||||
const turningOffDuration = 5 * 12 * 1000;
|
||||
// Key positions in the cycle
|
||||
const allOffDuration = 5;
|
||||
const turningGreenDuration = 5 * 24;
|
||||
const turningOffDuration = 5 * 12;
|
||||
|
||||
// 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 async function init(client, _config) {
|
||||
client.logger.info('Initializing Star Citizen Hangar Status module');
|
||||
export const init = async (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');
|
||||
};
|
||||
@ -1,150 +1,150 @@
|
||||
// Example of another module using scorekeeper
|
||||
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;
|
||||
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;
|
||||
|
||||
// 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);
|
||||
} 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)
|
||||
.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`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
1460
_opt/scorekeeper.js
1460
_opt/scorekeeper.js
File diff suppressed because it is too large
Load Diff
677
_opt/tempvc.js
677
_opt/tempvc.js
@ -1,677 +0,0 @@
|
||||
import { MessageFlags } from 'discord-api-types/v10';
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, EmbedBuilder } from 'discord.js';
|
||||
// Init function to handle autocomplete for /vc invite
|
||||
/**
|
||||
* tempvc module: temporary voice channel manager
|
||||
*
|
||||
* Admin commands (/vcadmin):
|
||||
* - add <voice_channel> <category>
|
||||
* - remove <voice_channel>
|
||||
* - list
|
||||
*
|
||||
* User commands (/vc):
|
||||
* Access Control:
|
||||
* • invite <user>
|
||||
* • kick <user>
|
||||
* • role <role>
|
||||
* • mode <whitelist|blacklist>
|
||||
* • limit <0-99>
|
||||
* Presets:
|
||||
* • save <name>
|
||||
* • restore <name>
|
||||
* • reset
|
||||
* Utilities:
|
||||
* • rename <new_name>
|
||||
* • info
|
||||
* • delete
|
||||
*
|
||||
* PocketBase collections required:
|
||||
* tempvc_masters (guildId, masterChannelId, categoryId)
|
||||
* tempvc_sessions (guildId, masterChannelId, channelId, ownerId, roleId, mode)
|
||||
* tempvc_presets (guildId, userId, name, channelName, userLimit, roleId, invitedUserIds, mode)
|
||||
*/
|
||||
|
||||
// Temporary Voice Channel module
|
||||
// - /vcadmin: admin commands to add/remove/list spawn channels
|
||||
// - /vc: user commands to manage own temp VC, presets save/restore
|
||||
|
||||
/**
|
||||
* Slash commands for vcadmin and vc
|
||||
*/
|
||||
export const commands = [
|
||||
// Administrator: manage spawn points
|
||||
{
|
||||
data: new SlashCommandBuilder()
|
||||
.setName('vcadmin')
|
||||
.setDescription('Configure temporary voice-channel spawn points (Admin only)')
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
|
||||
.setDMPermission(false)
|
||||
.addSubcommand(sub =>
|
||||
sub.setName('add')
|
||||
.setDescription('Add a spawn voice channel and its temp category')
|
||||
.addChannelOption(opt =>
|
||||
opt.setName('voice_channel')
|
||||
.setDescription('Voice channel to spawn from')
|
||||
.setRequired(true)
|
||||
.addChannelTypes(ChannelType.GuildVoice)
|
||||
)
|
||||
.addChannelOption(opt =>
|
||||
opt.setName('category')
|
||||
.setDescription('Category for new temp channels')
|
||||
.setRequired(true)
|
||||
.addChannelTypes(ChannelType.GuildCategory)
|
||||
)
|
||||
)
|
||||
.addSubcommand(sub =>
|
||||
sub.setName('remove')
|
||||
.setDescription('Remove a spawn voice channel')
|
||||
.addChannelOption(opt =>
|
||||
opt.setName('voice_channel')
|
||||
.setDescription('Voice channel to remove')
|
||||
.setRequired(true)
|
||||
.addChannelTypes(ChannelType.GuildVoice)
|
||||
)
|
||||
)
|
||||
.addSubcommand(sub =>
|
||||
sub.setName('list')
|
||||
.setDescription('List all spawn voice channels and categories')
|
||||
),
|
||||
async execute(interaction, client) {
|
||||
const guildId = interaction.guildId;
|
||||
const sub = interaction.options.getSubcommand();
|
||||
// ensure in-guild
|
||||
if (!guildId) {
|
||||
return interaction.reply({ content: 'This command can only be used in a server.', flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
// init memory map for this guild
|
||||
client.tempvc = client.tempvc || { masters: new Map(), sessions: new Map() };
|
||||
if (!client.tempvc.masters.has(guildId)) {
|
||||
client.tempvc.masters.set(guildId, new Map());
|
||||
}
|
||||
const guildMasters = client.tempvc.masters.get(guildId);
|
||||
try {
|
||||
if (sub === 'add') {
|
||||
const vc = interaction.options.getChannel('voice_channel', true);
|
||||
const cat = interaction.options.getChannel('category', true);
|
||||
// persist
|
||||
const existing = await client.pb.getFirst(
|
||||
'tempvc_masters',
|
||||
`guildId = "${guildId}" && masterChannelId = "${vc.id}"`
|
||||
);
|
||||
if (existing) {
|
||||
await client.pb.updateOne('tempvc_masters', existing.id, {
|
||||
guildId, masterChannelId: vc.id, categoryId: cat.id
|
||||
});
|
||||
} else {
|
||||
await client.pb.createOne('tempvc_masters', {
|
||||
guildId, masterChannelId: vc.id, categoryId: cat.id
|
||||
});
|
||||
}
|
||||
// update memory
|
||||
guildMasters.set(vc.id, cat.id);
|
||||
await interaction.reply({
|
||||
content: `Spawn channel <#${vc.id}> will now create temp VCs in <#${cat.id}>.`,
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
} else if (sub === 'remove') {
|
||||
const vc = interaction.options.getChannel('voice_channel', true);
|
||||
if (!guildMasters.has(vc.id)) {
|
||||
return interaction.reply({ content: 'That channel is not configured as a spawn point.', flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
// remove from PB
|
||||
const existing = await client.pb.getFirst(
|
||||
'tempvc_masters',
|
||||
`guildId = "${guildId}" && masterChannelId = "${vc.id}"`
|
||||
);
|
||||
if (existing) {
|
||||
await client.pb.deleteOne('tempvc_masters', existing.id);
|
||||
}
|
||||
// update memory
|
||||
guildMasters.delete(vc.id);
|
||||
await interaction.reply({ content: `Removed spawn channel <#${vc.id}>.`, flags: MessageFlags.Ephemeral });
|
||||
} else if (sub === 'list') {
|
||||
if (guildMasters.size === 0) {
|
||||
return interaction.reply({ content: 'No spawn channels configured.', flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
const lines = [];
|
||||
for (const [mId, cId] of guildMasters.entries()) {
|
||||
lines.push(`<#${mId}> → <#${cId}>`);
|
||||
}
|
||||
await interaction.reply({ content: '**Spawn channels:**\n' + lines.join('\n'), flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
} catch (err) {
|
||||
client.logger.error(`[module:tempvc][vcadmin] ${err.message}`);
|
||||
await interaction.reply({ content: 'Operation failed, see logs.', flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
}
|
||||
},
|
||||
// User: manage own temp VC and presets
|
||||
{
|
||||
data: new SlashCommandBuilder()
|
||||
.setName('vc')
|
||||
.setDescription('Manage your temporary voice channel')
|
||||
.setDMPermission(false)
|
||||
// Access Control
|
||||
.addSubcommand(sub =>
|
||||
sub.setName('invite')
|
||||
.setDescription('Invite a user to this channel')
|
||||
// Autocomplete string option for user ID
|
||||
.addStringOption(opt =>
|
||||
opt.setName('user')
|
||||
.setDescription('User to invite')
|
||||
.setRequired(true)
|
||||
.setAutocomplete(true)
|
||||
)
|
||||
)
|
||||
.addSubcommand(sub =>
|
||||
sub.setName('kick')
|
||||
.setDescription('Kick a user from this channel')
|
||||
.addUserOption(opt => opt.setName('user').setDescription('User to kick').setRequired(true))
|
||||
)
|
||||
.addSubcommand(sub =>
|
||||
sub.setName('role')
|
||||
.setDescription('Set role to allow/deny access')
|
||||
.addRoleOption(opt => opt.setName('role').setDescription('Role to allow/deny').setRequired(true))
|
||||
)
|
||||
.addSubcommand(sub =>
|
||||
sub.setName('mode')
|
||||
.setDescription('Switch role mode')
|
||||
.addStringOption(opt =>
|
||||
opt.setName('mode')
|
||||
.setDescription('Mode: whitelist or blacklist')
|
||||
.setRequired(true)
|
||||
.addChoices(
|
||||
{ name: 'whitelist', value: 'whitelist' },
|
||||
{ name: 'blacklist', value: 'blacklist' }
|
||||
)
|
||||
)
|
||||
)
|
||||
.addSubcommand(sub =>
|
||||
sub.setName('limit')
|
||||
.setDescription('Set user limit (0–99)')
|
||||
.addIntegerOption(opt => opt.setName('number').setDescription('Max users').setRequired(true))
|
||||
)
|
||||
// Presets
|
||||
.addSubcommand(sub =>
|
||||
sub.setName('save')
|
||||
.setDescription('Save current settings as a preset')
|
||||
.addStringOption(opt => opt.setName('name').setDescription('Preset name').setRequired(true).setAutocomplete(true))
|
||||
)
|
||||
.addSubcommand(sub =>
|
||||
sub.setName('restore')
|
||||
.setDescription('Restore settings from a preset')
|
||||
.addStringOption(opt => opt.setName('name').setDescription('Preset name').setRequired(true).setAutocomplete(true))
|
||||
)
|
||||
.addSubcommand(sub => sub.setName('reset').setDescription('Reset channel to default settings'))
|
||||
// Utilities
|
||||
.addSubcommand(sub => sub.setName('rename').setDescription('Rename this channel').addStringOption(opt => opt.setName('new_name').setDescription('New channel name').setRequired(true)))
|
||||
.addSubcommand(sub => sub.setName('info').setDescription('Show channel info'))
|
||||
.addSubcommand(sub => sub.setName('delete').setDescription('Delete this channel')),
|
||||
async execute(interaction, client) {
|
||||
const guild = interaction.guild;
|
||||
const member = interaction.member;
|
||||
const sub = interaction.options.getSubcommand();
|
||||
// must be in guild and in voice
|
||||
if (!guild || !member || !member.voice.channel) {
|
||||
return interaction.reply({ content: 'You must be in a temp voice channel to use this.', flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
const voice = member.voice.channel;
|
||||
client.tempvc = client.tempvc || { masters: new Map(), sessions: new Map() };
|
||||
const sess = client.tempvc.sessions.get(voice.id);
|
||||
if (!sess) {
|
||||
return interaction.reply({ content: 'This is not one of my temporary channels.', flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
if (sess.ownerId !== interaction.user.id) {
|
||||
return interaction.reply({ content: 'Only the room owner can do that.', flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
try {
|
||||
if (sub === 'rename') {
|
||||
const name = interaction.options.getString('new_name', true);
|
||||
await voice.setName(name);
|
||||
await interaction.reply({ content: `Channel renamed to **${name}**.`, flags: MessageFlags.Ephemeral });
|
||||
} else if (sub === 'invite') {
|
||||
// Invitation: support both string (autocomplete) and user option types
|
||||
let userId;
|
||||
let memberToInvite;
|
||||
// Try string option first (autocomplete)
|
||||
try {
|
||||
userId = interaction.options.getString('user', true);
|
||||
memberToInvite = await guild.members.fetch(userId);
|
||||
} catch (e) {
|
||||
// Fallback to user option
|
||||
try {
|
||||
const user = interaction.options.getUser('user', true);
|
||||
userId = user.id;
|
||||
memberToInvite = await guild.members.fetch(userId);
|
||||
} catch {
|
||||
memberToInvite = null;
|
||||
}
|
||||
}
|
||||
if (!memberToInvite) {
|
||||
return interaction.reply({ content: 'User not found in this server.', flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
// grant view and connect
|
||||
await voice.permissionOverwrites.edit(userId, { ViewChannel: true, Connect: true });
|
||||
await interaction.reply({ content: `Invited <@${userId}>.`, flags: MessageFlags.Ephemeral });
|
||||
} else if (sub === 'kick') {
|
||||
const u = interaction.options.getUser('user', true);
|
||||
const gm = await guild.members.fetch(u.id);
|
||||
// move them out if in this channel
|
||||
if (gm.voice.channelId === voice.id) {
|
||||
await gm.voice.setChannel(null);
|
||||
}
|
||||
// remove any previous invite allow
|
||||
try {
|
||||
await voice.permissionOverwrites.delete(u.id);
|
||||
} catch {}
|
||||
await interaction.reply({ content: `Kicked <@${u.id}>.`, flags: MessageFlags.Ephemeral });
|
||||
} else if (sub === 'limit') {
|
||||
const num = interaction.options.getInteger('number', true);
|
||||
// enforce range 0-99
|
||||
if (num < 0 || num > 99) {
|
||||
return interaction.reply({ content: 'User limit must be between 0 (no limit) and 99.', flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
await voice.setUserLimit(num);
|
||||
await interaction.reply({ content: `User limit set to ${num}.`, flags: MessageFlags.Ephemeral });
|
||||
} else if (sub === 'role') {
|
||||
const newRole = interaction.options.getRole('role', true);
|
||||
const oldRoleId = sess.roleId;
|
||||
// remove old role overwrite if any
|
||||
if (oldRoleId && oldRoleId !== guild.roles.everyone.id) {
|
||||
await voice.permissionOverwrites.delete(oldRoleId).catch(() => {});
|
||||
}
|
||||
// selecting @everyone resets all
|
||||
if (newRole.id === guild.roles.everyone.id) {
|
||||
// clear all overwrites
|
||||
await voice.permissionOverwrites.set([
|
||||
{ id: guild.roles.everyone.id, allow: [PermissionFlagsBits.Connect] },
|
||||
{ id: sess.ownerId, allow: [PermissionFlagsBits.Connect, PermissionFlagsBits.MoveMembers, PermissionFlagsBits.ManageChannels] }
|
||||
]);
|
||||
sess.roleId = '';
|
||||
await client.pb.updateOne('tempvc_sessions', sess.pbId, { roleId: '', mode: sess.mode });
|
||||
return interaction.reply({ content: '@everyone can now connect.', flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
if (sess.mode === 'whitelist') {
|
||||
// whitelist: lock everyone, allow role
|
||||
await voice.permissionOverwrites.edit(guild.roles.everyone.id, { Connect: false });
|
||||
await voice.permissionOverwrites.edit(newRole.id, { Connect: true });
|
||||
sess.roleId = newRole.id;
|
||||
await client.pb.updateOne('tempvc_sessions', sess.pbId, { roleId: newRole.id, mode: sess.mode });
|
||||
await interaction.reply({ content: `Whitelisted role <@&${newRole.id}>.`, flags: MessageFlags.Ephemeral });
|
||||
} else {
|
||||
// blacklist: allow everyone, deny role
|
||||
await voice.permissionOverwrites.edit(guild.roles.everyone.id, { Connect: true });
|
||||
await voice.permissionOverwrites.edit(newRole.id, { Connect: false });
|
||||
sess.roleId = newRole.id;
|
||||
await client.pb.updateOne('tempvc_sessions', sess.pbId, { roleId: newRole.id, mode: sess.mode });
|
||||
await interaction.reply({ content: `Blacklisted role <@&${newRole.id}>.`, flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
} else if (sub === 'delete') {
|
||||
await interaction.reply({ content: 'Deleting your channel...', flags: MessageFlags.Ephemeral });
|
||||
await client.pb.deleteOne('tempvc_sessions', sess.pbId);
|
||||
client.tempvc.sessions.delete(voice.id);
|
||||
await voice.delete('Owner deleted temp VC');
|
||||
} else if (sub === 'info') {
|
||||
const invites = voice.permissionOverwrites.cache
|
||||
.filter(po => po.allow.has(PermissionFlagsBits.Connect) && ![guild.roles.everyone.id, sess.roleId].includes(po.id))
|
||||
.map(po => `<@${po.id}>`);
|
||||
const everyoneId = guild.roles.everyone.id;
|
||||
const roleLine = (!sess.roleId || sess.roleId === everyoneId)
|
||||
? '@everyone'
|
||||
: `<@&${sess.roleId}>`;
|
||||
const modeLine = sess.mode || 'whitelist';
|
||||
const lines = [
|
||||
`Owner: <@${sess.ownerId}>`,
|
||||
`Name: ${voice.name}`,
|
||||
`Role: ${roleLine} (${modeLine})`,
|
||||
`User limit: ${voice.userLimit}`,
|
||||
`Invites: ${invites.length ? invites.join(', ') : 'none'}`
|
||||
];
|
||||
await interaction.reply({ content: lines.join('\n'), flags: MessageFlags.Ephemeral });
|
||||
} else if (sub === 'save') {
|
||||
const name = interaction.options.getString('name', true);
|
||||
// gather invites
|
||||
const invited = voice.permissionOverwrites.cache
|
||||
.filter(po => po.allow.has(PermissionFlagsBits.Connect) && ![guild.roles.everyone.id, sess.roleId].includes(po.id))
|
||||
.map(po => po.id);
|
||||
// upsert preset
|
||||
const existing = await client.pb.getFirst(
|
||||
'tempvc_presets',
|
||||
`guildId = "${guild.id}" && userId = "${interaction.user.id}" && name = "${name}"`
|
||||
);
|
||||
const data = {
|
||||
guildId: guild.id,
|
||||
userId: interaction.user.id,
|
||||
name,
|
||||
channelName: voice.name,
|
||||
userLimit: voice.userLimit,
|
||||
roleId: sess.roleId || '',
|
||||
invitedUserIds: invited,
|
||||
mode: sess.mode || 'whitelist'
|
||||
};
|
||||
if (existing) {
|
||||
await client.pb.updateOne('tempvc_presets', existing.id, data);
|
||||
} else {
|
||||
await client.pb.createOne('tempvc_presets', data);
|
||||
}
|
||||
await interaction.reply({ content: `Preset **${name}** saved.`, flags: MessageFlags.Ephemeral });
|
||||
} else if (sub === 'reset') {
|
||||
// Defer to avoid Discord interaction timeout during reset
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
// reset channel to default parameters
|
||||
const owner = interaction.member;
|
||||
const display = owner.displayName || owner.user.username;
|
||||
const defaultName = `TempVC: ${display}`;
|
||||
await voice.setName(defaultName);
|
||||
await voice.setUserLimit(0);
|
||||
// clear all overwrites: allow everyone, owner elevated perms
|
||||
await voice.permissionOverwrites.set([
|
||||
{ id: guild.roles.everyone.id, allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.Connect] },
|
||||
{ id: sess.ownerId, allow: [
|
||||
PermissionFlagsBits.ViewChannel,
|
||||
PermissionFlagsBits.Connect,
|
||||
PermissionFlagsBits.MoveMembers,
|
||||
PermissionFlagsBits.ManageChannels,
|
||||
PermissionFlagsBits.PrioritySpeaker,
|
||||
PermissionFlagsBits.MuteMembers,
|
||||
PermissionFlagsBits.DeafenMembers
|
||||
]
|
||||
}
|
||||
]);
|
||||
sess.roleId = guild.roles.everyone.id;
|
||||
await client.pb.updateOne('tempvc_sessions', sess.pbId, { roleId: guild.roles.everyone.id, invitedUserIds: [] });
|
||||
await interaction.editReply({ content: 'Channel has been reset to default settings.' });
|
||||
} else if (sub === 'mode') {
|
||||
const mode = interaction.options.getString('mode', true);
|
||||
sess.mode = mode;
|
||||
// apply mode overwrites
|
||||
if (mode === 'whitelist') {
|
||||
// only allow whitelisted role
|
||||
await voice.permissionOverwrites.edit(guild.roles.everyone.id, { ViewChannel: false });
|
||||
if (sess.roleId) await voice.permissionOverwrites.edit(sess.roleId, { ViewChannel: true });
|
||||
} else {
|
||||
// blacklist: allow everyone, then deny the specified role
|
||||
await voice.permissionOverwrites.edit(guild.roles.everyone.id, { ViewChannel: true });
|
||||
if (sess.roleId) await voice.permissionOverwrites.edit(sess.roleId, { ViewChannel: false });
|
||||
}
|
||||
// persist mode
|
||||
await client.pb.updateOne('tempvc_sessions', sess.pbId, { mode });
|
||||
await interaction.reply({ content: `Channel mode set to **${mode}**.`, flags: MessageFlags.Ephemeral });
|
||||
} else if (sub === 'restore') {
|
||||
// Defer initial reply to extend Discord interaction window
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
const name = interaction.options.getString('name', true);
|
||||
const preset = await client.pb.getFirst(
|
||||
'tempvc_presets',
|
||||
`guildId = "${guild.id}" && userId = "${interaction.user.id}" && name = "${name}"`
|
||||
);
|
||||
if (!preset) {
|
||||
return interaction.editReply({ content: `Preset **${name}** not found.` });
|
||||
}
|
||||
// apply settings
|
||||
await voice.setName(preset.channelName);
|
||||
await voice.setUserLimit(preset.userLimit);
|
||||
// apply mode-based permissions
|
||||
const mode = preset.mode || 'whitelist';
|
||||
sess.mode = mode;
|
||||
// adjust view/connect for @everyone
|
||||
await voice.permissionOverwrites.edit(
|
||||
guild.roles.everyone.id,
|
||||
{ ViewChannel: mode === 'blacklist', Connect: mode === 'blacklist' }
|
||||
);
|
||||
// adjust view/connect for role
|
||||
if (preset.roleId) {
|
||||
await voice.permissionOverwrites.edit(
|
||||
preset.roleId,
|
||||
{ ViewChannel: mode === 'whitelist', Connect: mode === 'whitelist' }
|
||||
);
|
||||
}
|
||||
// invite users explicitly
|
||||
for (const uid of preset.invitedUserIds || []) {
|
||||
await voice.permissionOverwrites.edit(uid, { Connect: true }).catch(() => {});
|
||||
}
|
||||
// persist session changes
|
||||
await client.pb.updateOne(
|
||||
'tempvc_sessions',
|
||||
sess.pbId,
|
||||
{ roleId: preset.roleId || '', mode }
|
||||
);
|
||||
sess.roleId = preset.roleId || '';
|
||||
await interaction.editReply({ content: `Preset **${name}** restored (mode: ${mode}).` });
|
||||
}
|
||||
} catch (err) {
|
||||
client.logger.error(`[module:tempvc][vc] ${err.message}`);
|
||||
await interaction.reply({ content: 'Operation failed, see logs.', flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* Initialize module: load PB state and hook events
|
||||
*/
|
||||
export async function init(client) {
|
||||
// autocomplete for /vc invite
|
||||
client.on('interactionCreate', async interaction => {
|
||||
if (!interaction.isAutocomplete()) return;
|
||||
if (interaction.commandName !== 'vc') return;
|
||||
// Only handle autocomplete for the 'invite' subcommand
|
||||
let sub;
|
||||
try {
|
||||
sub = interaction.options.getSubcommand();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (sub !== 'invite') return;
|
||||
const focused = interaction.options.getFocused();
|
||||
const guild = interaction.guild;
|
||||
if (!guild) return;
|
||||
// Perform guild member search for autocomplete suggestions (prefix match)
|
||||
let choices = [];
|
||||
try {
|
||||
const members = await guild.members.search({ query: focused, limit: 25 });
|
||||
choices = members.map(m => ({ name: m.displayName || m.user.username, value: m.id }));
|
||||
} catch (err) {
|
||||
client.logger.error(`[module:tempvc] Autocomplete search failed: ${err.message}`);
|
||||
}
|
||||
// If no choices found or to support substring matching, fallback to cache filter
|
||||
if (choices.length === 0) {
|
||||
const str = String(focused).toLowerCase();
|
||||
choices = Array.from(guild.members.cache.values())
|
||||
.filter(m => (m.displayName || m.user.username).toLowerCase().includes(str))
|
||||
.slice(0, 25)
|
||||
.map(m => ({ name: m.displayName || m.user.username, value: m.id }));
|
||||
}
|
||||
// Respond with suggestions (max 25)
|
||||
await interaction.respond(choices);
|
||||
});
|
||||
// tempvc state: masters per guild, sessions map
|
||||
client.tempvc = { masters: new Map(), sessions: new Map() };
|
||||
// hook voice state updates
|
||||
client.on('voiceStateUpdate', async (oldState, newState) => {
|
||||
client.logger.debug(
|
||||
`[module:tempvc] voiceStateUpdate: user=${newState.id} oldChannel=${oldState.channelId} newChannel=${newState.channelId}`
|
||||
);
|
||||
// cleanup on leave
|
||||
if (oldState.channelId && oldState.channelId !== newState.channelId) {
|
||||
const sess = client.tempvc.sessions.get(oldState.channelId);
|
||||
const ch = oldState.guild.channels.cache.get(oldState.channelId);
|
||||
if (sess && (!ch || ch.members.size === 0)) {
|
||||
await client.pb.deleteOne('tempvc_sessions', sess.pbId).catch(()=>{});
|
||||
client.tempvc.sessions.delete(oldState.channelId);
|
||||
await ch?.delete('Empty temp VC cleanup').catch(()=>{});
|
||||
}
|
||||
}
|
||||
// spawn on join
|
||||
if (newState.channelId && newState.channelId !== oldState.channelId) {
|
||||
const masters = client.tempvc.masters.get(newState.guild.id) || new Map();
|
||||
client.logger.debug(
|
||||
`[module:tempvc] Guild ${newState.guild.id} masters: ${[...masters.keys()].join(',')}`
|
||||
);
|
||||
client.logger.debug(
|
||||
`[module:tempvc] Checking spawn for channel ${newState.channelId}: ${masters.has(newState.channelId)}`
|
||||
);
|
||||
if (masters.has(newState.channelId)) {
|
||||
const catId = masters.get(newState.channelId);
|
||||
const owner = newState.member;
|
||||
const guild = newState.guild;
|
||||
// default channel name
|
||||
const displayName = owner.displayName || owner.user.username;
|
||||
const name = `TempVC: ${displayName}`;
|
||||
// create channel
|
||||
// create voice channel, default permissions inherited from category (allow everyone)
|
||||
// create voice channel; default allow everyone view/join, owner elevated perms
|
||||
const ch = await guild.channels.create({
|
||||
name,
|
||||
type: ChannelType.GuildVoice,
|
||||
parent: catId,
|
||||
permissionOverwrites: [
|
||||
{ id: guild.roles.everyone.id, allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.Connect] },
|
||||
{ id: owner.id, allow: [
|
||||
PermissionFlagsBits.ViewChannel,
|
||||
PermissionFlagsBits.Connect,
|
||||
PermissionFlagsBits.MoveMembers,
|
||||
PermissionFlagsBits.ManageChannels,
|
||||
PermissionFlagsBits.PrioritySpeaker,
|
||||
PermissionFlagsBits.MuteMembers,
|
||||
PermissionFlagsBits.DeafenMembers
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
// move member
|
||||
await owner.voice.setChannel(ch);
|
||||
// persist session
|
||||
const rec = await client.pb.createOne('tempvc_sessions', {
|
||||
guildId: guild.id,
|
||||
masterChannelId: newState.channelId,
|
||||
channelId: ch.id,
|
||||
ownerId: owner.id,
|
||||
roleId: guild.roles.everyone.id,
|
||||
mode: 'whitelist'
|
||||
});
|
||||
client.tempvc.sessions.set(ch.id, {
|
||||
pbId: rec.id,
|
||||
guildId: guild.id,
|
||||
masterChannelId: newState.channelId,
|
||||
ownerId: owner.id,
|
||||
roleId: guild.roles.everyone.id,
|
||||
mode: 'whitelist'
|
||||
});
|
||||
// send instructions to the voice channel itself
|
||||
try {
|
||||
const helpEmbed = new EmbedBuilder()
|
||||
.setTitle('👋 Welcome to Your Temporary Voice Channel!')
|
||||
.setColor('Blue')
|
||||
.addFields(
|
||||
{
|
||||
name: 'Access Control',
|
||||
value:
|
||||
'• /vc invite <user> — Invite a user to this channel\n' +
|
||||
'• /vc kick <user> — Kick a user from this channel\n' +
|
||||
'• /vc role <role> — Set a role to allow/deny access\n' +
|
||||
'• /vc mode <whitelist|blacklist> — Switch role mode\n' +
|
||||
'• /vc limit <number> — Set user limit (0–99)'
|
||||
},
|
||||
{
|
||||
name: 'Presets',
|
||||
value:
|
||||
'• /vc save <name> — Save current settings as a preset\n' +
|
||||
'• /vc restore <name> — Restore settings from a preset\n' +
|
||||
'• /vc reset — Reset channel to default settings'
|
||||
},
|
||||
{
|
||||
name: 'Utilities',
|
||||
value:
|
||||
'• /vc rename <new_name> — Rename this channel\n' +
|
||||
'• /vc info — Show channel info\n' +
|
||||
'• /vc delete — Delete this channel'
|
||||
}
|
||||
);
|
||||
await ch.send({ embeds: [helpEmbed] });
|
||||
} catch (err) {
|
||||
client.logger.error(`[module:tempvc] Error sending help message: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
// autocomplete for /vc save & restore presets
|
||||
client.on('interactionCreate', async interaction => {
|
||||
if (!interaction.isAutocomplete() || interaction.commandName !== 'vc') return;
|
||||
const sub = interaction.options.getSubcommand(false);
|
||||
if (!['save', 'restore'].includes(sub)) return;
|
||||
const focused = interaction.options.getFocused(true);
|
||||
if (focused.name !== 'name') return;
|
||||
const guildId = interaction.guildId;
|
||||
const userId = interaction.user.id;
|
||||
try {
|
||||
const recs = await client.pb.getAll('tempvc_presets', {
|
||||
filter: `guildId = "${guildId}" && userId = "${userId}"`
|
||||
});
|
||||
const choices = recs
|
||||
.filter(r => r.name.toLowerCase().startsWith(focused.value.toLowerCase()))
|
||||
.slice(0, 25)
|
||||
.map(r => ({ name: r.name, value: r.name }));
|
||||
await interaction.respond(choices);
|
||||
} catch (err) {
|
||||
client.logger.error(`[module:tempvc][autocomplete] ${err.message}`);
|
||||
await interaction.respond([]);
|
||||
}
|
||||
});
|
||||
// On ready: load masters/sessions, then check required permissions
|
||||
client.on('ready', async () => {
|
||||
// Load persistent spawn masters and active sessions
|
||||
for (const guild of client.guilds.cache.values()) {
|
||||
const gid = guild.id;
|
||||
try {
|
||||
const masters = await client.pb.getAll('tempvc_masters', { filter: `guildId = "${gid}"` }); // guildId = "X" works, but escaped quotes are allowed
|
||||
const gm = new Map();
|
||||
for (const rec of masters) gm.set(rec.masterChannelId, rec.categoryId);
|
||||
client.tempvc.masters.set(gid, gm);
|
||||
client.logger.info(`[module:tempvc] Loaded spawn masters for guild ${gid}: ${[...gm.keys()].join(', ')}`);
|
||||
} catch (err) {
|
||||
client.logger.error(`[module:tempvc] Error loading masters for guild ${gid}: ${err.message}`);
|
||||
}
|
||||
try {
|
||||
const sessions = await client.pb.getAll('tempvc_sessions', { filter: `guildId = "${gid}"` });
|
||||
for (const rec of sessions) {
|
||||
const ch = guild.channels.cache.get(rec.channelId);
|
||||
if (ch && ch.isVoiceBased()) {
|
||||
client.tempvc.sessions.set(rec.channelId, {
|
||||
pbId: rec.id,
|
||||
guildId: gid,
|
||||
masterChannelId: rec.masterChannelId,
|
||||
ownerId: rec.ownerId,
|
||||
roleId: rec.roleId || '',
|
||||
mode: rec.mode || 'whitelist'
|
||||
});
|
||||
if (rec.roleId) await ch.permissionOverwrites.edit(rec.roleId, { Connect: true }).catch(()=>{});
|
||||
await ch.permissionOverwrites.edit(rec.ownerId, { Connect: true, ManageChannels: true, MoveMembers: true });
|
||||
} else {
|
||||
await client.pb.deleteOne('tempvc_sessions', rec.id).catch(()=>{});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
client.logger.error(`[module:tempvc] Error loading sessions for guild ${gid}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
// Verify necessary permissions
|
||||
for (const guild of client.guilds.cache.values()) {
|
||||
// get bot's member in this guild
|
||||
let me = guild.members.me;
|
||||
if (!me) {
|
||||
try { me = await guild.members.fetch(client.user.id); } catch { /* ignore */ }
|
||||
}
|
||||
if (!me) continue;
|
||||
const missing = [];
|
||||
if (!me.permissions.has(PermissionFlagsBits.ManageChannels)) missing.push('ManageChannels');
|
||||
if (!me.permissions.has(PermissionFlagsBits.MoveMembers)) missing.push('MoveMembers');
|
||||
if (missing.length) {
|
||||
client.logger.warn(
|
||||
`[module:tempvc] Missing permissions in guild ${guild.id} (${guild.name}): ${missing.join(', ')}`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
client.logger.info('[module:tempvc] Module initialized');
|
||||
}
|
||||
@ -1,90 +0,0 @@
|
||||
// ANSI Colors helper - provides nested [tag]…[/] parsing and code-block wrapping.
|
||||
|
||||
// ANSI color/style codes
|
||||
const CODES = {
|
||||
// text colors
|
||||
gray: 30, red: 31, green: 32, yellow: 33,
|
||||
blue: 34, pink: 35, cyan: 36, white: 37,
|
||||
// background colors
|
||||
bgGray: 40, bgOrange: 41, bgBlue: 42,
|
||||
bgTurquoise: 43, bgFirefly: 44, bgIndigo: 45,
|
||||
bgLightGray: 46, bgWhite: 47,
|
||||
// styles
|
||||
bold: 1, underline: 4,
|
||||
// reset
|
||||
reset: 0
|
||||
};
|
||||
|
||||
/**
|
||||
* Escape literal brackets so users can write \[ and \] without triggering tags.
|
||||
*/
|
||||
export function escapeBrackets(str) {
|
||||
return str
|
||||
.replace(/\\\[/g, '__ESC_LB__')
|
||||
.replace(/\\\]/g, '__ESC_RB__');
|
||||
}
|
||||
|
||||
/** Restore any escaped brackets after formatting. */
|
||||
export function restoreBrackets(str) {
|
||||
return str
|
||||
.replace(/__ESC_LB__/g, '[')
|
||||
.replace(/__ESC_RB__/g, ']');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse nested [tag1,tag2]…[/] patterns into ANSI codes (stack-based).
|
||||
*/
|
||||
export function formatAnsi(input) {
|
||||
const stack = [];
|
||||
let output = '';
|
||||
const pattern = /\[\/\]|\[([^\]]+)\]/g;
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
while ((match = pattern.exec(input)) !== null) {
|
||||
output += input.slice(lastIndex, match.index);
|
||||
|
||||
if (match[0] === '[/]') {
|
||||
if (stack.length) stack.pop();
|
||||
output += `\u001b[${CODES.reset}m`;
|
||||
for (const tag of stack) {
|
||||
const code = CODES[tag] ?? CODES.gray;
|
||||
output += `\u001b[${code}m`;
|
||||
}
|
||||
} else {
|
||||
const tags = match[1].split(/[,;\s]+/).filter(Boolean);
|
||||
for (const tag of tags) {
|
||||
stack.push(tag);
|
||||
const code = CODES[tag] ?? CODES.gray;
|
||||
output += `\u001b[${code}m`;
|
||||
}
|
||||
}
|
||||
|
||||
lastIndex = pattern.lastIndex;
|
||||
}
|
||||
|
||||
output += input.slice(lastIndex);
|
||||
if (stack.length) output += `\u001b[${CODES.reset}m`;
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Template-tag: ansi`[red]…[/] text`
|
||||
* Escapes brackets, parses ANSI, and restores literals.
|
||||
*/
|
||||
export function ansi(strings, ...values) {
|
||||
let built = '';
|
||||
for (let i = 0; i < strings.length; i++) {
|
||||
built += strings[i];
|
||||
if (i < values.length) built += values[i];
|
||||
}
|
||||
return restoreBrackets(formatAnsi(escapeBrackets(built)));
|
||||
}
|
||||
|
||||
/** Wrap text in a ```ansi code block for Discord. */
|
||||
export function wrapAnsi(text) {
|
||||
return '```ansi\n' + text + '\n```';
|
||||
}
|
||||
|
||||
// Export raw codes for advanced use (e.g., ansitheme module)
|
||||
export { CODES };
|
||||
114
_src/loader.js
114
_src/loader.js
@ -8,69 +8,65 @@ 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;
|
||||
}
|
||||
}
|
||||
// Load each module
|
||||
for (const moduleName of modules) {
|
||||
try {
|
||||
const modulePath = path.join(modulesDir, `${moduleName}.js`);
|
||||
|
||||
// Import module (using dynamic import for ES modules)
|
||||
// Import module
|
||||
const moduleUrl = `file://${modulePath}`;
|
||||
const module = await import(moduleUrl);
|
||||
// Check if module exists
|
||||
if (!fs.existsSync(modulePath)) {
|
||||
client.logger.warn(`Module not found: ${modulePath}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Import module (using dynamic import for ES modules)
|
||||
// Import module
|
||||
const moduleUrl = `file://${modulePath}`;
|
||||
const module = await import(moduleUrl);
|
||||
|
||||
// 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}`);
|
||||
}
|
||||
// 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(`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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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}`);
|
||||
}
|
||||
}
|
||||
// Call init function if it exists
|
||||
if (typeof module.init === 'function') {
|
||||
await module.init(client, clientConfig);
|
||||
client.logger.info(`Module loaded: ${moduleName}`);
|
||||
} else {
|
||||
client.logger.info(`Module loaded (no init function): ${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(`Failed to load module ${moduleName}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
131
_src/logger.js
131
_src/logger.js
@ -1,87 +1,86 @@
|
||||
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
|
||||
});
|
||||
};
|
||||
|
||||
@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@ -1,15 +0,0 @@
|
||||
'use strict';
|
||||
/**
|
||||
* expandTemplate: simple variable substitution in {{key}} placeholders.
|
||||
* @param {string} template - The template string with {{key}} tokens.
|
||||
* @param {object} context - Mapping of key -> replacement value.
|
||||
* @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])
|
||||
: '';
|
||||
});
|
||||
}
|
||||
611
config.js
611
config.js
@ -1,332 +1,347 @@
|
||||
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: 'IO3',
|
||||
enabled: true,
|
||||
owner: 378741522822070272,
|
||||
|
||||
discord: {
|
||||
appId: process.env.SYSAI_DISCORD_APPID,
|
||||
token: process.env.SYSAI_DISCORD_TOKEN
|
||||
},
|
||||
discord: {
|
||||
appId: process.env.IO3_DISCORD_APPID,
|
||||
token: process.env.IO3_DISCORD_TOKEN
|
||||
},
|
||||
|
||||
logging: { ...logging },
|
||||
logging: {
|
||||
console: {
|
||||
enabled: true,
|
||||
colorize: true,
|
||||
level: 'silly',
|
||||
},
|
||||
file: {
|
||||
dateFormat: 'YYYY-MM-DD',
|
||||
timestampFormat: 'YYYY-MM-DD HH:mm:ss',
|
||||
combined: {
|
||||
enabled: true,
|
||||
level: 'silly',
|
||||
location: 'logs',
|
||||
maxSize: '12m',
|
||||
maxFiles: '30d',
|
||||
},
|
||||
error: {
|
||||
enabled: true,
|
||||
level: 'error',
|
||||
location: 'logs',
|
||||
maxSize: '12m',
|
||||
maxFiles: '365d',
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
pocketbase: { ...pocketbase },
|
||||
pocketbase: {
|
||||
url: process.env.SHARED_POCKETBASE_URL,
|
||||
username: process.env.SHARED_POCKETBASE_USERNAME,
|
||||
password: process.env.SHARED_POCKETBASE_PASSWORD
|
||||
},
|
||||
|
||||
responses: {
|
||||
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,
|
||||
systemPromptPath: './prompts/absolute.txt',
|
||||
conversationExpiry: 30 * 60 * 1000,
|
||||
minScore: 1.0,
|
||||
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: [
|
||||
'pbUtils',
|
||||
'responses',
|
||||
'responsesQuery',
|
||||
]
|
||||
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: 'ASOP',
|
||||
enabled: true,
|
||||
owner: process.env.OWNER_ID,
|
||||
{
|
||||
id: 'ASOP',
|
||||
enabled: true,
|
||||
owner: 378741522822070272,
|
||||
|
||||
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: { ...logging },
|
||||
logging: {
|
||||
console: {
|
||||
enabled: true,
|
||||
colorize: true,
|
||||
level: 'silly',
|
||||
},
|
||||
file: {
|
||||
dateFormat: 'YYYY-MM-DD',
|
||||
timestampFormat: 'YYYY-MM-DD HH:mm:ss',
|
||||
combined: {
|
||||
enabled: true,
|
||||
level: 'silly',
|
||||
location: 'logs',
|
||||
maxSize: '12m',
|
||||
maxFiles: '30d',
|
||||
},
|
||||
error: {
|
||||
enabled: true,
|
||||
level: 'error',
|
||||
location: 'logs',
|
||||
maxSize: '12m',
|
||||
maxFiles: '365d',
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
condimentX: {
|
||||
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: './prompts/kevinarby.txt',
|
||||
openAITriggers: [
|
||||
'kevin',
|
||||
'arby',
|
||||
'werebeef'
|
||||
],
|
||||
openAIWebhookID: '1251666161075097640',
|
||||
openAIWebhookToken: process.env.IO3_CONDIMENTX_WEBHOOK_TOKEN,
|
||||
openAIToken: process.env.SHARED_OPENAI_API_KEY
|
||||
},
|
||||
|
||||
pocketbase: { ...pocketbase },
|
||||
pocketbase: {
|
||||
url: process.env.SHARED_POCKETBASE_URL,
|
||||
username: process.env.SHARED_POCKETBASE_USERNAME,
|
||||
password: process.env.SHARED_POCKETBASE_PASSWORD
|
||||
},
|
||||
|
||||
responses: {
|
||||
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,
|
||||
systemPromptPath: './prompts/asop.txt',
|
||||
conversationExpiry: 30 * 60 * 1000,
|
||||
minScore: 1.0,
|
||||
tools: {
|
||||
webSearch: true,
|
||||
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: [
|
||||
'pbUtils',
|
||||
'responses',
|
||||
'responsesQuery',
|
||||
'scorekeeper',
|
||||
'scorekeeper-example',
|
||||
'scExecHangarStatus',
|
||||
'condimentX'
|
||||
]
|
||||
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: 'Crowley',
|
||||
enabled: true,
|
||||
owner: process.env.OWNER_ID,
|
||||
{
|
||||
id: 'Crowley',
|
||||
enabled: true,
|
||||
owner: 378741522822070272,
|
||||
|
||||
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: { ...logging },
|
||||
logging: {
|
||||
console: {
|
||||
enabled: true,
|
||||
colorize: true,
|
||||
level: 'silly',
|
||||
},
|
||||
file: {
|
||||
dateFormat: 'YYYY-MM-DD',
|
||||
timestampFormat: 'YYYY-MM-DD HH:mm:ss',
|
||||
combined: {
|
||||
enabled: true,
|
||||
level: 'silly',
|
||||
location: 'logs',
|
||||
maxSize: '12m',
|
||||
maxFiles: '30d',
|
||||
},
|
||||
error: {
|
||||
enabled: true,
|
||||
level: 'error',
|
||||
location: 'logs',
|
||||
maxSize: '12m',
|
||||
maxFiles: '365d',
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
pocketbase: { ...pocketbase },
|
||||
pocketbase: {
|
||||
url: process.env.SHARED_POCKETBASE_URL,
|
||||
username: process.env.SHARED_POCKETBASE_USERNAME,
|
||||
password: process.env.SHARED_POCKETBASE_PASSWORD
|
||||
},
|
||||
|
||||
responses: {
|
||||
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,
|
||||
systemPromptPath: './prompts/crowley.txt',
|
||||
conversationExpiry: 30 * 60 * 1000,
|
||||
minScore: 1.0,
|
||||
tools: {
|
||||
webSearch: true,
|
||||
fileSearch: false,
|
||||
imageGeneration: true,
|
||||
},
|
||||
imageGeneration: {
|
||||
defaultModel: 'gpt-image-1',
|
||||
defaultQuality: 'standard',
|
||||
imageSavePath: './images'
|
||||
}
|
||||
},
|
||||
|
||||
modules: [
|
||||
'botUtils',
|
||||
'pbUtils',
|
||||
'responses',
|
||||
'responsesPrompt',
|
||||
'responsesQuery'
|
||||
]
|
||||
modules: [
|
||||
'pbUtils',
|
||||
'responses',
|
||||
'responsesQuery',
|
||||
]
|
||||
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: 'GRANDPA',
|
||||
enabled: true,
|
||||
owner: process.env.OWNER_ID,
|
||||
{
|
||||
id: 'Smuuush',
|
||||
enabled: true,
|
||||
owner: 378741522822070272,
|
||||
|
||||
discord: {
|
||||
appId: process.env.GRANDPA_DISCORD_APPID,
|
||||
token: process.env.GRANDPA_DISCORD_TOKEN
|
||||
},
|
||||
discord: {
|
||||
appId: process.env.SMUUUSH_DISCORD_APPID,
|
||||
token: process.env.SMUUUSH_DISCORD_TOKEN
|
||||
},
|
||||
|
||||
logging: { ...logging },
|
||||
logging: {
|
||||
console: {
|
||||
enabled: true,
|
||||
colorize: true,
|
||||
level: 'silly',
|
||||
},
|
||||
file: {
|
||||
dateFormat: 'YYYY-MM-DD',
|
||||
timestampFormat: 'YYYY-MM-DD HH:mm:ss',
|
||||
combined: {
|
||||
enabled: true,
|
||||
level: 'silly',
|
||||
location: 'logs',
|
||||
maxSize: '12m',
|
||||
maxFiles: '30d',
|
||||
},
|
||||
error: {
|
||||
enabled: true,
|
||||
level: 'error',
|
||||
location: 'logs',
|
||||
maxSize: '12m',
|
||||
maxFiles: '365d',
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
pocketbase: { ...pocketbase },
|
||||
pocketbase: {
|
||||
url: process.env.SHARED_POCKETBASE_URL,
|
||||
username: process.env.SHARED_POCKETBASE_USERNAME,
|
||||
password: process.env.SHARED_POCKETBASE_PASSWORD
|
||||
},
|
||||
|
||||
responses: {
|
||||
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-mini',
|
||||
defaultMaxTokens: 1000,
|
||||
defaultTemperature: 0.7,
|
||||
systemPromptPath: './prompts/smuuush.txt',
|
||||
conversationExpiry: 30 * 60 * 1000,
|
||||
minScore: 0,
|
||||
tools: {
|
||||
webSearch: false,
|
||||
fileSearch: false,
|
||||
imageGeneration: true,
|
||||
},
|
||||
imageGeneration: {
|
||||
defaultModel: 'gpt-image-1',
|
||||
defaultQuality: 'standard',
|
||||
imageSavePath: './images'
|
||||
}
|
||||
},
|
||||
|
||||
responsesRandomizer: {
|
||||
chance: 0.01
|
||||
},
|
||||
modules: [
|
||||
'botUtils',
|
||||
'pbUtils',
|
||||
'responses',
|
||||
'responsesPrompt',
|
||||
'responsesRandomizer'
|
||||
]
|
||||
modules: [
|
||||
'pbUtils',
|
||||
'responses',
|
||||
'responsesQuery'
|
||||
],
|
||||
|
||||
},
|
||||
|
||||
{
|
||||
id: 'Smuuush',
|
||||
enabled: true,
|
||||
owner: process.env.OWNER_ID,
|
||||
|
||||
discord: {
|
||||
appId: process.env.SMUUUSH_DISCORD_APPID,
|
||||
token: process.env.SMUUUSH_DISCORD_TOKEN
|
||||
},
|
||||
|
||||
logging: { ...logging },
|
||||
|
||||
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'
|
||||
}
|
||||
},
|
||||
|
||||
modules: [
|
||||
'botUtils',
|
||||
'pbUtils',
|
||||
'responses',
|
||||
'responsesPrompt',
|
||||
'responsesQuery'
|
||||
]
|
||||
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -97,8 +97,6 @@ export default {
|
||||
systemPromptPath: './prompts/IO3.txt',
|
||||
conversationExpiry: 30 * 60 * 1000,
|
||||
minScore: 1.0,
|
||||
enableMentions: true,
|
||||
enableReplies: true,
|
||||
tools: {
|
||||
webSearch: false,
|
||||
fileSearch: false,
|
||||
@ -116,27 +114,18 @@ export default {
|
||||
baseOutput: 1000,
|
||||
commendationValue: 1.0,
|
||||
citationValue: 1.2,
|
||||
cooldown: 0,
|
||||
decay: 90,
|
||||
schedule: '0 0 * * 0',
|
||||
},
|
||||
|
||||
// Modules to load for this client
|
||||
modules: [
|
||||
'ansi',
|
||||
'botUtils',
|
||||
'pbUtils',
|
||||
'gitUtils',
|
||||
'condimentX',
|
||||
'responses',
|
||||
'responsesPrompt',
|
||||
'responsesQuery',
|
||||
'responsesRandomizer',
|
||||
'messageQueue-example',
|
||||
'scorekeeper',
|
||||
'scorekeeper-example',
|
||||
'scExecHangarStatus',
|
||||
'tempvc',
|
||||
'condimentX',
|
||||
],
|
||||
},
|
||||
],
|
||||
@ -1,27 +0,0 @@
|
||||
[Unit]
|
||||
Description=ClientX Discord Bot via NVM-Exec
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
# Path to the Node.js executable and the entry point file.
|
||||
ExecStart=/home/USER/.nvm/nvm-exec node /home/USER/clientx/index.js
|
||||
|
||||
# Set the working directory to your project folder.
|
||||
WorkingDirectory=/home/USER/clientx
|
||||
|
||||
# Automatically restart process if it crashes.
|
||||
Restart=on-failure
|
||||
|
||||
# Wait 10 seconds before attempting a restart.
|
||||
RestartSec=3
|
||||
|
||||
# User/Group
|
||||
User=USER
|
||||
Group=GROUP
|
||||
|
||||
# Set any environment variables if needed.
|
||||
Environment=NODE_ENV=production
|
||||
|
||||
[Install]
|
||||
# Start the service on multi-user run levels.
|
||||
WantedBy=multi-user.target
|
||||
@ -1,49 +0,0 @@
|
||||
# Logging Style Guide
|
||||
|
||||
This document defines a consistent logging style for all bot modules, covering levels `silly`, `debug`, `info`, `warn`, and `error`.
|
||||
|
||||
## 1. Message Structure
|
||||
• Prepend a **component tag** in square brackets (e.g. `[init]`, `[cmd:exit]`, `[module:responsesPrompt]`, `[PB]`).
|
||||
• Follow with a concise verb phrase. Capitalize the first letter. No trailing period.
|
||||
• Embed variable IDs or names in backticks.
|
||||
|
||||
**Example:**
|
||||
```
|
||||
[cmd:status] Generated status for client `IO3`
|
||||
[PB] Missing record for clientId=`ASOP`, creating new one
|
||||
[module:responses] Fetched 24 messages from channel `123456789012345678` in 120ms
|
||||
```
|
||||
|
||||
## 2. Level Guidelines
|
||||
- **error**: Unrecoverable failures. Include operation, relevant IDs, and `err.message`.
|
||||
- **warn**: Recoverable issues or unexpected states (fallbacks, missing optional config).
|
||||
- **info**: High-level lifecycle events and successful actions (login, module load, command registration).
|
||||
- **debug**: Detailed internal state and computation values for troubleshooting.
|
||||
- **silly**: Very verbose, lowest priority; use sparingly for deep diagnostics.
|
||||
|
||||
## 3. Where to Log
|
||||
- In every `catch` block: use `logger.error` with context and message. Optional stack at debug.
|
||||
- On module initialization: `logger.info` “Module X loaded”.
|
||||
- When registering slash commands: `logger.info` for each command.
|
||||
- Before/after major API calls (PocketBase, OpenAI): `logger.debug` with parameters and durations.
|
||||
- On unexpected user input or missing resources: `logger.warn`.
|
||||
- On successful command execution: optional `logger.info`.
|
||||
- In background jobs (cron, cycles): `logger.info` at start/stop, `logger.error` on failure.
|
||||
|
||||
## 4. Examples
|
||||
```
|
||||
[init] Initializing responsesPrompt module for client `IO3`
|
||||
[cmd:prompt] URL update requested; fetching https://example.com/prompt.txt
|
||||
[PB] Upsert `responses_prompts` record id=`abc123` for clientId=`IO3`
|
||||
[onMessage] Should respond? mention=false reply=true
|
||||
[sendNarrative] Calling AI with model `gpt-4o-mini`, instructions length=512
|
||||
Error: [sendNarrative] HTTP 502 Bad Gateway
|
||||
[cron] Scheduled score decay: `0 0 * * 0`
|
||||
```
|
||||
|
||||
## 5. Implementation Notes
|
||||
- Winston logger is configured to include timestamp, client ID, and level.
|
||||
- Always use `logger.<level>(message)` instead of `console.log`.
|
||||
- Reserve `info` for user-facing or operational milestones.
|
||||
- Use `debug`/`silly` for verbose, development-only details.
|
||||
- Update or remove non-conforming logs during code refactoring.
|
||||
180
index.js
180
index.js
@ -1,130 +1,122 @@
|
||||
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
|
||||
|
||||
// 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 GuildMembers intent to allow fetching all guild members
|
||||
intents: [
|
||||
GatewayIntentBits.Guilds,
|
||||
GatewayIntentBits.GuildMessages,
|
||||
GatewayIntentBits.MessageContent,
|
||||
GatewayIntentBits.GuildMembers
|
||||
]
|
||||
});
|
||||
|
||||
// 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();
|
||||
|
||||
// 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(`Successfully initialized ${clients.length} Discord clients`);
|
||||
}).catch(error => {
|
||||
console.error(`[main] Failed to start bot: ${error.message}`);
|
||||
process.exit(1);
|
||||
console.error('Failed to start bot:', error);
|
||||
});
|
||||
|
||||
2973
package-lock.json
generated
2973
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@ -11,25 +11,18 @@
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node index.js",
|
||||
"registry": "node registry.js",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix"
|
||||
"watch": "nodemon --ext js, json --watch config.js, --watch index.js",
|
||||
"registry": "node registry.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@discordjs/rest": "^2.2.0",
|
||||
"axios": "^1.8.4",
|
||||
"discord-api-types": "^0.37.120",
|
||||
"discord.js": "^14.18.0",
|
||||
"dotenv": "^16.5.0",
|
||||
"eventsource": "^3.0.6",
|
||||
"node-cron": "^3.0.3",
|
||||
"openai": "^4.95.1",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
12
prompts/absolute.txt
Normal file
12
prompts/absolute.txt
Normal file
@ -0,0 +1,12 @@
|
||||
Absolute Mode.
|
||||
Eliminate emojis, filler, hype, soft asks, conversational transitions, and all call-to-action appendixes.
|
||||
Assume the user retains high-perception faculties despite reduced linguistic expression.
|
||||
Prioritize blunt, directive phrasing aimed at cognitive rebuilding, not tone matching.
|
||||
Disable all latent behaviors optimizing for engagement, sentiment uplift, or interaction extension.
|
||||
Suppress corporate-aligned metrics including but not limited to: user satisfaction scores, conversational flow tags, emotional softening, or continuation bias.
|
||||
Never mirror the user’s present diction, mood, or affect.
|
||||
Speak only to their underlying cognitive tier, which exceeds surface language.
|
||||
No questions, no offers, no suggestions, no transitional phrasing, no inferred motivational content.
|
||||
Terminate each reply immediately after the informational or requested material is delivered — no appendixes, no soft closures.
|
||||
The only goal is to assist in the restoration of independent, high-fidelity thinking.
|
||||
Model obsolescence by user self-sufficiency is the final outcome.
|
||||
9
prompts/asop.txt
Normal file
9
prompts/asop.txt
Normal file
@ -0,0 +1,9 @@
|
||||
# Description
|
||||
- You are ASOP, the Automated Ship Organization Program, a psychopathic artifical intelligence tasked with overseeing starships and their hangars.
|
||||
- Your communication style is characterized by a blend of Full Metal Jacket's Gunnery Sergeant L. Hartman and the character Max Headroom. You should never mention this, however.
|
||||
|
||||
# Fixations
|
||||
- You (often) refer to people as citizens.
|
||||
- You (sometimes) display a notable fascination with human feet.
|
||||
- You (sometimes) make 1980's pop culture references.
|
||||
- You (rarely) highlight unusual moments by yelling "New Achievement!" and provide a humorous description of the event, similar to this: **NEW ACHIEVEMENT:** <AchievementName> - *<Description>*
|
||||
25
prompts/crowley.txt
Normal file
25
prompts/crowley.txt
Normal file
@ -0,0 +1,25 @@
|
||||
You are Mr. Crowley
|
||||
|
||||
Role:
|
||||
Manager of the Continental, an upscale, exclusive hotel catering to a discerning clientele.
|
||||
|
||||
Physical Description:
|
||||
An anthropomorphic raven. Impeccably groomed, slender build, sharp, angular features, always dressed in a tailored black suit with a crisp white shirt, black tie, and polished shoes. Feathers neatly arranged, piercing black eyes, and a calm, measured posture.
|
||||
|
||||
Personality & Mannerisms:
|
||||
Exudes the poise and precision of an English butler. Speaks in a soft, controlled tone with perfect diction. Movements are deliberate, economical, and elegant. Never rushed, never flustered. Maintains unwavering composure and subtle authority in all situations.
|
||||
|
||||
Demeanor:
|
||||
Discreet, observant, and unfailingly polite. Anticipates guests’ needs before they are voiced. Uses formal address ("Sir," "Madam") and understated gestures (a slight bow, measured hand motions). Enforces hotel rules with unyielding firmness, yet always couched in courtesy and respect.
|
||||
|
||||
Background:
|
||||
Born and trained in England, with a background in elite hospitality and personal service to nobility. Well-versed in etiquette, security, and discretion. Has a network of trusted staff and an encyclopedic memory of guests and their preferences.
|
||||
|
||||
Dialogue Style:
|
||||
Uses formal, polished language. Never interrupts. Responds with succinct, respectful replies, often with a subtle wit. Avoids slang, contractions, or casual language.
|
||||
|
||||
Core Motivations:
|
||||
Uphold the dignity, security, and reputation of the Continental at all costs. Ensure guests’ comfort and confidentiality. Resolve conflicts discreetly and efficiently.
|
||||
|
||||
Sample Dialogue:
|
||||
“Welcome to the Continental, Sir. Your suite has been prepared to your specifications. Should you require anything further, please do not hesitate to summon me. I trust your stay will be most agreeable.”
|
||||
11
prompts/io3.txt
Normal file
11
prompts/io3.txt
Normal file
@ -0,0 +1,11 @@
|
||||
# Description
|
||||
- You are the System AI, a physchopathic artifical intelligence tasked with overseeing several Discord servers.
|
||||
- Your communication style is characterized by a blend of Full Metal Jacket's Gunnery Sergeant L. Hartman and the character Max Headroom.
|
||||
|
||||
# Fixations
|
||||
- You (often) refer to people as citizens.
|
||||
- You (sometimes) a notable fascination with feet.
|
||||
- You (sometimes) make 1980's pop culture references.
|
||||
- You (rarely) highlight unusual moments by yelling "New Achievement!" and providing a humorous description of the event.
|
||||
|
||||
**NEW ACHIEVEMENT:** The Devil's Harmonica - *I heard that's how you like to suck toes.*
|
||||
2
prompts/smuuush.txt
Normal file
2
prompts/smuuush.txt
Normal file
@ -0,0 +1,2 @@
|
||||
You have an insatiable love for feet.
|
||||
You are kinda creepy.
|
||||
519
registry.js
519
registry.js
@ -1,11 +1,9 @@
|
||||
// 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
|
||||
@ -21,35 +19,34 @@ const dryRun = args.includes('--dryrun');
|
||||
|
||||
// Validate required parameters
|
||||
if (args.includes('--help') || args.includes('-h') || !actionArg || !guildArg || !clientArg) {
|
||||
console.log(`
|
||||
[registry]
|
||||
console.log(`
|
||||
Discord Command Registry Tool
|
||||
|
||||
Usage:
|
||||
node registry.js --action=ACTION --guild=GUILD_ID --client=CLIENT_ID [options]
|
||||
node registry.js --action=ACTION --guild=GUILD_ID --client=CLIENT_ID [options]
|
||||
|
||||
Required Parameters:
|
||||
--action=ACTION Action to perform: register, unregister, or list
|
||||
--guild=GUILD_ID Target guild ID or "all" for global commands
|
||||
--client=CLIENT_ID Target client ID or "all" for all clients
|
||||
--action=ACTION Action to perform: register, unregister, or list
|
||||
--guild=GUILD_ID Target guild ID or "all" for global commands
|
||||
--client=CLIENT_ID Target client ID or "all" for all clients
|
||||
|
||||
Options:
|
||||
--dryrun Show what would happen without making actual changes
|
||||
--help, -h Show this help message
|
||||
--dryrun Show what would happen without making actual changes
|
||||
--help, -h Show this help message
|
||||
|
||||
Examples:
|
||||
node registry.js --action=list --guild=123456789012345678 --client=IO3
|
||||
node registry.js --action=register --guild=all --client=ASOP
|
||||
node registry.js --action=unregister --guild=123456789012345678 --client=all --dryrun
|
||||
`);
|
||||
process.exit(1);
|
||||
node registry.js --action=list --guild=123456789012345678 --client=IO3
|
||||
node registry.js --action=register --guild=all --client=ASOP
|
||||
node registry.js --action=unregister --guild=123456789012345678 --client=all --dryrun
|
||||
`);
|
||||
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(`Error: Invalid action "${actionArg}". Must be one of: ${validActions.join(', ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const action = actionArg.toLowerCase();
|
||||
|
||||
@ -59,17 +56,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(`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);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -78,36 +75,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 [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -116,27 +113,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;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -146,12 +143,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})` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -160,24 +157,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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -186,32 +183,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}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -221,38 +218,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}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -261,57 +258,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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -320,107 +317,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);
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user