Scorekeeper changes.

This commit is contained in:
jrmyr 2025-05-05 17:48:42 +00:00
parent 7df051795a
commit 455233b1c4
5 changed files with 149 additions and 15 deletions

View File

@ -114,7 +114,7 @@ function cacheResponse(client, key, id, ttlSeconds) {
*/
function awardOutput(client, guildId, userId, amount) {
if (client.scorekeeper && amount > 0) {
client.scorekeeper.addOutput(guildId, userId, amount)
client.scorekeeper.addOutput(guildId, userId, amount, 'AI_response')
.catch(err => client.logger.error(`Scorekeeper error: ${err.message}`));
}
}
@ -222,6 +222,12 @@ async function handleImage(client, message, resp, cfg) {
client.logger.info(`Saved image: ${filePath}`);
attachments.push(new AttachmentBuilder(buffer, { name: filename }));
}
// Award output points based on token usage for image generation
const tokens = imgRes.usage?.total_tokens ?? count;
if (client.scorekeeper && tokens > 0) {
client.scorekeeper.addOutput(message.guild.id, message.author.id, tokens, 'image_generation')
.catch(err => client.logger.error(`Scorekeeper error: ${err.message}`));
}
// Reply with attachments
await message.reply({ content: promptText, files: attachments });
} catch (err) {

View File

@ -134,6 +134,12 @@ async function handleImageInteraction(client, interaction, resp, cfg, ephemeral)
client.logger.info(`Saved image: ${filePath}`);
attachments.push(new AttachmentBuilder(buffer, { name: filename }));
}
// Award output points based on token usage for image generation
const tokens = imgRes.usage?.total_tokens ?? count;
if (client.scorekeeper && tokens > 0) {
client.scorekeeper.addOutput(interaction.guildId, interaction.user.id, tokens, 'image_generation')
.catch(err => client.logger.error(`Scorekeeper error: ${err.message}`));
}
// Reply with attachments
await interaction.editReply({ content: promptText, files: attachments });
return true;
@ -281,7 +287,7 @@ export const commands = [
// Award output tokens
const tokens = resp.usage?.total_tokens ?? resp.usage?.completion_tokens ?? 0;
if (client.scorekeeper && tokens > 0) {
client.scorekeeper.addOutput(interaction.guildId, interaction.user.id, tokens)
client.scorekeeper.addOutput(interaction.guildId, interaction.user.id, tokens, 'AI_query')
.catch(e => client.logger.error(`Scorekeeper error: ${e.message}`));
}
} catch (err) {

View File

@ -14,7 +14,7 @@ export const init = async (client, config) => {
// Do not award zero or negative points
if (points <= 0) return;
try {
await client.scorekeeper.addInput(message.guild.id, message.author.id, points);
await client.scorekeeper.addInput(message.guild.id, message.author.id, points, 'message');
} catch (error) {
client.logger.error(`Error adding input points: ${error.message}`);
}
@ -92,7 +92,7 @@ function processVoiceLeave(client, guild, member, channelId) {
const points = Math.min(Math.floor(duration), 30);
if (points > 0) {
try {
client.scorekeeper.addInput(guild.id, member.id, points)
client.scorekeeper.addInput(guild.id, member.id, points, 'voice_activity')
.then(() => {
client.logger.debug(`Added ${points} voice activity points for ${member.user.tag}`);
})

View File

@ -33,9 +33,23 @@ export const init = async (client, config) => {
await checkCategoriesCollection(client);
await checkEventsCollection(client);
// Create scorekeeper interface on client
client.scorekeeper = {
addInput: (guildId, userId, amount) => addInput(client, guildId, userId, amount),
addOutput: (guildId, userId, amount) => addOutput(client, guildId, userId, amount),
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),
@ -155,7 +169,15 @@ function setupDecayCron(client, schedule) {
/**
* Add input points for a user
*/
async function addInput(client, guildId, userId, amount) {
/**
* 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}`);
}
@ -170,9 +192,25 @@ async function addInput(client, guildId, userId, amount) {
client.logger.debug(`Updating record ${scoreData.id} - input from ${scoreData.input} to ${newInput}`);
// Use direct update with ID to avoid duplicate records
return await client.pb.collection('scorekeeper').update(scoreData.id, {
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;
@ -182,7 +220,15 @@ async function addInput(client, guildId, userId, amount) {
/**
* Add output points for a user
*/
async function addOutput(client, guildId, userId, amount) {
/**
* 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');
}
@ -197,9 +243,25 @@ async function addOutput(client, guildId, userId, amount) {
client.logger.debug(`Updating record ${scoreData.id} - output from ${scoreData.output} to ${newOutput}`);
// Use direct update with ID to avoid duplicate records
return await client.pb.collection('scorekeeper').update(scoreData.id, {
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;

View File

@ -1,5 +1,6 @@
import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, EmbedBuilder } from 'discord.js';
import { MessageFlags } from 'discord-api-types/v10';
// Init function to handle autocomplete for /vc invite
/**
* tempvc module: temporary voice channel manager
*
@ -154,7 +155,13 @@ export const commands = [
.addSubcommand(sub =>
sub.setName('invite')
.setDescription('Invite a user to this channel')
.addUserOption(opt => opt.setName('user').setDescription('User to invite').setRequired(true))
// Autocomplete string option for user ID
.addStringOption(opt =>
opt.setName('user')
.setDescription('User to invite')
.setRequired(true)
.setAutocomplete(true)
)
)
.addSubcommand(sub =>
sub.setName('kick')
@ -223,10 +230,29 @@ export const commands = [
await voice.setName(name);
await interaction.reply({ content: `Channel renamed to **${name}**.`, flags: MessageFlags.Ephemeral });
} else if (sub === 'invite') {
const u = interaction.options.getUser('user', true);
// Invitation: support both string (autocomplete) and user option types
let userId;
let memberToInvite;
// Try string option first (autocomplete)
try {
userId = interaction.options.getString('user', true);
memberToInvite = await guild.members.fetch(userId);
} catch (e) {
// Fallback to user option
try {
const user = interaction.options.getUser('user', true);
userId = user.id;
memberToInvite = await guild.members.fetch(userId);
} catch {
memberToInvite = null;
}
}
if (!memberToInvite) {
return interaction.reply({ content: 'User not found in this server.', flags: MessageFlags.Ephemeral });
}
// grant view and connect
await voice.permissionOverwrites.edit(u.id, { ViewChannel: true, Connect: true });
await interaction.reply({ content: `Invited <@${u.id}>.`, flags: MessageFlags.Ephemeral });
await voice.permissionOverwrites.edit(userId, { ViewChannel: true, Connect: true });
await interaction.reply({ content: `Invited <@${userId}>.`, flags: MessageFlags.Ephemeral });
} else if (sub === 'kick') {
const u = interaction.options.getUser('user', true);
const gm = await guild.members.fetch(u.id);
@ -421,6 +447,40 @@ export const commands = [
* Initialize module: load PB state and hook events
*/
export async function init(client) {
// autocomplete for /vc invite
client.on('interactionCreate', async interaction => {
if (!interaction.isAutocomplete()) return;
if (interaction.commandName !== 'vc') return;
// Only handle autocomplete for the 'invite' subcommand
let sub;
try {
sub = interaction.options.getSubcommand();
} catch {
return;
}
if (sub !== 'invite') return;
const focused = interaction.options.getFocused();
const guild = interaction.guild;
if (!guild) return;
// Perform guild member search for autocomplete suggestions (prefix match)
let choices = [];
try {
const members = await guild.members.search({ query: focused, limit: 25 });
choices = members.map(m => ({ name: m.displayName || m.user.username, value: m.id }));
} catch (err) {
client.logger.error(`[module:tempvc] Autocomplete search failed: ${err.message}`);
}
// If no choices found or to support substring matching, fallback to cache filter
if (choices.length === 0) {
const str = String(focused).toLowerCase();
choices = Array.from(guild.members.cache.values())
.filter(m => (m.displayName || m.user.username).toLowerCase().includes(str))
.slice(0, 25)
.map(m => ({ name: m.displayName || m.user.username, value: m.id }));
}
// Respond with suggestions (max 25)
await interaction.respond(choices);
});
// tempvc state: masters per guild, sessions map
client.tempvc = { masters: new Map(), sessions: new Map() };
// hook voice state updates