From 455233b1c44f0b49056aca2db57d61861c621885 Mon Sep 17 00:00:00 2001 From: jrmyr Date: Mon, 5 May 2025 17:48:42 +0000 Subject: [PATCH] Scorekeeper changes. --- _opt/responses.js | 8 +++- _opt/responsesQuery.js | 8 +++- _opt/scorekeeper-example.js | 4 +- _opt/scorekeeper.js | 76 +++++++++++++++++++++++++++++++++---- _opt/tempvc.js | 68 +++++++++++++++++++++++++++++++-- 5 files changed, 149 insertions(+), 15 deletions(-) diff --git a/_opt/responses.js b/_opt/responses.js index a8ca383..7f088d2 100644 --- a/_opt/responses.js +++ b/_opt/responses.js @@ -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) { diff --git a/_opt/responsesQuery.js b/_opt/responsesQuery.js index caa327c..2cde234 100644 --- a/_opt/responsesQuery.js +++ b/_opt/responsesQuery.js @@ -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) { diff --git a/_opt/scorekeeper-example.js b/_opt/scorekeeper-example.js index e9fe7ae..f25c3d3 100644 --- a/_opt/scorekeeper-example.js +++ b/_opt/scorekeeper-example.js @@ -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}`); }) diff --git a/_opt/scorekeeper.js b/_opt/scorekeeper.js index d900046..bc57995 100644 --- a/_opt/scorekeeper.js +++ b/_opt/scorekeeper.js @@ -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; diff --git a/_opt/tempvc.js b/_opt/tempvc.js index 4e0a584..c74e8ff 100644 --- a/_opt/tempvc.js +++ b/_opt/tempvc.js @@ -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