// opt/scorekeeper.js import cron from 'node-cron'; import { SlashCommandBuilder, EmbedBuilder, PermissionFlagsBits } from 'discord.js'; // Module state container const moduleState = { cronJobs: new Map(), // Store cron jobs by client ID }; /** * Initialize the scorekeeper module */ export const init = async (client, config) => { client.logger.info('Initializing Scorekeeper module'); // Check if configuration exists if (!config.scorekeeper) { client.logger.warn('Scorekeeper configuration missing, using defaults'); config.scorekeeper = { baseOutput: 1000, commendationValue: 1.0, citationValue: 1.2, decay: 90, schedule: '0 0 * * 0' // Default: weekly at midnight on Sunday }; } // Check if scorekeeper collection exists await checkCollection(client); // Ensure auxiliary collections exist (categories & events) await checkCategoriesCollection(client); await checkEventsCollection(client); // Create scorekeeper interface on client client.scorekeeper = { addInput: (guildId, userId, amount) => addInput(client, guildId, userId, amount), addOutput: (guildId, userId, amount) => addOutput(client, guildId, userId, amount), addCommendation: (guildId, userId, amount = 1) => addCommendation(client, guildId, userId, amount), addCitation: (guildId, userId, amount = 1) => addCitation(client, guildId, userId, amount), getScore: (guildId, userId) => getScore(client, guildId, userId), getScores: (guildId, limit = 10) => getScores(client, guildId, limit), runDecay: (guildId) => runDecay(client, guildId) }; // Set up cron job for decay setupDecayCron(client, config.scorekeeper.schedule); // Enable autocomplete for category options in commend/cite registerCategoryAutocomplete(client); client.logger.info('Scorekeeper module initialized'); }; /** * Check if the scorekeeper collection exists in PocketBase */ async function checkCollection(client) { try { // Check if collection exists by trying to list records await client.pb.collection('scorekeeper').getList(1, 1); client.logger.info('Scorekeeper collection exists in PocketBase'); } catch (error) { // If collection doesn't exist, log warning client.logger.warn('Scorekeeper collection does not exist in PocketBase'); client.logger.warn('Please create a "scorekeeper" collection with fields:'); client.logger.warn('- guildId (text, required)'); client.logger.warn('- userId (text, required)'); client.logger.warn('- input (number, default: 0)'); client.logger.warn('- output (number, default: 0)'); client.logger.warn('- commendations (number, default: 0)'); client.logger.warn('- citations (number, default: 0)'); client.logger.warn('- lastDecay (date, required)'); } } /** * Ensure the "scorekeeper_categories" collection exists in PocketBase. * Logs guidance if missing. This collection holds per-guild award categories. * @param {import('discord.js').Client} client - The Discord client with PocketBase attached. */ async function checkCategoriesCollection(client) { try { await client.pb.collection('scorekeeper_categories').getList(1, 1); client.logger.info('scorekeeper_categories collection exists'); } catch (error) { client.logger.warn('scorekeeper_categories collection does not exist in PocketBase'); client.logger.warn('Please create a "scorekeeper_categories" collection with fields:'); client.logger.warn('- guildId (text, required)'); client.logger.warn('- name (text, required, unique per guild)'); client.logger.warn('- createdBy (text, required)'); client.logger.warn('- createdAt (date, required)'); } } /** * Ensure the "scorekeeper_events" collection exists in PocketBase. * Logs guidance if missing. This collection stores each commendation/citation event with category. * @param {import('discord.js').Client} client - The Discord client with PocketBase attached. */ async function checkEventsCollection(client) { try { await client.pb.collection('scorekeeper_events').getList(1, 1); client.logger.info('scorekeeper_events collection exists'); } catch (error) { client.logger.warn('scorekeeper_events collection does not exist in PocketBase'); client.logger.warn('Please create a "scorekeeper_events" collection with fields:'); client.logger.warn('- guildId (text, required)'); client.logger.warn('- userId (text, required)'); client.logger.warn('- type (text, required) // "commendation" or "citation"'); client.logger.warn('- categoryId (text, required)'); client.logger.warn('- amount (number, required)'); client.logger.warn('- awardedBy (text, required)'); client.logger.warn('- timestamp (date, required)'); } } /** * Set up cron job for decay */ function setupDecayCron(client, schedule) { try { // Validate cron expression if (!cron.validate(schedule)) { client.logger.error(`Invalid cron schedule: ${schedule}, using default`); schedule = '0 0 * * 0'; // Default: weekly at midnight on Sunday } // Create and start the cron job const job = cron.schedule(schedule, async () => { client.logger.info('Running scheduled score decay'); try { // Get all guilds the bot is in const guilds = client.guilds.cache.map(guild => guild.id); // Run decay for each guild for (const guildId of guilds) { await runDecay(client, guildId); } client.logger.info('Score decay completed'); } catch (error) { client.logger.error(`Error during scheduled score decay: ${error.message}`); } }); // Store the job in module state moduleState.cronJobs.set(client.config.id, job); client.logger.info(`Score decay scheduled with cron: ${schedule}`); } catch (error) { client.logger.error(`Failed to set up decay cron job: ${error.message}`); } } /** * Add input points for a user */ async function addInput(client, guildId, userId, amount) { if (!guildId || !userId || !amount || amount <= 0) { throw new Error(`Invalid parameters for addInput - guildId: ${guildId}, userId: ${userId}, amount: ${amount}`); } try { // Get or create user record const scoreData = await getOrCreateScoreData(client, guildId, userId); // Calculate new input score const newInput = scoreData.input + amount; client.logger.debug(`Updating record ${scoreData.id} - input from ${scoreData.input} to ${newInput}`); // Use direct update with ID to avoid duplicate records return await client.pb.collection('scorekeeper').update(scoreData.id, { input: newInput }); } catch (error) { client.logger.error(`Error adding input points: ${error.message}`); throw error; } } /** * Add output points for a user */ async function addOutput(client, guildId, userId, amount) { if (!guildId || !userId || !amount || amount <= 0) { throw new Error('Invalid parameters for addOutput'); } try { // Get or create user record const scoreData = await getOrCreateScoreData(client, guildId, userId); // Calculate new output score const newOutput = scoreData.output + amount; client.logger.debug(`Updating record ${scoreData.id} - output from ${scoreData.output} to ${newOutput}`); // Use direct update with ID to avoid duplicate records return await client.pb.collection('scorekeeper').update(scoreData.id, { output: newOutput }); } catch (error) { client.logger.error(`Error adding output points: ${error.message}`); throw error; } } /** * Add commendations for a user */ async function addCommendation(client, guildId, userId, amount = 1) { if (!guildId || !userId || amount <= 0) { throw new Error('Invalid parameters for addCommendation'); } try { // Log the search to debug client.logger.debug(`Looking for record with guildId=${guildId} and userId=${userId}`); // Get or create user record const scoreData = await getOrCreateScoreData(client, guildId, userId); // Log the found/created record client.logger.debug(`Found/created record with ID: ${scoreData.id}`); // Calculate new commendations value const newCommendations = scoreData.commendations + amount; // Log the update attempt client.logger.debug(`Updating record ${scoreData.id} - commendations from ${scoreData.commendations} to ${newCommendations}`); // Use direct update with ID to avoid duplicate records return await client.pb.collection('scorekeeper').update(scoreData.id, { commendations: newCommendations }); } catch (error) { client.logger.error(`Error adding commendations: ${error.message}`); throw error; } } /** * Add citations for a user */ async function addCitation(client, guildId, userId, amount = 1) { if (!guildId || !userId || amount <= 0) { throw new Error('Invalid parameters for addCitation'); } try { // Get or create user record const scoreData = await getOrCreateScoreData(client, guildId, userId); // Calculate new citations value const newCitations = scoreData.citations + amount; client.logger.debug(`Updating record ${scoreData.id} - citations from ${scoreData.citations} to ${newCitations}`); // Use direct update with ID to avoid duplicate records return await client.pb.collection('scorekeeper').update(scoreData.id, { citations: newCitations }); } catch (error) { client.logger.error(`Error adding citations: ${error.message}`); throw error; } } /** * Get a user's score */ async function getScore(client, guildId, userId) { if (!guildId || !userId) { throw new Error('Guild ID and User ID are required'); } try { // Get score data, create if not exists const scoreData = await getOrCreateScoreData(client, guildId, userId); // Calculate total score const totalScore = calculateScore( scoreData, client.config.scorekeeper.baseOutput, client.config.scorekeeper.commendationValue, client.config.scorekeeper.citationValue ); return { ...scoreData, totalScore }; } catch (error) { client.logger.error(`Error getting score: ${error.message}`); throw error; } } /** * Get scores for a guild */ async function getScores(client, guildId, limit = 10) { if (!guildId) { throw new Error('Guild ID is required'); } try { // Fetch all score records for this guild const records = await client.pb.collection('scorekeeper').getFullList({ filter: `guildId = "${guildId}"` }); // Compute totalScore for each, then sort and limit const scored = records.map(record => { const totalScore = calculateScore( record, client.config.scorekeeper.baseOutput, client.config.scorekeeper.commendationValue, client.config.scorekeeper.citationValue ); return { ...record, totalScore }; }); // Sort descending by score scored.sort((a, b) => b.totalScore - a.totalScore); // Return top 'limit' entries return scored.slice(0, limit); } catch (error) { client.logger.error(`Error getting scores: ${error.message}`); throw error; } } /** * Run decay process for a guild */ async function runDecay(client, guildId) { if (!guildId) { throw new Error('Guild ID is required'); } try { const decayFactor = client.config.scorekeeper.decay / 100; const baseOutput = client.config.scorekeeper.baseOutput; // Get all records for this guild const records = await client.pb.collection('scorekeeper').getFullList({ filter: `guildId = "${guildId}"` }); // Update each record with decay let updatedCount = 0; for (const record of records) { try { const newInput = Math.floor(record.input * decayFactor); // Calculate new output, but ensure it never falls below baseOutput let newOutput = Math.floor(record.output * decayFactor); if (newOutput < baseOutput) { newOutput = baseOutput; client.logger.debug(`Output for record ${record.id} would fall below BaseOutput - setting to ${baseOutput}`); } // Update record directly await client.pb.collection('scorekeeper').update(record.id, { input: newInput, output: newOutput, lastDecay: new Date().toISOString() }); updatedCount++; } catch (updateError) { client.logger.error(`Error updating record ${record.id} during decay: ${updateError.message}`); } } client.logger.info(`Decay completed for guild ${guildId}: ${updatedCount} records updated`); return updatedCount; } catch (error) { client.logger.error(`Error running decay: ${error.message}`); throw error; } } /** * Calculate score based on formula (no scaling factor) * Formula: (1 + C*commendationValue - D*citationValue) * (input / (output + baseOutput)) */ function calculateScore(data, baseOutput, commendationValue, citationValue) { const multiplier = 1 + (data.commendations * commendationValue) - (data.citations * citationValue); const activityScore = data.input / (data.output + baseOutput); // Removed aesthetic scaling (×100) for raw score return multiplier * activityScore; } /** * Get or create score data for a user in a guild */ async function getOrCreateScoreData(client, guildId, userId) { try { // Always try to get existing record first let existingRecord = null; const baseOutput = client.config.scorekeeper.baseOutput; // Try to find the record using filter try { existingRecord = await client.pb.collection('scorekeeper').getFirstListItem( `guildId = "${guildId}" && userId = "${userId}"` ); client.logger.debug(`Found existing score record for ${userId} in guild ${guildId}`); return existingRecord; } catch (error) { // Only create new record if specifically not found (404) if (error.status === 404) { client.logger.debug(`No existing score record found, creating new one for ${userId} in guild ${guildId}`); // Create new record with default values // Note: output is now initialized to baseOutput instead of 0 const newData = { guildId, userId, input: 0, output: baseOutput, // Initialize to baseOutput, not 0 commendations: 0, citations: 0, lastDecay: new Date().toISOString() }; return await client.pb.collection('scorekeeper').create(newData); } // For any other error, rethrow client.logger.error(`Error searching for score record: ${error.message}`); throw error; } } catch (error) { client.logger.error(`Error in getOrCreateScoreData: ${error.message}`); throw error; } } // Define slash commands for the module export const commands = [ // Command to view a user's score { data: new SlashCommandBuilder() .setName('score') .setDescription('View your I/O score or another user\'s I/O score') .addUserOption(option => option.setName('user') .setDescription('User to check I/O score for (defaults to you)') .setRequired(false) ) .addBooleanOption(option => option.setName('ephemeral') .setDescription('Whether the response should be ephemeral') .setRequired(false) ), execute: async (interaction, client) => { const targetUser = interaction.options.getUser('user') || interaction.user; const ephemeral = interaction.options.getBoolean('ephemeral') ?? true; // Wrap score retrieval and embed generation in try/catch to handle errors gracefully try { // Fetch score data and compute multiplier const baseOutput = client.config.scorekeeper.baseOutput; const commendationValue = client.config.scorekeeper.commendationValue; const citationValue = client.config.scorekeeper.citationValue; const scoreData = await client.scorekeeper.getScore(interaction.guildId, targetUser.id); const multiplierValue = 1 + (scoreData.commendations * commendationValue) - (scoreData.citations * citationValue); // Load categories for breakdown const categories = await client.pb.collection('scorekeeper_categories').getFullList({ filter: `guildId = "${interaction.guildId}"` }); const catMap = new Map(categories.map(c => [c.id, c.name])); // Commendations per category const commendEvents = await client.pb.collection('scorekeeper_events').getFullList({ filter: `guildId = "${interaction.guildId}" && userId = "${targetUser.id}" && type = "commendation"` }); const commendByCat = new Map(); commendEvents.forEach(e => { const cnt = commendByCat.get(e.categoryId) || 0; commendByCat.set(e.categoryId, cnt + e.amount); }); const commendBreakdown = commendByCat.size > 0 ? Array.from(commendByCat.entries()).map(([cid, cnt]) => `${catMap.get(cid) || 'Unknown'}: ${cnt}`).join('\n') : 'None'; // Citations per category const citeEvents = await client.pb.collection('scorekeeper_events').getFullList({ filter: `guildId = "${interaction.guildId}" && userId = "${targetUser.id}" && type = "citation"` }); const citeByCat = new Map(); citeEvents.forEach(e => { const cnt = citeByCat.get(e.categoryId) || 0; citeByCat.set(e.categoryId, cnt + e.amount); }); const citeBreakdown = citeByCat.size > 0 ? Array.from(citeByCat.entries()).map(([cid, cnt]) => `${catMap.get(cid) || 'Unknown'}: ${cnt}`).join('\n') : 'None'; const embed = new EmbedBuilder() .setAuthor({ name: `${client.user.username}: Scorekeeper Module`, iconURL: client.user.displayAvatarURL() }) .setTitle(`I/O Score for ${(await interaction.guild.members.fetch(targetUser.id).catch(() => null))?.displayName || targetUser.username}`) .setColor(0x00AE86) .setThumbnail(targetUser.displayAvatarURL()) .setDescription('I/O is a mechanism to rank the users within a system based on the resources they put into the system and the resources they take from it. The resulting priority score can be used for a variety of dystopian purposes.') .addFields( { name: 'Commendations', value: commendBreakdown, inline: false }, { name: 'Citations', value: citeBreakdown, inline: false }, { name: 'Input', value: `${scoreData.input}`, inline: true }, { name: 'Output', value: `${scoreData.output}`, inline: true }, { name: 'Priority Score', value: `${scoreData.totalScore.toFixed(2)}`, inline: true }, { name: 'Base Output', value: `-# ${baseOutput}`, inline: true }, { name: 'Commendation Value', value: `-# ${commendationValue}`, inline: true }, { name: 'Citation Value', value: `-# ${citationValue}`, inline: true }, { name: 'Multiplier Formula', value: `-# 1 + (${scoreData.commendations} * ${commendationValue}) - (${scoreData.citations} * ${citationValue}) = ${multiplierValue.toFixed(2)}`, inline: false }, { name: 'Priority Score Formula', value: `-# ${multiplierValue.toFixed(2)} × ${scoreData.input} / (${scoreData.output} + ${baseOutput}) = ${scoreData.totalScore.toFixed(2)}`, inline: false }, ) .setFooter({ text: 'Last decay: ' + new Date(scoreData.lastDecay).toLocaleDateString() }) .setTimestamp(); await interaction.reply({ embeds: [embed], ephemeral }); } catch (error) { client.logger.error(`Error in score command: ${error.message}`); try { await interaction.reply({ content: 'Failed to retrieve I/O score.', ephemeral }); } catch {} } } }, // Command to view top scores { data: new SlashCommandBuilder() .setName('leaderboard') .setDescription('View the server\'s I/O score leaderboard') .addBooleanOption(option => option.setName('ephemeral') .setDescription('Whether the response should be ephemeral') .setRequired(false) ), execute: async (interaction, client) => { const ephemeral = interaction.options.getBoolean('ephemeral') ?? true; await interaction.deferReply({ ephemeral }); const limit = 10; try { const scores = await client.scorekeeper.getScores(interaction.guildId, limit); if (scores.length === 0) { return interaction.editReply('No scores found for this server.'); } // Format leaderboard const guild = interaction.guild; let leaderboardText = ''; for (let i = 0; i < scores.length; i++) { const score = scores[i]; const user = await guild.members.fetch(score.userId).catch(() => null); const displayName = user ? user.displayName : 'Unknown User'; leaderboardText += `${i + 1}. **${displayName}**: ${score.totalScore.toFixed(2)}\n`; } const embed = new EmbedBuilder() .setAuthor({ name: `${client.user.username}: Scorekeeper Module`, iconURL: client.user.displayAvatarURL() }) .setTitle('I/O Score Leaderboard') .setColor(0x00AE86) .setDescription(leaderboardText) .setFooter({ text: `Showing top ${scores.length} users` }) .setTimestamp(); await interaction.editReply({ embeds: [embed] }); } catch (error) { client.logger.error(`Error in leaderboard command: ${error.message}`); await interaction.editReply('Failed to retrieve leaderboard data.'); } } }, // Command to give a commendation (admin only) { data: new SlashCommandBuilder() .setName('commend') .setDescription('Give a commendation to a user (Admin only)') .addUserOption(option => option.setName('user') .setDescription('User to commend') .setRequired(true)) .addStringOption(option => option.setName('category') .setDescription('Category to award') .setRequired(true) .setAutocomplete(true) ) .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), execute: async (interaction, client) => { const guildId = interaction.guildId; // Ensure categories exist before proceeding let catList = []; try { catList = await client.pb.collection('scorekeeper_categories').getFullList({ filter: `guildId = "${guildId}"` }); } catch {} if (catList.length === 0) { return interaction.reply({ content: 'No categories defined for this server. Ask an admin to create one with /addcategory.', ephemeral: true }); } const targetUser = interaction.options.getUser('user'); const categoryId = interaction.options.getString('category'); const amount = 1; // Enforce per-category cooldown const cooldown = client.config.scorekeeper.cooldown || 0; if (cooldown > 0) { const recent = await client.pb.collection('scorekeeper_events').getList(1, 1, { filter: `guildId = \"${guildId}\" && userId = \"${targetUser.id}\" && type = \"commendation\" && categoryId = \"${categoryId}\"`, sort: '-timestamp' }); const lastItem = recent.items?.[0]; if (lastItem) { const lastTs = new Date(lastItem.timestamp).getTime(); const elapsed = Date.now() - lastTs; if (elapsed < cooldown) { const expireTs = lastTs + cooldown; const expireSec = Math.ceil(expireTs / 1000); const categoryRecord = catList.find(c => c.id === categoryId); const categoryName = categoryRecord ? categoryRecord.name : categoryId; return interaction.reply({ content: `${targetUser} cannot receive another commendation in the ${categoryName} category for .`, ephemeral: true }); } } } try { await client.scorekeeper.addCommendation(interaction.guildId, targetUser.id, amount); // Log event await client.pb.collection('scorekeeper_events').create({ guildId: interaction.guildId, userId: targetUser.id, type: 'commendation', categoryId, amount, awardedBy: interaction.user.id, timestamp: new Date().toISOString() }); await interaction.reply(`Added commendation to ${targetUser}.`); } catch (error) { client.logger.error(`Error in commend command: ${error.message}`); await interaction.reply({ content: 'Failed to add commendation.', ephemeral: true }); } } }, // Command to give a citation (admin only) { data: new SlashCommandBuilder() .setName('cite') .setDescription('Give a citation to a user (Admin only)') .addUserOption(option => option.setName('user') .setDescription('User to cite') .setRequired(true)) .addStringOption(option => option.setName('category') .setDescription('Category to award') .setRequired(true) .setAutocomplete(true) ) .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), execute: async (interaction, client) => { const guildId = interaction.guildId; // Ensure categories exist before proceeding let catList = []; try { catList = await client.pb.collection('scorekeeper_categories').getFullList({ filter: `guildId = "${guildId}"` }); } catch {} if (catList.length === 0) { return interaction.reply({ content: 'No categories defined for this server. Ask an admin to create one with /addcategory.', ephemeral: true }); } const targetUser = interaction.options.getUser('user'); const categoryId = interaction.options.getString('category'); const amount = 1; // Enforce per-category cooldown const cooldown = client.config.scorekeeper.cooldown || 0; if (cooldown > 0) { const recent = await client.pb.collection('scorekeeper_events').getList(1, 1, { filter: `guildId = \"${guildId}\" && userId = \"${targetUser.id}\" && type = \"citation\" && categoryId = \"${categoryId}\"`, sort: '-timestamp' }); const lastItem = recent.items?.[0]; if (lastItem) { const lastTs = new Date(lastItem.timestamp).getTime(); const elapsed = Date.now() - lastTs; if (elapsed < cooldown) { const expireTs = lastTs + cooldown; const expireSec = Math.ceil(expireTs / 1000); const categoryRecord = catList.find(c => c.id === categoryId); const categoryName = categoryRecord ? categoryRecord.name : categoryId; return interaction.reply({ content: `${targetUser} cannot receive another citation in the ${categoryName} category for .`, ephemeral: true }); } } } try { await client.scorekeeper.addCitation(interaction.guildId, targetUser.id, amount); // Log event await client.pb.collection('scorekeeper_events').create({ guildId: interaction.guildId, userId: targetUser.id, type: 'citation', categoryId, amount, awardedBy: interaction.user.id, timestamp: new Date().toISOString() }); await interaction.reply(`Added citation to ${targetUser}.`); } catch (error) { client.logger.error(`Error in cite command: ${error.message}`); await interaction.reply({ content: 'Failed to add citation.', ephemeral: true }); } } }, // Command to manually run decay (admin only) { data: new SlashCommandBuilder() .setName('run-decay') .setDescription('Manually run score decay (Admin only)') .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), execute: async (interaction, client) => { await interaction.deferReply(); try { const updatedCount = await client.scorekeeper.runDecay(interaction.guildId); await interaction.editReply(`Score decay completed. Updated ${updatedCount} user records.`); } catch (error) { client.logger.error(`Error in run-decay command: ${error.message}`); await interaction.editReply('Failed to run score decay.'); } } } // Admin command: add a new category ,{ data: new SlashCommandBuilder() .setName('addcategory') .setDescription('Create a new commendation/citation category (Admin only)') .addStringOption(opt => opt.setName('name') .setDescription('Name of the new category') .setRequired(true) ) .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), execute: async (interaction, client) => { const name = interaction.options.getString('name').trim(); const guildId = interaction.guildId; try { // Check for existing const existing = await client.pb.collection('scorekeeper_categories').getFirstListItem( `guildId = "${guildId}" && name = "${name}"` ).catch(() => null); if (existing) { return interaction.reply({ content: `Category '${name}' already exists.`, ephemeral: true }); } // Create new category await client.pb.collection('scorekeeper_categories').create({ guildId, name, createdBy: interaction.user.id, createdAt: new Date().toISOString() }); await interaction.reply({ content: `Category '${name}' created.`, ephemeral: true }); } catch (err) { client.logger.error(`Error in addcategory: ${err.message}`); await interaction.reply({ content: 'Failed to create category.', ephemeral: true }); } } } // Admin command: remove a category ,{ data: new SlashCommandBuilder() .setName('removecategory') .setDescription('Delete a commendation/citation category (Admin only)') .addStringOption(opt => opt.setName('name') .setDescription('Name of the category to remove') .setRequired(true) ) .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), execute: async (interaction, client) => { const name = interaction.options.getString('name').trim(); const guildId = interaction.guildId; try { const record = await client.pb.collection('scorekeeper_categories').getFirstListItem( `guildId = "${guildId}" && name = "${name}"` ).catch(() => null); if (!record) { return interaction.reply({ content: `Category '${name}' not found.`, ephemeral: true }); } await client.pb.collection('scorekeeper_categories').delete(record.id); await interaction.reply({ content: `Category '${name}' removed.`, ephemeral: true }); } catch (err) { client.logger.error(`Error in removecategory: ${err.message}`); await interaction.reply({ content: 'Failed to remove category.', ephemeral: true }); } } } // Public command: list categories ,{ data: new SlashCommandBuilder() .setName('listcategories') .setDescription('List all commendation/citation categories') .addBooleanOption(opt => opt.setName('ephemeral') .setDescription('Whether the result should be ephemeral') .setRequired(false) ), execute: async (interaction, client) => { const ephemeral = interaction.options.getBoolean('ephemeral') ?? true; const guildId = interaction.guildId; try { const records = await client.pb.collection('scorekeeper_categories').getFullList({ filter: `guildId = "${guildId}"` }); if (records.length === 0) { return interaction.reply({ content: 'No categories defined for this guild.', ephemeral }); } const list = records.map(r => r.name).join(', '); await interaction.reply({ content: `Categories: ${list}`, ephemeral }); } catch (err) { client.logger.error(`Error in listcategories: ${err.message}`); await interaction.reply({ content: 'Failed to list categories.', ephemeral }); } } } ]; /** * Attach autocomplete handlers for category options in commend and cite commands. */ /** * Attach a handler for slash-command autocomplete of "category" options. * Dynamically fetches and filters categories from PocketBase per guild. * @param {import('discord.js').Client} client - The Discord client. */ export function registerCategoryAutocomplete(client) { client.on('interactionCreate', async interaction => { if (!interaction.isAutocomplete()) return; const cmd = interaction.commandName; if (cmd !== 'commend' && cmd !== 'cite') return; const focused = interaction.options.getFocused(true); if (focused.name !== 'category') return; const guildId = interaction.guildId; try { const records = await client.pb.collection('scorekeeper_categories').getFullList({ filter: `guildId = "${guildId}"` }); const choices = records .filter(r => r.name.toLowerCase().startsWith(focused.value.toLowerCase())) .slice(0, 25) .map(r => ({ name: r.name, value: r.id })); await interaction.respond(choices); } catch (error) { client.logger.error(`Category autocomplete error: ${error.message}`); await interaction.respond([]); } }); }