2025-05-04 14:29:13 +00:00
import { MessageFlags } from 'discord-api-types/v10' ;
2025-04-25 21:27:00 -04:00
// opt/scorekeeper.js
import cron from 'node-cron' ;
import { SlashCommandBuilder , EmbedBuilder , PermissionFlagsBits } from 'discord.js' ;
// Module state container
const moduleState = {
cronJobs : new Map ( ) , // Store cron jobs by client ID
} ;
/ * *
* Initialize the scorekeeper module
* /
export const init = async ( client , config ) => {
2025-05-02 16:45:36 +00:00
client . logger . info ( '[module:scorekeeper] Initializing Scorekeeper module' ) ;
2025-04-25 21:27:00 -04:00
// 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-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-05 17:48:42 +00:00
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 ) ,
2025-04-25 21:27:00 -04:00
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-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
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 ) ;
2025-05-02 16:45:36 +00:00
client . logger . info ( '[module:scorekeeper] Scorekeeper collection exists in PocketBase' ) ;
2025-04-25 21:27:00 -04:00
} catch ( error ) {
// If collection doesn't exist, log warning
2025-05-02 16:45:36 +00:00
client . logger . warn ( '[module:scorekeeper] Scorekeeper collection does not exist in PocketBase' ) ;
2025-04-25 21:27:00 -04:00
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-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 ) {
try {
await client . pb . collection ( 'scorekeeper_categories' ) . getList ( 1 , 1 ) ;
2025-05-02 16:45:36 +00:00
client . logger . info ( '[module:scorekeeper] scorekeeper_categories collection exists' ) ;
2025-04-30 16:06:35 +00:00
} catch ( error ) {
2025-05-02 16:45:36 +00:00
client . logger . warn ( '[module:scorekeeper] scorekeeper_categories collection does not exist in PocketBase' ) ;
2025-04-30 16:06:35 +00:00
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 ) ;
2025-05-02 16:45:36 +00:00
client . logger . info ( '[module:scorekeeper] scorekeeper_events collection exists' ) ;
2025-04-30 16:06:35 +00:00
} catch ( error ) {
2025-05-02 16:45:36 +00:00
client . logger . warn ( '[module:scorekeeper] scorekeeper_events collection does not exist in PocketBase' ) ;
2025-04-30 16:06:35 +00:00
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-25 21:27:00 -04:00
/ * *
* 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
* /
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-04-25 21:27:00 -04: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
2025-05-05 17:48:42 +00:00
const updatedRecord = await client . pb . collection ( 'scorekeeper' ) . update ( scoreData . id , {
2025-04-25 21:27:00 -04:00
input : newInput
} ) ;
2025-05-05 17:48:42 +00:00
// 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 ;
2025-04-25 21:27:00 -04:00
} catch ( error ) {
client . logger . error ( ` Error adding input points: ${ error . message } ` ) ;
throw error ;
}
}
/ * *
* 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-04-25 21:27:00 -04: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
2025-05-05 17:48:42 +00:00
const updatedRecord = await client . pb . collection ( 'scorekeeper' ) . update ( scoreData . id , {
2025-04-25 21:27:00 -04:00
output : newOutput
} ) ;
2025-05-05 17:48:42 +00:00
// 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 ;
2025-04-25 21:27:00 -04:00
} catch ( error ) {
client . logger . error ( ` Error adding output points: ${ error . message } ` ) ;
throw error ;
}
}
/ * *
* Add commendations for a user
* /
async function addCommendation ( client , guildId , userId , amount = 1 ) {
if ( ! guildId || ! userId || amount <= 0 ) {
throw new Error ( 'Invalid parameters for addCommendation' ) ;
}
try {
// Log the search to debug
client . logger . debug ( ` Looking for record with guildId= ${ guildId } and userId= ${ userId } ` ) ;
// Get or create user record
const scoreData = await getOrCreateScoreData ( client , guildId , userId ) ;
// Log the found/created record
client . logger . debug ( ` Found/created record with ID: ${ scoreData . id } ` ) ;
// Calculate new commendations value
const newCommendations = scoreData . commendations + amount ;
// Log the update attempt
client . logger . debug ( ` Updating record ${ scoreData . id } - commendations from ${ scoreData . commendations } to ${ newCommendations } ` ) ;
// Use direct update with ID to avoid duplicate records
return await client . pb . collection ( 'scorekeeper' ) . update ( scoreData . id , {
commendations : newCommendations
} ) ;
} catch ( error ) {
client . logger . error ( ` Error adding commendations: ${ error . message } ` ) ;
throw error ;
}
}
/ * *
* Add citations for a user
* /
async function addCitation ( client , guildId , userId , amount = 1 ) {
if ( ! guildId || ! userId || amount <= 0 ) {
throw new Error ( 'Invalid parameters for addCitation' ) ;
}
try {
// Get or create user record
const scoreData = await getOrCreateScoreData ( client , guildId , userId ) ;
// Calculate new citations value
const newCitations = scoreData . citations + amount ;
client . logger . debug ( ` Updating record ${ scoreData . id } - citations from ${ scoreData . citations } to ${ newCitations } ` ) ;
// Use direct update with ID to avoid duplicate records
return await client . pb . collection ( 'scorekeeper' ) . update ( scoreData . id , {
citations : newCitations
} ) ;
} catch ( error ) {
client . logger . error ( ` Error adding citations: ${ error . message } ` ) ;
throw error ;
}
}
/ * *
* Get a user ' s score
* /
async function getScore ( client , guildId , userId ) {
if ( ! guildId || ! userId ) {
throw new Error ( 'Guild ID and User ID are required' ) ;
}
try {
// Get score data, create if not exists
const scoreData = await getOrCreateScoreData ( client , guildId , userId ) ;
// Calculate total score
const totalScore = calculateScore (
scoreData ,
client . config . scorekeeper . baseOutput ,
client . config . scorekeeper . commendationValue ,
client . config . scorekeeper . citationValue
) ;
return {
... scoreData ,
totalScore
} ;
} catch ( error ) {
client . logger . error ( ` Error getting score: ${ error . message } ` ) ;
throw error ;
}
}
/ * *
* Get scores for a guild
* /
async function getScores ( client , guildId , limit = 10 ) {
if ( ! guildId ) {
throw new Error ( 'Guild ID is required' ) ;
}
try {
// Fetch all score records for this guild
const records = await client . pb . collection ( 'scorekeeper' ) . getFullList ( {
filter : ` guildId = " ${ guildId } " `
} ) ;
// Compute totalScore for each, then sort and limit
const scored = records . map ( record => {
const totalScore = calculateScore (
record ,
client . config . scorekeeper . baseOutput ,
client . config . scorekeeper . commendationValue ,
client . config . scorekeeper . citationValue
) ;
return { ... record , totalScore } ;
} ) ;
// Sort descending by score
scored . sort ( ( a , b ) => b . totalScore - a . totalScore ) ;
// Return top 'limit' entries
return scored . slice ( 0 , limit ) ;
} catch ( error ) {
client . logger . error ( ` Error getting scores: ${ error . message } ` ) ;
throw error ;
}
}
/ * *
* Run decay process for a guild
* /
async function runDecay ( client , guildId ) {
if ( ! guildId ) {
throw new Error ( 'Guild ID is required' ) ;
}
try {
const decayFactor = client . config . scorekeeper . decay / 100 ;
const baseOutput = client . config . scorekeeper . baseOutput ;
// Get all records for this guild
const records = await client . pb . collection ( 'scorekeeper' ) . getFullList ( {
filter : ` guildId = " ${ guildId } " `
} ) ;
// Update each record with decay
let updatedCount = 0 ;
for ( const record of records ) {
try {
const newInput = Math . floor ( record . input * decayFactor ) ;
// Calculate new output, but ensure it never falls below baseOutput
let newOutput = Math . floor ( record . output * decayFactor ) ;
if ( newOutput < baseOutput ) {
newOutput = baseOutput ;
client . logger . debug ( ` Output for record ${ record . id } would fall below BaseOutput - setting to ${ baseOutput } ` ) ;
}
// Update record directly
await client . pb . collection ( 'scorekeeper' ) . update ( record . id , {
input : newInput ,
output : newOutput ,
lastDecay : new Date ( ) . toISOString ( )
} ) ;
updatedCount ++ ;
} catch ( updateError ) {
client . logger . error ( ` Error updating record ${ record . id } during decay: ${ updateError . message } ` ) ;
}
}
client . logger . info ( ` Decay completed for guild ${ guildId } : ${ updatedCount } records updated ` ) ;
return updatedCount ;
} catch ( error ) {
client . logger . error ( ` Error running decay: ${ error . message } ` ) ;
throw error ;
}
}
/ * *
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 ) {
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' )
2025-04-30 00:30:34 +00:00
. setDescription ( 'View your I/O score or another user\'s I/O score' )
2025-04-25 21:27:00 -04:00
. addUserOption ( option =>
option . setName ( 'user' )
2025-04-30 00:30:34 +00:00
. setDescription ( 'User to check I/O score for (defaults to you)' )
2025-04-25 21:27:00 -04:00
. setRequired ( false )
)
. addBooleanOption ( option =>
option . setName ( 'ephemeral' )
. setDescription ( 'Whether the response should be ephemeral' )
. setRequired ( false )
) ,
2025-05-02 16:45:36 +00:00
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 {
2025-04-30 00:30:34 +00:00
2025-04-30 02:13:32 +00:00
// Fetch score data and compute multiplier
2025-04-30 00:30:34 +00:00
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 ) ;
2025-04-30 02:13:32 +00:00
const multiplierValue = 1 + ( scoreData . commendations * commendationValue ) - ( scoreData . citations * citationValue ) ;
2025-04-30 16:06:35 +00:00
// 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 ] ) ) ;
2025-05-02 16:45:36 +00:00
// Commendations grouped by category with reasons
2025-04-30 16:06:35 +00:00
const commendEvents = await client . pb . collection ( 'scorekeeper_events' ) . getFullList ( {
filter : ` guildId = " ${ interaction . guildId } " && userId = " ${ targetUser . id } " && type = "commendation" `
} ) ;
2025-05-02 16:45:36 +00:00
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
2025-04-30 16:06:35 +00:00
const citeEvents = await client . pb . collection ( 'scorekeeper_events' ) . getFullList ( {
filter : ` guildId = " ${ interaction . guildId } " && userId = " ${ targetUser . id } " && type = "citation" `
} ) ;
2025-05-02 16:45:36 +00:00
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' ) ;
}
2025-04-30 00:30:34 +00:00
const embed = new EmbedBuilder ( )
2025-04-30 13:54:14 +00:00
. setAuthor ( { name : ` ${ client . user . username } : Scorekeeper Module ` , iconURL : client . user . displayAvatarURL ( ) } )
2025-04-30 00:30:34 +00:00
. setTitle ( ` I/O Score for ${ ( await interaction . guild . members . fetch ( targetUser . id ) . catch ( ( ) => null ) ) ? . displayName || targetUser . username } ` )
2025-04-30 13:54:14 +00:00
. 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.' )
2025-04-30 02:13:32 +00:00
. addFields (
2025-04-30 16:06:35 +00:00
{ name : 'Commendations' , value : commendBreakdown , inline : false } ,
{ name : 'Citations' , value : citeBreakdown , inline : false } ,
2025-04-30 13:54:14 +00:00
{ 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 } ,
2025-04-30 02:13:32 +00:00
)
2025-04-25 21:27:00 -04:00
. setFooter ( { text : 'Last decay: ' + new Date ( scoreData . lastDecay ) . toLocaleDateString ( ) } )
. setTimestamp ( ) ;
2025-05-02 16:45:36 +00:00
await interaction . editReply ( { embeds : [ embed ] } ) ;
2025-04-30 00:30:34 +00:00
} catch ( error ) {
2025-05-02 16:45:36 +00:00
client . logger . error ( ` [cmd:score] Error: ${ error . message } ` ) ;
try {
await interaction . editReply ( { content : 'Failed to retrieve I/O score.' } ) ;
} catch { }
2025-04-30 00:30:34 +00:00
}
2025-04-25 21:27:00 -04:00
}
} ,
// Command to view top scores
{
data : new SlashCommandBuilder ( )
. setName ( 'leaderboard' )
2025-04-30 00:30:34 +00:00
. setDescription ( 'View the server\'s I/O score leaderboard' )
2025-04-25 21:27:00 -04:00
. 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 } ) ;
2025-04-30 00:30:34 +00:00
const limit = 10 ;
2025-04-25 21:27:00 -04:00
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 ) ;
2025-04-30 00:30:34 +00:00
const displayName = user ? user . displayName : 'Unknown User' ;
2025-04-25 21:27:00 -04:00
2025-04-30 00:30:34 +00:00
leaderboardText += ` ${ i + 1 } . ** ${ displayName } **: ${ score . totalScore . toFixed ( 2 ) } \n ` ;
2025-04-25 21:27:00 -04:00
}
2025-04-30 13:54:14 +00:00
const embed = new EmbedBuilder ( )
. setAuthor ( { name : ` ${ client . user . username } : Scorekeeper Module ` , iconURL : client . user . displayAvatarURL ( ) } )
2025-04-30 00:30:34 +00:00
. setTitle ( 'I/O Score Leaderboard' )
2025-04-25 21:27:00 -04:00
. 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' )
2025-04-30 16:06:35 +00:00
. 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 )
)
2025-05-02 16:45:36 +00:00
. addStringOption ( option =>
option . setName ( 'reason' )
. setDescription ( 'Reason for commendation' )
. setRequired ( true )
)
2025-04-30 16:06:35 +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' ) ;
2025-05-02 16:45:36 +00:00
const reason = interaction . options . getString ( 'reason' ) ;
2025-04-30 16:06:35 +00:00
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 } \" ` ,
2025-04-30 17:00:56 +00:00
sort : '-created'
2025-04-30 16:06:35 +00:00
} ) ;
const lastItem = recent . items ? . [ 0 ] ;
if ( lastItem ) {
2025-04-30 17:00:56 +00:00
const lastTs = new Date ( lastItem . created ) . getTime ( ) ;
2025-04-30 16:06:35 +00:00
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-04-25 21:27:00 -04:00
try {
2025-04-30 16:06:35 +00:00
await client . scorekeeper . addCommendation ( interaction . guildId , targetUser . id , amount ) ;
// Log event
2025-04-30 17:00:56 +00:00
// Log event (timestamp managed by PocketBase "created" field)
2025-04-30 16:06:35 +00:00
await client . pb . collection ( 'scorekeeper_events' ) . create ( {
guildId : interaction . guildId ,
userId : targetUser . id ,
type : 'commendation' ,
categoryId ,
amount ,
2025-05-02 16:45:36 +00:00
reason ,
2025-04-30 17:00:56 +00:00
awardedBy : interaction . user . id
2025-04-30 16:06:35 +00:00
} ) ;
2025-05-02 16:45:36 +00:00
client . logger . info ( ` [cmd:commend] Added commendation to ${ targetUser . id } in category ${ categoryId } with reason: ${ reason } ` ) ;
await interaction . reply ( ` Added commendation to ${ targetUser } . ` ) ;
2025-04-25 21:27:00 -04:00
} 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 ( )
2025-04-30 16:06:35 +00:00
. 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 )
)
2025-05-02 16:45:36 +00:00
. addStringOption ( option =>
option . setName ( 'reason' )
. setDescription ( 'Reason for citation' )
. setRequired ( true )
)
2025-04-25 21:27:00 -04:00
. setDefaultMemberPermissions ( PermissionFlagsBits . Administrator ) ,
2025-04-30 16:06:35 +00:00
execute : async ( interaction , client ) => {
const guildId = interaction . guildId ;
// Ensure categories exist before proceeding
let catList = [ ] ;
try {
catList = await client . pb . collection ( 'scorekeeper_categories' ) . getFullList ( {
filter : ` guildId = " ${ guildId } " `
} ) ;
} catch { }
if ( catList . length === 0 ) {
return interaction . reply ( {
content : 'No categories defined for this server. Ask an admin to create one with /addcategory.' ,
ephemeral : true
} ) ;
}
const targetUser = interaction . options . getUser ( 'user' ) ;
const categoryId = interaction . options . getString ( 'category' ) ;
const amount = 1 ;
// Enforce per-category cooldown
const cooldown = client . config . scorekeeper . cooldown || 0 ;
if ( cooldown > 0 ) {
const recent = await client . pb . collection ( 'scorekeeper_events' ) . getList ( 1 , 1 , {
filter : ` guildId = \" ${ guildId } \" && userId = \" ${ targetUser . id } \" && type = \" citation \" && categoryId = \" ${ categoryId } \" ` ,
2025-04-30 17:00:56 +00:00
sort : '-created'
2025-04-30 16:06:35 +00:00
} ) ;
const lastItem = recent . items ? . [ 0 ] ;
if ( lastItem ) {
2025-04-30 17:00:56 +00:00
const lastTs = new Date ( lastItem . created ) . getTime ( ) ;
2025-04-30 16:06:35 +00:00
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
try {
2025-04-30 16:06:35 +00:00
await client . scorekeeper . addCitation ( interaction . guildId , targetUser . id , amount ) ;
// Log event
2025-04-30 17:00:56 +00:00
// Log event (timestamp managed by PocketBase "created" field)
2025-04-30 16:06:35 +00:00
await client . pb . collection ( 'scorekeeper_events' ) . create ( {
guildId : interaction . guildId ,
userId : targetUser . id ,
type : 'citation' ,
categoryId ,
amount ,
2025-05-02 16:45:36 +00:00
reason ,
2025-04-30 17:00:56 +00:00
awardedBy : interaction . user . id
2025-04-30 16:06:35 +00:00
} ) ;
2025-05-02 16:45:36 +00:00
client . logger . info ( ` [cmd:cite] Added citation to ${ targetUser . id } in category ${ categoryId } with reason: ${ reason } ` ) ;
2025-04-30 16:06:35 +00:00
await interaction . reply ( ` Added citation to ${ targetUser } . ` ) ;
2025-04-25 21:27:00 -04:00
} 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.' ) ;
}
}
}
2025-04-30 16:06:35 +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 } ` ) ;
2025-05-04 14:29:13 +00:00
await interaction . reply ( { content : 'Failed to create category.' , flags : MessageFlags . Ephemeral } ) ;
2025-04-30 16:06:35 +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 } ` ) ;
2025-05-04 14:29:13 +00:00
await interaction . reply ( { content : 'Failed to remove category.' , flags : MessageFlags . Ephemeral } ) ;
2025-04-30 16:06:35 +00:00
}
}
}
2025-05-02 16:45:36 +00:00
// Public command: list categories (admin-only)
2025-04-30 16:06:35 +00:00
, {
data : new SlashCommandBuilder ( )
. setName ( 'listcategories' )
2025-05-02 16:45:36 +00:00
. setDescription ( 'List all commendation/citation categories (Admin only)' )
. setDefaultMemberPermissions ( PermissionFlagsBits . Administrator )
2025-04-30 16:06:35 +00:00
. 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-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 ) {
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 ( [ ] ) ;
}
} ) ;
}