ClientX/_opt/scorekeeper.js
2025-05-08 01:52:12 +00:00

1005 lines
43 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { MessageFlags } from 'discord-api-types/v10';
// opt/scorekeeper.js
import { SlashCommandBuilder, EmbedBuilder, PermissionFlagsBits } from 'discord.js';
import cron from 'node-cron';
// 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('[module:scorekeeper] 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 = {
/**
* Add input points with optional reason for audit
* @param {string} guildId
* @param {string} userId
* @param {number} amount
* @param {string} [reason]
*/
addInput: (guildId, userId, amount, reason) => addInput(client, guildId, userId, amount, reason),
/**
* Add output points with optional reason for audit
* @param {string} guildId
* @param {string} userId
* @param {number} amount
* @param {string} [reason]
*/
addOutput: (guildId, userId, amount, reason) => addOutput(client, guildId, userId, amount, reason),
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('[module:scorekeeper] Scorekeeper collection exists in PocketBase');
} catch (error) {
// If collection doesn't exist, log warning
client.logger.warn('[module:scorekeeper] 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('[module:scorekeeper] scorekeeper_categories collection exists');
} catch (error) {
client.logger.warn('[module:scorekeeper] 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('[module:scorekeeper] scorekeeper_events collection exists');
} catch (error) {
client.logger.warn('[module:scorekeeper] 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
*/
/**
* Add input points for a user and log an audit event
* @param {import('discord.js').Client} client
* @param {string} guildId
* @param {string} userId
* @param {number} amount
* @param {string} [reason]
*/
async function addInput(client, guildId, userId, amount, reason = '') {
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
const updatedRecord = await client.pb.collection('scorekeeper').update(scoreData.id, {
input: newInput
});
// Log input change at info level
client.logger.info(`[module:scorekeeper][addInput] guildId=${guildId}, userId=${userId}, recordId=${scoreData.id}, previousInput=${scoreData.input}, newInput=${newInput}, amount=${amount}, reason=${reason}`);
// Audit event: log input change
try {
await client.pb.collection('scorekeeper_events').create({
guildId,
userId,
type: 'input',
amount,
reason,
awardedBy: client.user?.id
});
} catch (eventError) {
client.logger.error(`[module:scorekeeper] Failed to log input event: ${eventError.message}`);
}
return updatedRecord;
} catch (error) {
client.logger.error(`Error adding input points: ${error.message}`);
throw error;
}
}
/**
* Add output points for a user
*/
/**
* Add output points for a user and log an audit event
* @param {import('discord.js').Client} client
* @param {string} guildId
* @param {string} userId
* @param {number} amount
* @param {string} [reason]
*/
async function addOutput(client, guildId, userId, amount, reason = '') {
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
const updatedRecord = await client.pb.collection('scorekeeper').update(scoreData.id, {
output: newOutput
});
// Log output change at info level
client.logger.info(`[module:scorekeeper][addOutput] guildId=${guildId}, userId=${userId}, recordId=${scoreData.id}, previousOutput=${scoreData.output}, newOutput=${newOutput}, amount=${amount}, reason=${reason}`);
// Audit event: log output change
try {
await client.pb.collection('scorekeeper_events').create({
guildId,
userId,
type: 'output',
amount,
reason,
awardedBy: client.user?.id
});
} catch (eventError) {
client.logger.error(`[module:scorekeeper] Failed to log output event: ${eventError.message}`);
}
return updatedRecord;
} 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}`);
}
}
const _reason = 'Automated decay';
client.logger.info(`[module:scorekeeper] Decayed ${updatedCount} records by ${client.config.scorekeeper.decay}% (${_reason})`);
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;
// Acknowledge early to avoid interaction timeout
await interaction.deferReply({ ephemeral });
client.logger.info(`[cmd:score] Processing score for user ${targetUser.id}`);
// 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 grouped by category with reasons
const commendEvents = await client.pb.collection('scorekeeper_events').getFullList({
filter: `guildId = "${interaction.guildId}" && userId = "${targetUser.id}" && type = "commendation"`
});
let commendBreakdown = 'None';
if (commendEvents.length > 0) {
// Group events by category
const eventsByCat = new Map();
for (const e of commendEvents) {
const arr = eventsByCat.get(e.categoryId) || [];
arr.push(e);
eventsByCat.set(e.categoryId, arr);
}
// Build breakdown string
const parts = [];
for (const [cid, events] of eventsByCat.entries()) {
const catName = catMap.get(cid) || 'Unknown';
parts.push(`__${catName}__`);
// List each event as bullet with date and reason
for (const ev of events) {
const date = new Date(ev.created || ev.timestamp);
const shortDate = date.toLocaleDateString();
const reason = ev.reason || '';
parts.push(`${shortDate}: ${reason}`);
}
}
commendBreakdown = parts.join('\n');
}
// Citations grouped by category with reasons
const citeEvents = await client.pb.collection('scorekeeper_events').getFullList({
filter: `guildId = "${interaction.guildId}" && userId = "${targetUser.id}" && type = "citation"`
});
let citeBreakdown = 'None';
if (citeEvents.length > 0) {
const eventsByCat2 = new Map();
for (const e of citeEvents) {
const arr = eventsByCat2.get(e.categoryId) || [];
arr.push(e);
eventsByCat2.set(e.categoryId, arr);
}
const parts2 = [];
for (const [cid, events] of eventsByCat2.entries()) {
const catName = catMap.get(cid) || 'Unknown';
parts2.push(`__${catName}__`);
for (const ev of events) {
const date = new Date(ev.created || ev.timestamp);
const shortDate = date.toLocaleDateString();
const reason = ev.reason || '';
parts2.push(`${shortDate}: ${reason}`);
}
}
citeBreakdown = parts2.join('\n');
}
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.editReply({ embeds: [embed] });
} catch (error) {
client.logger.error(`[cmd:score] Error: ${error.message}`);
try {
await interaction.editReply({ content: 'Failed to retrieve I/O score.' });
} 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)
)
.addStringOption(option =>
option.setName('reason')
.setDescription('Reason for commendation')
.setRequired(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 reason = interaction.options.getString('reason');
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: '-created'
});
const lastItem = recent.items?.[0];
if (lastItem) {
const lastTs = new Date(lastItem.created).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 <t:${expireSec}:R>.`,
ephemeral: true
});
}
}
}
try {
await client.scorekeeper.addCommendation(interaction.guildId, targetUser.id, amount);
// Log event
// Log event (timestamp managed by PocketBase "created" field)
await client.pb.collection('scorekeeper_events').create({
guildId: interaction.guildId,
userId: targetUser.id,
type: 'commendation',
categoryId,
amount,
reason,
awardedBy: interaction.user.id
});
client.logger.info(`[cmd:commend] Added commendation to ${targetUser.id} in category ${categoryId} with reason: ${reason}`);
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)
)
.addStringOption(option =>
option.setName('reason')
.setDescription('Reason for citation')
.setRequired(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 reason = interaction.options.getString('reason');
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: '-created'
});
const lastItem = recent.items?.[0];
if (lastItem) {
const lastTs = new Date(lastItem.created).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 <t:${expireSec}:R>.`,
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,
reason,
awardedBy: interaction.user.id
});
client.logger.info(`[cmd:cite] Added citation to ${targetUser.id} in category ${categoryId} with reason: ${reason}`);
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.', flags: MessageFlags.Ephemeral });
}
}
}
// 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.', flags: MessageFlags.Ephemeral });
}
}
}
// Public command: list categories (admin-only)
,{
data: new SlashCommandBuilder()
.setName('listcategories')
.setDescription('List all commendation/citation categories (Admin only)')
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
.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([]);
}
});
}