1005 lines
43 KiB
JavaScript
1005 lines
43 KiB
JavaScript
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([]);
|
||
}
|
||
});
|
||
}
|