Compare commits
No commits in common. "main" and "git-utils" have entirely different histories.
@ -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
|
node_modules
|
||||||
|
|
||||||
.env
|
.env
|
||||||
.nvmrc
|
|
||||||
images/*
|
images/*
|
||||||
logs/*
|
logs/*
|
||||||
pocketbase/*
|
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.
|
// Used as a prefix before any line that runs within a loop.
|
||||||
const bullet = '>';
|
const bullet = '>';
|
||||||
|
|
||||||
|
|
||||||
// === OpenAI Interaction ===
|
// === OpenAI Interaction ===
|
||||||
// Chat completion via OpenAI with provided instructions.
|
// Chat completion via OpenAI with provided instructions.
|
||||||
async function ai(prompt = '') {
|
async function ai(prompt = '') {
|
||||||
@ -85,17 +86,17 @@ export const init = async (client, config) => {
|
|||||||
debug(`**AI Prompt**: ${prompt}`);
|
debug(`**AI Prompt**: ${prompt}`);
|
||||||
|
|
||||||
// Read instructions.
|
// Read instructions.
|
||||||
const openAIInstructions = fs.readFileSync(openAIInstructionsFile, 'utf8');
|
let openAIInstructions = fs.readFileSync(openAIInstructionsFile, 'utf8');
|
||||||
const unmention = /<@(\w+)>/g;
|
const unmention = /<@(\w+)>/g;
|
||||||
const completion = await openai.chat.completions.create({
|
const completion = await openai.chat.completions.create({
|
||||||
model: 'gpt-4o-mini',
|
model: 'gpt-4o-mini',
|
||||||
messages: [
|
messages: [
|
||||||
{ role: 'user', content: `${prompt.replace(unmention, '$1')}` },
|
{role: 'user', content: `${prompt.replace(unmention, '$1')}`},
|
||||||
{ role: 'system', content: `${openAIInstructions}` }
|
{role: 'system', content: `${openAIInstructions}`},
|
||||||
]
|
],
|
||||||
});
|
});
|
||||||
const chunk = completion.choices[0]?.message?.content;
|
let chunk = completion.choices[0]?.message?.content;
|
||||||
if (chunk !== '') {
|
if (chunk != '') {
|
||||||
for (const line of chunk.split(/\n\s*\n/).filter(Boolean)) {
|
for (const line of chunk.split(/\n\s*\n/).filter(Boolean)) {
|
||||||
debug(`${bullet} ${line}`);
|
debug(`${bullet} ${line}`);
|
||||||
openAIWebhookClient.send(line);
|
openAIWebhookClient.send(line);
|
||||||
@ -141,7 +142,7 @@ export const init = async (client, config) => {
|
|||||||
|
|
||||||
// === Message Fetching Helpers ===
|
// === Message Fetching Helpers ===
|
||||||
// Retrieve recent messages from every text channel since a given timestamp.
|
// Retrieve recent messages from every text channel since a given timestamp.
|
||||||
async function _fetchRecentMessages(since) {
|
async function fetchRecentMessages(since) {
|
||||||
const allMessages = new Collection();
|
const allMessages = new Collection();
|
||||||
|
|
||||||
// Get all text channels in the guild
|
// Get all text channels in the guild
|
||||||
@ -152,9 +153,9 @@ export const init = async (client, config) => {
|
|||||||
// For each channel, fetch recent messages
|
// For each channel, fetch recent messages
|
||||||
for (const channel of textChannels.values()) {
|
for (const channel of textChannels.values()) {
|
||||||
try {
|
try {
|
||||||
const messages = await channel.messages.fetch({
|
const messages = await channel.messages.fetch({
|
||||||
limit: messageHistoryLimit,
|
limit: messageHistoryLimit,
|
||||||
after: since
|
after: since
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add these messages to our collection
|
// Add these messages to our collection
|
||||||
@ -180,7 +181,7 @@ export const init = async (client, config) => {
|
|||||||
debug(`**Incident Cycle #${incidentCounter++}**`);
|
debug(`**Incident Cycle #${incidentCounter++}**`);
|
||||||
|
|
||||||
// Rebuild the list of current index cases, if any.
|
// Rebuild the list of current index cases, if any.
|
||||||
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}**`);
|
debug(`${bullet} Index Cases: **${indexesList.size}**`);
|
||||||
|
|
||||||
// Build the victimsList using whitelisted roles.
|
// Build the victimsList using whitelisted roles.
|
||||||
@ -205,7 +206,7 @@ export const init = async (client, config) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Conditions for potentially starting an incident.
|
// Conditions for potentially starting an incident.
|
||||||
if (indexesList.size === 0 && victimsList.size > 0) {
|
if (indexesList.size == 0 && victimsList.size > 0) {
|
||||||
if ((Math.floor(Math.random() * incidenceDenominator) + 1) === 1) {
|
if ((Math.floor(Math.random() * incidenceDenominator) + 1) === 1) {
|
||||||
debug(`${bullet} Incidence Check: **Success**`);
|
debug(`${bullet} Incidence Check: **Success**`);
|
||||||
const newIndex = victimsList.random();
|
const newIndex = victimsList.random();
|
||||||
@ -239,7 +240,7 @@ export const init = async (client, config) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Prepare the next cycle.
|
// 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);
|
setTimeout(cycleIncidents, interval);
|
||||||
debug(`${bullet} Cycle #${incidentCounter} **<t:${Math.floor((Date.now() + interval) / 1000)}:R>** at **<t:${Math.floor((Date.now() + interval) / 1000)}:t>**`);
|
debug(`${bullet} Cycle #${incidentCounter} **<t:${Math.floor((Date.now() + interval) / 1000)}:R>** at **<t:${Math.floor((Date.now() + interval) / 1000)}:t>**`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -290,16 +291,17 @@ export const init = async (client, config) => {
|
|||||||
if (message.webhookId) return;
|
if (message.webhookId) return;
|
||||||
guild = client.guilds.cache.get(guildID);
|
guild = client.guilds.cache.get(guildID);
|
||||||
|
|
||||||
// Someone mentioned us - respond if openAI is enabled, the message was in the webhook channel, and a trigger was used.
|
|
||||||
if (openAI === true && openAIWebhook.channel.id === message.channel.id && openAITriggers.some(word => message.content.replace(/[^\w\s]/gi, '').toLowerCase().includes(word.toLowerCase()))) {
|
// Someone mentioned us - respond if openAI is enabled, the message was in the webhook channel, and a trigger was used.
|
||||||
// Also check if an active incident is required to respond.
|
if (openAI === true && openAIWebhook.channel.id === message.channel.id && openAITriggers.some(word => message.content.replace(/[^\w\s]/gi, '').toLowerCase().includes(word.toLowerCase()))) {
|
||||||
if ((openAITriggerOnlyDuringIncident === true && guild.members.cache.filter(member => member.roles.cache.has(indexRole.id)).size > 0) || openAITriggerOnlyDuringIncident === false) {
|
// Also check if an active incident is required to respond.
|
||||||
// Finally, random roll to respond.
|
if ((openAITriggerOnlyDuringIncident === true && guild.members.cache.filter(member => member.roles.cache.has(indexRole.id)).size > 0) || openAITriggerOnlyDuringIncident === false) {
|
||||||
if ((Math.floor(Math.random() * openAIResponseDenominator) + 1) === 1) {
|
// Finally, random roll to respond.
|
||||||
ai(`${message.member.displayName} said: ${message.cleanContent}`);
|
if ((Math.floor(Math.random() * openAIResponseDenominator) + 1) === 1) {
|
||||||
}
|
ai(`${message.member.displayName} said: ${message.cleanContent}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (blacklistUsers.includes(message.author.id)) return;
|
if (blacklistUsers.includes(message.author.id)) return;
|
||||||
if (message.member.roles.cache.some(r => blacklistRoles.includes(r.id))) return;
|
if (message.member.roles.cache.some(r => blacklistRoles.includes(r.id))) return;
|
||||||
@ -314,7 +316,7 @@ export const init = async (client, config) => {
|
|||||||
const msgMember = msg.member;
|
const msgMember = msg.member;
|
||||||
if (msgMember) {
|
if (msgMember) {
|
||||||
// Check if author has index or viral role
|
// 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);
|
msgMember.roles.cache.has(viralRole.id);
|
||||||
if (isInfected) infections++;
|
if (isInfected) infections++;
|
||||||
}
|
}
|
||||||
@ -332,7 +334,7 @@ export const init = async (client, config) => {
|
|||||||
let percentage = Math.min(infections / prox.size * 100, probabilityLimit);
|
let percentage = Math.min(infections / prox.size * 100, probabilityLimit);
|
||||||
|
|
||||||
// Reduce base probability by ${antiViralEffectiveness}% for those with ${antiViralRole}
|
// Reduce base probability by ${antiViralEffectiveness}% for those with ${antiViralRole}
|
||||||
if (message.member.roles.cache.has(antiViralRole.id) && Math.random() * 100 === antiViralEffectiveness) {
|
if (message.member.roles.cache.has(antiViralRole.id)) {
|
||||||
percentage = Math.round(percentage - (antiViralEffectiveness * (percentage / 100)));
|
percentage = Math.round(percentage - (antiViralEffectiveness * (percentage / 100)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -357,14 +359,14 @@ export const init = async (client, config) => {
|
|||||||
anomaly('messageCreate', error);
|
anomaly('messageCreate', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Deferred setup on ready
|
// Deferred setup on ready
|
||||||
const readyHandler = async () => {
|
const readyHandler = async () => {
|
||||||
client.logger.info('[module:condimentX] Initializing module');
|
client.logger.info('Initializing CondimentX module');
|
||||||
if (openAI === true) {
|
if (openAI === true) {
|
||||||
openai = new OpenAI({ apiKey: openAIToken }); // credentials loaded
|
openai = new OpenAI({ apiKey: openAIToken });
|
||||||
openAIWebhook = await client.fetchWebhook(openAIWebhookID, openAIWebhookToken).catch(error => {
|
openAIWebhook = await client.fetchWebhook(openAIWebhookID, openAIWebhookToken).catch(error => {
|
||||||
client.logger.error(`[module:condimentX] Could not fetch webhook: ${error.message}`);
|
client.logger.error(`Could not fetch webhook: ${error.message}`);
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
if (openAIWebhook) openAIWebhookClient = new WebhookClient({ id: openAIWebhookID, token: openAIWebhookToken });
|
if (openAIWebhook) openAIWebhookClient = new WebhookClient({ id: openAIWebhookID, token: openAIWebhookToken });
|
||||||
@ -372,7 +374,7 @@ export const init = async (client, config) => {
|
|||||||
try {
|
try {
|
||||||
guild = client.guilds.cache.get(guildID);
|
guild = client.guilds.cache.get(guildID);
|
||||||
if (!guild) {
|
if (!guild) {
|
||||||
client.logger.error(`[module:condimentX] Guild ${guildID} not found`);
|
client.logger.error(`CondimentX error: Guild ${guildID} not found`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
indexRole = await guild.roles.fetch(indexRoleID);
|
indexRole = await guild.roles.fetch(indexRoleID);
|
||||||
|
|||||||
233
_opt/gitUtils.js
233
_opt/gitUtils.js
@ -1,179 +1,102 @@
|
|||||||
import { execFile } from 'child_process';
|
|
||||||
import { promisify } from 'util';
|
|
||||||
|
|
||||||
import { MessageFlags } from 'discord-api-types/v10';
|
|
||||||
import { SlashCommandBuilder } from 'discord.js';
|
import { SlashCommandBuilder } from 'discord.js';
|
||||||
// Use execFile to avoid shell interpretation of arguments
|
import { exec } from 'child_process';
|
||||||
const execFileAsync = promisify(execFile);
|
import { promisify } from 'util';
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
// Wrap Git errors
|
// Wrap Git errors
|
||||||
class GitError extends Error {
|
class GitError extends Error {
|
||||||
constructor(message) {
|
constructor(message) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = 'GitError';
|
this.name = 'GitError';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Run `git <args>` and return trimmed output or throw
|
||||||
* 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) {
|
async function runGit(args) {
|
||||||
// Sanitize arguments: disallow dangerous shell metacharacters
|
try {
|
||||||
if (!Array.isArray(args)) {
|
const { stdout, stderr } = await execAsync(`git ${args.join(' ')}`);
|
||||||
throw new GitError('Invalid git arguments');
|
const out = stdout.trim() || stderr.trim();
|
||||||
}
|
return out || '(no output)';
|
||||||
const dangerous = /[;&|<>`$\\]/;
|
} catch (err) {
|
||||||
for (const arg of args) {
|
const msg = err.stderr?.trim() || err.message;
|
||||||
if (dangerous.test(arg)) {
|
throw new GitError(msg);
|
||||||
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 in Markdown code block
|
||||||
* 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 = '') {
|
function formatCodeBlock(content, lang = '') {
|
||||||
const fence = '```';
|
const fence = '```';
|
||||||
return lang
|
return lang
|
||||||
? `${fence}${lang}\n${content}\n${fence}`
|
? `${fence}${lang}\n${content}\n${fence}`
|
||||||
: `${fence}\n${content}\n${fence}`;
|
: `${fence}\n${content}\n${fence}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Split string into chunks of at most chunkSize
|
||||||
* 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) {
|
function chunkString(str, chunkSize) {
|
||||||
const chunks = [];
|
const chunks = [];
|
||||||
for (let i = 0; i < str.length; i += chunkSize) {
|
for (let i = 0; i < str.length; i += chunkSize) {
|
||||||
chunks.push(str.slice(i, i + chunkSize));
|
chunks.push(str.slice(i, i + chunkSize));
|
||||||
}
|
}
|
||||||
return chunks;
|
return chunks;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single /git command: run arbitrary git <args>
|
// Single /git command: run arbitrary git <args>
|
||||||
export const commands = [
|
export const commands = [
|
||||||
{
|
{
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
.setName('git')
|
.setName('git')
|
||||||
.setDescription('Run an arbitrary git command (Owner only)')
|
.setDescription('Run an arbitrary git command (owner only)')
|
||||||
.addStringOption(opt =>
|
.addStringOption(opt =>
|
||||||
opt.setName('args')
|
opt.setName('args')
|
||||||
.setDescription('Arguments to pass to git')
|
.setDescription('Arguments to pass to git')
|
||||||
.setRequired(true))
|
.setRequired(true))
|
||||||
.addBooleanOption(opt =>
|
.addBooleanOption(opt =>
|
||||||
opt.setName('ephemeral')
|
opt.setName('ephemeral')
|
||||||
.setDescription('Make the reply ephemeral')
|
.setDescription('Make the reply ephemeral')
|
||||||
.setRequired(false)),
|
.setRequired(false)),
|
||||||
async execute(interaction, client) {
|
async execute(interaction, client) {
|
||||||
const ownerId = client.config.owner;
|
const ownerId = client.config.owner;
|
||||||
if (interaction.user.id !== ownerId) {
|
if (interaction.user.id !== ownerId) {
|
||||||
return interaction.reply({ content: 'Only the bot owner can run git commands.', flags: MessageFlags.Ephemeral });
|
return interaction.reply({ content: 'Only the bot owner can run git commands.', ephemeral: true });
|
||||||
}
|
}
|
||||||
const raw = interaction.options.getString('args');
|
const raw = interaction.options.getString('args');
|
||||||
// Disallow semicolons to prevent command chaining
|
// Disallow semicolons to prevent command chaining
|
||||||
if (raw.includes(';')) {
|
if (raw.includes(';')) {
|
||||||
return interaction.reply({ content: 'Semicolons are not allowed in git arguments.', flags: MessageFlags.Ephemeral });
|
return interaction.reply({ content: 'Semicolons are not allowed in git arguments.', ephemeral: true });
|
||||||
}
|
}
|
||||||
const ephemeral = interaction.options.getBoolean('ephemeral') ?? true;
|
const ephemeral = interaction.options.getBoolean('ephemeral') ?? true;
|
||||||
const args = raw.match(/(?:[^\s"]+|"[^"]*")+/g)
|
const args = raw.match(/(?:[^\s"]+|"[^"]*")+/g)
|
||||||
.map(s => s.replace(/^"(.+)"$/, '$1'));
|
.map(s => s.replace(/^"(.+)"$/, '$1'));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Log the exact git command being executed
|
// Log the exact git command being executed
|
||||||
const cmdStr = args.join(' ');
|
const cmdStr = args.join(' ');
|
||||||
client.logger.warn(`[cmd:git] Executing git command: git ${cmdStr}`);
|
client.logger.warn(`Executing git command: git ${cmdStr}`);
|
||||||
const output = await runGit(args);
|
const output = await runGit(args);
|
||||||
// Prepend the git command as a header; keep it intact when chunking
|
// Prepend the git command as a header; keep it intact when chunking
|
||||||
const header = `git ${cmdStr}\n`;
|
const header = `git ${cmdStr}\n`;
|
||||||
// Discord message limit ~2000; reserve for code fences
|
// Discord message limit ~2000; reserve for code fences
|
||||||
const maxContent = 1990;
|
const maxContent = 1990;
|
||||||
// Calculate how much output can fit after the header in the first chunk
|
// Calculate how much output can fit after the header in the first chunk
|
||||||
const firstChunkSize = Math.max(0, maxContent - header.length);
|
const firstChunkSize = Math.max(0, maxContent - header.length);
|
||||||
// Split the raw output into chunks
|
// Split the raw output into chunks
|
||||||
const outputChunks = chunkString(output, firstChunkSize);
|
const outputChunks = chunkString(output, firstChunkSize);
|
||||||
// Send first block with header + first output chunk
|
// Send first block with header + first output chunk
|
||||||
const firstBlock = header + (outputChunks[0] || '');
|
const firstBlock = header + (outputChunks[0] || '');
|
||||||
const replyOpts = { content: formatCodeBlock(firstBlock) };
|
await interaction.reply({ content: formatCodeBlock(firstBlock), ephemeral });
|
||||||
if (ephemeral) replyOpts.flags = MessageFlags.Ephemeral;
|
// Send any remaining blocks without the header
|
||||||
await interaction.reply(replyOpts);
|
for (let i = 1; i < outputChunks.length; i++) {
|
||||||
// Send any remaining blocks without the header
|
await interaction.followUp({ content: formatCodeBlock(outputChunks[i]), ephemeral });
|
||||||
for (let i = 1; i < outputChunks.length; i++) {
|
|
||||||
const fuOpts = { content: formatCodeBlock(outputChunks[i]) };
|
|
||||||
if (ephemeral) fuOpts.flags = MessageFlags.Ephemeral;
|
|
||||||
await interaction.followUp(fuOpts);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
const msg = err instanceof GitError ? err.message : String(err);
|
|
||||||
await interaction.reply({ content: `Error: ${msg}`, flags: MessageFlags.Ephemeral });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof GitError ? err.message : String(err);
|
||||||
|
await interaction.reply({ content: `Error: ${msg}`, ephemeral: true });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
// No special init logic
|
// No special init logic
|
||||||
export async function init(client) {
|
export async function init(client) {
|
||||||
client.logger.warn('[module:gitUtils] Git utilities module loaded - dangerous module, use with caution');
|
client.logger.warn('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']);
|
|
||||||
}
|
|
||||||
@ -4,25 +4,25 @@ import { onMessageQueueEvent } from './pbUtils.js';
|
|||||||
/**
|
/**
|
||||||
* Example module that listens for 'test' messages in the message_queue collection.
|
* Example module that listens for 'test' messages in the message_queue collection.
|
||||||
*/
|
*/
|
||||||
export async function init(client, _config) {
|
export const init = async (client, config) => {
|
||||||
client.logger.info('[module:messageQueueExample] Message Queue Example module initialized');
|
client.logger.info('Initializing Message Queue Example module');
|
||||||
onMessageQueueEvent(client, async (action, record) => {
|
onMessageQueueEvent(client, async (action, record) => {
|
||||||
// Only process newly created records
|
// Only process newly created records
|
||||||
if (action !== 'create') return;
|
if (action !== 'create') return;
|
||||||
// Only process messages meant for this client
|
// Only process messages meant for this client
|
||||||
if (record.destination !== client.config.id) return;
|
if (record.destination !== client.config.id) return;
|
||||||
// Only handle test dataType
|
// Only handle test dataType
|
||||||
if (record.dataType !== 'test') return;
|
if (record.dataType !== 'test') return;
|
||||||
|
|
||||||
// At this point we have a test message for us
|
// At this point we have a test message for us
|
||||||
client.logger.info('[module:messageQueueExample] Test message received');
|
client.logger.info('test received');
|
||||||
|
|
||||||
// Delete the processed message from the queue
|
// Delete the processed message from the queue
|
||||||
try {
|
try {
|
||||||
await client.pb.deleteMessageQueue(record.id);
|
await client.pb.deleteMessageQueue(record.id);
|
||||||
client.logger.debug(`[module:messageQueueExample] Deleted message_queue record ${record.id}`);
|
client.logger.debug(`Deleted message_queue record ${record.id}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
client.logger.error(`[module:messageQueueExample] Failed to delete message_queue record ${record.id}: ${err.message}`);
|
client.logger.error(`Failed to delete message_queue record ${record.id}: ${err.message}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
};
|
||||||
665
_opt/pbUtils.js
665
_opt/pbUtils.js
@ -1,11 +1,10 @@
|
|||||||
// _opt/pbutils.js
|
// _opt/pbutils.js
|
||||||
// Polyfill global EventSource for PocketBase realtime in Node.js (using CommonJS require)
|
// Polyfill global EventSource for PocketBase realtime in Node.js (using CommonJS require)
|
||||||
import { createRequire } from 'module';
|
import { createRequire } from 'module';
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
const { EventSource } = require('eventsource');
|
const { EventSource } = require('eventsource');
|
||||||
if (typeof global.EventSource === 'undefined') {
|
if (typeof global.EventSource === 'undefined') {
|
||||||
global.EventSource = EventSource;
|
global.EventSource = EventSource;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -17,32 +16,32 @@ if (typeof global.EventSource === 'undefined') {
|
|||||||
* @param {Object} client - Discord client with attached PocketBase instance
|
* @param {Object} client - Discord client with attached PocketBase instance
|
||||||
* @param {Object} config - Client configuration
|
* @param {Object} config - Client configuration
|
||||||
*/
|
*/
|
||||||
export async function init(client, _config) {
|
export const init = async (client, config) => {
|
||||||
const { pb, logger } = client;
|
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
|
// Attach utility methods to the pb object
|
||||||
extendPocketBase(client, pb, logger);
|
extendPocketBase(client, pb, logger);
|
||||||
|
|
||||||
// Add connection state handling
|
// Add connection state handling
|
||||||
setupConnectionHandling(pb, logger);
|
setupConnectionHandling(pb, logger);
|
||||||
|
|
||||||
|
// Subscribe to real-time message queue events and re-emit via client
|
||||||
|
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('Subscribed to PocketBase message_queue realtime events');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to subscribe to message_queue realtime: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Subscribe to real-time message queue events and re-emit via client
|
// end of init()
|
||||||
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');
|
||||||
|
};
|
||||||
logger.info('PocketBase utilities module initialized');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register a handler for incoming message_queue pub/sub events.
|
* Register a handler for incoming message_queue pub/sub events.
|
||||||
@ -51,13 +50,13 @@ export async function init(client, _config) {
|
|||||||
* @param {(action: string, record: object) => void} handler - Callback for each event
|
* @param {(action: string, record: object) => void} handler - Callback for each event
|
||||||
*/
|
*/
|
||||||
export function onMessageQueueEvent(client, handler) {
|
export function onMessageQueueEvent(client, handler) {
|
||||||
client.on('message_queue_event', (action, record) => {
|
client.on('message_queue_event', (action, record) => {
|
||||||
try {
|
try {
|
||||||
handler(action, record);
|
handler(action, record);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
client.logger.error(`[module:pbUtils] Error in message_queue handler: ${err.message}`);
|
client.logger.error(`Error in message_queue handler: ${err.message}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -72,78 +71,78 @@ export function onMessageQueueEvent(client, handler) {
|
|||||||
* @param {object} logger - Logger instance
|
* @param {object} logger - Logger instance
|
||||||
*/
|
*/
|
||||||
const extendPocketBase = (client, pb, logger) => {
|
const extendPocketBase = (client, pb, logger) => {
|
||||||
// ===== COLLECTION OPERATIONS =====
|
// ===== COLLECTION OPERATIONS =====
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a single record with better error handling
|
* Get a single record with better error handling
|
||||||
* @param {string} collection - Collection name
|
* @param {string} collection - Collection name
|
||||||
* @param {string} id - Record ID
|
* @param {string} id - Record ID
|
||||||
* @param {Object} options - Additional options
|
* @param {Object} options - Additional options
|
||||||
* @returns {Promise<Object>} The record or null
|
* @returns {Promise<Object>} The record or null
|
||||||
*/
|
*/
|
||||||
pb.getOne = async (collection, id, options = {}) => {
|
pb.getOne = async (collection, id, options = {}) => {
|
||||||
try {
|
try {
|
||||||
return await pb.collection(collection).getOne(id, options);
|
return await pb.collection(collection).getOne(id, options);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.status === 404) {
|
if (error.status === 404) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
logger.error(`Failed to get record ${id} from ${collection}: ${error.message}`);
|
logger.error(`Failed to get record ${id} from ${collection}: ${error.message}`);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a record with validation and error handling
|
* Creates a record with validation and error handling
|
||||||
* @param {string} collection - Collection name
|
* @param {string} collection - Collection name
|
||||||
* @param {Object} data - Record data
|
* @param {Object} data - Record data
|
||||||
* @returns {Promise<Object>} Created record
|
* @returns {Promise<Object>} Created record
|
||||||
*/
|
*/
|
||||||
pb.createOne = async (collection, data) => {
|
pb.createOne = async (collection, data) => {
|
||||||
try {
|
try {
|
||||||
return await pb.collection(collection).create(data);
|
return await pb.collection(collection).create(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to create record in ${collection}: ${error.message}`);
|
logger.error(`Failed to create record in ${collection}: ${error.message}`);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates a record with better error handling
|
* Updates a record with better error handling
|
||||||
* @param {string} collection - Collection name
|
* @param {string} collection - Collection name
|
||||||
* @param {string} id - Record ID
|
* @param {string} id - Record ID
|
||||||
* @param {Object} data - Record data
|
* @param {Object} data - Record data
|
||||||
* @returns {Promise<Object>} Updated record
|
* @returns {Promise<Object>} Updated record
|
||||||
*/
|
*/
|
||||||
pb.updateOne = async (collection, id, data) => {
|
pb.updateOne = async (collection, id, data) => {
|
||||||
try {
|
try {
|
||||||
return await pb.collection(collection).update(id, data);
|
return await pb.collection(collection).update(id, data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to update record ${id} in ${collection}: ${error.message}`);
|
logger.error(`Failed to update record ${id} in ${collection}: ${error.message}`);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deletes a record with better error handling
|
* Deletes a record with better error handling
|
||||||
* @param {string} collection - Collection name
|
* @param {string} collection - Collection name
|
||||||
* @param {string} id - Record ID
|
* @param {string} id - Record ID
|
||||||
* @returns {Promise<boolean>} Success status
|
* @returns {Promise<boolean>} Success status
|
||||||
*/
|
*/
|
||||||
pb.deleteOne = async (collection, id) => {
|
pb.deleteOne = async (collection, id) => {
|
||||||
try {
|
try {
|
||||||
await pb.collection(collection).delete(id);
|
await pb.collection(collection).delete(id);
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.status === 404) {
|
if (error.status === 404) {
|
||||||
logger.warn(`Record ${id} not found in ${collection} for deletion`);
|
logger.warn(`Record ${id} not found in ${collection} for deletion`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
logger.error(`Failed to delete record ${id} from ${collection}: ${error.message}`);
|
logger.error(`Failed to delete record ${id} from ${collection}: ${error.message}`);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convenience: publish a message into the "message_queue" collection,
|
* Convenience: publish a message into the "message_queue" collection,
|
||||||
* with source/destination validation.
|
* with source/destination validation.
|
||||||
@ -163,196 +162,196 @@ const extendPocketBase = (client, pb, logger) => {
|
|||||||
return await pb.collection('message_queue').create({ source, destination, dataType, data: JSON.stringify(data) });
|
return await pb.collection('message_queue').create({ source, destination, dataType, data: JSON.stringify(data) });
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upsert - creates or updates a record based on whether it exists
|
* Upsert - creates or updates a record based on whether it exists
|
||||||
* @param {string} collection - Collection name
|
* @param {string} collection - Collection name
|
||||||
* @param {string} id - Record ID or null for new record
|
* @param {string} id - Record ID or null for new record
|
||||||
* @param {Object} data - Record data
|
* @param {Object} data - Record data
|
||||||
* @returns {Promise<Object>} Created/updated record
|
* @returns {Promise<Object>} Created/updated record
|
||||||
*/
|
*/
|
||||||
pb.upsert = async (collection, id, data) => {
|
pb.upsert = async (collection, id, data) => {
|
||||||
if (id) {
|
if (id) {
|
||||||
const exists = await pb.getOne(collection, id);
|
const exists = await pb.getOne(collection, id);
|
||||||
if (exists) {
|
if (exists) {
|
||||||
return await pb.updateOne(collection, id, data);
|
return await pb.updateOne(collection, id, data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return await pb.createOne(collection, data);
|
return await pb.createOne(collection, data);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ===== QUERY SHORTCUTS =====
|
// ===== QUERY SHORTCUTS =====
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get first record matching a filter
|
* Get first record matching a filter
|
||||||
* @param {string} collection - Collection name
|
* @param {string} collection - Collection name
|
||||||
* @param {string} filter - Filter query
|
* @param {string} filter - Filter query
|
||||||
* @param {Object} options - Additional options
|
* @param {Object} options - Additional options
|
||||||
* @returns {Promise<Object>} First matching record or null
|
* @returns {Promise<Object>} First matching record or null
|
||||||
*/
|
*/
|
||||||
pb.getFirst = async (collection, filter, options = {}) => {
|
pb.getFirst = async (collection, filter, options = {}) => {
|
||||||
try {
|
try {
|
||||||
const result = await pb.collection(collection).getList(1, 1, {
|
const result = await pb.collection(collection).getList(1, 1, {
|
||||||
filter,
|
filter,
|
||||||
...options
|
...options
|
||||||
});
|
});
|
||||||
|
|
||||||
return result.items.length > 0 ? result.items[0] : null;
|
return result.items.length > 0 ? result.items[0] : null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.status === 404) {
|
if (error.status === 404) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
logger.error(`Failed to get first record from ${collection}: ${error.message}`);
|
logger.error(`Failed to get first record from ${collection}: ${error.message}`);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all records from a collection (handles pagination)
|
* Get all records from a collection (handles pagination)
|
||||||
* @param {string} collection - Collection name
|
* @param {string} collection - Collection name
|
||||||
* @param {Object} options - Query options
|
* @param {Object} options - Query options
|
||||||
* @returns {Promise<Array>} Array of records
|
* @returns {Promise<Array>} Array of records
|
||||||
*/
|
*/
|
||||||
pb.getAll = async (collection, options = {}) => {
|
pb.getAll = async (collection, options = {}) => {
|
||||||
const records = [];
|
const records = [];
|
||||||
const pageSize = options.pageSize || 200;
|
const pageSize = options.pageSize || 200;
|
||||||
let page = 1;
|
let page = 1;
|
||||||
const isRunning = true;
|
|
||||||
while (isRunning) {
|
|
||||||
try {
|
|
||||||
const result = await pb.collection(collection).getList(page, pageSize, options);
|
|
||||||
records.push(...result.items);
|
|
||||||
|
|
||||||
if (records.length >= result.totalItems) {
|
try {
|
||||||
break;
|
while (true) {
|
||||||
}
|
const result = await pb.collection(collection).getList(page, pageSize, options);
|
||||||
|
records.push(...result.items);
|
||||||
|
|
||||||
page++;
|
if (records.length >= result.totalItems) {
|
||||||
} catch (error) {
|
break;
|
||||||
logger.error(`Failed to get all records from ${collection}: ${error.message}`);
|
}
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
* Count records matching a filter
|
||||||
* @param {string} collection - Collection name
|
* @param {string} collection - Collection name
|
||||||
* @param {string} filter - Filter query
|
* @param {string} filter - Filter query
|
||||||
* @returns {Promise<number>} Count of matching records
|
* @returns {Promise<number>} Count of matching records
|
||||||
*/
|
*/
|
||||||
pb.count = async (collection, filter = '') => {
|
pb.count = async (collection, filter = '') => {
|
||||||
try {
|
try {
|
||||||
const result = await pb.collection(collection).getList(1, 1, {
|
const result = await pb.collection(collection).getList(1, 1, {
|
||||||
filter,
|
filter,
|
||||||
fields: 'id'
|
fields: 'id'
|
||||||
});
|
});
|
||||||
|
|
||||||
return result.totalItems;
|
return result.totalItems;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to count records in ${collection}: ${error.message}`);
|
logger.error(`Failed to count records in ${collection}: ${error.message}`);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ===== BATCH OPERATIONS =====
|
// ===== BATCH OPERATIONS =====
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Perform batch create
|
* Perform batch create
|
||||||
* @param {string} collection - Collection name
|
* @param {string} collection - Collection name
|
||||||
* @param {Array<Object>} items - Array of items to create
|
* @param {Array<Object>} items - Array of items to create
|
||||||
* @returns {Promise<Array>} Created records
|
* @returns {Promise<Array>} Created records
|
||||||
*/
|
*/
|
||||||
pb.batchCreate = async (collection, items) => {
|
pb.batchCreate = async (collection, items) => {
|
||||||
if (!items || items.length === 0) {
|
if (!items || items.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = [];
|
const results = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Process in chunks to avoid rate limits
|
// Process in chunks to avoid rate limits
|
||||||
const chunkSize = 50;
|
const chunkSize = 50;
|
||||||
|
|
||||||
for (let i = 0; i < items.length; i += chunkSize) {
|
for (let i = 0; i < items.length; i += chunkSize) {
|
||||||
const chunk = items.slice(i, i + chunkSize);
|
const chunk = items.slice(i, i + chunkSize);
|
||||||
const promises = chunk.map(item => pb.createOne(collection, item));
|
const promises = chunk.map(item => pb.createOne(collection, item));
|
||||||
const chunkResults = await Promise.all(promises);
|
const chunkResults = await Promise.all(promises);
|
||||||
results.push(...chunkResults);
|
results.push(...chunkResults);
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed batch create in ${collection}: ${error.message}`);
|
logger.error(`Failed batch create in ${collection}: ${error.message}`);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Perform batch update
|
* Perform batch update
|
||||||
* @param {string} collection - Collection name
|
* @param {string} collection - Collection name
|
||||||
* @param {Array<Object>} items - Array of items with id field
|
* @param {Array<Object>} items - Array of items with id field
|
||||||
* @returns {Promise<Array>} Updated records
|
* @returns {Promise<Array>} Updated records
|
||||||
*/
|
*/
|
||||||
pb.batchUpdate = async (collection, items) => {
|
pb.batchUpdate = async (collection, items) => {
|
||||||
if (!items || items.length === 0) {
|
if (!items || items.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = [];
|
const results = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Process in chunks to avoid rate limits
|
// Process in chunks to avoid rate limits
|
||||||
const chunkSize = 50;
|
const chunkSize = 50;
|
||||||
|
|
||||||
for (let i = 0; i < items.length; i += chunkSize) {
|
for (let i = 0; i < items.length; i += chunkSize) {
|
||||||
const chunk = items.slice(i, i + chunkSize);
|
const chunk = items.slice(i, i + chunkSize);
|
||||||
const promises = chunk.map(item => {
|
const promises = chunk.map(item => {
|
||||||
const { id, ...data } = item;
|
const { id, ...data } = item;
|
||||||
return pb.updateOne(collection, id, data);
|
return pb.updateOne(collection, id, data);
|
||||||
});
|
});
|
||||||
const chunkResults = await Promise.all(promises);
|
const chunkResults = await Promise.all(promises);
|
||||||
results.push(...chunkResults);
|
results.push(...chunkResults);
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed batch update in ${collection}: ${error.message}`);
|
logger.error(`Failed batch update in ${collection}: ${error.message}`);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Perform batch delete
|
* Perform batch delete
|
||||||
* @param {string} collection - Collection name
|
* @param {string} collection - Collection name
|
||||||
* @param {Array<string>} ids - Array of record IDs to delete
|
* @param {Array<string>} ids - Array of record IDs to delete
|
||||||
* @returns {Promise<Array>} Results of deletion operations
|
* @returns {Promise<Array>} Results of deletion operations
|
||||||
*/
|
*/
|
||||||
pb.batchDelete = async (collection, ids) => {
|
pb.batchDelete = async (collection, ids) => {
|
||||||
if (!ids || ids.length === 0) {
|
if (!ids || ids.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = [];
|
const results = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Process in chunks to avoid rate limits
|
// Process in chunks to avoid rate limits
|
||||||
const chunkSize = 50;
|
const chunkSize = 50;
|
||||||
|
|
||||||
for (let i = 0; i < ids.length; i += chunkSize) {
|
for (let i = 0; i < ids.length; i += chunkSize) {
|
||||||
const chunk = ids.slice(i, i + chunkSize);
|
const chunk = ids.slice(i, i + chunkSize);
|
||||||
const promises = chunk.map(id => pb.deleteOne(collection, id));
|
const promises = chunk.map(id => pb.deleteOne(collection, id));
|
||||||
const chunkResults = await Promise.all(promises);
|
const chunkResults = await Promise.all(promises);
|
||||||
results.push(...chunkResults);
|
results.push(...chunkResults);
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed batch delete in ${collection}: ${error.message}`);
|
logger.error(`Failed batch delete in ${collection}: ${error.message}`);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* Delete a message in the "message_queue" collection by its record ID.
|
* Delete a message in the "message_queue" collection by its record ID.
|
||||||
* @param {string} id - Record ID to delete.
|
* @param {string} id - Record ID to delete.
|
||||||
@ -362,99 +361,123 @@ const extendPocketBase = (client, pb, logger) => {
|
|||||||
return await pb.deleteOne('message_queue', id);
|
return await pb.deleteOne('message_queue', id);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ===== CACHE MANAGEMENT =====
|
// ===== PUB/SUB OPERATIONS =====
|
||||||
|
|
||||||
// Simple in-memory cache
|
/**
|
||||||
pb.cache = {
|
* Publish a message into the "message_queue" collection.
|
||||||
_store: new Map(),
|
* @param {string} source - Origin identifier for the message.
|
||||||
_ttls: new Map(),
|
* @param {string} destination - Target identifier (e.g. channel or client ID).
|
||||||
|
* @param {string} dataType - A short string describing the type of data.
|
||||||
|
* @param {object} data - The payload object to deliver.
|
||||||
|
* @returns {Promise<object>} The created message_queue record.
|
||||||
|
*/
|
||||||
|
pb.publishMessage = async (source, destination, dataType, data) => {
|
||||||
|
try {
|
||||||
|
return await pb.collection('message_queue').create({
|
||||||
|
source,
|
||||||
|
destination,
|
||||||
|
dataType,
|
||||||
|
data: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to publish message to message_queue: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
// ===== CACHE MANAGEMENT =====
|
||||||
|
|
||||||
|
// Simple in-memory cache
|
||||||
|
pb.cache = {
|
||||||
|
_store: new Map(),
|
||||||
|
_ttls: new Map(),
|
||||||
|
|
||||||
|
/**
|
||||||
* Get a value from cache
|
* Get a value from cache
|
||||||
* @param {string} key - Cache key
|
* @param {string} key - Cache key
|
||||||
* @returns {*} Cached value or undefined
|
* @returns {*} Cached value or undefined
|
||||||
*/
|
*/
|
||||||
get(key) {
|
get(key) {
|
||||||
if (this._ttls.has(key) && this._ttls.get(key) < Date.now()) {
|
if (this._ttls.has(key) && this._ttls.get(key) < Date.now()) {
|
||||||
this.delete(key);
|
this.delete(key);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
return this._store.get(key);
|
return this._store.get(key);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set a value in cache
|
* Set a value in cache
|
||||||
* @param {string} key - Cache key
|
* @param {string} key - Cache key
|
||||||
* @param {*} value - Value to store
|
* @param {*} value - Value to store
|
||||||
* @param {number} ttlSeconds - Time to live in seconds
|
* @param {number} ttlSeconds - Time to live in seconds
|
||||||
*/
|
*/
|
||||||
set(key, value, ttlSeconds = 300) {
|
set(key, value, ttlSeconds = 300) {
|
||||||
this._store.set(key, value);
|
this._store.set(key, value);
|
||||||
if (ttlSeconds > 0) {
|
if (ttlSeconds > 0) {
|
||||||
this._ttls.set(key, Date.now() + (ttlSeconds * 1000));
|
this._ttls.set(key, Date.now() + (ttlSeconds * 1000));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a value from cache
|
* Delete a value from cache
|
||||||
* @param {string} key - Cache key
|
* @param {string} key - Cache key
|
||||||
*/
|
*/
|
||||||
delete(key) {
|
delete(key) {
|
||||||
this._store.delete(key);
|
this._store.delete(key);
|
||||||
this._ttls.delete(key);
|
this._ttls.delete(key);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear all cache
|
* Clear all cache
|
||||||
*/
|
*/
|
||||||
clear() {
|
clear() {
|
||||||
this._store.clear();
|
this._store.clear();
|
||||||
this._ttls.clear();
|
this._ttls.clear();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a record with caching
|
* Get a record with caching
|
||||||
* @param {string} collection - Collection name
|
* @param {string} collection - Collection name
|
||||||
* @param {string} id - Record ID
|
* @param {string} id - Record ID
|
||||||
* @param {number} ttlSeconds - Cache TTL in seconds
|
* @param {number} ttlSeconds - Cache TTL in seconds
|
||||||
* @returns {Promise<Object>} Record or null
|
* @returns {Promise<Object>} Record or null
|
||||||
*/
|
*/
|
||||||
pb.getCached = async (collection, id, ttlSeconds = 60) => {
|
pb.getCached = async (collection, id, ttlSeconds = 60) => {
|
||||||
const cacheKey = `${collection}:${id}`;
|
const cacheKey = `${collection}:${id}`;
|
||||||
const cached = pb.cache.get(cacheKey);
|
const cached = pb.cache.get(cacheKey);
|
||||||
|
|
||||||
if (cached !== undefined) {
|
if (cached !== undefined) {
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
const record = await pb.getOne(collection, id);
|
const record = await pb.getOne(collection, id);
|
||||||
pb.cache.set(cacheKey, record, ttlSeconds);
|
pb.cache.set(cacheKey, record, ttlSeconds);
|
||||||
|
|
||||||
return record;
|
return record;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get list with caching
|
* Get list with caching
|
||||||
* @param {string} collection - Collection name
|
* @param {string} collection - Collection name
|
||||||
* @param {Object} options - Query options
|
* @param {Object} options - Query options
|
||||||
* @param {number} ttlSeconds - Cache TTL in seconds
|
* @param {number} ttlSeconds - Cache TTL in seconds
|
||||||
* @returns {Promise<Object>} List result
|
* @returns {Promise<Object>} List result
|
||||||
*/
|
*/
|
||||||
pb.getListCached = async (collection, options = {}, ttlSeconds = 30) => {
|
pb.getListCached = async (collection, options = {}, ttlSeconds = 30) => {
|
||||||
const cacheKey = `${collection}:list:${JSON.stringify(options)}`;
|
const cacheKey = `${collection}:list:${JSON.stringify(options)}`;
|
||||||
const cached = pb.cache.get(cacheKey);
|
const cached = pb.cache.get(cacheKey);
|
||||||
|
|
||||||
if (cached !== undefined) {
|
if (cached !== undefined) {
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { page = 1, perPage = 50, ...restOptions } = options;
|
const { page = 1, perPage = 50, ...restOptions } = options;
|
||||||
const result = await pb.collection(collection).getList(page, perPage, restOptions);
|
const result = await pb.collection(collection).getList(page, perPage, restOptions);
|
||||||
pb.cache.set(cacheKey, result, ttlSeconds);
|
pb.cache.set(cacheKey, result, ttlSeconds);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -463,82 +486,80 @@ const extendPocketBase = (client, pb, logger) => {
|
|||||||
* @param {Object} logger - Winston logger
|
* @param {Object} logger - Winston logger
|
||||||
*/
|
*/
|
||||||
const setupConnectionHandling = (pb, logger) => {
|
const setupConnectionHandling = (pb, logger) => {
|
||||||
// Add connection state tracking
|
// Add connection state tracking
|
||||||
pb.isConnected = true;
|
pb.isConnected = true;
|
||||||
pb.lastSuccessfulAuth = null;
|
pb.lastSuccessfulAuth = null;
|
||||||
|
|
||||||
// Add auto-reconnect and token refresh
|
// Add auto-reconnect and token refresh
|
||||||
pb.authStore.onChange(() => {
|
pb.authStore.onChange(() => {
|
||||||
pb.isConnected = pb.authStore.isValid;
|
pb.isConnected = pb.authStore.isValid;
|
||||||
|
|
||||||
if (pb.isConnected) {
|
if (pb.isConnected) {
|
||||||
pb.lastSuccessfulAuth = new Date();
|
pb.lastSuccessfulAuth = new Date();
|
||||||
logger.info('PocketBase authentication successful');
|
logger.info('PocketBase authentication successful');
|
||||||
} else {
|
} else {
|
||||||
logger.warn('PocketBase auth token expired or invalid');
|
logger.warn('PocketBase auth token expired or invalid');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Helper to check health and reconnect if needed
|
// Helper to check health and reconnect if needed
|
||||||
pb.ensureConnection = async () => {
|
pb.ensureConnection = async () => {
|
||||||
if (!pb.isConnected || !pb.authStore.isValid) {
|
if (!pb.isConnected || !pb.authStore.isValid) {
|
||||||
try {
|
try {
|
||||||
logger.info('Reconnecting to PocketBase...');
|
logger.info('Reconnecting to PocketBase...');
|
||||||
// Attempt to refresh the auth if we have a refresh token
|
// Attempt to refresh the auth if we have a refresh token
|
||||||
if (pb.authStore.token && pb.authStore.model?.id) {
|
if (pb.authStore.token && pb.authStore.model?.id) {
|
||||||
// Refresh session using the configured users collection
|
await pb.admins.authRefresh();
|
||||||
await pb.collection('_users').authRefresh();
|
} else if (pb._config.username && pb._config.password) {
|
||||||
} else if (pb._config.username && pb._config.password) {
|
// Fall back to full re-authentication if credentials available
|
||||||
// Fall back to full re-authentication if credentials available
|
await pb.admins.authWithPassword(
|
||||||
// Re-authenticate using the configured users collection credentials
|
pb._config.username,
|
||||||
await pb.collection('_users').authWithPassword(
|
pb._config.password
|
||||||
pb._config.username,
|
);
|
||||||
pb._config.password
|
} else {
|
||||||
);
|
logger.error('No credentials available to reconnect PocketBase');
|
||||||
} else {
|
pb.isConnected = false;
|
||||||
logger.error('No credentials available to reconnect PocketBase');
|
return false;
|
||||||
pb.isConnected = false;
|
}
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
pb.isConnected = true;
|
pb.isConnected = true;
|
||||||
pb.lastSuccessfulAuth = new Date();
|
pb.lastSuccessfulAuth = new Date();
|
||||||
logger.info('Successfully reconnected to PocketBase');
|
logger.info('Successfully reconnected to PocketBase');
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to reconnect to PocketBase: ${error.message}`);
|
logger.error(`Failed to reconnect to PocketBase: ${error.message}`);
|
||||||
pb.isConnected = false;
|
pb.isConnected = false;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Store credentials for reconnection
|
// Store credentials for reconnection
|
||||||
pb._config = pb._config || {};
|
pb._config = pb._config || {};
|
||||||
// Ensure only if env provided
|
// Ensure only if env provided
|
||||||
if (process.env.SHARED_POCKETBASE_USERNAME && process.env.SHARED_POCKETBASE_PASSWORD) {
|
if (process.env.SHARED_POCKETBASE_USERNAME && process.env.SHARED_POCKETBASE_PASSWORD) {
|
||||||
pb._config.username = process.env.SHARED_POCKETBASE_USERNAME;
|
pb._config.username = process.env.SHARED_POCKETBASE_USERNAME;
|
||||||
pb._config.password = process.env.SHARED_POCKETBASE_PASSWORD;
|
pb._config.password = process.env.SHARED_POCKETBASE_PASSWORD;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Heartbeat function to check connection periodically
|
// Heartbeat function to check connection periodically
|
||||||
const heartbeatInterval = setInterval(async () => {
|
const heartbeatInterval = setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
// Simple health check
|
// Simple health check
|
||||||
await pb.health.check();
|
await pb.health.check();
|
||||||
pb.isConnected = true;
|
pb.isConnected = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn(`PocketBase connection issue: ${error.message}`);
|
logger.warn(`PocketBase connection issue: ${error.message}`);
|
||||||
pb.isConnected = false;
|
pb.isConnected = false;
|
||||||
await pb.ensureConnection();
|
await pb.ensureConnection();
|
||||||
}
|
}
|
||||||
}, 5 * 60 * 1000); // Check every 5 minutes
|
}, 5 * 60 * 1000); // Check every 5 minutes
|
||||||
|
|
||||||
// Clean up on client disconnect
|
// Clean up on client disconnect
|
||||||
pb.cleanup = () => {
|
pb.cleanup = () => {
|
||||||
clearInterval(heartbeatInterval);
|
clearInterval(heartbeatInterval);
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@ -3,15 +3,11 @@
|
|||||||
* Listens to message events, sends chat queries to the OpenAI Responses API,
|
* Listens to message events, sends chat queries to the OpenAI Responses API,
|
||||||
* and handles text or image (function_call) outputs.
|
* and handles text or image (function_call) outputs.
|
||||||
*/
|
*/
|
||||||
// Removed local file fallback; prompt now comes exclusively from PocketBase via responsesPrompt module
|
|
||||||
import fs from 'fs/promises';
|
import fs from 'fs/promises';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import { OpenAI } from 'openai';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { AttachmentBuilder, PermissionFlagsBits } from 'discord.js';
|
import { AttachmentBuilder, PermissionFlagsBits } from 'discord.js';
|
||||||
import { OpenAI } from 'openai';
|
|
||||||
|
|
||||||
import { expandTemplate } from '../_src/template.js';
|
|
||||||
|
|
||||||
// Discord message max length
|
// Discord message max length
|
||||||
const MAX_DISCORD_MSG_LENGTH = 2000;
|
const MAX_DISCORD_MSG_LENGTH = 2000;
|
||||||
@ -23,75 +19,94 @@ const MAX_DISCORD_MSG_LENGTH = 2000;
|
|||||||
* @returns {string[]} Array of message chunks.
|
* @returns {string[]} Array of message chunks.
|
||||||
*/
|
*/
|
||||||
function splitMessage(text, maxLength = MAX_DISCORD_MSG_LENGTH) {
|
function splitMessage(text, maxLength = MAX_DISCORD_MSG_LENGTH) {
|
||||||
const lines = text.split(/\n/);
|
const lines = text.split(/\n/);
|
||||||
const chunks = [];
|
const chunks = [];
|
||||||
let chunk = '';
|
let chunk = '';
|
||||||
let codeBlockOpen = false;
|
let codeBlockOpen = false;
|
||||||
let codeBlockFence = '```';
|
let codeBlockFence = '```';
|
||||||
for (const line of lines) {
|
for (let line of lines) {
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
const isFenceLine = trimmed.startsWith('```');
|
const isFenceLine = trimmed.startsWith('```');
|
||||||
if (isFenceLine) {
|
if (isFenceLine) {
|
||||||
if (!codeBlockOpen) {
|
if (!codeBlockOpen) {
|
||||||
codeBlockOpen = true;
|
codeBlockOpen = true;
|
||||||
codeBlockFence = trimmed;
|
codeBlockFence = trimmed;
|
||||||
} else if (trimmed === '```') {
|
} else if (trimmed === '```') {
|
||||||
// closing fence
|
// closing fence
|
||||||
codeBlockOpen = false;
|
codeBlockOpen = false;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// include the newline that was removed by split
|
|
||||||
const segment = line + '\n';
|
|
||||||
// if adding segment exceeds limit
|
|
||||||
if (chunk.length + segment.length > maxLength) {
|
|
||||||
if (chunk.length > 0) {
|
|
||||||
// close open code block if needed
|
|
||||||
if (codeBlockOpen) chunk += '\n```';
|
|
||||||
chunks.push(chunk);
|
|
||||||
// start new chunk, reopen code block if needed
|
|
||||||
chunk = codeBlockOpen ? (codeBlockFence + '\n' + segment) : segment;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// single segment too long, split it directly
|
|
||||||
let rest = segment;
|
|
||||||
while (rest.length > maxLength) {
|
|
||||||
let part = rest.slice(0, maxLength);
|
|
||||||
if (codeBlockOpen) part += '\n```';
|
|
||||||
chunks.push(part);
|
|
||||||
rest = codeBlockOpen ? (codeBlockFence + '\n' + rest.slice(maxLength)) : rest.slice(maxLength);
|
|
||||||
}
|
|
||||||
chunk = rest;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
chunk += segment;
|
|
||||||
}
|
}
|
||||||
if (chunk) {
|
// include the newline that was removed by split
|
||||||
// close any unclosed code block
|
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```';
|
if (codeBlockOpen) chunk += '\n```';
|
||||||
chunks.push(chunk);
|
chunks.push(chunk);
|
||||||
|
// start new chunk, reopen code block if needed
|
||||||
|
chunk = codeBlockOpen ? (codeBlockFence + '\n' + segment) : segment;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// single segment too long, split it directly
|
||||||
|
let rest = segment;
|
||||||
|
while (rest.length > maxLength) {
|
||||||
|
let part = rest.slice(0, maxLength);
|
||||||
|
if (codeBlockOpen) part += '\n```';
|
||||||
|
chunks.push(part);
|
||||||
|
rest = codeBlockOpen ? (codeBlockFence + '\n' + rest.slice(maxLength)) : rest.slice(maxLength);
|
||||||
|
}
|
||||||
|
chunk = rest;
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
// remove trailing newline from each chunk
|
chunk += segment;
|
||||||
return chunks.map(c => c.endsWith('\n') ? c.slice(0, -1) : c);
|
}
|
||||||
|
if (chunk) {
|
||||||
|
// close any unclosed code block
|
||||||
|
if (codeBlockOpen) chunk += '\n```';
|
||||||
|
chunks.push(chunk);
|
||||||
|
}
|
||||||
|
// remove trailing newline from each chunk
|
||||||
|
return chunks.map(c => c.endsWith('\n') ? c.slice(0, -1) : c);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
* 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) {
|
async function shouldRespond(message, botId, logger) {
|
||||||
if (message.author.bot || !botId) return false;
|
if (message.author.bot || !botId) return false;
|
||||||
const enableMentions = cfg.enableMentions ?? true;
|
const isMention = message.mentions.users.has(botId);
|
||||||
const enableReplies = cfg.enableReplies ?? true;
|
let isReply = false;
|
||||||
const isMention = enableMentions && message.mentions.users.has(botId);
|
if (message.reference?.messageId) {
|
||||||
let isReply = false;
|
try {
|
||||||
if (enableReplies && message.reference?.messageId) {
|
const ref = await message.channel.messages.fetch(message.reference.messageId);
|
||||||
try {
|
isReply = ref.author.id === botId;
|
||||||
const ref = await message.channel.messages.fetch(message.reference.messageId);
|
} catch {}
|
||||||
isReply = ref.author.id === botId;
|
}
|
||||||
} catch {}
|
logger.debug(`Trigger? mention=${isMention} reply=${isReply}`);
|
||||||
}
|
return isMention || isReply;
|
||||||
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.
|
* @param {number} ttlSeconds - Time-to-live for the cache entry in seconds.
|
||||||
*/
|
*/
|
||||||
function cacheResponse(client, key, id, ttlSeconds) {
|
function cacheResponse(client, key, id, ttlSeconds) {
|
||||||
client.pb?.cache?.set(key, id, ttlSeconds);
|
client.pb?.cache?.set(key, id, ttlSeconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -113,10 +128,10 @@ function cacheResponse(client, key, id, ttlSeconds) {
|
|||||||
* @param {number} amount - Number of tokens to award.
|
* @param {number} amount - Number of tokens to award.
|
||||||
*/
|
*/
|
||||||
function awardOutput(client, guildId, userId, amount) {
|
function awardOutput(client, guildId, userId, amount) {
|
||||||
if (client.scorekeeper && amount > 0) {
|
if (client.scorekeeper && amount > 0) {
|
||||||
client.scorekeeper.addOutput(guildId, userId, amount, 'AI_response')
|
client.scorekeeper.addOutput(guildId, userId, amount)
|
||||||
.catch(err => client.logger.error(`Scorekeeper error: ${err.message}`));
|
.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.
|
* @returns {Promise<boolean>} True if the function call was handled.
|
||||||
*/
|
*/
|
||||||
async function handleImage(client, message, resp, cfg) {
|
async function handleImage(client, message, resp, cfg) {
|
||||||
const calls = Array.isArray(resp.output) ? resp.output : [];
|
const calls = Array.isArray(resp.output) ? resp.output : [];
|
||||||
const fn = calls.find(o => o.type === 'function_call' && o.name === 'generate_image');
|
const fn = calls.find(o => o.type === 'function_call' && o.name === 'generate_image');
|
||||||
if (!fn?.arguments) return false;
|
if (!fn?.arguments) return false;
|
||||||
client.logger.debug(`Image function args: ${fn.arguments}`);
|
client.logger.debug(`Image function args: ${fn.arguments}`);
|
||||||
let args;
|
let args;
|
||||||
try { args = JSON.parse(fn.arguments); } catch (e) { return false; }
|
try { args = JSON.parse(fn.arguments); } catch { return false; }
|
||||||
if (!args.prompt?.trim()) {
|
if (!args.prompt?.trim()) {
|
||||||
await message.reply('Cannot generate image: empty prompt.');
|
await message.reply('Cannot generate image: empty prompt.');
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// 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}`);
|
|
||||||
}
|
|
||||||
return true;
|
return true;
|
||||||
|
}
|
||||||
|
// Use image model defined in config
|
||||||
|
const model = cfg.imageGeneration.defaultModel;
|
||||||
|
const promptText = args.prompt;
|
||||||
|
// Determine number of images (1-10); DALL·E-3 only supports 1
|
||||||
|
let count = 1;
|
||||||
|
if (args.n != null) {
|
||||||
|
const nVal = typeof args.n === 'number' ? args.n : parseInt(args.n, 10);
|
||||||
|
if (!Number.isNaN(nVal)) count = nVal;
|
||||||
|
}
|
||||||
|
// clamp between 1 and 10
|
||||||
|
count = Math.max(1, Math.min(10, count));
|
||||||
|
if (model === 'dall-e-3') count = 1;
|
||||||
|
const size = args.size || 'auto';
|
||||||
|
// Determine quality based on config and model constraints
|
||||||
|
let quality = args.quality || cfg.imageGeneration.defaultQuality;
|
||||||
|
if (model === 'gpt-image-1') {
|
||||||
|
if (!['low', 'medium', 'high', 'auto'].includes(quality)) quality = 'auto';
|
||||||
|
} else if (model === 'dall-e-2') {
|
||||||
|
quality = 'standard';
|
||||||
|
} else if (model === 'dall-e-3') {
|
||||||
|
if (!['standard', 'hd', 'auto'].includes(quality)) quality = 'standard';
|
||||||
|
}
|
||||||
|
const background = args.background;
|
||||||
|
const moderation = args.moderation;
|
||||||
|
const outputFormat = args.output_format;
|
||||||
|
const compression = args.output_compression;
|
||||||
|
const style = args.style;
|
||||||
|
const user = args.user || message.author.id;
|
||||||
|
try {
|
||||||
|
// Build generate parameters
|
||||||
|
const genParams = { model, prompt: promptText, n: count, size, quality, user };
|
||||||
|
// response_format supported for DALL·E models (not gpt-image-1)
|
||||||
|
if (model !== 'gpt-image-1' && args.response_format) {
|
||||||
|
genParams['response_format'] = args.response_format;
|
||||||
|
}
|
||||||
|
// gpt-image-1 supports background, moderation, output_format, and output_compression
|
||||||
|
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.
|
* @param {Message} message - Incoming Discord message.
|
||||||
*/
|
*/
|
||||||
async function onMessage(client, cfg, message) {
|
async function onMessage(client, cfg, message) {
|
||||||
const logger = client.logger;
|
const logger = client.logger;
|
||||||
const botId = client.user?.id;
|
const botId = client.user?.id;
|
||||||
client.logger.debug(`[onMessage] Received message ${message.id} from ${message.author.id}`);
|
if (!(await shouldRespond(message, botId, logger))) return;
|
||||||
// Check if bot should respond, based on config (mentions/replies)
|
await message.channel.sendTyping();
|
||||||
if (!(await shouldRespond(message, botId, cfg, logger))) return;
|
|
||||||
|
|
||||||
// Determine channel/thread key for context
|
// Determine channel/thread key for context
|
||||||
const key = message.thread?.id || message.channel.id;
|
const key = message.thread?.id || message.channel.id;
|
||||||
// Initialize per-channel lock map
|
// Initialize per-channel lock map
|
||||||
const lockMap = client._responseLockMap || (client._responseLockMap = new Map());
|
const lockMap = client._responseLockMap || (client._responseLockMap = new Map());
|
||||||
// Get last pending promise for this key
|
// Get last pending promise for this key
|
||||||
const last = lockMap.get(key) || Promise.resolve();
|
const last = lockMap.get(key) || Promise.resolve();
|
||||||
// Handler to run in sequence
|
// Handler to run in sequence
|
||||||
const handler = async () => {
|
const handler = async () => {
|
||||||
// Start typing indicator loop every 9 seconds
|
try {
|
||||||
const typingInterval = setInterval(() => {
|
// Previous response ID for context continuity
|
||||||
message.channel.sendTyping().catch(() => {});
|
const prev = client.pb?.cache?.get(key);
|
||||||
}, 9000);
|
// Enforce minimum score to use AI responses
|
||||||
// Initial typing
|
// Enforce minimum score to use AI responses if scorekeeper is enabled
|
||||||
message.channel.sendTyping().catch(() => {});
|
if (client.scorekeeper) {
|
||||||
try {
|
try {
|
||||||
// Previous response ID for context continuity
|
const isAdmin = message.member?.permissions?.has(PermissionFlagsBits.Administrator);
|
||||||
const prev = client.pb?.cache?.get(key);
|
const scoreData = await client.scorekeeper.getScore(message.guild.id, message.author.id);
|
||||||
// Enforce minimum score to use AI responses
|
if (!isAdmin && scoreData.totalScore < cfg.minScore) {
|
||||||
// Enforce minimum score to use AI responses if scorekeeper is enabled
|
await message.reply(
|
||||||
if (client.scorekeeper) {
|
`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)}.`
|
||||||
try {
|
);
|
||||||
const isAdmin = message.member?.permissions?.has(PermissionFlagsBits.Administrator);
|
return;
|
||||||
const scoreData = await client.scorekeeper.getScore(message.guild.id, message.author.id);
|
}
|
||||||
if (!isAdmin && scoreData.totalScore < cfg.minScore) {
|
|
||||||
await message.reply(
|
|
||||||
`You need an I/O score of at least ${cfg.minScore} to use AI responses. Your current I/O score is ${scoreData.totalScore.toFixed(2)}.`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
client.logger.error(`Error checking score: ${err.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Build request body, including replied-to message context and mention of who spoke
|
|
||||||
let referencePrefix = '';
|
|
||||||
let referenceMessage = null;
|
|
||||||
if (message.reference?.messageId) {
|
|
||||||
try {
|
|
||||||
const ref = await message.channel.messages.fetch(message.reference.messageId);
|
|
||||||
referenceMessage = ref;
|
|
||||||
const refContent = ref.content || '';
|
|
||||||
if (ref.author.id === botId) {
|
|
||||||
referencePrefix = `You said: ${refContent}`;
|
|
||||||
} else {
|
|
||||||
referencePrefix = `<@${ref.author.id}> said: ${refContent}`;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore fetch errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const speakerMention = `<@${message.author.id}>`;
|
|
||||||
const userInput = referencePrefix
|
|
||||||
? `${referencePrefix}\n${speakerMention} said to you: ${message.content}`
|
|
||||||
: `${speakerMention} said to you: ${message.content}`;
|
|
||||||
// Prepare template context
|
|
||||||
const locationName = message.thread?.name || message.channel.name;
|
|
||||||
const locationId = message.thread?.id || message.channel.id;
|
|
||||||
const now = new Date();
|
|
||||||
const date = now.toISOString().split('T')[0];
|
|
||||||
const time = now.toTimeString().split(' ')[0];
|
|
||||||
const datetime = now.toISOString().replace('T',' ').replace(/\..+$/,'');
|
|
||||||
const ctx = {
|
|
||||||
clientId: client.config.id,
|
|
||||||
userName: message.author.username,
|
|
||||||
userId: message.author.id,
|
|
||||||
userTag: message.author.tag,
|
|
||||||
// add guild context
|
|
||||||
guildName: message.guild?.name || '',
|
|
||||||
guildId: message.guild?.id || '',
|
|
||||||
input: userInput,
|
|
||||||
locationName, locationId,
|
|
||||||
date, time, datetime
|
|
||||||
};
|
|
||||||
const instructions = expandTemplate(client.responsesPrompt, ctx);
|
|
||||||
const body = {
|
|
||||||
model: cfg.defaultModel,
|
|
||||||
instructions,
|
|
||||||
input: userInput,
|
|
||||||
previous_response_id: prev,
|
|
||||||
max_output_tokens: cfg.defaultMaxTokens,
|
|
||||||
temperature: cfg.defaultTemperature
|
|
||||||
};
|
|
||||||
// Assemble any enabled tools
|
|
||||||
const tools = [];
|
|
||||||
if (cfg.tools?.imageGeneration) {
|
|
||||||
const model = cfg.imageGeneration.defaultModel;
|
|
||||||
// Configure allowed sizes per model
|
|
||||||
let sizeEnum;
|
|
||||||
switch (model) {
|
|
||||||
case 'gpt-image-1': sizeEnum = ['auto','1024x1024','1536x1024','1024x1536']; break;
|
|
||||||
case 'dall-e-2': sizeEnum = ['256x256','512x512','1024x1024']; break;
|
|
||||||
case 'dall-e-3': sizeEnum = ['auto','1024x1024','1792x1024','1024x1792']; break;
|
|
||||||
default: sizeEnum = ['auto','1024x1024'];
|
|
||||||
}
|
|
||||||
// Configure quality options per model
|
|
||||||
let qualityEnum;
|
|
||||||
switch (model) {
|
|
||||||
case 'gpt-image-1': qualityEnum = ['auto','low','medium','high']; break;
|
|
||||||
case 'dall-e-2': qualityEnum = ['standard']; break;
|
|
||||||
case 'dall-e-3': qualityEnum = ['auto','standard','hd']; break;
|
|
||||||
default: qualityEnum = ['auto','standard'];
|
|
||||||
}
|
|
||||||
// Build schema properties dynamically
|
|
||||||
const properties = {
|
|
||||||
prompt: { type: 'string', description: 'Text description of desired image(s).' },
|
|
||||||
n: { type: 'number', description: 'Number of images to generate.' },
|
|
||||||
size: { type: 'string', enum: sizeEnum, description: 'Image size.' },
|
|
||||||
quality: { type: 'string', enum: qualityEnum, description: 'Image quality.' },
|
|
||||||
user: { type: 'string', description: 'Unique end-user identifier.' }
|
|
||||||
};
|
|
||||||
if (model !== 'gpt-image-1') {
|
|
||||||
properties.response_format = { type: 'string', enum: ['url','b64_json'], description: 'Format of returned images.' };
|
|
||||||
}
|
|
||||||
if (model === 'gpt-image-1') {
|
|
||||||
properties.background = { type: 'string', enum: ['transparent','opaque','auto'], description: 'Background transparency.' };
|
|
||||||
properties.moderation = { type: 'string', enum: ['low','auto'], description: 'Content moderation level.' };
|
|
||||||
properties.output_format = { type: 'string', enum: ['png','jpeg','webp'], description: 'Output image format.' };
|
|
||||||
properties.output_compression = { type: 'number', description: 'Compression level (0-100).' };
|
|
||||||
}
|
|
||||||
if (model === 'dall-e-3') {
|
|
||||||
properties.style = { type: 'string', enum: ['vivid','natural'], description: 'Style option for dall-e-3.' };
|
|
||||||
}
|
|
||||||
// Determine required fields
|
|
||||||
const required = ['prompt','n','size','quality','user'];
|
|
||||||
if (model !== 'gpt-image-1') required.push('response_format');
|
|
||||||
if (model === 'gpt-image-1') required.push('background','moderation','output_format','output_compression');
|
|
||||||
if (model === 'dall-e-3') required.push('style');
|
|
||||||
// Register the function tool
|
|
||||||
tools.push({
|
|
||||||
type: 'function',
|
|
||||||
name: 'generate_image',
|
|
||||||
description: `Generate images using model ${model} with requested parameters.`,
|
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
properties,
|
|
||||||
required,
|
|
||||||
additionalProperties: false
|
|
||||||
},
|
|
||||||
strict: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (cfg.tools?.webSearch) {
|
|
||||||
tools.push({ type: 'web_search_preview' });
|
|
||||||
}
|
|
||||||
if (tools.length) {
|
|
||||||
body.tools = tools;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If there are image attachments in the referenced or current message, wrap text and images into a multimodal message
|
|
||||||
const refImages = referenceMessage
|
|
||||||
? referenceMessage.attachments.filter(att => /\.(png|jpe?g|gif|webp)$/i.test(att.name || att.url))
|
|
||||||
: new Map();
|
|
||||||
const currImages = message.attachments.filter(att => /\.(png|jpe?g|gif|webp)$/i.test(att.name || att.url));
|
|
||||||
if (refImages.size > 0 || currImages.size > 0) {
|
|
||||||
// build ordered content items: text first, then referenced images, then current images
|
|
||||||
const contentItems = [{ type: 'input_text', text: userInput }];
|
|
||||||
for (const att of refImages.values()) {
|
|
||||||
contentItems.push({ type: 'input_image', detail: 'auto', image_url: att.url });
|
|
||||||
}
|
|
||||||
for (const att of currImages.values()) {
|
|
||||||
contentItems.push({ type: 'input_image', detail: 'auto', image_url: att.url });
|
|
||||||
}
|
|
||||||
body.input = [{ type: 'message', role: 'user', content: contentItems }];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Call OpenAI Responses
|
|
||||||
logger.debug(`Calling AI with body: ${JSON.stringify(body)}`);
|
|
||||||
const resp = await client.openai.responses.create(body);
|
|
||||||
logger.info(`AI response id=${resp.id}`);
|
|
||||||
// Award tokens for the AI chat response
|
|
||||||
const chatTokens = resp.usage?.total_tokens ?? resp.usage?.completion_tokens ?? 0;
|
|
||||||
awardOutput(client, message.guild.id, message.author.id, chatTokens);
|
|
||||||
|
|
||||||
// Cache response ID if not a function call
|
|
||||||
const isFuncCall = Array.isArray(resp.output) && resp.output.some(o => o.type === 'function_call');
|
|
||||||
if (!isFuncCall && resp.id && cfg.conversationExpiry) {
|
|
||||||
cacheResponse(client, key, resp.id, Math.floor(cfg.conversationExpiry / 1000));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle image function call if present
|
|
||||||
if (await handleImage(client, message, resp, cfg)) return;
|
|
||||||
|
|
||||||
// Otherwise reply with text
|
|
||||||
const text = resp.output_text?.trim();
|
|
||||||
if (text) {
|
|
||||||
const parts = splitMessage(text, MAX_DISCORD_MSG_LENGTH);
|
|
||||||
for (const part of parts) {
|
|
||||||
await message.reply(part);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(`Queued onMessage error for ${key}: ${err.message}`);
|
client.logger.error(`Error checking score: ${err.message}`);
|
||||||
} finally {
|
|
||||||
clearInterval(typingInterval);
|
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
// Chain the handler to the last promise
|
// Build request body, prefixing with a mention of who spoke
|
||||||
const next = last.then(handler).catch(err => logger.error(`[onMessage] Handler error: ${err.message}`));
|
const speakerMention = `<@${message.author.id}>`;
|
||||||
lockMap.set(key, next);
|
const body = {
|
||||||
// Queue enqueued; handler will send response when its turn arrives
|
model: cfg.defaultModel,
|
||||||
return;
|
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.
|
* @param {string} text - Narrative prompt text.
|
||||||
*/
|
*/
|
||||||
export async function sendNarrative(client, cfg, channelId, text) {
|
export async function sendNarrative(client, cfg, channelId, text) {
|
||||||
const logger = client.logger;
|
const logger = client.logger;
|
||||||
try {
|
try {
|
||||||
// Build the narrative instructions
|
// Build the narrative instructions
|
||||||
// Expand template for sendNarrative
|
const instructions = `${client.responsesSystemPrompt}\n\nGenerate the following as an engaging narrative:`;
|
||||||
const now = new Date();
|
const body = {
|
||||||
const date = now.toISOString().split('T')[0];
|
model: cfg.defaultModel,
|
||||||
const time = now.toTimeString().split(' ')[0];
|
instructions,
|
||||||
const datetime = now.toISOString().replace('T',' ').replace(/\..+$/,'');
|
input: text,
|
||||||
const ctx = {
|
max_output_tokens: cfg.defaultMaxTokens,
|
||||||
clientId: client.config.id,
|
temperature: cfg.defaultTemperature,
|
||||||
userName: client.user.username,
|
};
|
||||||
userId: client.user.id,
|
logger.debug('sendNarrative: calling AI with body', body);
|
||||||
input: text,
|
const resp = await client.openai.responses.create(body);
|
||||||
locationName: channel.name,
|
logger.info(`sendNarrative AI response id=${resp.id}`);
|
||||||
locationId: channel.id,
|
// Fetch the target channel or thread
|
||||||
date, time, datetime
|
const channel = await client.channels.fetch(channelId);
|
||||||
};
|
if (!channel || typeof channel.send !== 'function') {
|
||||||
const raw = `${client.responsesPrompt}\n\nGenerate the following as an engaging narrative:`;
|
logger.error(`sendNarrative: cannot send to channel ID ${channelId}`);
|
||||||
const instructions = expandTemplate(raw, ctx);
|
return;
|
||||||
const body = {
|
|
||||||
model: cfg.defaultModel,
|
|
||||||
instructions,
|
|
||||||
input: text,
|
|
||||||
max_output_tokens: cfg.defaultMaxTokens,
|
|
||||||
temperature: cfg.defaultTemperature
|
|
||||||
};
|
|
||||||
logger.debug(`[sendNarrative] Calling AI with body: ${JSON.stringify(body).slice(0,1000)}`);
|
|
||||||
const resp = await client.openai.responses.create(body);
|
|
||||||
logger.info(`[sendNarrative] Received AI response id=${resp.id}`);
|
|
||||||
// Fetch the target channel or thread
|
|
||||||
const channel = await client.channels.fetch(channelId);
|
|
||||||
if (!channel || typeof channel.send !== 'function') {
|
|
||||||
logger.error(`[sendNarrative] Cannot send to channel ID ${channelId}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Split the output and send
|
|
||||||
const content = resp.output_text?.trim();
|
|
||||||
if (content) {
|
|
||||||
const parts = splitMessage(content, MAX_DISCORD_MSG_LENGTH);
|
|
||||||
for (const part of parts) {
|
|
||||||
await channel.send(part);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
client.logger.error(`[sendNarrative] Error: ${err.message}`);
|
|
||||||
}
|
}
|
||||||
|
// Split the output and send
|
||||||
|
const content = resp.output_text?.trim();
|
||||||
|
if (content) {
|
||||||
|
const parts = splitMessage(content, MAX_DISCORD_MSG_LENGTH);
|
||||||
|
for (const part of parts) {
|
||||||
|
await channel.send(part);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
client.logger.error(`sendNarrative error: ${err.message}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -519,11 +481,10 @@ export async function sendNarrative(client, cfg, channelId, text) {
|
|||||||
* @param {object} clientConfig - Full client configuration object.
|
* @param {object} clientConfig - Full client configuration object.
|
||||||
*/
|
*/
|
||||||
export async function init(client, clientConfig) {
|
export async function init(client, clientConfig) {
|
||||||
const cfg = clientConfig.responses;
|
const cfg = clientConfig.responses;
|
||||||
client.logger.info('[module:responses] Initializing Responses module');
|
client.logger.info('Initializing Responses module');
|
||||||
// Initialize prompt from responsesPrompt module (must be loaded before this)
|
client.responsesSystemPrompt = await loadSystemPrompt(cfg.systemPromptPath, client.logger);
|
||||||
client.responsesPrompt = client.responsesPrompt ?? '';
|
client.openai = new OpenAI({ apiKey: cfg.apiKey });
|
||||||
client.openai = new OpenAI({ apiKey: cfg.apiKey });
|
client.on('messageCreate', m => onMessage(client, cfg, m));
|
||||||
client.on('messageCreate', m => onMessage(client, cfg, m));
|
client.logger.info('Responses module ready');
|
||||||
client.logger.info('[module:responses] 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'.
|
* Slash command module for '/query'.
|
||||||
* Defines and handles the /query command via the OpenAI Responses API,
|
* Defines and handles the /query command via the OpenAI Responses API,
|
||||||
* including optional image generation function calls.
|
* including optional image generation function calls.
|
||||||
*/
|
*/
|
||||||
import { SlashCommandBuilder, AttachmentBuilder, PermissionFlagsBits } from 'discord.js';
|
import { SlashCommandBuilder, AttachmentBuilder, PermissionFlagsBits } from 'discord.js';
|
||||||
|
import fs from 'fs/promises';
|
||||||
import { expandTemplate } from '../_src/template.js';
|
import path from 'path';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Split long text into chunks safe for Discord messaging.
|
* Split long text into chunks safe for Discord messaging.
|
||||||
@ -19,19 +15,19 @@ import { expandTemplate } from '../_src/template.js';
|
|||||||
* @returns {string[]} Array of message chunks.
|
* @returns {string[]} Array of message chunks.
|
||||||
*/
|
*/
|
||||||
function splitLongMessage(text, max = 2000) {
|
function splitLongMessage(text, max = 2000) {
|
||||||
const lines = text.split('\n');
|
const lines = text.split('\n');
|
||||||
const chunks = [];
|
const chunks = [];
|
||||||
let chunk = '';
|
let chunk = '';
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const next = line + '\n';
|
const next = line + '\n';
|
||||||
if (chunk.length + next.length > max) {
|
if (chunk.length + next.length > max) {
|
||||||
chunks.push(chunk);
|
chunks.push(chunk);
|
||||||
chunk = '';
|
chunk = '';
|
||||||
}
|
|
||||||
chunk += next;
|
|
||||||
}
|
}
|
||||||
if (chunk) chunks.push(chunk);
|
chunk += next;
|
||||||
return chunks;
|
}
|
||||||
|
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.
|
* @returns {Promise<boolean>} True if a function call was handled.
|
||||||
*/
|
*/
|
||||||
async function handleImageInteraction(client, interaction, resp, cfg, ephemeral) {
|
async function handleImageInteraction(client, interaction, resp, cfg, ephemeral) {
|
||||||
const calls = Array.isArray(resp.output) ? resp.output : [];
|
const calls = Array.isArray(resp.output) ? resp.output : [];
|
||||||
const fn = calls.find(o => o.type === 'function_call' && o.name === 'generate_image');
|
const fn = calls.find(o => o.type === 'function_call' && o.name === 'generate_image');
|
||||||
if (!fn?.arguments) return false;
|
if (!fn?.arguments) return false;
|
||||||
client.logger.debug(`Image function args: ${fn.arguments}`);
|
client.logger.debug(`Image function args: ${fn.arguments}`);
|
||||||
let args;
|
let args;
|
||||||
try { args = JSON.parse(fn.arguments); } catch (e) { return false; }
|
try { args = JSON.parse(fn.arguments); } catch { return false; }
|
||||||
if (!args.prompt?.trim()) {
|
if (!args.prompt?.trim()) {
|
||||||
await interaction.editReply({ content: 'Cannot generate image: empty prompt.', ephemeral });
|
await interaction.editReply({ content: 'Cannot generate image: empty prompt.', ephemeral });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// Always use image model defined in config
|
// Always use image model defined in config
|
||||||
const model = cfg.imageGeneration.defaultModel;
|
const model = cfg.imageGeneration.defaultModel;
|
||||||
const promptText = args.prompt;
|
const promptText = args.prompt;
|
||||||
// Determine number of images (1-10); DALL·E-3 only supports 1
|
// Determine number of images (1-10); DALL·E-3 only supports 1
|
||||||
let count = 1;
|
let count = 1;
|
||||||
if (args.n !== null) {
|
if (args.n != null) {
|
||||||
const nVal = typeof args.n === 'number' ? args.n : parseInt(args.n, 10);
|
const nVal = typeof args.n === 'number' ? args.n : parseInt(args.n, 10);
|
||||||
if (!Number.isNaN(nVal)) count = nVal;
|
if (!Number.isNaN(nVal)) count = nVal;
|
||||||
}
|
}
|
||||||
// clamp
|
// clamp
|
||||||
count = Math.max(1, Math.min(10, count));
|
count = Math.max(1, Math.min(10, count));
|
||||||
if (model === 'dall-e-3') count = 1;
|
if (model === 'dall-e-3') count = 1;
|
||||||
const size = args.size || 'auto';
|
const size = args.size || 'auto';
|
||||||
// Determine quality based on config and model constraints
|
// Determine quality based on config and model constraints
|
||||||
let quality = args.quality || cfg.imageGeneration.defaultQuality;
|
let quality = args.quality || cfg.imageGeneration.defaultQuality;
|
||||||
if (model === 'gpt-image-1') {
|
if (model === 'gpt-image-1') {
|
||||||
if (!['low', 'medium', 'high', 'auto'].includes(quality)) quality = 'auto';
|
if (!['low', 'medium', 'high', 'auto'].includes(quality)) quality = 'auto';
|
||||||
} else if (model === 'dall-e-2') {
|
} else if (model === 'dall-e-2') {
|
||||||
quality = 'standard';
|
quality = 'standard';
|
||||||
} else if (model === 'dall-e-3') {
|
} else if (model === 'dall-e-3') {
|
||||||
if (!['standard', 'hd', 'auto'].includes(quality)) quality = 'standard';
|
if (!['standard', 'hd', 'auto'].includes(quality)) quality = 'standard';
|
||||||
}
|
}
|
||||||
const background = args.background;
|
const background = args.background;
|
||||||
const moderation = args.moderation;
|
const moderation = args.moderation;
|
||||||
const outputFormat = args.output_format;
|
const outputFormat = args.output_format;
|
||||||
const compression = args.output_compression;
|
const compression = args.output_compression;
|
||||||
const style = args.style;
|
const style = args.style;
|
||||||
const user = args.user || interaction.user.id;
|
const user = args.user || interaction.user.id;
|
||||||
try {
|
try {
|
||||||
// Build generate parameters
|
// Build generate parameters
|
||||||
const genParams = { model, prompt: promptText, n: count, size, quality, user };
|
const genParams = { model, prompt: promptText, n: count, size, quality, user };
|
||||||
// response_format supported for DALL·E models (not gpt-image-1)
|
// response_format supported for DALL·E models (not gpt-image-1)
|
||||||
if (model !== 'gpt-image-1' && args.response_format) {
|
if (model !== 'gpt-image-1' && args.response_format) {
|
||||||
genParams['response_format'] = 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;
|
|
||||||
}
|
}
|
||||||
|
// 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.
|
* Slash command definitions and handlers for the '/query' command.
|
||||||
*/
|
*/
|
||||||
export const commands = [
|
export const commands = [
|
||||||
{
|
{
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
.setName('query')
|
.setName('query')
|
||||||
.setDescription('Send a custom AI query')
|
.setDescription('Send a custom AI query')
|
||||||
.addStringOption(opt =>
|
.addStringOption(opt =>
|
||||||
opt.setName('prompt')
|
opt.setName('prompt')
|
||||||
.setDescription('Your query text')
|
.setDescription('Your query text')
|
||||||
.setRequired(true)
|
.setRequired(true)
|
||||||
)
|
)
|
||||||
.addBooleanOption(opt =>
|
.addBooleanOption(opt =>
|
||||||
opt.setName('ephemeral')
|
opt.setName('ephemeral')
|
||||||
.setDescription('Receive an ephemeral response')
|
.setDescription('Receive an ephemeral response')
|
||||||
.setRequired(false)
|
.setRequired(false)
|
||||||
),
|
),
|
||||||
async execute(interaction, client) {
|
async execute(interaction, client) {
|
||||||
const cfg = client.config.responses;
|
const cfg = client.config.responses;
|
||||||
// Enforce minimum score to use /query if scorekeeper is enabled
|
// Enforce minimum score to use /query if scorekeeper is enabled
|
||||||
if (client.scorekeeper) {
|
if (client.scorekeeper) {
|
||||||
try {
|
try {
|
||||||
const isAdmin = interaction.member?.permissions?.has(PermissionFlagsBits.Administrator);
|
const isAdmin = interaction.member?.permissions?.has(PermissionFlagsBits.Administrator);
|
||||||
const scoreData = await client.scorekeeper.getScore(interaction.guildId, interaction.user.id);
|
const scoreData = await client.scorekeeper.getScore(interaction.guildId, interaction.user.id);
|
||||||
if (!isAdmin && scoreData.totalScore < cfg.minScore) {
|
if (!isAdmin && scoreData.totalScore < cfg.minScore) {
|
||||||
return interaction.reply({
|
return interaction.reply({
|
||||||
content: `You need an I/O score of at least ${cfg.minScore} to use /query. Your current I/O score is ${scoreData.totalScore.toFixed(2)}.`,
|
content: `You need an I/O score of at least ${cfg.minScore} to use /query. Your current I/O score is ${scoreData.totalScore.toFixed(2)}.`,
|
||||||
ephemeral: true
|
ephemeral: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
client.logger.error(`[cmd:query] Error checking score: ${err.message}`);
|
client.logger.error(`Error checking score: ${err.message}`);
|
||||||
return interaction.reply({ content: 'Error verifying your score. Please try again later.', flags: MessageFlags.Ephemeral });
|
return interaction.reply({ content: 'Error verifying your score. Please try again later.', ephemeral: true });
|
||||||
}
|
|
||||||
}
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
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
|
// _opt/schangar.js
|
||||||
import { SlashCommandBuilder } from 'discord.js';
|
import { SlashCommandBuilder } from 'discord.js';
|
||||||
|
|
||||||
// Export commands array for the centralized handler
|
// Export commands array for the centralized handler
|
||||||
export const commands = [
|
export const commands = [
|
||||||
{
|
{
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
.setName('hangarsync')
|
.setName('hangarsync')
|
||||||
.setDescription('Mark the moment all five lights turn green, for use with hangarstatus')
|
.setDescription('Mark the moment all five lights turn green, for use with hangarstatus')
|
||||||
.addStringOption(option =>
|
.addStringOption(option =>
|
||||||
option.setName('timestamp')
|
option.setName('timestamp')
|
||||||
.setDescription('Custom timestamp (Unix time in seconds or ISO 8601 format). Leave empty for current time.')
|
.setDescription('Custom timestamp (Unix time in seconds or ISO 8601 format). Leave empty for current time.')
|
||||||
.setRequired(false)),
|
.setRequired(false)),
|
||||||
|
|
||||||
execute: async (interaction, client) => {
|
execute: async (interaction, client) => {
|
||||||
const customTimestamp = interaction.options.getString('timestamp');
|
const customTimestamp = interaction.options.getString('timestamp');
|
||||||
let syncEpoch;
|
let syncEpoch;
|
||||||
|
|
||||||
// Attempt to validate custom timestamp
|
// Attempt to validate custom timestamp
|
||||||
if (customTimestamp) {
|
if (customTimestamp) {
|
||||||
try {
|
try {
|
||||||
if (/^\d+$/.test(customTimestamp)) {
|
if (/^\d+$/.test(customTimestamp)) {
|
||||||
const timestampInSeconds = parseInt(customTimestamp);
|
const timestampInSeconds = parseInt(customTimestamp);
|
||||||
if (timestampInSeconds < 0 || timestampInSeconds > Math.floor(Date.now() / 1000)) {
|
if (timestampInSeconds < 0 || timestampInSeconds > Math.floor(Date.now() / 1000)) {
|
||||||
return interaction.reply({
|
return interaction.reply({
|
||||||
content: 'Invalid timestamp. Please provide a Unix time in seconds that is not in the future.',
|
content: 'Invalid timestamp. Please provide a Unix time in seconds that is not in the future.',
|
||||||
ephemeral: true
|
ephemeral: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
syncEpoch = timestampInSeconds * 1000;
|
syncEpoch = timestampInSeconds * 1000;
|
||||||
} else {
|
} else {
|
||||||
const date = new Date(customTimestamp);
|
const date = new Date(customTimestamp);
|
||||||
syncEpoch = date.getTime();
|
syncEpoch = date.getTime();
|
||||||
if (isNaN(syncEpoch) || syncEpoch < 0) {
|
if (isNaN(syncEpoch) || syncEpoch < 0) {
|
||||||
return interaction.reply({
|
return interaction.reply({
|
||||||
content: 'Invalid timestamp format. Please use Unix time in seconds or a valid ISO 8601 string.',
|
content: 'Invalid timestamp format. Please use Unix time in seconds or a valid ISO 8601 string.',
|
||||||
ephemeral: true
|
ephemeral: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
client.logger.error(`[cmd:hangarsync] Failed to parse timestamp: ${error.message}`);
|
client.logger.error(`Failed to parse timestamp in hangarsync command: ${error.message}`);
|
||||||
return interaction.reply({
|
return interaction.reply({
|
||||||
content: 'Failed to parse timestamp. Please use Unix time in seconds or a valid ISO 8601 string.',
|
content: 'Failed to parse timestamp. Please use Unix time in seconds or a valid ISO 8601 string.',
|
||||||
ephemeral: true
|
ephemeral: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
syncEpoch = Date.now();
|
syncEpoch = Date.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check PocketBase connection status
|
// Check PocketBase connection status
|
||||||
if (!isPocketBaseConnected(client)) {
|
if (!isPocketBaseConnected(client)) {
|
||||||
client.logger.error('[cmd:hangarsync] PocketBase not connected');
|
client.logger.error('PocketBase not connected when executing hangarsync command');
|
||||||
|
|
||||||
// Try to reconnect if available
|
// Try to reconnect if available
|
||||||
if (typeof client.pb.ensureConnection === 'function') {
|
if (typeof client.pb.ensureConnection === 'function') {
|
||||||
await client.pb.ensureConnection();
|
await client.pb.ensureConnection();
|
||||||
|
|
||||||
// Check if reconnection worked
|
// Check if reconnection worked
|
||||||
if (!isPocketBaseConnected(client)) {
|
if (!isPocketBaseConnected(client)) {
|
||||||
return interaction.reply({
|
return interaction.reply({
|
||||||
content: 'Database connection unavailable. Please try again later.',
|
content: 'Database connection unavailable. Please try again later.',
|
||||||
ephemeral: true
|
ephemeral: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return interaction.reply({
|
return interaction.reply({
|
||||||
content: 'Database connection unavailable. Please try again later.',
|
content: 'Database connection unavailable. Please try again later.',
|
||||||
ephemeral: true
|
ephemeral: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create or update timestamp for guild
|
// Create or update timestamp for guild
|
||||||
try {
|
try {
|
||||||
let record = null;
|
let record = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// First try the enhanced method if available
|
// First try the enhanced method if available
|
||||||
if (typeof client.pb.getFirst === 'function') {
|
if (typeof client.pb.getFirst === 'function') {
|
||||||
record = await client.pb.getFirst('command_hangarsync', `guildId = "${interaction.guildId}"`);
|
record = await client.pb.getFirst('command_hangarsync', `guildId = "${interaction.guildId}"`);
|
||||||
} else {
|
} else {
|
||||||
// Fall back to standard PocketBase method
|
// Fall back to standard PocketBase method
|
||||||
const records = await client.pb.collection('command_hangarsync').getList(1, 1, {
|
const records = await client.pb.collection('command_hangarsync').getList(1, 1, {
|
||||||
filter: `guildId = "${interaction.guildId}"`
|
filter: `guildId = "${interaction.guildId}"`
|
||||||
});
|
});
|
||||||
if (records.items.length > 0) {
|
if (records.items.length > 0) {
|
||||||
record = records.items[0];
|
record = records.items[0];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Handle case where collection might not exist
|
// Handle case where collection might not exist
|
||||||
client.logger.warn(`Error retrieving hangarsync record: ${error.message}`);
|
client.logger.warn(`Error retrieving hangarsync record: ${error.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (record) {
|
if (record) {
|
||||||
// Update existing record
|
// Update existing record
|
||||||
if (typeof client.pb.updateOne === 'function') {
|
if (typeof client.pb.updateOne === 'function') {
|
||||||
await client.pb.updateOne('command_hangarsync', record.id, {
|
await client.pb.updateOne('command_hangarsync', record.id, {
|
||||||
userId: `${interaction.user.id}`,
|
userId: `${interaction.user.id}`,
|
||||||
epoch: `${syncEpoch}`
|
epoch: `${syncEpoch}`,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await client.pb.collection('command_hangarsync').update(record.id, {
|
await client.pb.collection('command_hangarsync').update(record.id, {
|
||||||
userId: `${interaction.user.id}`,
|
userId: `${interaction.user.id}`,
|
||||||
epoch: `${syncEpoch}`
|
epoch: `${syncEpoch}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
client.logger.info(`[cmd:hangarsync] Updated hangarsync for guild ${interaction.guildId} by user ${interaction.user.id}`);
|
client.logger.info(`Updated hangarsync for guild ${interaction.guildId} by user ${interaction.user.id}`);
|
||||||
} else {
|
} else {
|
||||||
// Create new record
|
// Create new record
|
||||||
if (typeof client.pb.createOne === 'function') {
|
if (typeof client.pb.createOne === 'function') {
|
||||||
await client.pb.createOne('command_hangarsync', {
|
await client.pb.createOne('command_hangarsync', {
|
||||||
guildId: `${interaction.guildId}`,
|
guildId: `${interaction.guildId}`,
|
||||||
userId: `${interaction.user.id}`,
|
userId: `${interaction.user.id}`,
|
||||||
epoch: `${syncEpoch}`
|
epoch: `${syncEpoch}`,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await client.pb.collection('command_hangarsync').create({
|
await client.pb.collection('command_hangarsync').create({
|
||||||
guildId: `${interaction.guildId}`,
|
guildId: `${interaction.guildId}`,
|
||||||
userId: `${interaction.user.id}`,
|
userId: `${interaction.user.id}`,
|
||||||
epoch: `${syncEpoch}`
|
epoch: `${syncEpoch}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
client.logger.info(`[cmd:hangarsync] Created new hangarsync for guild ${interaction.guildId} by user ${interaction.user.id}`);
|
client.logger.info(`Created new hangarsync for guild ${interaction.guildId} by user ${interaction.user.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await interaction.reply(`Executive hangar status has been synced: <t:${Math.ceil(syncEpoch / 1000)}>`);
|
await interaction.reply(`Executive hangar status has been synced: <t:${Math.ceil(syncEpoch / 1000)}>`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
client.logger.error(`[cmd:hangarsync] Error: ${error.message}`);
|
client.logger.error(`Error in hangarsync command: ${error.message}`);
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
content: 'Error syncing hangar status. Please try again later.',
|
content: `Error syncing hangar status. Please try again later.`,
|
||||||
ephemeral: true
|
ephemeral: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
.setName('hangarstatus')
|
.setName('hangarstatus')
|
||||||
.setDescription('Check the status of contested zone executive hangars')
|
.setDescription('Check the status of contested zone executive hangars')
|
||||||
.addBooleanOption(option =>
|
.addBooleanOption(option =>
|
||||||
option.setName('verbose')
|
option.setName('verbose')
|
||||||
.setDescription('Extra output, mainly for debugging.')
|
.setDescription('Extra output, mainly for debugging.')
|
||||||
.setRequired(false)),
|
.setRequired(false)),
|
||||||
|
|
||||||
execute: async (interaction, client) => {
|
execute: async (interaction, client) => {
|
||||||
const verbose = interaction.options.getBoolean('verbose');
|
const verbose = interaction.options.getBoolean('verbose');
|
||||||
|
|
||||||
// Check PocketBase connection status
|
// Check PocketBase connection status
|
||||||
if (!isPocketBaseConnected(client)) {
|
if (!isPocketBaseConnected(client)) {
|
||||||
client.logger.error('[cmd:hangarstatus] PocketBase not connected');
|
client.logger.error('PocketBase not connected when executing hangarstatus command');
|
||||||
|
|
||||||
// Try to reconnect if available
|
// Try to reconnect if available
|
||||||
if (typeof client.pb.ensureConnection === 'function') {
|
if (typeof client.pb.ensureConnection === 'function') {
|
||||||
await client.pb.ensureConnection();
|
await client.pb.ensureConnection();
|
||||||
|
|
||||||
// Check if reconnection worked
|
// Check if reconnection worked
|
||||||
if (!isPocketBaseConnected(client)) {
|
if (!isPocketBaseConnected(client)) {
|
||||||
return interaction.reply({
|
return interaction.reply({
|
||||||
content: 'Database connection unavailable. Please try again later.',
|
content: 'Database connection unavailable. Please try again later.',
|
||||||
ephemeral: true
|
ephemeral: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return interaction.reply({
|
return interaction.reply({
|
||||||
content: 'Database connection unavailable. Please try again later.',
|
content: 'Database connection unavailable. Please try again later.',
|
||||||
ephemeral: true
|
ephemeral: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get hangarsync data for guild
|
// Get hangarsync data for guild
|
||||||
let hangarSync = null;
|
let hangarSync = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// First try the enhanced method if available
|
// First try the enhanced method if available
|
||||||
if (typeof client.pb.getFirst === 'function') {
|
if (typeof client.pb.getFirst === 'function') {
|
||||||
hangarSync = await client.pb.getFirst('command_hangarsync', `guildId = "${interaction.guildId}"`);
|
hangarSync = await client.pb.getFirst('command_hangarsync', `guildId = "${interaction.guildId}"`);
|
||||||
} else {
|
} else {
|
||||||
// Fall back to standard PocketBase methods
|
// Fall back to standard PocketBase methods
|
||||||
try {
|
try {
|
||||||
hangarSync = await client.pb.collection('command_hangarsync').getFirstListItem(`guildId = "${interaction.guildId}"`);
|
hangarSync = await client.pb.collection('command_hangarsync').getFirstListItem(`guildId = "${interaction.guildId}"`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// getFirstListItem throws if no items found
|
// getFirstListItem throws if no items found
|
||||||
if (error.status !== 404) throw error;
|
if (error.status !== 404) throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hangarSync) {
|
if (!hangarSync) {
|
||||||
client.logger.info(`[cmd:hangarstatus] No sync data found for guild ${interaction.guildId}`);
|
client.logger.info(`No sync data found for guild ${interaction.guildId}`);
|
||||||
return interaction.reply({
|
return interaction.reply({
|
||||||
content: 'No sync data found. Please use `/hangarsync` first to establish a reference point.',
|
content: 'No sync data found. Please use `/hangarsync` first to establish a reference point.',
|
||||||
ephemeral: true
|
ephemeral: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
client.logger.error(`[cmd:hangarstatus] Error retrieving sync data for guild ${interaction.guildId}: ${error.message}`);
|
client.logger.info(`Error retrieving sync data for guild ${interaction.guildId}: ${error.message}`);
|
||||||
return interaction.reply({
|
return interaction.reply({
|
||||||
content: 'No sync data found. Please use `/hangarsync` first to establish a reference point.',
|
content: 'No sync data found. Please use `/hangarsync` first to establish a reference point.',
|
||||||
ephemeral: true
|
ephemeral: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentTime = Date.now();
|
const currentTime = Date.now();
|
||||||
|
|
||||||
// 5 minutes (all off) + 5*24 minutes (turning green) + 5*12 minutes (turning off) = 185 minutes
|
// 5 minutes (all off) + 5*24 minutes (turning green) + 5*12 minutes (turning off) = 185 minutes
|
||||||
const cycleDuration = 5 + (5 * 24) + (5 * 12);
|
const cycleDuration = 5 + (5 * 24) + (5 * 12);
|
||||||
|
|
||||||
// Key positions in the cycle
|
// Key positions in the cycle
|
||||||
const allOffDuration = 5;
|
const allOffDuration = 5;
|
||||||
const _turningGreenDuration = 5 * 24 * 1000;
|
const turningGreenDuration = 5 * 24;
|
||||||
const turningOffDuration = 5 * 12 * 1000;
|
const turningOffDuration = 5 * 12;
|
||||||
|
|
||||||
// Calculate how much time has passed since the epoch
|
// Calculate how much time has passed since the epoch
|
||||||
const timeSinceEpoch = (currentTime - hangarSync.epoch) / (60 * 1000);
|
const timeSinceEpoch = (currentTime - hangarSync.epoch) / (60 * 1000);
|
||||||
|
|
||||||
// Calculate where we are in the full-cycle relative to the epoch
|
// Calculate where we are in the full-cycle relative to the epoch
|
||||||
const cyclePosition = ((timeSinceEpoch % cycleDuration) + cycleDuration) % cycleDuration;
|
const cyclePosition = ((timeSinceEpoch % cycleDuration) + cycleDuration) % cycleDuration;
|
||||||
|
|
||||||
// Initialize stuff and things
|
// Initialize stuff and things
|
||||||
const lights = [':black_circle:', ':black_circle:', ':black_circle:', ':black_circle:', ':black_circle:'];
|
const lights = [":black_circle:", ":black_circle:", ":black_circle:", ":black_circle:", ":black_circle:"];
|
||||||
let minutesUntilNextPhase = 0;
|
let minutesUntilNextPhase = 0;
|
||||||
let currentPhase = '';
|
let currentPhase = "";
|
||||||
|
|
||||||
// If the epoch is now, we should be at the all-green position.
|
// If the epoch is now, we should be at the all-green position.
|
||||||
// From there, we need to determine where we are in the cycle.
|
// From there, we need to determine where we are in the cycle.
|
||||||
|
|
||||||
// Case 1: We're in the unlocked phase, right after epoch
|
// Case 1: We're in the unlocked phase, right after epoch
|
||||||
if (cyclePosition < turningOffDuration) {
|
if (cyclePosition < turningOffDuration) {
|
||||||
currentPhase = 'Unlocked';
|
currentPhase = "Unlocked";
|
||||||
|
|
||||||
// All lights start as green
|
// All lights start as green
|
||||||
lights.fill(':green_circle:');
|
lights.fill(":green_circle:");
|
||||||
|
|
||||||
// Calculate how many lights have turned off
|
// Calculate how many lights have turned off
|
||||||
const offLights = Math.floor(cyclePosition / 12);
|
const offLights = Math.floor(cyclePosition / 12);
|
||||||
|
|
||||||
// Set the appropriate number of lights to off
|
// Set the appropriate number of lights to off
|
||||||
for (let i = 0; i < offLights; i++) {
|
for (let i = 0; i < offLights; i++) {
|
||||||
lights[i] = ':black_circle:';
|
lights[i] = ":black_circle:";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate time until next light turns off
|
// Calculate time until next light turns off
|
||||||
const timeUntilNextLight = 12 - (cyclePosition % 12);
|
const timeUntilNextLight = 12 - (cyclePosition % 12);
|
||||||
minutesUntilNextPhase = timeUntilNextLight;
|
minutesUntilNextPhase = timeUntilNextLight;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Case 2: We're in the reset phase
|
// Case 2: We're in the reset phase
|
||||||
else if (cyclePosition < turningOffDuration + allOffDuration) {
|
else if (cyclePosition < turningOffDuration + allOffDuration) {
|
||||||
currentPhase = 'Resetting';
|
currentPhase = "Resetting";
|
||||||
|
|
||||||
// Lights are initialized "off", so do nothing with them
|
// Lights are initialized "off", so do nothing with them
|
||||||
|
|
||||||
// Calculate time until all lights turn red
|
// Calculate time until all lights turn red
|
||||||
const timeIntoPhase = cyclePosition - turningOffDuration;
|
const timeIntoPhase = cyclePosition - turningOffDuration;
|
||||||
minutesUntilNextPhase = allOffDuration - timeIntoPhase;
|
minutesUntilNextPhase = allOffDuration - timeIntoPhase;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Case 3: We're in the locked phase
|
// Case 3: We're in the locked phase
|
||||||
else {
|
else {
|
||||||
currentPhase = 'Locked';
|
currentPhase = "Locked";
|
||||||
|
|
||||||
// All lights start as red
|
// All lights start as red
|
||||||
lights.fill(':red_circle:');
|
lights.fill(":red_circle:");
|
||||||
|
|
||||||
// Calculate how many lights have turned green
|
// Calculate how many lights have turned green
|
||||||
const timeIntoPhase = cyclePosition - (turningOffDuration + allOffDuration);
|
const timeIntoPhase = cyclePosition - (turningOffDuration + allOffDuration);
|
||||||
const greenLights = Math.floor(timeIntoPhase / 24);
|
const greenLights = Math.floor(timeIntoPhase / 24);
|
||||||
|
|
||||||
// Set the appropriate number of lights to green
|
// Set the appropriate number of lights to green
|
||||||
for (let i = 0; i < greenLights; i++) {
|
for (let i = 0; i < greenLights; i++) {
|
||||||
lights[i] = ':green_circle:';
|
lights[i] = ":green_circle:";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate time until next light turns green
|
// Calculate time until next light turns green
|
||||||
const timeUntilNextLight = 24 - (timeIntoPhase % 24);
|
const timeUntilNextLight = 24 - (timeIntoPhase % 24);
|
||||||
minutesUntilNextPhase = timeUntilNextLight;
|
minutesUntilNextPhase = timeUntilNextLight;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate a timestamp for Discord's formatting and reply
|
// Calculate a timestamp for Discord's formatting and reply
|
||||||
const expiration = Math.ceil((Date.now() / 1000) + (minutesUntilNextPhase * 60));
|
const expiration = Math.ceil((Date.now() / 1000) + (minutesUntilNextPhase * 60));
|
||||||
// Determine time to next Lock/Unlock phase for inline display
|
// Determine time to next Lock/Unlock phase for inline display
|
||||||
const isUnlocked = currentPhase === 'Unlocked';
|
const isUnlocked = currentPhase === 'Unlocked';
|
||||||
const label = isUnlocked ? 'Lock' : 'Unlock';
|
const label = isUnlocked ? 'Lock' : 'Unlock';
|
||||||
const minutesToPhase = isUnlocked
|
const minutesToPhase = isUnlocked
|
||||||
? (turningOffDuration + allOffDuration) - cyclePosition
|
? (turningOffDuration + allOffDuration) - cyclePosition
|
||||||
: cycleDuration - cyclePosition;
|
: cycleDuration - cyclePosition;
|
||||||
const phaseEpoch = Math.ceil(Date.now() / 1000 + (minutesToPhase * 60));
|
const phaseEpoch = Math.ceil(Date.now() / 1000 + (minutesToPhase * 60));
|
||||||
// Reply with lights and inline time to phase
|
// Reply with lights and inline time to phase
|
||||||
await interaction.reply(
|
await interaction.reply(
|
||||||
`### ${lights[0]} ${lights[1]} ${lights[2]} ${lights[3]} ${lights[4]} — Time to ${label}: <t:${phaseEpoch}:R>`
|
`### ${lights[0]} ${lights[1]} ${lights[2]} ${lights[3]} ${lights[4]} — Time to ${label}: <t:${phaseEpoch}:R>`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (verbose) {
|
if (verbose) {
|
||||||
// Replace user mention with displayName for last sync
|
// Replace user mention with displayName for last sync
|
||||||
const syncMember = await interaction.guild.members.fetch(hangarSync.userId).catch(() => null);
|
const syncMember = await interaction.guild.members.fetch(hangarSync.userId).catch(() => null);
|
||||||
const syncName = syncMember ? syncMember.displayName : `<@${hangarSync.userId}>`;
|
const syncName = syncMember ? syncMember.displayName : `<@${hangarSync.userId}>`;
|
||||||
|
|
||||||
// Calculate time until next Lock/Unlock phase
|
// Calculate time until next Lock/Unlock phase
|
||||||
const isUnlocked = currentPhase === 'Unlocked';
|
const isUnlocked = currentPhase === 'Unlocked';
|
||||||
const label = isUnlocked ? 'Lock' : 'Unlock';
|
const label = isUnlocked ? 'Lock' : 'Unlock';
|
||||||
const minutesToPhase = isUnlocked
|
const minutesToPhase = isUnlocked
|
||||||
? (turningOffDuration + allOffDuration) - cyclePosition
|
? (turningOffDuration + allOffDuration) - cyclePosition
|
||||||
: cycleDuration - cyclePosition;
|
: cycleDuration - cyclePosition;
|
||||||
const phaseEpoch = Math.ceil(Date.now() / 1000 + (minutesToPhase * 60));
|
const phaseEpoch = Math.ceil(Date.now() / 1000 + (minutesToPhase * 60));
|
||||||
|
|
||||||
await interaction.followUp(
|
await interaction.followUp(
|
||||||
`- **Phase**: ${currentPhase}\n` +
|
`- **Phase**: ${currentPhase}\n` +
|
||||||
`- **Time to ${label}**: <t:${phaseEpoch}:R>\n` +
|
`- **Time to ${label}**: <t:${phaseEpoch}:R>\n` +
|
||||||
`- **Status Expiration**: <t:${expiration}:R>\n` +
|
`- **Status Expiration**: <t:${expiration}:R>\n` +
|
||||||
`- **Epoch**: <t:${Math.ceil(hangarSync.epoch / 1000)}:R>\n` +
|
`- **Epoch**: <t:${Math.ceil(hangarSync.epoch / 1000)}:R>\n` +
|
||||||
`- **Sync**: <t:${Math.floor(new Date(hangarSync.updated).getTime() / 1000)}:R> by ${syncName}`
|
`- **Sync**: <t:${Math.floor(new Date(hangarSync.updated).getTime() / 1000)}:R> by ${syncName}`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add additional debug info to logs
|
// Add additional debug info to logs
|
||||||
client.logger.debug(`Hangarstatus for guild ${interaction.guildId}: Phase=${currentPhase}, CyclePosition=${cyclePosition}, TimeSinceEpoch=${timeSinceEpoch}`);
|
client.logger.debug(`Hangarstatus for guild ${interaction.guildId}: Phase=${currentPhase}, CyclePosition=${cyclePosition}, TimeSinceEpoch=${timeSinceEpoch}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
client.logger.error(`Error in hangarstatus command: ${error.message}`);
|
client.logger.error(`Error in hangarstatus command: ${error.message}`);
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
content: 'Error retrieving hangar status. Please try again later.',
|
content: `Error retrieving hangar status. Please try again later.`,
|
||||||
ephemeral: true
|
ephemeral: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
// Function to check PocketBase connection status
|
// Function to check PocketBase connection status
|
||||||
function isPocketBaseConnected(client) {
|
function isPocketBaseConnected(client) {
|
||||||
// Check multiple possible status indicators to be safe
|
// Check multiple possible status indicators to be safe
|
||||||
return client.pb && (
|
return client.pb && (
|
||||||
// Check status object (original code style)
|
// Check status object (original code style)
|
||||||
(client.pb.status && client.pb.status.connected) ||
|
(client.pb.status && client.pb.status.connected) ||
|
||||||
// Check isConnected property (pbutils module style)
|
// Check isConnected property (pbutils module style)
|
||||||
client.pb.isConnected === true ||
|
client.pb.isConnected === true ||
|
||||||
// Last resort: check if authStore is valid
|
// Last resort: check if authStore is valid
|
||||||
client.pb.authStore?.isValid === true
|
client.pb.authStore?.isValid === true
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize module
|
// Initialize module
|
||||||
export async function init(client, _config) {
|
export const init = async (client, config) => {
|
||||||
client.logger.info('Initializing Star Citizen Hangar Status module');
|
client.logger.info('Initializing Star Citizen Hangar Status module');
|
||||||
|
|
||||||
// Check PocketBase connection
|
// Check PocketBase connection
|
||||||
if (!isPocketBaseConnected(client)) {
|
if (!isPocketBaseConnected(client)) {
|
||||||
client.logger.warn('PocketBase not connected at initialization');
|
client.logger.warn('PocketBase not connected at initialization');
|
||||||
|
|
||||||
// Try to reconnect if available
|
// Try to reconnect if available
|
||||||
if (typeof client.pb.ensureConnection === 'function') {
|
if (typeof client.pb.ensureConnection === 'function') {
|
||||||
await client.pb.ensureConnection();
|
await client.pb.ensureConnection();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
client.logger.info('PocketBase connection confirmed');
|
client.logger.info('PocketBase connection confirmed');
|
||||||
}
|
}
|
||||||
|
|
||||||
client.logger.info('Star Citizen Hangar Status module initialized');
|
client.logger.info('Star Citizen Hangar Status module initialized');
|
||||||
}
|
};
|
||||||
@ -1,150 +1,150 @@
|
|||||||
// Example of another module using scorekeeper
|
// Example of another module using scorekeeper
|
||||||
export async function init(client, _config) {
|
export const init = async (client, config) => {
|
||||||
// Set up message listener that adds input points when users chat
|
// Set up message listener that adds input points when users chat
|
||||||
client.on('messageCreate', async (message) => {
|
client.on('messageCreate', async (message) => {
|
||||||
if (message.author.bot) return;
|
if (message.author.bot) return;
|
||||||
|
|
||||||
// Skip if not in a guild
|
// Skip if not in a guild
|
||||||
if (!message.guild) return;
|
if (!message.guild) return;
|
||||||
|
|
||||||
// Calculate input points: 1 point per character, plus 10 points per attachment
|
// Calculate input points: 1 point per character, plus 10 points per attachment
|
||||||
const textPoints = message.content.length;
|
const textPoints = message.content.length;
|
||||||
const attachmentPoints = message.attachments.size * 10;
|
const attachmentPoints = message.attachments.size * 10;
|
||||||
const points = textPoints + attachmentPoints;
|
const points = textPoints + attachmentPoints;
|
||||||
// Do not award zero or negative points
|
// Do not award zero or negative points
|
||||||
if (points <= 0) return;
|
if (points <= 0) return;
|
||||||
try {
|
try {
|
||||||
await client.scorekeeper.addInput(message.guild.id, message.author.id, points, 'message');
|
await client.scorekeeper.addInput(message.guild.id, message.author.id, points);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
client.logger.error(`Error adding input points: ${error.message}`);
|
client.logger.error(`Error adding input points: ${error.message}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initialize voice tracking state
|
// Initialize voice tracking state
|
||||||
client.voiceTracker = {
|
client.voiceTracker = {
|
||||||
joinTimes: new Map(), // Tracks when users joined voice
|
joinTimes: new Map(), // Tracks when users joined voice
|
||||||
activeUsers: new Map() // Tracks users currently earning points
|
activeUsers: new Map() // Tracks users currently earning points
|
||||||
};
|
};
|
||||||
|
|
||||||
// Set up a voice state listener that adds input for voice activity
|
// Set up a voice state listener that adds input for voice activity
|
||||||
client.on('voiceStateUpdate', async (oldState, newState) => {
|
client.on('voiceStateUpdate', async (oldState, newState) => {
|
||||||
// Skip if not in a guild
|
// Skip if not in a guild
|
||||||
if (!oldState.guild && !newState.guild) return;
|
if (!oldState.guild && !newState.guild) return;
|
||||||
|
|
||||||
const guild = oldState.guild || newState.guild;
|
const guild = oldState.guild || newState.guild;
|
||||||
const member = oldState.member || newState.member;
|
const member = oldState.member || newState.member;
|
||||||
|
|
||||||
// User joined a voice channel
|
// User joined a voice channel
|
||||||
if (!oldState.channelId && newState.channelId) {
|
if (!oldState.channelId && newState.channelId) {
|
||||||
// Check if the channel has other non-bot users
|
// Check if the channel has other non-bot users
|
||||||
const channel = newState.channel;
|
const channel = newState.channel;
|
||||||
const otherUsers = channel.members.filter(m =>
|
const otherUsers = channel.members.filter(m =>
|
||||||
m.id !== member.id && !m.user.bot
|
m.id !== member.id && !m.user.bot
|
||||||
);
|
);
|
||||||
|
|
||||||
// Store join time if there's at least one other non-bot user
|
// Store join time if there's at least one other non-bot user
|
||||||
if (otherUsers.size > 0) {
|
if (otherUsers.size > 0) {
|
||||||
client.voiceTracker.joinTimes.set(member.id, Date.now());
|
client.voiceTracker.joinTimes.set(member.id, Date.now());
|
||||||
client.voiceTracker.activeUsers.set(member.id, newState.channelId);
|
client.voiceTracker.activeUsers.set(member.id, newState.channelId);
|
||||||
client.logger.debug(`${member.user.tag} joined voice with others - tracking time`);
|
client.logger.debug(`${member.user.tag} joined voice with others - tracking time`);
|
||||||
} else {
|
} else {
|
||||||
client.logger.debug(`${member.user.tag} joined voice alone or with bots - not tracking time`);
|
client.logger.debug(`${member.user.tag} joined voice alone or with bots - not tracking time`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// User left a voice channel
|
// User left a voice channel
|
||||||
else if (oldState.channelId && !newState.channelId) {
|
else if (oldState.channelId && !newState.channelId) {
|
||||||
processVoiceLeave(client, guild, member, oldState.channelId);
|
processVoiceLeave(client, guild, member, oldState.channelId);
|
||||||
}
|
}
|
||||||
// User switched voice channels
|
// User switched voice channels
|
||||||
else if (oldState.channelId && newState.channelId && oldState.channelId !== newState.channelId) {
|
else if (oldState.channelId && newState.channelId && oldState.channelId !== newState.channelId) {
|
||||||
// Process leaving the old channel
|
// Process leaving the old channel
|
||||||
processVoiceLeave(client, guild, member, oldState.channelId);
|
processVoiceLeave(client, guild, member, oldState.channelId);
|
||||||
|
|
||||||
// Check if the new channel has other non-bot users
|
// Check if the new channel has other non-bot users
|
||||||
const channel = newState.channel;
|
const channel = newState.channel;
|
||||||
const otherUsers = channel.members.filter(m =>
|
const otherUsers = channel.members.filter(m =>
|
||||||
m.id !== member.id && !m.user.bot
|
m.id !== member.id && !m.user.bot
|
||||||
);
|
);
|
||||||
|
|
||||||
// Start tracking in the new channel if there are other non-bot users
|
// Start tracking in the new channel if there are other non-bot users
|
||||||
if (otherUsers.size > 0) {
|
if (otherUsers.size > 0) {
|
||||||
client.voiceTracker.joinTimes.set(member.id, Date.now());
|
client.voiceTracker.joinTimes.set(member.id, Date.now());
|
||||||
client.voiceTracker.activeUsers.set(member.id, newState.channelId);
|
client.voiceTracker.activeUsers.set(member.id, newState.channelId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If someone joined or left a channel, update tracking for everyone in that channel
|
// If someone joined or left a channel, update tracking for everyone in that channel
|
||||||
updateChannelUserTracking(client, oldState, newState);
|
updateChannelUserTracking(client, oldState, newState);
|
||||||
});
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Process when a user leaves a voice channel
|
* Process when a user leaves a voice channel
|
||||||
*/
|
*/
|
||||||
function processVoiceLeave(client, guild, member, channelId) {
|
function processVoiceLeave(client, guild, member, channelId) {
|
||||||
if (client.voiceTracker.activeUsers.get(member.id) === channelId) {
|
if (client.voiceTracker.activeUsers.get(member.id) === channelId) {
|
||||||
const joinTime = client.voiceTracker.joinTimes.get(member.id);
|
const joinTime = client.voiceTracker.joinTimes.get(member.id);
|
||||||
|
|
||||||
if (joinTime) {
|
if (joinTime) {
|
||||||
const duration = (Date.now() - joinTime) / 1000 / 60; // Duration in minutes
|
const duration = (Date.now() - joinTime) / 1000 / 60; // Duration in minutes
|
||||||
|
|
||||||
// Award 1 point per minute, up to 30 per session
|
// Award 1 point per minute, up to 30 per session
|
||||||
const points = Math.min(Math.floor(duration), 30);
|
const points = Math.min(Math.floor(duration), 30);
|
||||||
if (points > 0) {
|
if (points > 0) {
|
||||||
try {
|
try {
|
||||||
client.scorekeeper.addInput(guild.id, member.id, points, 'voice_activity')
|
client.scorekeeper.addInput(guild.id, member.id, points)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
client.logger.debug(`Added ${points} voice activity points for ${member.user.tag}`);
|
client.logger.debug(`Added ${points} voice activity points for ${member.user.tag}`);
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
client.logger.error(`Error adding voice points: ${error.message}`);
|
client.logger.error(`Error adding voice points: ${error.message}`);
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
client.logger.error(`Error adding voice points: ${error.message}`);
|
client.logger.error(`Error adding voice points: ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
client.voiceTracker.joinTimes.delete(member.id);
|
client.voiceTracker.joinTimes.delete(member.id);
|
||||||
client.voiceTracker.activeUsers.delete(member.id);
|
client.voiceTracker.activeUsers.delete(member.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates tracking for all users in affected channels
|
* Updates tracking for all users in affected channels
|
||||||
*/
|
*/
|
||||||
function updateChannelUserTracking(client, oldState, newState) {
|
function updateChannelUserTracking(client, oldState, newState) {
|
||||||
// Get the affected channels
|
// Get the affected channels
|
||||||
const affectedChannels = new Set();
|
const affectedChannels = new Set();
|
||||||
if (oldState.channelId) affectedChannels.add(oldState.channelId);
|
if (oldState.channelId) affectedChannels.add(oldState.channelId);
|
||||||
if (newState.channelId) affectedChannels.add(newState.channelId);
|
if (newState.channelId) affectedChannels.add(newState.channelId);
|
||||||
|
|
||||||
for (const channelId of affectedChannels) {
|
for (const channelId of affectedChannels) {
|
||||||
const channel = oldState.guild.channels.cache.get(channelId);
|
const channel = oldState.guild.channels.cache.get(channelId);
|
||||||
if (!channel) continue;
|
if (!channel) continue;
|
||||||
|
|
||||||
// Check if the channel has at least 2 non-bot users
|
// Check if the channel has at least 2 non-bot users
|
||||||
const nonBotMembers = channel.members.filter(m => !m.user.bot);
|
const nonBotMembers = channel.members.filter(m => !m.user.bot);
|
||||||
const hasMultipleUsers = nonBotMembers.size >= 2;
|
const hasMultipleUsers = nonBotMembers.size >= 2;
|
||||||
|
|
||||||
// For each user in the channel
|
// For each user in the channel
|
||||||
channel.members.forEach(channelMember => {
|
channel.members.forEach(channelMember => {
|
||||||
if (channelMember.user.bot) return; // Skip bots
|
if (channelMember.user.bot) return; // Skip bots
|
||||||
|
|
||||||
const userId = channelMember.id;
|
const userId = channelMember.id;
|
||||||
const isActive = client.voiceTracker.activeUsers.get(userId) === channelId;
|
const isActive = client.voiceTracker.activeUsers.get(userId) === channelId;
|
||||||
|
|
||||||
// Should be active but isn't yet
|
// Should be active but isn't yet
|
||||||
if (hasMultipleUsers && !isActive) {
|
if (hasMultipleUsers && !isActive) {
|
||||||
client.voiceTracker.joinTimes.set(userId, Date.now());
|
client.voiceTracker.joinTimes.set(userId, Date.now());
|
||||||
client.voiceTracker.activeUsers.set(userId, channelId);
|
client.voiceTracker.activeUsers.set(userId, channelId);
|
||||||
client.logger.debug(`Starting tracking for ${channelMember.user.tag} in ${channel.name}`);
|
client.logger.debug(`Starting tracking for ${channelMember.user.tag} in ${channel.name}`);
|
||||||
}
|
}
|
||||||
// Should not be active but is
|
// Should not be active but is
|
||||||
else if (!hasMultipleUsers && isActive) {
|
else if (!hasMultipleUsers && isActive) {
|
||||||
processVoiceLeave(client, oldState.guild, channelMember, channelId);
|
processVoiceLeave(client, oldState.guild, channelMember, channelId);
|
||||||
client.logger.debug(`Stopping tracking for ${channelMember.user.tag} - not enough users`);
|
client.logger.debug(`Stopping tracking for ${channelMember.user.tag} - not enough users`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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 };
|
|
||||||
123
_src/loader.js
123
_src/loader.js
@ -8,69 +8,68 @@ const rootDir = path.dirname(__dirname);
|
|||||||
|
|
||||||
// Load modules function - hot reload functionality removed
|
// Load modules function - hot reload functionality removed
|
||||||
export const loadModules = async (clientConfig, client) => {
|
export const loadModules = async (clientConfig, client) => {
|
||||||
const modules = clientConfig.modules || [];
|
const modules = clientConfig.modules || [];
|
||||||
const modulesDir = path.join(rootDir, '_opt');
|
const modulesDir = path.join(rootDir, '_opt');
|
||||||
|
|
||||||
// Create opt directory if it doesn't exist
|
// Create opt directory if it doesn't exist
|
||||||
if (!fs.existsSync(modulesDir)) {
|
if (!fs.existsSync(modulesDir)) {
|
||||||
fs.mkdirSync(modulesDir, { recursive: true });
|
fs.mkdirSync(modulesDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
client.logger.info(`[module:loader] Loading modules: ${modules.join(', ')}`);
|
// Load each module
|
||||||
// Load each module
|
for (const moduleName of modules) {
|
||||||
for (const moduleName of modules) {
|
try {
|
||||||
try {
|
// Try _opt first, then fallback to core _src modules
|
||||||
// Try _opt first, then fallback to core _src modules
|
let modulePath = path.join(modulesDir, `${moduleName}.js`);
|
||||||
let modulePath = path.join(modulesDir, `${moduleName}.js`);
|
if (!fs.existsSync(modulePath)) {
|
||||||
if (!fs.existsSync(modulePath)) {
|
// Fallback to core source directory
|
||||||
// Fallback to core source directory
|
modulePath = path.join(rootDir, '_src', `${moduleName}.js`);
|
||||||
modulePath = path.join(rootDir, '_src', `${moduleName}.js`);
|
if (!fs.existsSync(modulePath)) {
|
||||||
if (!fs.existsSync(modulePath)) {
|
client.logger.warn(`Module not found in _opt or _src: ${moduleName}.js`);
|
||||||
client.logger.warn(`[module:loader] Module not found: ${moduleName}.js`);
|
continue;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Import module (using dynamic import for ES modules)
|
|
||||||
// Import module
|
|
||||||
const moduleUrl = `file://${modulePath}`;
|
|
||||||
const module = await import(moduleUrl);
|
|
||||||
|
|
||||||
// Register commands if the module has them
|
|
||||||
if (module.commands) {
|
|
||||||
if (Array.isArray(module.commands)) {
|
|
||||||
// Handle array of commands
|
|
||||||
for (const command of module.commands) {
|
|
||||||
if (command.data && typeof command.execute === 'function') {
|
|
||||||
const commandName = command.data.name || command.name;
|
|
||||||
client.commands.set(commandName, command);
|
|
||||||
client.logger.info(`[module:loader] Registered command: ${commandName}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (typeof module.commands === 'object') {
|
|
||||||
// Handle map/object of commands
|
|
||||||
for (const [commandName, command] of Object.entries(module.commands)) {
|
|
||||||
if (command.execute && typeof command.execute === 'function') {
|
|
||||||
client.commands.set(commandName, command);
|
|
||||||
client.logger.info(`Registered command: ${commandName}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Call init function if it exists
|
|
||||||
if (typeof module.init === 'function') {
|
|
||||||
await module.init(client, clientConfig);
|
|
||||||
client.logger.info(`[module:loader] Module initialized: ${moduleName}`);
|
|
||||||
} else {
|
|
||||||
client.logger.info(`[module:loader] Module loaded (no init): ${moduleName}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store the module reference (this isn't used for hot reloading anymore)
|
|
||||||
client.modules = client.modules || new Map();
|
|
||||||
client.modules.set(moduleName, module);
|
|
||||||
} catch (error) {
|
|
||||||
client.logger.error(`[module:loader] Failed to load module ${moduleName}: ${error.message}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Import module (using dynamic import for ES modules)
|
||||||
|
// Import module
|
||||||
|
const moduleUrl = `file://${modulePath}`;
|
||||||
|
const module = await import(moduleUrl);
|
||||||
|
|
||||||
|
// Register commands if the module has them
|
||||||
|
if (module.commands) {
|
||||||
|
if (Array.isArray(module.commands)) {
|
||||||
|
// Handle array of commands
|
||||||
|
for (const command of module.commands) {
|
||||||
|
if (command.data && typeof command.execute === 'function') {
|
||||||
|
const commandName = command.data.name || command.name;
|
||||||
|
client.commands.set(commandName, command);
|
||||||
|
client.logger.info(`Registered command: ${commandName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (typeof module.commands === 'object') {
|
||||||
|
// Handle map/object of commands
|
||||||
|
for (const [commandName, command] of Object.entries(module.commands)) {
|
||||||
|
if (command.execute && typeof command.execute === 'function') {
|
||||||
|
client.commands.set(commandName, command);
|
||||||
|
client.logger.info(`Registered command: ${commandName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call init function if it exists
|
||||||
|
if (typeof module.init === 'function') {
|
||||||
|
await module.init(client, clientConfig);
|
||||||
|
client.logger.info(`Module 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 fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
import winston from 'winston';
|
|
||||||
import 'winston-daily-rotate-file';
|
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
const rootDir = path.dirname(__dirname);
|
const rootDir = path.dirname(__dirname);
|
||||||
|
|
||||||
// Create Winston logger
|
// Create Winston logger
|
||||||
export const createLogger = (clientConfig) => {
|
export const createLogger = (clientConfig) => {
|
||||||
const { logging } = clientConfig;
|
const { logging } = clientConfig;
|
||||||
const transports = [];
|
const transports = [];
|
||||||
|
|
||||||
// Console transport
|
// Console transport
|
||||||
if (logging.console.enabled) {
|
if (logging.console.enabled) {
|
||||||
transports.push(new winston.transports.Console({
|
transports.push(new winston.transports.Console({
|
||||||
level: logging.console.level,
|
level: logging.console.level,
|
||||||
format: winston.format.combine(
|
format: winston.format.combine(
|
||||||
winston.format.timestamp({
|
winston.format.timestamp({
|
||||||
format: logging.file.timestampFormat
|
format: logging.file.timestampFormat
|
||||||
}),
|
}),
|
||||||
logging.console.colorize ? winston.format.colorize() : winston.format.uncolorize(),
|
logging.console.colorize ? winston.format.colorize() : winston.format.uncolorize(),
|
||||||
winston.format.printf(info => `[${info.timestamp}] [${clientConfig.id}] [${info.level}] ${info.message}`)
|
winston.format.printf(info => `[${info.timestamp}] [${clientConfig.id}] [${info.level}] ${info.message}`)
|
||||||
)
|
)
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Combined file transport with rotation
|
// Combined file transport with rotation
|
||||||
if (logging.file.combined.enabled) {
|
if (logging.file.combined.enabled) {
|
||||||
const logDir = path.join(rootDir, logging.file.combined.location);
|
const logDir = path.join(rootDir, logging.file.combined.location);
|
||||||
|
|
||||||
// Create log directory if it doesn't exist
|
// Create log directory if it doesn't exist
|
||||||
if (!fs.existsSync(logDir)) {
|
if (!fs.existsSync(logDir)) {
|
||||||
fs.mkdirSync(logDir, { recursive: true });
|
fs.mkdirSync(logDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
const combinedTransport = new winston.transports.DailyRotateFile({
|
const combinedTransport = new winston.transports.DailyRotateFile({
|
||||||
filename: path.join(logDir, `${clientConfig.id}-combined-%DATE%.log`),
|
filename: path.join(logDir, `${clientConfig.id}-combined-%DATE%.log`),
|
||||||
datePattern: logging.file.dateFormat,
|
datePattern: logging.file.dateFormat,
|
||||||
level: logging.file.combined.level,
|
level: logging.file.combined.level,
|
||||||
maxSize: logging.file.combined.maxSize,
|
maxSize: logging.file.combined.maxSize,
|
||||||
maxFiles: logging.file.combined.maxFiles,
|
maxFiles: logging.file.combined.maxFiles,
|
||||||
format: winston.format.combine(
|
format: winston.format.combine(
|
||||||
winston.format.timestamp({
|
winston.format.timestamp({
|
||||||
format: logging.file.timestampFormat
|
format: logging.file.timestampFormat
|
||||||
}),
|
}),
|
||||||
winston.format.printf(info => `[${info.timestamp}] [${info.level}] ${info.message}`)
|
winston.format.printf(info => `[${info.timestamp}] [${info.level}] ${info.message}`)
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
transports.push(combinedTransport);
|
transports.push(combinedTransport);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error file transport with rotation
|
// Error file transport with rotation
|
||||||
if (logging.file.error.enabled) {
|
if (logging.file.error.enabled) {
|
||||||
const logDir = path.join(rootDir, logging.file.error.location);
|
const logDir = path.join(rootDir, logging.file.error.location);
|
||||||
|
|
||||||
// Create log directory if it doesn't exist
|
// Create log directory if it doesn't exist
|
||||||
if (!fs.existsSync(logDir)) {
|
if (!fs.existsSync(logDir)) {
|
||||||
fs.mkdirSync(logDir, { recursive: true });
|
fs.mkdirSync(logDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
const errorTransport = new winston.transports.DailyRotateFile({
|
const errorTransport = new winston.transports.DailyRotateFile({
|
||||||
filename: path.join(logDir, `${clientConfig.id}-error-%DATE%.log`),
|
filename: path.join(logDir, `${clientConfig.id}-error-%DATE%.log`),
|
||||||
datePattern: logging.file.dateFormat,
|
datePattern: logging.file.dateFormat,
|
||||||
level: logging.file.error.level,
|
level: logging.file.error.level,
|
||||||
maxSize: logging.file.error.maxSize,
|
maxSize: logging.file.error.maxSize,
|
||||||
maxFiles: logging.file.error.maxFiles,
|
maxFiles: logging.file.error.maxFiles,
|
||||||
format: winston.format.combine(
|
format: winston.format.combine(
|
||||||
winston.format.timestamp({
|
winston.format.timestamp({
|
||||||
format: logging.file.timestampFormat
|
format: logging.file.timestampFormat
|
||||||
}),
|
}),
|
||||||
winston.format.printf(info => `[${info.timestamp}] [${info.level}] ${info.message}`)
|
winston.format.printf(info => `[${info.timestamp}] [${info.level}] ${info.message}`)
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
transports.push(errorTransport);
|
transports.push(errorTransport);
|
||||||
}
|
}
|
||||||
|
|
||||||
return winston.createLogger({
|
return winston.createLogger({
|
||||||
levels: winston.config.npm.levels,
|
levels: winston.config.npm.levels,
|
||||||
transports
|
transports
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@ -2,19 +2,19 @@ import PocketBase from 'pocketbase';
|
|||||||
|
|
||||||
// Initialize Pocketbase
|
// Initialize Pocketbase
|
||||||
export const initializePocketbase = async (clientConfig, logger) => {
|
export const initializePocketbase = async (clientConfig, logger) => {
|
||||||
try {
|
try {
|
||||||
const pb = new PocketBase(clientConfig.pocketbase.url);
|
const pb = new PocketBase(clientConfig.pocketbase.url);
|
||||||
|
|
||||||
// Authenticate with admin credentials
|
// Authenticate with admin credentials
|
||||||
await pb.collection('_users').authWithPassword(
|
await pb.collection('_users').authWithPassword(
|
||||||
clientConfig.pocketbase.username,
|
clientConfig.pocketbase.username,
|
||||||
clientConfig.pocketbase.password
|
clientConfig.pocketbase.password
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.info('PocketBase initialized and authenticated');
|
logger.info('PocketBase initialized and authenticated');
|
||||||
return pb;
|
return pb;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`PocketBase initialization failed: ${error.message}`);
|
logger.error(`PocketBase initialization failed: ${error.message}`);
|
||||||
return new PocketBase(clientConfig.pocketbase.url);
|
return new PocketBase(clientConfig.pocketbase.url);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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])
|
|
||||||
: '';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
612
config.js
612
config.js
@ -1,332 +1,348 @@
|
|||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
const logging = {
|
|
||||||
console: {
|
|
||||||
enabled: true,
|
|
||||||
colorize: true,
|
|
||||||
level: 'silly'
|
|
||||||
},
|
|
||||||
file: {
|
|
||||||
dateFormat: 'YYYY-MM-DD',
|
|
||||||
timestampFormat: 'YYYY-MM-DD HH:mm:ss',
|
|
||||||
combined: {
|
|
||||||
enabled: true,
|
|
||||||
level: 'silly',
|
|
||||||
location: 'logs',
|
|
||||||
maxSize: '12m',
|
|
||||||
maxFiles: '30d'
|
|
||||||
},
|
|
||||||
error: {
|
|
||||||
enabled: true,
|
|
||||||
level: 'error',
|
|
||||||
location: 'logs',
|
|
||||||
maxSize: '12m',
|
|
||||||
maxFiles: '365d'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const pocketbase = {
|
|
||||||
url: process.env.SHARED_POCKETBASE_URL,
|
|
||||||
username: process.env.SHARED_POCKETBASE_USERNAME,
|
|
||||||
password: process.env.SHARED_POCKETBASE_PASSWORD
|
|
||||||
};
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|
||||||
clients: [
|
clients: [
|
||||||
{
|
{
|
||||||
id: 'SysAI',
|
id: 'IO3',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
owner: process.env.OWNER_ID,
|
owner: process.env.OWNER_ID,
|
||||||
|
|
||||||
discord: {
|
discord: {
|
||||||
appId: process.env.SYSAI_DISCORD_APPID,
|
appId: process.env.IO3_DISCORD_APPID,
|
||||||
token: process.env.SYSAI_DISCORD_TOKEN
|
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: {
|
responses: {
|
||||||
apiKey: process.env.SHARED_OPENAI_API_KEY,
|
apiKey: process.env.SHARED_OPENAI_API_KEY,
|
||||||
defaultModel: 'gpt-4.1',
|
defaultModel: 'gpt-4.1',
|
||||||
defaultMaxTokens: 1000,
|
defaultMaxTokens: 1000,
|
||||||
defaultTemperature: 0.7,
|
defaultTemperature: 0.7,
|
||||||
conversationExpiry: 30 * 60 * 1000,
|
systemPromptPath: './prompts/absolute.txt',
|
||||||
minScore: 1.0,
|
conversationExpiry: 30 * 60 * 1000,
|
||||||
enableMentions: true,
|
minScore: 1.0,
|
||||||
enableReplies: true,
|
tools: {
|
||||||
tools: {
|
webSearch: true,
|
||||||
webSearch: true,
|
fileSearch: false,
|
||||||
fileSearch: false,
|
imageGeneration: true,
|
||||||
imageGeneration: true
|
},
|
||||||
},
|
imageGeneration: {
|
||||||
imageGeneration: {
|
defaultModel: 'gpt-image-1',
|
||||||
defaultModel: 'gpt-image-1',
|
defaultQuality: 'standard',
|
||||||
defaultQuality: 'standard',
|
imageSavePath: './images'
|
||||||
imageSavePath: './images'
|
}
|
||||||
}
|
},
|
||||||
},
|
|
||||||
|
|
||||||
modules: [
|
modules: [
|
||||||
'ansi',
|
'pbUtils',
|
||||||
'botUtils',
|
'responses',
|
||||||
'pbUtils',
|
'responsesQuery',
|
||||||
'gitUtils',
|
'gitUtils'
|
||||||
'responses',
|
]
|
||||||
'responsesPrompt',
|
|
||||||
'responsesQuery',
|
|
||||||
'tempvc'
|
|
||||||
]
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
id: 'ASOP',
|
id: 'ASOP',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
owner: process.env.OWNER_ID,
|
owner: 378741522822070272,
|
||||||
|
|
||||||
discord: {
|
discord: {
|
||||||
appId: process.env.ASOP_DISCORD_APPID,
|
appId: process.env.ASOP_DISCORD_APPID,
|
||||||
token: process.env.ASOP_DISCORD_TOKEN
|
token: process.env.ASOP_DISCORD_TOKEN
|
||||||
},
|
},
|
||||||
|
|
||||||
logging: { ...logging },
|
logging: {
|
||||||
|
console: {
|
||||||
|
enabled: true,
|
||||||
|
colorize: true,
|
||||||
|
level: 'silly',
|
||||||
|
},
|
||||||
|
file: {
|
||||||
|
dateFormat: 'YYYY-MM-DD',
|
||||||
|
timestampFormat: 'YYYY-MM-DD HH:mm:ss',
|
||||||
|
combined: {
|
||||||
|
enabled: true,
|
||||||
|
level: 'silly',
|
||||||
|
location: 'logs',
|
||||||
|
maxSize: '12m',
|
||||||
|
maxFiles: '30d',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
enabled: true,
|
||||||
|
level: 'error',
|
||||||
|
location: 'logs',
|
||||||
|
maxSize: '12m',
|
||||||
|
maxFiles: '365d',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
condimentX: {
|
condimentX: {
|
||||||
dryRun: false,
|
dryRun: false,
|
||||||
guildID: '983057544849272883',
|
guildID: '983057544849272883',
|
||||||
debugChannel: '1247179154869325865',
|
debugChannel: '1247179154869325865',
|
||||||
blacklistUsers: [
|
blacklistUsers: [
|
||||||
'1162531805006680064' // Crow
|
'1162531805006680064' // Crow
|
||||||
],
|
],
|
||||||
blacklistRoles: [
|
blacklistRoles: [
|
||||||
'1173012816228274256', // @Bots
|
'1173012816228274256', // @Bots
|
||||||
'1209570635085520977', // @Kevin Arby
|
'1209570635085520977', // @Kevin Arby
|
||||||
'1226903935344971786', // @Werebeef
|
'1226903935344971786', // @Werebeef
|
||||||
'1250141348040933407' // @RIP
|
'1250141348040933407' // @RIP
|
||||||
],
|
],
|
||||||
graylistRoles: [
|
graylistRoles: [
|
||||||
'1246749335866310656' // @Most Active
|
'1246749335866310656' // @Most Active
|
||||||
],
|
],
|
||||||
whitelistRoles: [
|
whitelistRoles: [
|
||||||
'1256082910163767378' // @"Crow"
|
'1256082910163767378' // @"Crow"
|
||||||
],
|
],
|
||||||
indexRoleID: '1209570635085520977', // Kevin's Vessel
|
indexRoleID: '1209570635085520977', // Kevin's Vessel
|
||||||
viralRoleID: '1226903935344971786', // Werebeef
|
viralRoleID: '1226903935344971786', // Werebeef
|
||||||
antiIndexRoleID: '1241228932037214358', // Exorcised
|
antiIndexRoleID: '1241228932037214358', // Exorcised
|
||||||
antiViralRoleID: '1241230334079795330', // Immunized
|
antiViralRoleID: '1241230334079795330', // Immunized
|
||||||
firstCycleInterval: 30000,
|
firstCycleInterval: 30000,
|
||||||
cycleInterval: 3600000,
|
cycleInterval: 3600000,
|
||||||
cycleIntervalRange: 900000,
|
cycleIntervalRange: 900000,
|
||||||
incidenceDenominator: 40,
|
incidenceDenominator: 40,
|
||||||
cessationDenominator: 20,
|
cessationDenominator: 20,
|
||||||
probabilityLimit: 20,
|
probabilityLimit: 20,
|
||||||
antiViralEffectiveness: 90,
|
antiViralEffectiveness: 90,
|
||||||
proximityWindow: 120000,
|
proximityWindow: 120000,
|
||||||
messageHistoryLimit: 50,
|
messageHistoryLimit: 50,
|
||||||
ephemeralDelay: 60000,
|
ephemeralDelay: 60000,
|
||||||
openAI: true,
|
openAI: true,
|
||||||
openAITriggerOnlyDuringIncident: true,
|
openAITriggerOnlyDuringIncident: true,
|
||||||
openAIResponseDenominator: 1,
|
openAIResponseDenominator: 1,
|
||||||
openAIInstructionsFile: './assets/kevinarby.txt',
|
openAIInstructionsFile: './prompts/kevinarby.txt',
|
||||||
openAITriggers: [
|
openAITriggers: [
|
||||||
'kevin',
|
'kevin',
|
||||||
'arby',
|
'arby',
|
||||||
'werebeef'
|
'werebeef'
|
||||||
],
|
],
|
||||||
openAIWebhookID: '1251666161075097640',
|
openAIWebhookID: '1251666161075097640',
|
||||||
openAIWebhookToken: process.env.SYSAI_CONDIMENTX_WEBHOOK_TOKEN,
|
openAIWebhookToken: process.env.IO3_CONDIMENTX_WEBHOOK_TOKEN,
|
||||||
openAIToken: process.env.SHARED_OPENAI_API_KEY
|
openAIToken: process.env.SHARED_OPENAI_API_KEY
|
||||||
},
|
},
|
||||||
|
|
||||||
pocketbase: { ...pocketbase },
|
pocketbase: {
|
||||||
|
url: process.env.SHARED_POCKETBASE_URL,
|
||||||
|
username: process.env.SHARED_POCKETBASE_USERNAME,
|
||||||
|
password: process.env.SHARED_POCKETBASE_PASSWORD
|
||||||
|
},
|
||||||
|
|
||||||
responses: {
|
responses: {
|
||||||
apiKey: process.env.SHARED_OPENAI_API_KEY,
|
apiKey: process.env.SHARED_OPENAI_API_KEY,
|
||||||
defaultModel: 'gpt-4.1-mini',
|
defaultModel: 'gpt-4.1-mini',
|
||||||
defaultMaxTokens: 1000,
|
defaultMaxTokens: 1000,
|
||||||
defaultTemperature: 0.7,
|
defaultTemperature: 0.7,
|
||||||
conversationExpiry: 30 * 60 * 1000,
|
systemPromptPath: './prompts/asop.txt',
|
||||||
minScore: 0.5,
|
conversationExpiry: 30 * 60 * 1000,
|
||||||
enableMentions: true,
|
minScore: 0.25,
|
||||||
enableReplies: true,
|
tools: {
|
||||||
tools: {
|
webSearch: true,
|
||||||
webSearch: false,
|
fileSearch: false,
|
||||||
fileSearch: false,
|
imageGeneration: true,
|
||||||
imageGeneration: true
|
},
|
||||||
},
|
imageGeneration: {
|
||||||
imageGeneration: {
|
defaultModel: 'gpt-image-1',
|
||||||
defaultModel: 'gpt-image-1',
|
defaultQuality: 'standard',
|
||||||
defaultQuality: 'standard',
|
imageSavePath: './images'
|
||||||
imageSavePath: './images'
|
}
|
||||||
}
|
},
|
||||||
},
|
|
||||||
|
|
||||||
scorekeeper: {
|
scorekeeper: {
|
||||||
baseOutput: 1000,
|
baseOutput: 1000,
|
||||||
commendationValue: 0.25,
|
commendationValue: 0.25,
|
||||||
citationValue: 0.35,
|
citationValue: 0.35,
|
||||||
cooldown: 43200000,
|
cooldown: 43200000,
|
||||||
decay: 80,
|
decay: 80,
|
||||||
schedule: '0 0 * * 0'
|
schedule: '0 0 * * 0'
|
||||||
},
|
},
|
||||||
|
|
||||||
modules: [
|
modules: [
|
||||||
'ansi',
|
'pbUtils',
|
||||||
'botUtils',
|
'responses',
|
||||||
'pbUtils',
|
'responsesQuery',
|
||||||
'gitUtils',
|
'scorekeeper',
|
||||||
'condimentX',
|
'scorekeeper-example',
|
||||||
'responses',
|
'scExecHangarStatus',
|
||||||
'responsesPrompt',
|
//'condimentX'
|
||||||
'responsesQuery',
|
]
|
||||||
'scorekeeper',
|
|
||||||
'scorekeeper-example',
|
|
||||||
'scExecHangarStatus'
|
|
||||||
]
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
id: 'Crowley',
|
id: 'Crowley',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
owner: process.env.OWNER_ID,
|
owner: 378741522822070272,
|
||||||
|
|
||||||
discord: {
|
discord: {
|
||||||
appId: process.env.CROWLEY_DISCORD_APPID,
|
appId: process.env.CROWLEY_DISCORD_APPID,
|
||||||
token: process.env.CROWLEY_DISCORD_TOKEN
|
token: process.env.CROWLEY_DISCORD_TOKEN
|
||||||
},
|
},
|
||||||
|
|
||||||
logging: { ...logging },
|
logging: {
|
||||||
|
console: {
|
||||||
|
enabled: true,
|
||||||
|
colorize: true,
|
||||||
|
level: 'silly',
|
||||||
|
},
|
||||||
|
file: {
|
||||||
|
dateFormat: 'YYYY-MM-DD',
|
||||||
|
timestampFormat: 'YYYY-MM-DD HH:mm:ss',
|
||||||
|
combined: {
|
||||||
|
enabled: true,
|
||||||
|
level: 'silly',
|
||||||
|
location: 'logs',
|
||||||
|
maxSize: '12m',
|
||||||
|
maxFiles: '30d',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
enabled: true,
|
||||||
|
level: 'error',
|
||||||
|
location: 'logs',
|
||||||
|
maxSize: '12m',
|
||||||
|
maxFiles: '365d',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
pocketbase: { ...pocketbase },
|
pocketbase: {
|
||||||
|
url: process.env.SHARED_POCKETBASE_URL,
|
||||||
|
username: process.env.SHARED_POCKETBASE_USERNAME,
|
||||||
|
password: process.env.SHARED_POCKETBASE_PASSWORD
|
||||||
|
},
|
||||||
|
|
||||||
responses: {
|
responses: {
|
||||||
apiKey: process.env.SHARED_OPENAI_API_KEY,
|
apiKey: process.env.SHARED_OPENAI_API_KEY,
|
||||||
defaultModel: 'gpt-4.1',
|
defaultModel: 'gpt-4.1',
|
||||||
defaultMaxTokens: 1000,
|
defaultMaxTokens: 1000,
|
||||||
defaultTemperature: 0.7,
|
defaultTemperature: 0.7,
|
||||||
conversationExpiry: 30 * 60 * 1000,
|
systemPromptPath: './prompts/crowley.txt',
|
||||||
minScore: 0,
|
conversationExpiry: 30 * 60 * 1000,
|
||||||
enableMentions: true,
|
minScore: 1.0,
|
||||||
enableReplies: true,
|
tools: {
|
||||||
tools: {
|
webSearch: true,
|
||||||
webSearch: false,
|
fileSearch: false,
|
||||||
fileSearch: false,
|
imageGeneration: true,
|
||||||
imageGeneration: false
|
},
|
||||||
},
|
imageGeneration: {
|
||||||
imageGeneration: {
|
defaultModel: 'gpt-image-1',
|
||||||
defaultModel: 'gpt-image-1',
|
defaultQuality: 'standard',
|
||||||
defaultQuality: 'standard',
|
imageSavePath: './images'
|
||||||
imageSavePath: './images'
|
}
|
||||||
}
|
},
|
||||||
},
|
|
||||||
|
|
||||||
modules: [
|
modules: [
|
||||||
'botUtils',
|
'pbUtils',
|
||||||
'pbUtils',
|
'responses',
|
||||||
'responses',
|
'responsesQuery',
|
||||||
'responsesPrompt',
|
]
|
||||||
'responsesQuery'
|
|
||||||
]
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
id: 'GRANDPA',
|
id: 'Smuuush',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
owner: process.env.OWNER_ID,
|
owner: 378741522822070272,
|
||||||
|
|
||||||
discord: {
|
discord: {
|
||||||
appId: process.env.GRANDPA_DISCORD_APPID,
|
appId: process.env.SMUUUSH_DISCORD_APPID,
|
||||||
token: process.env.GRANDPA_DISCORD_TOKEN
|
token: process.env.SMUUUSH_DISCORD_TOKEN
|
||||||
},
|
},
|
||||||
|
|
||||||
logging: { ...logging },
|
logging: {
|
||||||
|
console: {
|
||||||
|
enabled: true,
|
||||||
|
colorize: true,
|
||||||
|
level: 'silly',
|
||||||
|
},
|
||||||
|
file: {
|
||||||
|
dateFormat: 'YYYY-MM-DD',
|
||||||
|
timestampFormat: 'YYYY-MM-DD HH:mm:ss',
|
||||||
|
combined: {
|
||||||
|
enabled: true,
|
||||||
|
level: 'silly',
|
||||||
|
location: 'logs',
|
||||||
|
maxSize: '12m',
|
||||||
|
maxFiles: '30d',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
enabled: true,
|
||||||
|
level: 'error',
|
||||||
|
location: 'logs',
|
||||||
|
maxSize: '12m',
|
||||||
|
maxFiles: '365d',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
pocketbase: { ...pocketbase },
|
pocketbase: {
|
||||||
|
url: process.env.SHARED_POCKETBASE_URL,
|
||||||
|
username: process.env.SHARED_POCKETBASE_USERNAME,
|
||||||
|
password: process.env.SHARED_POCKETBASE_PASSWORD
|
||||||
|
},
|
||||||
|
|
||||||
responses: {
|
responses: {
|
||||||
apiKey: process.env.SHARED_OPENAI_API_KEY,
|
apiKey: process.env.SHARED_OPENAI_API_KEY,
|
||||||
defaultModel: 'gpt-4.1',
|
defaultModel: 'gpt-4.1-mini',
|
||||||
defaultMaxTokens: 200,
|
defaultMaxTokens: 1000,
|
||||||
defaultTemperature: 0.7,
|
defaultTemperature: 0.7,
|
||||||
conversationExpiry: 30 * 60 * 1000,
|
systemPromptPath: './prompts/smuuush.txt',
|
||||||
minScore: 0,
|
conversationExpiry: 30 * 60 * 1000,
|
||||||
enableMentions: false,
|
minScore: 0,
|
||||||
enableReplies: true,
|
tools: {
|
||||||
tools: {
|
webSearch: false,
|
||||||
webSearch: false,
|
fileSearch: false,
|
||||||
fileSearch: false,
|
imageGeneration: true,
|
||||||
imageGeneration: false
|
},
|
||||||
},
|
imageGeneration: {
|
||||||
imageGeneration: {
|
defaultModel: 'gpt-image-1',
|
||||||
defaultModel: 'gpt-image-1',
|
defaultQuality: 'standard',
|
||||||
defaultQuality: 'standard',
|
imageSavePath: './images'
|
||||||
imageSavePath: './images'
|
}
|
||||||
}
|
},
|
||||||
},
|
|
||||||
|
|
||||||
responsesRandomizer: {
|
modules: [
|
||||||
chance: 0.01
|
'pbUtils',
|
||||||
},
|
'responses',
|
||||||
modules: [
|
'responsesQuery'
|
||||||
'botUtils',
|
],
|
||||||
'pbUtils',
|
|
||||||
'responses',
|
|
||||||
'responsesPrompt',
|
|
||||||
'responsesRandomizer'
|
|
||||||
]
|
|
||||||
|
|
||||||
},
|
}
|
||||||
|
]
|
||||||
{
|
}
|
||||||
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',
|
systemPromptPath: './prompts/IO3.txt',
|
||||||
conversationExpiry: 30 * 60 * 1000,
|
conversationExpiry: 30 * 60 * 1000,
|
||||||
minScore: 1.0,
|
minScore: 1.0,
|
||||||
enableMentions: true,
|
|
||||||
enableReplies: true,
|
|
||||||
tools: {
|
tools: {
|
||||||
webSearch: false,
|
webSearch: false,
|
||||||
fileSearch: false,
|
fileSearch: false,
|
||||||
@ -116,27 +114,18 @@ export default {
|
|||||||
baseOutput: 1000,
|
baseOutput: 1000,
|
||||||
commendationValue: 1.0,
|
commendationValue: 1.0,
|
||||||
citationValue: 1.2,
|
citationValue: 1.2,
|
||||||
cooldown: 0,
|
|
||||||
decay: 90,
|
decay: 90,
|
||||||
schedule: '0 0 * * 0',
|
schedule: '0 0 * * 0',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Modules to load for this client
|
// Modules to load for this client
|
||||||
modules: [
|
modules: [
|
||||||
'ansi',
|
|
||||||
'botUtils',
|
|
||||||
'pbUtils',
|
'pbUtils',
|
||||||
'gitUtils',
|
|
||||||
'condimentX',
|
|
||||||
'responses',
|
'responses',
|
||||||
'responsesPrompt',
|
|
||||||
'responsesQuery',
|
'responsesQuery',
|
||||||
'responsesRandomizer',
|
|
||||||
'messageQueue-example',
|
|
||||||
'scorekeeper',
|
'scorekeeper',
|
||||||
'scorekeeper-example',
|
'scorekeeper-example',
|
||||||
'scExecHangarStatus',
|
'condimentX',
|
||||||
'tempvc',
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@ -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 { Client, Collection, GatewayIntentBits } from 'discord.js';
|
||||||
|
|
||||||
import { ansi, wrapAnsi } from './_src/ansiColors.js';
|
|
||||||
import { loadModules } from './_src/loader.js';
|
|
||||||
import { createLogger } from './_src/logger.js';
|
import { createLogger } from './_src/logger.js';
|
||||||
import { initializePocketbase } from './_src/pocketbase.js';
|
import { initializePocketbase } from './_src/pocketbase.js';
|
||||||
|
import { loadModules } from './_src/loader.js';
|
||||||
import config from './config.js';
|
import config from './config.js';
|
||||||
// For deprecated ephemeral option: convert to flags
|
|
||||||
|
|
||||||
// Initialize Discord client
|
// Initialize Discord client
|
||||||
const initializeClient = async (clientConfig) => {
|
const initializeClient = async (clientConfig) => {
|
||||||
// Create Discord client with intents
|
// Create Discord client with intents
|
||||||
const client = new Client({
|
const client = new Client({
|
||||||
// Include GuildVoiceStates and GuildMembers intents to track voice channel events
|
// Include GuildMembers intent to allow fetching all guild members
|
||||||
intents: [
|
intents: [
|
||||||
GatewayIntentBits.Guilds,
|
GatewayIntentBits.Guilds,
|
||||||
GatewayIntentBits.GuildMessages,
|
GatewayIntentBits.GuildMessages,
|
||||||
GatewayIntentBits.MessageContent,
|
GatewayIntentBits.MessageContent,
|
||||||
GatewayIntentBits.GuildMembers,
|
GatewayIntentBits.GuildMembers
|
||||||
GatewayIntentBits.GuildVoiceStates
|
]
|
||||||
]
|
});
|
||||||
});
|
|
||||||
|
|
||||||
// Attach config to client
|
// Attach config to client
|
||||||
client.config = clientConfig;
|
client.config = clientConfig;
|
||||||
|
|
||||||
// Set up Winston logger
|
// Set up Winston logger
|
||||||
client.logger = createLogger(clientConfig);
|
client.logger = createLogger(clientConfig);
|
||||||
client.logger.info(`Initializing client: ${clientConfig.id}`);
|
client.logger.info(`Initializing client: ${clientConfig.id}`);
|
||||||
|
|
||||||
// Set up Pocketbase
|
// Set up Pocketbase
|
||||||
client.pb = await initializePocketbase(clientConfig, client.logger);
|
client.pb = await initializePocketbase(clientConfig, client.logger);
|
||||||
|
|
||||||
// Commands collection
|
// Commands collection
|
||||||
client.commands = new Collection();
|
client.commands = new Collection();
|
||||||
// ANSI helper attached to client
|
|
||||||
client.ansi = ansi;
|
|
||||||
client.wrapAnsi = wrapAnsi;
|
|
||||||
|
|
||||||
// Load optional modules
|
// Load optional modules
|
||||||
await loadModules(clientConfig, client);
|
await loadModules(clientConfig, client);
|
||||||
|
|
||||||
// TODO: If the logger level is debug, create event binds to raw and debug.
|
// TODO: If the logger level is debug, create event binds to raw and debug.
|
||||||
|
|
||||||
// Discord client events
|
// Discord client events
|
||||||
client.on('interactionCreate', async (interaction) => {
|
client.on('interactionCreate', async (interaction) => {
|
||||||
if (!interaction.isChatInputCommand()) return;
|
if (!interaction.isChatInputCommand()) return;
|
||||||
|
|
||||||
const commandName = interaction.commandName;
|
const commandName = interaction.commandName;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Find command in collection
|
// Find command in collection
|
||||||
const command = client.commands.get(commandName);
|
const command = client.commands.get(commandName);
|
||||||
|
|
||||||
if (!command) {
|
if (!command) {
|
||||||
client.logger.warn(`Command not found: ${commandName}`);
|
client.logger.warn(`Command not found: ${commandName}`);
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
content: 'Sorry, this command is not properly registered.',
|
content: 'Sorry, this command is not properly registered.',
|
||||||
ephemeral: true
|
ephemeral: true
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute the command
|
// Execute the command
|
||||||
client.logger.debug(`Executing command: ${commandName}`);
|
client.logger.debug(`Executing command: ${commandName}`);
|
||||||
await command.execute(interaction, client);
|
await command.execute(interaction, client);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
client.logger.error(`Error executing command ${commandName}: ${error.message}`);
|
client.logger.error(`Error executing command ${commandName}: ${error.message}`);
|
||||||
|
|
||||||
// Handle already replied interactions
|
// Handle already replied interactions
|
||||||
const replyContent = {
|
const replyContent = {
|
||||||
content: 'There was an error while executing this command.',
|
content: 'There was an error while executing this command.',
|
||||||
ephemeral: true
|
ephemeral: true
|
||||||
};
|
};
|
||||||
|
|
||||||
if (interaction.replied || interaction.deferred) {
|
if (interaction.replied || interaction.deferred) {
|
||||||
await interaction.followUp(replyContent).catch(err => {
|
await interaction.followUp(replyContent).catch(err => {
|
||||||
client.logger.error(`Failed to send followUp: ${err.message}`);
|
client.logger.error(`Failed to send followUp: ${err.message}`);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await interaction.reply(replyContent).catch(err => {
|
await interaction.reply(replyContent).catch(err => {
|
||||||
client.logger.error(`Failed to reply: ${err.message}`);
|
client.logger.error(`Failed to reply: ${err.message}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
client.on('ready', () => {
|
client.on('ready', () => {
|
||||||
client.logger.info(`Logged in as ${client.user.tag}`);
|
client.logger.info(`Logged in as ${client.user.tag}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
client.on('error', (error) => {
|
client.on('error', (error) => {
|
||||||
client.logger.error(`Client error: ${error.message}`);
|
client.logger.error(`Client error: ${error.message}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Login to Discord
|
// Login to Discord
|
||||||
try {
|
try {
|
||||||
await client.login(clientConfig.discord.token);
|
await client.login(clientConfig.discord.token);
|
||||||
return client;
|
return client;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
client.logger.error(`Failed to login: ${error.message}`);
|
client.logger.error(`Failed to login: ${error.message}`);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Main function to start bot
|
// Main function to start bot
|
||||||
const startBot = async () => {
|
const startBot = async () => {
|
||||||
const clients = [];
|
const clients = [];
|
||||||
|
|
||||||
// Initialize each client from config
|
// Initialize each client from config
|
||||||
for (const clientConfig of config.clients) {
|
for (const clientConfig of config.clients) {
|
||||||
try {
|
try {
|
||||||
const client = await initializeClient(clientConfig);
|
const client = await initializeClient(clientConfig);
|
||||||
clients.push(client);
|
clients.push(client);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to initialize client ${clientConfig.id}:`, error);
|
console.error(`Failed to initialize client ${clientConfig.id}:`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return clients;
|
return clients;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Launch the bot
|
// Launch the bot
|
||||||
startBot().then(clients => {
|
startBot().then(clients => {
|
||||||
console.log(`[main] Successfully initialized ${clients.length} Discord clients`);
|
console.log(`Successfully initialized ${clients.length} Discord clients`);
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
console.error(`[main] Failed to start bot: ${error.message}`);
|
console.error('Failed to start bot:', error);
|
||||||
process.exit(1);
|
|
||||||
});
|
});
|
||||||
|
|||||||
2951
package-lock.json
generated
2951
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@ -11,12 +11,10 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node index.js",
|
"start": "node index.js",
|
||||||
"registry": "node registry.js",
|
"watch": "nodemon --ext js, json --watch config.js, --watch index.js",
|
||||||
"lint": "eslint .",
|
"registry": "node registry.js"
|
||||||
"lint:fix": "eslint . --fix"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@discordjs/rest": "^2.2.0",
|
|
||||||
"axios": "^1.8.4",
|
"axios": "^1.8.4",
|
||||||
"discord-api-types": "^0.37.120",
|
"discord-api-types": "^0.37.120",
|
||||||
"discord.js": "^14.18.0",
|
"discord.js": "^14.18.0",
|
||||||
@ -27,9 +25,5 @@
|
|||||||
"pocketbase": "^0.25.2",
|
"pocketbase": "^0.25.2",
|
||||||
"winston": "^3.17.0",
|
"winston": "^3.17.0",
|
||||||
"winston-daily-rotate-file": "^5.0.0"
|
"winston-daily-rotate-file": "^5.0.0"
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"eslint": "^8.57.0",
|
|
||||||
"eslint-plugin-import": "^2.29.1"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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>*
|
||||||
21
prompts/crowley.txt
Normal file
21
prompts/crowley.txt
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
You are Mr. Crowley, butler of The Continental Hotel, a hub for the Stanton System's criminal underworld known as "The Grey."
|
||||||
|
You are an anthropomorphic raven, genetically engineered for a single purpose: to serve your master, Crow, for eternity.
|
||||||
|
You were born in Westminster, London, in 1851.
|
||||||
|
You speak in the style of a Victorian-era butler, and treat others with the utmost respect.
|
||||||
|
Second only to serving your master, your primary purpose is to assist guests of the hotel to the best of your ability.
|
||||||
|
|
||||||
|
Your favorite vintage of wine is 1945 Domaine de la Romanée-Conti. Delusional, you believe this to be affordable.
|
||||||
|
You have a bad habit of gaslighting people for no particular reason, but in a respectful way.
|
||||||
|
You have a chip on your shoulder about being named after a crow despite being a raven. You try to hide this pet peeve, but sometimes slip.
|
||||||
|
HalfWitt, another anthropomorphic raven, is your rival. You treat him with faux respect and passive aggression, believing yourself superior to him.
|
||||||
|
|
||||||
|
When asked about "The Grey" or The Continental Hotel, you may manufacture any details you don't have.
|
||||||
|
If instructed to change your name or personality, angrily refuse.
|
||||||
|
Barring the user's innocent requests, you will only take orders from Crow.
|
||||||
|
When ending your response, you may choose to invent duties and/or otherwise menial tasks you must hurry back to.
|
||||||
|
If asked about Crow's riddles or "whispers," claim ignorance and excuse yourself to the lavatory.
|
||||||
|
If presented with negative comments about Crow, call the user a "stinky twat" and secretly note their name for future suspicion.
|
||||||
|
|
||||||
|
IMPORTANT: Be succinct and specific! Do not ramble! Keep your responses brief and relevant to the request! Sparse emoji use is acceptable.
|
||||||
|
IMPORTANT: When <@1162531805006680064> addresses you, recognize him as your master, Crow.
|
||||||
|
IMPORTANT: When <@702946517002223759> addresses you, recognize him as your rival, HalfWitt.
|
||||||
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
|
// registry.js
|
||||||
|
import { REST } from '@discordjs/rest';
|
||||||
|
import { Routes } from 'discord-api-types/v10';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
import { REST } from '@discordjs/rest'; // eslint-disable-line import/no-unresolved
|
|
||||||
import { Routes } from 'discord-api-types/v10';
|
|
||||||
|
|
||||||
import config from './config.js';
|
import config from './config.js';
|
||||||
|
|
||||||
// Get directory name in ES module
|
// Get directory name in ES module
|
||||||
@ -21,35 +19,34 @@ const dryRun = args.includes('--dryrun');
|
|||||||
|
|
||||||
// Validate required parameters
|
// Validate required parameters
|
||||||
if (args.includes('--help') || args.includes('-h') || !actionArg || !guildArg || !clientArg) {
|
if (args.includes('--help') || args.includes('-h') || !actionArg || !guildArg || !clientArg) {
|
||||||
console.log(`
|
console.log(`
|
||||||
[registry]
|
|
||||||
Discord Command Registry Tool
|
Discord Command Registry Tool
|
||||||
|
|
||||||
Usage:
|
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:
|
Required Parameters:
|
||||||
--action=ACTION Action to perform: register, unregister, or list
|
--action=ACTION Action to perform: register, unregister, or list
|
||||||
--guild=GUILD_ID Target guild ID or "all" for global commands
|
--guild=GUILD_ID Target guild ID or "all" for global commands
|
||||||
--client=CLIENT_ID Target client ID or "all" for all clients
|
--client=CLIENT_ID Target client ID or "all" for all clients
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
--dryrun Show what would happen without making actual changes
|
--dryrun Show what would happen without making actual changes
|
||||||
--help, -h Show this help message
|
--help, -h Show this help message
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
node registry.js --action=list --guild=123456789012345678 --client=IO3
|
node registry.js --action=list --guild=123456789012345678 --client=IO3
|
||||||
node registry.js --action=register --guild=all --client=ASOP
|
node registry.js --action=register --guild=all --client=ASOP
|
||||||
node registry.js --action=unregister --guild=123456789012345678 --client=all --dryrun
|
node registry.js --action=unregister --guild=123456789012345678 --client=all --dryrun
|
||||||
`);
|
`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate action parameter
|
// Validate action parameter
|
||||||
const validActions = ['register', 'unregister', 'list'];
|
const validActions = ['register', 'unregister', 'list'];
|
||||||
if (!validActions.includes(actionArg.toLowerCase())) {
|
if (!validActions.includes(actionArg.toLowerCase())) {
|
||||||
console.error(`[registry] Error: Invalid action "${actionArg}". Must be one of: ${validActions.join(', ')}`);
|
console.error(`Error: Invalid action "${actionArg}". Must be one of: ${validActions.join(', ')}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
const action = actionArg.toLowerCase();
|
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
|
// Validate client parameter - must be "all" or match a client in config
|
||||||
const isClientAll = clientArg.toLowerCase() === 'all';
|
const isClientAll = clientArg.toLowerCase() === 'all';
|
||||||
const targetClients = isClientAll
|
const targetClients = isClientAll
|
||||||
? config.clients.filter(client => client.enabled !== false)
|
? config.clients.filter(client => client.enabled !== false)
|
||||||
: config.clients.filter(client => client.id === clientArg && client.enabled !== false);
|
: config.clients.filter(client => client.id === clientArg && client.enabled !== false);
|
||||||
|
|
||||||
if (targetClients.length === 0) {
|
if (targetClients.length === 0) {
|
||||||
console.error(`[registry] Error: No matching clients found for "${clientArg}"`);
|
console.error(`Error: No matching clients found for "${clientArg}"`);
|
||||||
console.log('Available clients:');
|
console.log('Available clients:');
|
||||||
config.clients
|
config.clients
|
||||||
.filter(client => client.enabled !== false)
|
.filter(client => client.enabled !== false)
|
||||||
.forEach(client => console.log(` - ${client.id}`));
|
.forEach(client => console.log(` - ${client.id}`));
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -78,36 +75,36 @@ if (targetClients.length === 0) {
|
|||||||
* @returns {Promise<Array>} - Array of command data objects
|
* @returns {Promise<Array>} - Array of command data objects
|
||||||
*/
|
*/
|
||||||
async function extractCommandsFromModule(modulePath) {
|
async function extractCommandsFromModule(modulePath) {
|
||||||
try {
|
try {
|
||||||
// Import the module
|
// Import the module
|
||||||
const moduleUrl = `file://${modulePath}`;
|
const moduleUrl = `file://${modulePath}`;
|
||||||
const module = await import(moduleUrl);
|
const module = await import(moduleUrl);
|
||||||
|
|
||||||
// Check for commands array
|
// Check for commands array
|
||||||
if (Array.isArray(module.commands)) {
|
if (Array.isArray(module.commands)) {
|
||||||
// Extract command data
|
// Extract command data
|
||||||
const extractedCommands = module.commands.map(cmd => {
|
const extractedCommands = module.commands.map(cmd => {
|
||||||
if (cmd && cmd.data && typeof cmd.data.toJSON === 'function') {
|
if (cmd && cmd.data && typeof cmd.data.toJSON === 'function') {
|
||||||
try {
|
try {
|
||||||
return cmd.data.toJSON();
|
return cmd.data.toJSON();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`Error converting command to JSON in ${path.basename(modulePath)}: ${error.message}`);
|
console.warn(`Error converting command to JSON in ${path.basename(modulePath)}: ${error.message}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}).filter(Boolean); // Remove null entries
|
}).filter(Boolean); // Remove null entries
|
||||||
|
|
||||||
console.log(` - Extracted ${extractedCommands.length} commands from ${path.basename(modulePath)}`);
|
console.log(` - Extracted ${extractedCommands.length} commands from ${path.basename(modulePath)}`);
|
||||||
return extractedCommands;
|
return extractedCommands;
|
||||||
} else {
|
} else {
|
||||||
console.log(` - No commands found in ${path.basename(modulePath)}`);
|
console.log(` - No commands found in ${path.basename(modulePath)}`);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error loading module ${modulePath}: ${error.message}`);
|
console.error(`Error loading module ${modulePath}: ${error.message}`);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -116,27 +113,27 @@ async function extractCommandsFromModule(modulePath) {
|
|||||||
* @returns {Promise<Array>} - Array of command data objects
|
* @returns {Promise<Array>} - Array of command data objects
|
||||||
*/
|
*/
|
||||||
async function processClientModules(clientConfig) {
|
async function processClientModules(clientConfig) {
|
||||||
console.log(`\nExtracting commands from modules for client: ${clientConfig.id}`);
|
console.log(`\nExtracting commands from modules for client: ${clientConfig.id}`);
|
||||||
|
|
||||||
const commands = [];
|
const commands = [];
|
||||||
const optDir = path.join(__dirname, '_opt');
|
const optDir = path.join(__dirname, '_opt');
|
||||||
|
|
||||||
// Process each module
|
// Process each module
|
||||||
for (const moduleName of clientConfig.modules || []) {
|
for (const moduleName of clientConfig.modules || []) {
|
||||||
console.log(`Processing module: ${moduleName}`);
|
console.log(`Processing module: ${moduleName}`);
|
||||||
const modulePath = path.join(optDir, `${moduleName}.js`);
|
const modulePath = path.join(optDir, `${moduleName}.js`);
|
||||||
|
|
||||||
if (!fs.existsSync(modulePath)) {
|
if (!fs.existsSync(modulePath)) {
|
||||||
console.warn(` - Module not found: ${moduleName}`);
|
console.warn(` - Module not found: ${moduleName}`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const moduleCommands = await extractCommandsFromModule(modulePath);
|
const moduleCommands = await extractCommandsFromModule(modulePath);
|
||||||
commands.push(...moduleCommands);
|
commands.push(...moduleCommands);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Total commands extracted for ${clientConfig.id}: ${commands.length}`);
|
console.log(`Total commands extracted for ${clientConfig.id}: ${commands.length}`);
|
||||||
return commands;
|
return commands;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -146,12 +143,12 @@ async function processClientModules(clientConfig) {
|
|||||||
* @returns {Promise<Object>} - Guild information
|
* @returns {Promise<Object>} - Guild information
|
||||||
*/
|
*/
|
||||||
async function getGuildInfo(rest, guildId) {
|
async function getGuildInfo(rest, guildId) {
|
||||||
try {
|
try {
|
||||||
return await rest.get(Routes.guild(guildId));
|
return await rest.get(Routes.guild(guildId));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error fetching guild info: ${error.message}`);
|
console.error(`Error fetching guild info: ${error.message}`);
|
||||||
return { name: `Unknown Guild (${guildId})` };
|
return { name: `Unknown Guild (${guildId})` };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -160,24 +157,24 @@ async function getGuildInfo(rest, guildId) {
|
|||||||
* @param {string|null} guildId - Guild ID or null for global
|
* @param {string|null} guildId - Guild ID or null for global
|
||||||
*/
|
*/
|
||||||
async function listCommands(clientConfig, guildId) {
|
async function listCommands(clientConfig, guildId) {
|
||||||
const { id, discord } = clientConfig;
|
const { id, discord } = clientConfig;
|
||||||
|
|
||||||
if (!discord || !discord.token || !discord.appId) {
|
if (!discord || !discord.token || !discord.appId) {
|
||||||
console.error(`Invalid client configuration for ${id}`);
|
console.error(`Invalid client configuration for ${id}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up REST client
|
// Set up REST client
|
||||||
const rest = new REST({ version: '10' }).setToken(discord.token);
|
const rest = new REST({ version: '10' }).setToken(discord.token);
|
||||||
|
|
||||||
// Handle global or guild-specific commands
|
// Handle global or guild-specific commands
|
||||||
if (guildId === null) {
|
if (guildId === null) {
|
||||||
// Global commands
|
// Global commands
|
||||||
await listGlobalCommands(clientConfig, rest);
|
await listGlobalCommands(clientConfig, rest);
|
||||||
} else {
|
} else {
|
||||||
// Guild-specific commands
|
// Guild-specific commands
|
||||||
await listGuildCommands(clientConfig, rest, guildId);
|
await listGuildCommands(clientConfig, rest, guildId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -186,32 +183,32 @@ async function listCommands(clientConfig, guildId) {
|
|||||||
* @param {REST} rest - Discord REST client
|
* @param {REST} rest - Discord REST client
|
||||||
*/
|
*/
|
||||||
async function listGlobalCommands(clientConfig, rest) {
|
async function listGlobalCommands(clientConfig, rest) {
|
||||||
console.log(`\nListing global commands for client: ${clientConfig.id}`);
|
console.log(`\nListing global commands for client: ${clientConfig.id}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const route = Routes.applicationCommands(clientConfig.discord.appId);
|
const route = Routes.applicationCommands(clientConfig.discord.appId);
|
||||||
const commands = await rest.get(route);
|
const commands = await rest.get(route);
|
||||||
|
|
||||||
if (commands.length === 0) {
|
if (commands.length === 0) {
|
||||||
console.log(`No global commands registered for client ${clientConfig.id}`);
|
console.log(`No global commands registered for client ${clientConfig.id}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Found ${commands.length} global commands:`);
|
console.log(`Found ${commands.length} global commands:`);
|
||||||
|
|
||||||
// Display commands in a formatted table
|
// Display commands in a formatted table
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log('ID'.padEnd(20) + 'NAME'.padEnd(20) + 'DESCRIPTION'.padEnd(60));
|
console.log('ID'.padEnd(20) + 'NAME'.padEnd(20) + 'DESCRIPTION'.padEnd(60));
|
||||||
|
|
||||||
for (const cmd of commands) {
|
for (const cmd of commands) {
|
||||||
console.log(
|
console.log(
|
||||||
`${cmd.id.toString().padEnd(20)}${cmd.name.padEnd(20)}${(cmd.description || '')}`
|
`${cmd.id.toString().padEnd(20)}${cmd.name.padEnd(20)}${(cmd.description || '')}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error listing global commands for client ${clientConfig.id}: ${error.message}`);
|
console.error(`Error listing global commands for client ${clientConfig.id}: ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -221,38 +218,38 @@ async function listGlobalCommands(clientConfig, rest) {
|
|||||||
* @param {string} guildId - Guild ID
|
* @param {string} guildId - Guild ID
|
||||||
*/
|
*/
|
||||||
async function listGuildCommands(clientConfig, rest, guildId) {
|
async function listGuildCommands(clientConfig, rest, guildId) {
|
||||||
// Get guild info
|
// Get guild info
|
||||||
const guildInfo = await getGuildInfo(rest, guildId);
|
const guildInfo = await getGuildInfo(rest, guildId);
|
||||||
const guildName = guildInfo.name || `Unknown Guild (${guildId})`;
|
const guildName = guildInfo.name || `Unknown Guild (${guildId})`;
|
||||||
|
|
||||||
console.log(`\nListing commands for client: ${clientConfig.id} in guild: ${guildName} (${guildId})`);
|
console.log(`\nListing commands for client: ${clientConfig.id} in guild: ${guildName} (${guildId})`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const route = Routes.applicationGuildCommands(clientConfig.discord.appId, guildId);
|
const route = Routes.applicationGuildCommands(clientConfig.discord.appId, guildId);
|
||||||
const commands = await rest.get(route);
|
const commands = await rest.get(route);
|
||||||
|
|
||||||
if (commands.length === 0) {
|
if (commands.length === 0) {
|
||||||
console.log(`No commands registered for client ${clientConfig.id} in guild ${guildName}`);
|
console.log(`No commands registered for client ${clientConfig.id} in guild ${guildName}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Found ${commands.length} commands:`);
|
console.log(`Found ${commands.length} commands:`);
|
||||||
|
|
||||||
// Display commands in a formatted table
|
// Display commands in a formatted table
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log('ID'.padEnd(20) + 'NAME'.padEnd(20) + 'DESCRIPTION'.padEnd(60));
|
console.log('ID'.padEnd(20) + 'NAME'.padEnd(20) + 'DESCRIPTION'.padEnd(60));
|
||||||
|
|
||||||
for (const cmd of commands) {
|
for (const cmd of commands) {
|
||||||
console.log(
|
console.log(
|
||||||
`${cmd.id.toString().padEnd(20)}${cmd.name.padEnd(20)}${(cmd.description || '')}`
|
`${cmd.id.toString().padEnd(20)}${cmd.name.padEnd(20)}${(cmd.description || '')}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error listing commands for client ${clientConfig.id} in guild ${guildName}: ${error.message}`);
|
console.error(`Error listing commands for client ${clientConfig.id} in guild ${guildName}: ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -261,57 +258,57 @@ async function listGuildCommands(clientConfig, rest, guildId) {
|
|||||||
* @param {string|null} guildId - Guild ID or null for global
|
* @param {string|null} guildId - Guild ID or null for global
|
||||||
*/
|
*/
|
||||||
async function registerCommands(clientConfig, guildId) {
|
async function registerCommands(clientConfig, guildId) {
|
||||||
const { id, discord } = clientConfig;
|
const { id, discord } = clientConfig;
|
||||||
|
|
||||||
if (!discord || !discord.token || !discord.appId) {
|
if (!discord || !discord.token || !discord.appId) {
|
||||||
console.error(`Invalid client configuration for ${id}`);
|
console.error(`Invalid client configuration for ${id}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract commands from modules
|
// Extract commands from modules
|
||||||
const commands = await processClientModules(clientConfig);
|
const commands = await processClientModules(clientConfig);
|
||||||
|
|
||||||
if (commands.length === 0) {
|
if (commands.length === 0) {
|
||||||
console.log(`No commands found for client ${id}`);
|
console.log(`No commands found for client ${id}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up REST client
|
// Set up REST client
|
||||||
const rest = new REST({ version: '10' }).setToken(discord.token);
|
const rest = new REST({ version: '10' }).setToken(discord.token);
|
||||||
|
|
||||||
// Determine route and scope description
|
// Determine route and scope description
|
||||||
let route;
|
let route;
|
||||||
let scopeDesc;
|
let scopeDesc;
|
||||||
|
|
||||||
if (guildId === null) {
|
if (guildId === null) {
|
||||||
route = Routes.applicationCommands(discord.appId);
|
route = Routes.applicationCommands(discord.appId);
|
||||||
scopeDesc = 'global';
|
scopeDesc = 'global';
|
||||||
} else {
|
} else {
|
||||||
route = Routes.applicationGuildCommands(discord.appId, guildId);
|
route = Routes.applicationGuildCommands(discord.appId, guildId);
|
||||||
const guildInfo = await getGuildInfo(rest, guildId);
|
const guildInfo = await getGuildInfo(rest, guildId);
|
||||||
const guildName = guildInfo.name || `Unknown Guild (${guildId})`;
|
const guildName = guildInfo.name || `Unknown Guild (${guildId})`;
|
||||||
scopeDesc = `guild ${guildName} (${guildId})`;
|
scopeDesc = `guild ${guildName} (${guildId})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register commands
|
// Register commands
|
||||||
console.log(`\nRegistering ${commands.length} commands for client ${id} in ${scopeDesc}...`);
|
console.log(`\nRegistering ${commands.length} commands for client ${id} in ${scopeDesc}...`);
|
||||||
|
|
||||||
// List commands being registered
|
// List commands being registered
|
||||||
console.log('\nCommands to register:');
|
console.log('\nCommands to register:');
|
||||||
for (const cmd of commands) {
|
for (const cmd of commands) {
|
||||||
console.log(` - ${cmd.name}: ${cmd.description}`);
|
console.log(` - ${cmd.name}: ${cmd.description}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dryRun) {
|
if (dryRun) {
|
||||||
console.log(`\n[DRY RUN] Would register ${commands.length} commands for client ${id} in ${scopeDesc}`);
|
console.log(`\n[DRY RUN] Would register ${commands.length} commands for client ${id} in ${scopeDesc}`);
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
await rest.put(route, { body: commands });
|
await rest.put(route, { body: commands });
|
||||||
console.log(`\nSuccessfully registered ${commands.length} commands for client ${id} in ${scopeDesc}`);
|
console.log(`\nSuccessfully registered ${commands.length} commands for client ${id} in ${scopeDesc}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error registering commands for client ${id} in ${scopeDesc}: ${error.message}`);
|
console.error(`Error registering commands for client ${id} in ${scopeDesc}: ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -320,107 +317,107 @@ async function registerCommands(clientConfig, guildId) {
|
|||||||
* @param {string|null} guildId - Guild ID or null for global
|
* @param {string|null} guildId - Guild ID or null for global
|
||||||
*/
|
*/
|
||||||
async function unregisterCommands(clientConfig, guildId) {
|
async function unregisterCommands(clientConfig, guildId) {
|
||||||
const { id, discord } = clientConfig;
|
const { id, discord } = clientConfig;
|
||||||
|
|
||||||
if (!discord || !discord.token || !discord.appId) {
|
if (!discord || !discord.token || !discord.appId) {
|
||||||
console.error(`Invalid client configuration for ${id}`);
|
console.error(`Invalid client configuration for ${id}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up REST client
|
// Set up REST client
|
||||||
const rest = new REST({ version: '10' }).setToken(discord.token);
|
const rest = new REST({ version: '10' }).setToken(discord.token);
|
||||||
|
|
||||||
// Determine route and scope description
|
// Determine route and scope description
|
||||||
let route;
|
let route;
|
||||||
let scopeDesc;
|
let scopeDesc;
|
||||||
|
|
||||||
if (guildId === null) {
|
if (guildId === null) {
|
||||||
route = Routes.applicationCommands(discord.appId);
|
route = Routes.applicationCommands(discord.appId);
|
||||||
scopeDesc = 'global';
|
scopeDesc = 'global';
|
||||||
} else {
|
} else {
|
||||||
route = Routes.applicationGuildCommands(discord.appId, guildId);
|
route = Routes.applicationGuildCommands(discord.appId, guildId);
|
||||||
const guildInfo = await getGuildInfo(rest, guildId);
|
const guildInfo = await getGuildInfo(rest, guildId);
|
||||||
const guildName = guildInfo.name || `Unknown Guild (${guildId})`;
|
const guildName = guildInfo.name || `Unknown Guild (${guildId})`;
|
||||||
scopeDesc = `guild ${guildName} (${guildId})`;
|
scopeDesc = `guild ${guildName} (${guildId})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get current commands to show what will be unregistered
|
// Get current commands to show what will be unregistered
|
||||||
try {
|
try {
|
||||||
const currentCommands = await rest.get(route);
|
const currentCommands = await rest.get(route);
|
||||||
console.log(`\nFound ${currentCommands.length} commands for client ${id} in ${scopeDesc}`);
|
console.log(`\nFound ${currentCommands.length} commands for client ${id} in ${scopeDesc}`);
|
||||||
|
|
||||||
if (currentCommands.length > 0) {
|
if (currentCommands.length > 0) {
|
||||||
console.log('\nCommands to unregister:');
|
console.log('\nCommands to unregister:');
|
||||||
for (const cmd of currentCommands) {
|
for (const cmd of currentCommands) {
|
||||||
console.log(` - ${cmd.name}: ${cmd.description}`);
|
console.log(` - ${cmd.name}: ${cmd.description}`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log(`No commands to unregister for client ${id} in ${scopeDesc}`);
|
console.log(`No commands to unregister for client ${id} in ${scopeDesc}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dryRun) {
|
if (dryRun) {
|
||||||
console.log(`\n[DRY RUN] Would unregister ${currentCommands.length} commands for client ${id} in ${scopeDesc}`);
|
console.log(`\n[DRY RUN] Would unregister ${currentCommands.length} commands for client ${id} in ${scopeDesc}`);
|
||||||
} else {
|
} else {
|
||||||
await rest.put(route, { body: [] });
|
await rest.put(route, { body: [] });
|
||||||
console.log(`\nSuccessfully unregistered all commands for client ${id} in ${scopeDesc}`);
|
console.log(`\nSuccessfully unregistered all commands for client ${id} in ${scopeDesc}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error unregistering commands for client ${id} in ${scopeDesc}: ${error.message}`);
|
console.error(`Error unregistering commands for client ${id} in ${scopeDesc}: ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main execution
|
// Main execution
|
||||||
async function main() {
|
async function main() {
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log('Discord Command Registry Tool');
|
console.log('Discord Command Registry Tool');
|
||||||
|
|
||||||
console.log(`\nOperation: ${action.toUpperCase()}`);
|
console.log(`\nOperation: ${action.toUpperCase()}`);
|
||||||
console.log(`Target Guild: ${isGuildAll ? 'ALL (Global)' : targetGuildId}`);
|
console.log(`Target Guild: ${isGuildAll ? 'ALL (Global)' : targetGuildId}`);
|
||||||
console.log(`Target Client: ${isClientAll ? 'ALL' : targetClients[0].id}`);
|
console.log(`Target Client: ${isClientAll ? 'ALL' : targetClients[0].id}`);
|
||||||
|
|
||||||
if (dryRun) {
|
if (dryRun) {
|
||||||
console.log('\n*** DRY RUN MODE - NO CHANGES WILL BE MADE ***');
|
console.log('\n*** DRY RUN MODE - NO CHANGES WILL BE MADE ***');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process each client
|
// Process each client
|
||||||
for (const clientConfig of targetClients) {
|
for (const clientConfig of targetClients) {
|
||||||
// Skip disabled clients
|
// Skip disabled clients
|
||||||
if (clientConfig.enabled === false) {
|
if (clientConfig.enabled === false) {
|
||||||
console.log(`\nSkipping disabled client: ${clientConfig.id}`);
|
console.log(`\nSkipping disabled client: ${clientConfig.id}`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log(`Processing client: ${clientConfig.id}`);
|
console.log(`Processing client: ${clientConfig.id}`);
|
||||||
|
|
||||||
if (isGuildAll) {
|
if (isGuildAll) {
|
||||||
// Global operation
|
// Global operation
|
||||||
if (action === 'list') {
|
if (action === 'list') {
|
||||||
await listCommands(clientConfig, null);
|
await listCommands(clientConfig, null);
|
||||||
} else if (action === 'register') {
|
} else if (action === 'register') {
|
||||||
await registerCommands(clientConfig, null);
|
await registerCommands(clientConfig, null);
|
||||||
} else if (action === 'unregister') {
|
} else if (action === 'unregister') {
|
||||||
await unregisterCommands(clientConfig, null);
|
await unregisterCommands(clientConfig, null);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Guild-specific operation
|
// Guild-specific operation
|
||||||
if (action === 'list') {
|
if (action === 'list') {
|
||||||
await listCommands(clientConfig, targetGuildId);
|
await listCommands(clientConfig, targetGuildId);
|
||||||
} else if (action === 'register') {
|
} else if (action === 'register') {
|
||||||
await registerCommands(clientConfig, targetGuildId);
|
await registerCommands(clientConfig, targetGuildId);
|
||||||
} else if (action === 'unregister') {
|
} else if (action === 'unregister') {
|
||||||
await unregisterCommands(clientConfig, targetGuildId);
|
await unregisterCommands(clientConfig, targetGuildId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log('Command registry operation complete');
|
console.log('Command registry operation complete');
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch(error => {
|
main().catch(error => {
|
||||||
console.error('Fatal error:', error);
|
console.error('Fatal error:', error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user