ClientX/_opt/scorekeeper.js

1005 lines
43 KiB
JavaScript
Raw Permalink Normal View History

import { MessageFlags } from 'discord-api-types/v10';
2025-04-25 21:27:00 -04:00
// opt/scorekeeper.js
import { SlashCommandBuilder, EmbedBuilder, PermissionFlagsBits } from 'discord.js';
2025-05-08 01:52:12 +00:00
import cron from 'node-cron';
2025-04-25 21:27:00 -04:00
// Module state container
const moduleState = {
2025-05-08 01:52:12 +00:00
cronJobs: new Map() // Store cron jobs by client ID
2025-04-25 21:27:00 -04:00
};
/**
* Initialize the scorekeeper module
*/
export const init = async (client, config) => {
2025-05-08 01:52:12 +00:00
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);
2025-04-25 21:27:00 -04:00
2025-04-30 16:06:35 +00:00
// Ensure auxiliary collections exist (categories & events)
await checkCategoriesCollection(client);
await checkEventsCollection(client);
// Create scorekeeper interface on client
2025-05-08 01:52:12 +00:00
client.scorekeeper = {
/**
2025-05-05 17:48:42 +00:00
* Add input points with optional reason for audit
* @param {string} guildId
* @param {string} userId
* @param {number} amount
* @param {string} [reason]
*/
2025-05-08 01:52:12 +00:00
addInput: (guildId, userId, amount, reason) => addInput(client, guildId, userId, amount, reason),
/**
2025-05-05 17:48:42 +00:00
* Add output points with optional reason for audit
* @param {string} guildId
* @param {string} userId
* @param {number} amount
* @param {string} [reason]
*/
2025-05-08 01:52:12 +00:00
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)
};
2025-04-25 21:27:00 -04:00
2025-04-30 16:06:35 +00:00
// Set up cron job for decay
setupDecayCron(client, config.scorekeeper.schedule);
// Enable autocomplete for category options in commend/cite
registerCategoryAutocomplete(client);
2025-04-25 21:27:00 -04:00
2025-05-08 01:52:12 +00:00
client.logger.info('Scorekeeper module initialized');
2025-04-25 21:27:00 -04:00
};
/**
* Check if the scorekeeper collection exists in PocketBase
*/
async function checkCollection(client) {
2025-05-08 01:52:12 +00:00
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)');
}
2025-04-25 21:27:00 -04:00
}
2025-04-30 16:06:35 +00:00
/**
* 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) {
2025-05-08 01:52:12 +00:00
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)');
}
2025-04-30 16:06:35 +00:00
}
/**
* 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) {
2025-05-08 01:52:12 +00:00
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)');
}
2025-04-30 16:06:35 +00:00
}
2025-04-25 21:27:00 -04:00
/**
* Set up cron job for decay
*/
function setupDecayCron(client, schedule) {
2025-05-08 01:52:12 +00:00
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}`);
}
2025-04-25 21:27:00 -04:00
}
/**
* Add input points for a user
*/
2025-05-05 17:48:42 +00:00
/**
* 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 = '') {
2025-05-08 01:52:12 +00:00
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;
}
2025-04-25 21:27:00 -04:00
}
/**
* Add output points for a user
*/
2025-05-05 17:48:42 +00:00
/**
* 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 = '') {
2025-05-08 01:52:12 +00:00
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;
}
2025-04-25 21:27:00 -04:00
}
/**
* Add commendations for a user
*/
async function addCommendation(client, guildId, userId, amount = 1) {
2025-05-08 01:52:12 +00:00
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;
}
2025-04-25 21:27:00 -04:00
}
/**
* Add citations for a user
*/
async function addCitation(client, guildId, userId, amount = 1) {
2025-05-08 01:52:12 +00:00
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;
}
2025-04-25 21:27:00 -04:00
}
/**
* Get a user's score
*/
async function getScore(client, guildId, userId) {
2025-05-08 01:52:12 +00:00
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;
}
2025-04-25 21:27:00 -04:00
}
/**
* Get scores for a guild
*/
async function getScores(client, guildId, limit = 10) {
2025-05-08 01:52:12 +00:00
if (!guildId) {
throw new Error('Guild ID is required');
}
2025-04-25 21:27:00 -04:00
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) {
2025-05-08 01:52:12 +00:00
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;
}
2025-04-25 21:27:00 -04:00
}
/**
2025-04-30 00:30:34 +00:00
* Calculate score based on formula (no scaling factor)
* Formula: (1 + C*commendationValue - D*citationValue) * (input / (output + baseOutput))
2025-04-25 21:27:00 -04:00
*/
function calculateScore(data, baseOutput, commendationValue, citationValue) {
2025-04-30 00:30:34 +00:00
const multiplier = 1 + (data.commendations * commendationValue) - (data.citations * citationValue);
const activityScore = data.input / (data.output + baseOutput);
2025-04-25 21:27:00 -04:00
2025-04-30 00:30:34 +00:00
// Removed aesthetic scaling (×100) for raw score
return multiplier * activityScore;
2025-04-25 21:27:00 -04:00
}
/**
* Get or create score data for a user in a guild
*/
async function getOrCreateScoreData(client, guildId, userId) {
2025-05-08 01:52:12 +00:00
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;
}
2025-04-25 21:27:00 -04:00
}
// Define slash commands for the module
export const commands = [
2025-05-08 01:52:12 +00:00
// 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) {
2025-05-02 16:45:36 +00:00
// Group events by category
2025-05-08 01:52:12 +00:00
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');
2025-05-02 16:45:36 +00:00
}
2025-05-08 01:52:12 +00:00
// 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);
2025-05-02 16:45:36 +00:00
}
2025-05-08 01:52:12 +00:00
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');
2025-05-02 16:45:36 +00:00
}
2025-05-08 01:52:12 +00:00
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 {}
2025-05-02 16:45:36 +00:00
}
2025-05-08 01:52:12 +00:00
}
},
// 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`;
2025-05-02 16:45:36 +00:00
}
2025-05-08 01:52:12 +00:00
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
});
2025-05-02 16:45:36 +00:00
}
}
}
2025-05-08 01:52:12 +00:00
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)
)
2025-05-08 01:52:12 +00:00
.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
});
}
}
}
2025-04-25 21:27:00 -04:00
2025-05-08 01:52:12 +00:00
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}.`);
2025-04-30 00:30:34 +00:00
} catch (error) {
2025-05-08 01:52:12 +00:00
client.logger.error(`Error in cite command: ${error.message}`);
await interaction.reply({
content: 'Failed to add citation.',
ephemeral: true
});
2025-04-30 00:30:34 +00:00
}
2025-05-08 01:52:12 +00:00
}
},
// Command to manually run decay (admin only)
{
data: new SlashCommandBuilder()
.setName('run-decay')
.setDescription('Manually run score decay (Admin only)')
2025-04-30 16:06:35 +00:00
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
2025-05-08 01:52:12 +00:00
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.');
}
2025-04-30 16:06:35 +00:00
}
}
2025-05-08 01:52:12 +00:00
// 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 });
}
2025-04-30 16:06:35 +00:00
}
}
2025-05-08 01:52:12 +00:00
// 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 });
}
2025-04-30 16:06:35 +00:00
}
}
2025-04-25 21:27:00 -04:00
];
2025-04-30 16:06:35 +00:00
/**
* 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) {
2025-05-08 01:52:12 +00:00
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([]);
}
});
2025-04-30 16:06:35 +00:00
}