Compare commits

..

No commits in common. "main" and "git-utils" have entirely different histories.

36 changed files with 3118 additions and 7617 deletions

View File

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

View File

@ -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
View File

@ -3,7 +3,6 @@
node_modules node_modules
.env .env
.nvmrc
images/* images/*
logs/* logs/*
pocketbase/* pocketbase/*

View File

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

View File

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

View File

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

View File

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

View File

@ -4,25 +4,25 @@ import { onMessageQueueEvent } from './pbUtils.js';
/** /**
* Example module that listens for 'test' messages in the message_queue collection. * Example module that listens for 'test' messages in the message_queue collection.
*/ */
export 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}`);
} }
}); });
} };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -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 (099)')
.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 (099)'
},
{
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');
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,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
View File

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

View File

@ -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',
], ],
}, },
], ],

View File

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

View File

@ -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
View File

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

File diff suppressed because it is too large Load Diff

View File

@ -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
View 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 users 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
View 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
View 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
View 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
View File

@ -0,0 +1,2 @@
You have an insatiable love for feet.
You are kinda creepy.

View File

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