Files
broccolini-bot/handlers/commands/response.js
indifferentketchup cdb5db0082 Add per-staff metrics: StaffAction event log + /stats command
Event-sourced tracking of staff/ticket lifecycle actions, plus a /stats
command. Foundation for a future tickets-website analytics dashboard.

Data:
- StaffAction model (event log) + Ticket.game / Ticket.closedAt
- STATS_ADMIN_IDS config (who may view others' stats)

Recording (fire-and-forget, idempotent on real state transitions):
- claim, response (channel reply + /response send), escalate, de-escalate,
  transfer, close (4 sites), reopen — each denormalizes ticketType, tier,
  priority, game, requester (senderEmail / creatorId), guildId
- close events carry closerType / resolverId (claimer credit) / wasClaimed;
  transfer carries fromId / toId; reopen stamps resolverId
- conditional close transition helper (atomic open->closed + closedAt) shared
  by all four close paths

Query + command:
- pure period parser (presets + free-text) and stats shaper (per-metric keys)
- command-aware autocomplete dispatch
- /stats: period (autocomplete) + member (admin-gated) + source (all/email/
  discord), ManageMessages + staff-role gated, ephemeral, tier-labeled embed

288+ unit tests; timing/busiest-times data is collected but displayed later.
2026-06-05 02:02:48 +00:00

174 lines
6.2 KiB
JavaScript

/**
* /response (saved tags) and its autocomplete.
*
* /response is itself a router over its subcommands:
* send / create / edit / delete / list
* The autocomplete handler also lives here since the only autocompleting
* slash command is /response.
*/
const {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
EmbedBuilder,
MessageFlags
} = require('discord.js');
const { mongoose } = require('../../db-connection');
const { CONFIG } = require('../../config');
const { replaceVariables } = require('../../utils');
const { logError } = require('../../services/debugLog');
const { recordAction } = require('../../services/staffStats');
const Tag = mongoose.model('Tag');
const Ticket = mongoose.model('Ticket');
async function handleResponse(interaction) {
const subcommand = interaction.options.getSubcommand();
const handler = RESPONSE_SUBCOMMANDS[subcommand];
if (!handler) return;
try {
await handler(interaction);
} catch (err) {
logError('response-command', err, interaction).catch(() => {});
const errorMsg = '❌ An error occurred while processing the response command.';
if (interaction.deferred) {
await interaction.editReply(errorMsg);
} else {
await interaction.reply({ content: errorMsg, flags: MessageFlags.Ephemeral });
}
}
}
async function handleResponseSend(interaction, _TagModel, _TicketModel, _recordAction) {
const TTag = _TagModel || Tag;
const TTicket = _TicketModel || Ticket;
const record = _recordAction || recordAction;
const name = interaction.options.getString('name');
const tag = await TTag.findOne({ name }).lean();
if (!tag) {
return interaction.reply({ content: `❌ Tag "${name}" not found.`, flags: MessageFlags.Ephemeral });
}
const ticket = await TTicket.findOne({ discordThreadId: interaction.channel.id }).lean();
const context = {
ticket: ticket || {},
staff: {
username: interaction.user.username,
displayName: interaction.member?.displayName,
mention: interaction.user.toString()
},
guild: interaction.guild
};
const content = replaceVariables(tag.content, context);
await TTag.updateOne({ name }, { $inc: { useCount: 1 } });
// Tag bodies are staff-authored but may include variable substitutions from user/ticket data.
// Disable mention parsing so a `@everyone` in a tag body never pings.
await interaction.reply({ content, allowedMentions: { parse: [] } });
if (ticket) {
record(interaction.user.id, 'response', { ticket, guildId: interaction.guild?.id });
}
}
async function handleResponseCreate(interaction) {
const name = interaction.options.getString('name');
const content = interaction.options.getString('content');
try {
await Tag.create({ name, content, createdBy: interaction.user.id });
await interaction.reply({ content: `✅ Tag "${name}" created successfully.`, flags: MessageFlags.Ephemeral });
} catch (err) {
if (err.code === 11000 || err.message?.includes('duplicate')) {
await interaction.reply({ content: `❌ Tag "${name}" already exists.`, flags: MessageFlags.Ephemeral });
} else {
logError('tag-create', err, interaction).catch(() => {});
await interaction.reply({ content: '❌ Failed to create tag.', flags: MessageFlags.Ephemeral });
}
}
}
async function handleResponseEdit(interaction) {
const name = interaction.options.getString('name');
const content = interaction.options.getString('content');
try {
const result = await Tag.updateOne({ name }, { $set: { content } });
if (result.matchedCount === 0) {
await interaction.reply({ content: `❌ Tag "${name}" not found.`, flags: MessageFlags.Ephemeral });
} else {
await interaction.reply({ content: `✅ Tag "${name}" updated successfully.`, flags: MessageFlags.Ephemeral });
}
} catch (err) {
logError('tag-edit', err, interaction).catch(() => {});
await interaction.reply({ content: '❌ Failed to edit tag.', flags: MessageFlags.Ephemeral });
}
}
async function handleResponseDelete(interaction) {
const name = interaction.options.getString('name');
// Use :: delimiter so tag names with underscores parse correctly (Discord customId max 100 chars).
const customId = `confirm_delete_tag::${name}`.slice(0, 100);
const confirmRow = new ActionRowBuilder().addComponents(
new ButtonBuilder()
.setCustomId(customId)
.setLabel('Yes, Delete Tag')
.setStyle(ButtonStyle.Danger),
new ButtonBuilder()
.setCustomId('cancel_delete_tag')
.setLabel('Cancel')
.setStyle(ButtonStyle.Secondary)
);
return interaction.reply({
content: `⚠️ Are you sure you want to delete the tag "${name}"? This action cannot be undone.`,
components: [confirmRow],
flags: MessageFlags.Ephemeral
});
}
async function handleResponseList(interaction) {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const tags = await Tag.find().sort({ useCount: -1 }).select('name useCount').lean();
if (!tags || tags.length === 0) {
return interaction.editReply({ content: '📋 No tags available.' });
}
const embed = new EmbedBuilder()
.setTitle('📋 Available Saved Responses')
.setDescription(
tags.map((t, i) => `${i + 1}. **${t.name}** (used ${t.useCount || 0}x)`).join('\n')
)
.setColor(CONFIG.EMBED_COLOR_INFO)
.setFooter({ text: `Total: ${tags.length} tags` });
await interaction.editReply({ embeds: [embed] });
}
const RESPONSE_SUBCOMMANDS = {
send: handleResponseSend,
create: handleResponseCreate,
edit: handleResponseEdit,
delete: handleResponseDelete,
list: handleResponseList
};
/** Autocomplete handler for /response. Routed here by the dispatcher in index.js. */
async function handleAutocomplete(interaction) {
const subcommand = interaction.options.getSubcommand();
if (!['send', 'edit', 'delete'].includes(subcommand)) return;
const focusedValue = interaction.options.getFocused();
const tags = await Tag.find().sort({ name: 1 }).select('name').lean();
const filtered = tags
.filter(t => t.name.toLowerCase().includes(focusedValue.toLowerCase()))
.slice(0, 25)
.map(t => ({ name: t.name, value: t.name }));
await interaction.respond(filtered);
}
module.exports = { handleResponse, handleAutocomplete, handleResponseSend };