strip: remove /backup /export /search /stats /fix-stale-tickets + analytics module

- delete handlers/analytics.js
- remove trackInteraction calls; replace trackError with logError().catch(() => {})
- remove 5 slash commands from register.js
- remove BACKUP_EXPORT_CHANNEL_ID from config + schema + .env.example
This commit is contained in:
2026-04-21 16:44:01 +00:00
parent fa7d4af132
commit 34dc55c20b
8 changed files with 13 additions and 409 deletions

View File

@@ -34,7 +34,6 @@ ROLE_ID_TO_PING= # Role ID to ping on new tickets (config also
TRANSCRIPT_CHANNEL_ID= # Channel for ticket transcripts on close TRANSCRIPT_CHANNEL_ID= # Channel for ticket transcripts on close
LOGGING_CHANNEL_ID= # Channel for lifecycle log messages LOGGING_CHANNEL_ID= # Channel for lifecycle log messages
DEBUGGING_CHANNEL_ID= # Channel for error logs (escalate, poll, etc.); optional DEBUGGING_CHANNEL_ID= # Channel for error logs (escalate, poll, etc.); optional
BACKUP_EXPORT_CHANNEL_ID= # Channel where /backup and /export post .txt files; optional
DISCORD_CHANNEL_ID= # General Discord channel (if used) DISCORD_CHANNEL_ID= # General Discord channel (if used)
# --- Discord: Ticket copy & buttons --- # --- Discord: Ticket copy & buttons ---

View File

@@ -277,71 +277,6 @@ async function registerCommands() {
) )
), ),
new SlashCommandBuilder()
.setName('backup')
.setDescription('Export full ticket list to a .txt file in the backup/export channel')
.setContexts([InteractionContextType.Guild])
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
new SlashCommandBuilder()
.setName('export')
.setDescription('Export tickets (optional filter and limit) to a .txt file in the backup/export channel')
.setContexts([InteractionContextType.Guild])
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
.addStringOption(opt =>
opt
.setName('status')
.setDescription('Filter by status')
.setRequired(false)
.addChoices(
{ name: 'Open', value: 'open' },
{ name: 'Closed', value: 'closed' }
)
)
.addIntegerOption(opt =>
opt
.setName('limit')
.setDescription('Max number of tickets to export (default 500)')
.setMinValue(1)
.setMaxValue(5000)
.setRequired(false)
),
new SlashCommandBuilder()
.setName('search')
.setDescription('Search for tickets')
.setContexts([InteractionContextType.Guild])
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages)
.addStringOption(opt =>
opt
.setName('query')
.setDescription('Search query (email, subject, or ticket number)')
.setMinLength(2)
.setMaxLength(100)
.setRequired(true)
)
.addStringOption(opt =>
opt
.setName('status')
.setDescription('Filter by status')
.setRequired(false)
.addChoices(
{ name: 'Open', value: 'open' },
{ name: 'Closed', value: 'closed' },
{ name: 'All', value: 'all' }
)
),
new SlashCommandBuilder()
.setName('stats')
.setDescription('View bot statistics and analytics')
.setContexts([InteractionContextType.Guild])
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
new SlashCommandBuilder() new SlashCommandBuilder()
.setName('closetimer') .setName('closetimer')
.setDescription('Set the force-close countdown duration') .setDescription('Set the force-close countdown duration')
@@ -456,13 +391,6 @@ async function registerCommands() {
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall]) .setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages), .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages),
new SlashCommandBuilder()
.setName('fix-stale-tickets')
.setDescription('Admin only: backfill lastActivity on open tickets where it is null (sets to createdAt).')
.setContexts([InteractionContextType.Guild])
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
new SlashCommandBuilder() new SlashCommandBuilder()
.setName('signature') .setName('signature')
.setDescription('Set your personal email signature (valediction, display name, tagline)') .setDescription('Set your personal email signature (valediction, display name, tagline)')

View File

@@ -47,7 +47,6 @@ const CONFIG = {
TRANSCRIPT_CHAN: process.env.TRANSCRIPT_CHANNEL_ID, TRANSCRIPT_CHAN: process.env.TRANSCRIPT_CHANNEL_ID,
LOG_CHAN: process.env.LOGGING_CHANNEL_ID, LOG_CHAN: process.env.LOGGING_CHANNEL_ID,
DEBUGGING_CHANNEL_ID: process.env.DEBUGGING_CHANNEL_ID || null, DEBUGGING_CHANNEL_ID: process.env.DEBUGGING_CHANNEL_ID || null,
BACKUP_EXPORT_CHANNEL_ID: process.env.BACKUP_EXPORT_CHANNEL_ID || null,
DISCORD_CHANNEL_ID: process.env.DISCORD_CHANNEL_ID || null, DISCORD_CHANNEL_ID: process.env.DISCORD_CHANNEL_ID || null,
CLIENT_ID: process.env.DISCORD_APPLICATION_ID, CLIENT_ID: process.env.DISCORD_APPLICATION_ID,
REFRESH_TOKEN: process.env.REFRESH_TOKEN, REFRESH_TOKEN: process.env.REFRESH_TOKEN,

View File

@@ -1,32 +0,0 @@
{
"project_zomboid": "Project Zomboid",
"satisfactory": "Satisfactory",
"palworld": "Palworld",
"minecraft": "Minecraft",
"valheim": "Valheim",
"enshrouded": "Enshrouded",
"7_days_to_die": "7 Days to Die",
"hytale": "Hytale",
"icarus": "ICARUS",
"abiotic_factor": "Abiotic Factor",
"ark_survival_evolved": "ARK: Survival Evolved",
"conan_exiles": "Conan Exiles",
"core_keeper": "Core Keeper",
"counter_strike_2": "Counter-Strike 2",
"dayz": "DayZ",
"eco": "ECO",
"factorio": "Factorio",
"fivem": "FiveM",
"the_front": "The Front",
"garrys_mod": "Garry's Mod",
"necesse": "Necesse",
"rust": "Rust",
"sons_of_the_forest": "Sons of the Forest",
"soulmask": "Soulmask",
"star_rupture": "Star Rupture",
"terraria": "Terraria",
"vein": "VEIN",
"vintage_story": "Vintage Story",
"voyagers_of_nera": "Voyagers of Nera",
"v_rising": "V Rising"
}

View File

@@ -1,89 +0,0 @@
/**
* In-memory analytics and error tracking.
*/
const { logError } = require('../services/debugLog');
const analytics = {
commands: {},
buttons: {},
modals: {},
contextMenus: {},
errors: [],
startTime: Date.now()
};
function trackInteraction(type, name, userId = 'unknown') {
analytics[type][name] = (analytics[type][name] || 0) + 1;
console.log(`📊 Analytics: ${type}/${name} by ${userId}`);
}
function getTotalInteractions() {
let total = 0;
for (const type of ['commands', 'buttons', 'modals', 'contextMenus']) {
for (const key in analytics[type]) {
total += analytics[type][key];
}
}
return total;
}
function trackError(context, error, interaction = null) {
const errorEntry = {
context,
message: error.message,
stack: error.stack,
timestamp: Date.now(),
user: interaction?.user?.tag || 'system',
command: interaction?.commandName || 'N/A'
};
analytics.errors.push(errorEntry);
if (analytics.errors.length > 100) {
analytics.errors.shift();
}
console.error(`❌ Error tracked: ${context}:`, error.message);
logError(context, error, interaction);
const recentErrors = analytics.errors.filter(e =>
Date.now() - e.timestamp < 3600000
);
const errorRate = recentErrors.length / Math.max(1, getTotalInteractions());
if (errorRate > 0.05) {
console.warn(`⚠️ HIGH ERROR RATE: ${(errorRate * 100).toFixed(2)}% in last hour`);
}
}
function getAnalyticsSummary() {
const uptime = Math.floor((Date.now() - analytics.startTime) / 1000);
const totalInteractions = getTotalInteractions();
const recentErrors = analytics.errors.filter(e =>
Date.now() - e.timestamp < 3600000
);
return {
uptime: `${Math.floor(uptime / 3600)}h ${Math.floor((uptime % 3600) / 60)}m`,
totalInteractions,
commandsUsed: Object.keys(analytics.commands).length,
mostUsedCommand: Object.entries(analytics.commands)
.sort((a, b) => b[1] - a[1])[0]?.[0] || 'None',
errorsLastHour: recentErrors.length,
errorRate: `${((recentErrors.length / Math.max(1, totalInteractions)) * 100).toFixed(2)}%`,
topCommands: Object.entries(analytics.commands)
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([cmd, count]) => `${cmd}: ${count}`)
};
}
module.exports = {
analytics,
trackInteraction,
trackError,
getTotalInteractions,
getAnalyticsSummary
};

View File

@@ -23,7 +23,6 @@ const { sanitizeEmbedText, truncateEmbedDescription, truncateEmbedField, enforce
const { setEmailRouting } = require('../services/guildSettings'); const { setEmailRouting } = require('../services/guildSettings');
const { enqueueRename, enqueueSend } = require('../services/channelQueue'); const { enqueueRename, enqueueSend } = require('../services/channelQueue');
const { runEscalation, runDeescalation } = require('./commands'); const { runEscalation, runDeescalation } = require('./commands');
const { trackInteraction, trackError } = require('./analytics');
const { pendingCloses } = require('./pendingCloses'); const { pendingCloses } = require('./pendingCloses');
const { logError, logSystem } = require('../services/debugLog'); const { logError, logSystem } = require('../services/debugLog');
@@ -90,7 +89,7 @@ async function handleButton(interaction) {
ephemeral: true ephemeral: true
}); });
} catch (err) { } catch (err) {
trackError('email-routing-button', err, interaction); logError('email-routing-button', err, interaction).catch(() => {});
await interaction.reply({ await interaction.reply({
content: 'Failed to update email routing.', content: 'Failed to update email routing.',
ephemeral: true ephemeral: true
@@ -215,7 +214,7 @@ async function handleButton(interaction) {
await interaction.deferReply(); await interaction.deferReply();
await runEscalation(interaction, ticket, 1, null); await runEscalation(interaction, ticket, 1, null);
} catch (err) { } catch (err) {
trackError('escalate-button-tier2', err, interaction); logError('escalate-button-tier2', err, interaction).catch(() => {});
await interaction.editReply({ content: 'Failed to escalate to tier 2.' }).catch(() => await interaction.editReply({ content: 'Failed to escalate to tier 2.' }).catch(() =>
interaction.followUp({ content: 'Failed to escalate to tier 2.', ephemeral: true }).catch(() => {}) interaction.followUp({ content: 'Failed to escalate to tier 2.', ephemeral: true }).catch(() => {})
); );
@@ -238,7 +237,7 @@ async function handleButton(interaction) {
await interaction.deferReply(); await interaction.deferReply();
await runEscalation(interaction, ticket, 2, null); await runEscalation(interaction, ticket, 2, null);
} catch (err) { } catch (err) {
trackError('escalate-button-tier3', err, interaction); logError('escalate-button-tier3', err, interaction).catch(() => {});
await interaction.editReply({ content: 'Failed to escalate to tier 3.' }).catch(() => await interaction.editReply({ content: 'Failed to escalate to tier 3.' }).catch(() =>
interaction.followUp({ content: 'Failed to escalate to tier 3.', ephemeral: true }).catch(() => {}) interaction.followUp({ content: 'Failed to escalate to tier 3.', ephemeral: true }).catch(() => {})
); );
@@ -256,7 +255,7 @@ async function handleButton(interaction) {
await interaction.deferReply({ ephemeral: true }); await interaction.deferReply({ ephemeral: true });
await runDeescalation(interaction, ticket); await runDeescalation(interaction, ticket);
} catch (err) { } catch (err) {
trackError('deescalate-button', err, interaction); logError('deescalate-button', err, interaction).catch(() => {});
await interaction.editReply({ content: 'Failed to deescalate this ticket.' }).catch(() => await interaction.editReply({ content: 'Failed to deescalate this ticket.' }).catch(() =>
interaction.followUp({ content: 'Failed to deescalate this ticket.', ephemeral: true }).catch(() => {}) interaction.followUp({ content: 'Failed to deescalate this ticket.', ephemeral: true }).catch(() => {})
); );
@@ -266,7 +265,6 @@ async function handleButton(interaction) {
// --- TAG DELETE CONFIRM --- // --- TAG DELETE CONFIRM ---
if (interaction.customId.startsWith('confirm_delete_tag::')) { if (interaction.customId.startsWith('confirm_delete_tag::')) {
trackInteraction('buttons', 'confirm-delete-tag', interaction.user.tag);
const tagName = interaction.customId.slice('confirm_delete_tag::'.length); const tagName = interaction.customId.slice('confirm_delete_tag::'.length);
try { try {
@@ -284,7 +282,7 @@ async function handleButton(interaction) {
}); });
} }
} catch (err) { } catch (err) {
trackError('tag-delete-confirm', err, interaction); logError('tag-delete-confirm', err, interaction).catch(() => {});
await interaction.update({ await interaction.update({
content: '❌ Failed to delete tag.', content: '❌ Failed to delete tag.',
components: [] components: []

View File

@@ -12,14 +12,13 @@ const {
} = require('discord.js'); } = require('discord.js');
const { mongoose } = require('../db-connection'); const { mongoose } = require('../db-connection');
const { CONFIG } = require('../config'); const { CONFIG } = require('../config');
const { getPriorityEmoji, replaceVariables, escapeRegex } = require('../utils'); const { getPriorityEmoji, replaceVariables } = require('../utils');
const { makeTicketName, resolveCreatorNickname, getOrCreateTicketCategory, createDiscordTicketAsThread, checkTicketCreationRateLimit } = require('../services/tickets'); const { makeTicketName, resolveCreatorNickname, getOrCreateTicketCategory, createDiscordTicketAsThread, checkTicketCreationRateLimit } = require('../services/tickets');
const { sendTicketNotificationEmail } = require('../services/gmail'); const { sendTicketNotificationEmail } = require('../services/gmail');
const { getTicketActionRow } = require('../utils/ticketComponents'); const { getTicketActionRow } = require('../utils/ticketComponents');
const { getEmailRouting } = require('../services/guildSettings'); const { getEmailRouting } = require('../services/guildSettings');
const { enqueueRename, enqueueMove, enqueueSend } = require('../services/channelQueue'); const { enqueueRename, enqueueMove, enqueueSend } = require('../services/channelQueue');
const { setNotifyDm } = require('../services/staffSettings'); const { setNotifyDm } = require('../services/staffSettings');
const { trackInteraction, trackError, getAnalyticsSummary } = require('./analytics');
const { logTicketEvent, logSecurity, logError } = require('../services/debugLog'); const { logTicketEvent, logSecurity, logError } = require('../services/debugLog');
const { handleSetupCommand } = require('./setup'); const { handleSetupCommand } = require('./setup');
const { pendingCloses } = require('./pendingCloses'); const { pendingCloses } = require('./pendingCloses');
@@ -249,7 +248,7 @@ async function handleCommand(interaction) {
components: [row] components: [row]
}); });
} catch (err) { } catch (err) {
trackError('email-routing-command', err, interaction); logError('email-routing-command', err, interaction).catch(() => {});
await interaction.editReply('Failed to load routing options.').catch(() => {}); await interaction.editReply('Failed to load routing options.').catch(() => {});
} }
return; return;
@@ -631,7 +630,6 @@ async function handleCommand(interaction) {
// /response saved response tags (send, create, edit, delete, list) // /response saved response tags (send, create, edit, delete, list)
if (interaction.commandName === 'response') { if (interaction.commandName === 'response') {
trackInteraction('commands', 'response', interaction.user.tag);
const subcommand = interaction.options.getSubcommand(); const subcommand = interaction.options.getSubcommand();
try { try {
@@ -671,7 +669,7 @@ async function handleCommand(interaction) {
if (err.code === 11000 || err.message?.includes('duplicate')) { if (err.code === 11000 || err.message?.includes('duplicate')) {
await interaction.reply({ content: `❌ Tag "${name}" already exists.`, ephemeral: true }); await interaction.reply({ content: `❌ Tag "${name}" already exists.`, ephemeral: true });
} else { } else {
trackError('tag-create', err, interaction); logError('tag-create', err, interaction).catch(() => {});
await interaction.reply({ content: '❌ Failed to create tag.', ephemeral: true }); await interaction.reply({ content: '❌ Failed to create tag.', ephemeral: true });
} }
} }
@@ -690,7 +688,7 @@ async function handleCommand(interaction) {
await interaction.reply({ content: `✅ Tag "${name}" updated successfully.`, ephemeral: true }); await interaction.reply({ content: `✅ Tag "${name}" updated successfully.`, ephemeral: true });
} }
} catch (err) { } catch (err) {
trackError('tag-edit', err, interaction); logError('tag-edit', err, interaction).catch(() => {});
await interaction.reply({ content: '❌ Failed to edit tag.', ephemeral: true }); await interaction.reply({ content: '❌ Failed to edit tag.', ephemeral: true });
} }
} }
@@ -737,7 +735,7 @@ async function handleCommand(interaction) {
await interaction.editReply({ embeds: [embed] }); await interaction.editReply({ embeds: [embed] });
} }
} catch (err) { } catch (err) {
trackError('response-command', err, interaction); logError('response-command', err, interaction).catch(() => {});
const errorMsg = '❌ An error occurred while processing the response command.'; const errorMsg = '❌ An error occurred while processing the response command.';
if (interaction.deferred) { if (interaction.deferred) {
await interaction.editReply(errorMsg); await interaction.editReply(errorMsg);
@@ -898,201 +896,6 @@ async function handleCommand(interaction) {
} }
} }
// /backup export full ticket list to BACKUP_EXPORT_CHANNEL_ID
if (interaction.commandName === 'backup') {
trackInteraction('commands', 'backup', interaction.user.tag);
await interaction.deferReply({ ephemeral: true });
if (!CONFIG.BACKUP_EXPORT_CHANNEL_ID) {
return interaction.editReply('BACKUP_EXPORT_CHANNEL_ID is not set in .env.');
}
try {
// Stream every ticket through a Mongoose cursor to a tmp file so peak RSS
// stays bounded regardless of collection size; attach the file, then unlink.
const fs = require('fs');
const os = require('os');
const path = require('path');
const tmpName = `ticket-backup-${Date.now()}-${process.pid}.txt`;
const tmpPath = path.join(os.tmpdir(), tmpName);
const ws = fs.createWriteStream(tmpPath, { encoding: 'utf8' });
ws.write('# Ticket backup ' + new Date().toISOString() + '\n');
ws.write('ticketNumber\tstatus\tsenderEmail\tsubject\tcreatedAt\tclaimedBy\tpriority\tescalationTier\n');
let count = 0;
const cursor = Ticket.find().sort({ ticketNumber: 1 }).lean().cursor();
for await (const t of cursor) {
const created = t.createdAt ? new Date(t.createdAt).toISOString() : '';
ws.write([
t.ticketNumber,
t.status || '',
(t.senderEmail || '').replace(/\t/g, ' '),
(t.subject || '').replace(/\t/g, ' ').slice(0, 200),
created,
(t.claimedBy || '').replace(/\t/g, ' '),
t.priority || '',
t.escalationTier ?? ''
].join('\t') + '\n');
count++;
}
await new Promise((resolve, reject) => ws.end(err => err ? reject(err) : resolve()));
try {
const channel = await interaction.client.channels.fetch(CONFIG.BACKUP_EXPORT_CHANNEL_ID);
await enqueueSend(channel, {
content: `Ticket backup by ${interaction.user.tag} (${count} tickets)`,
files: [new AttachmentBuilder(tmpPath, { name: tmpName })]
});
await interaction.editReply(`Backup complete. ${count} tickets sent to the backup channel.`);
} finally {
fs.promises.unlink(tmpPath).catch(() => {});
}
} catch (err) {
trackError('backup-command', err, interaction);
await interaction.editReply('Failed to create backup: ' + (err.message || err));
}
}
// /export export tickets with optional status and limit to BACKUP_EXPORT_CHANNEL_ID
if (interaction.commandName === 'export') {
trackInteraction('commands', 'export', interaction.user.tag);
await interaction.deferReply({ ephemeral: true });
if (!CONFIG.BACKUP_EXPORT_CHANNEL_ID) {
return interaction.editReply('BACKUP_EXPORT_CHANNEL_ID is not set in .env.');
}
try {
const status = interaction.options.getString('status') || null;
const limit = interaction.options.getInteger('limit') || 500;
const filter = status ? { status } : {};
const tickets = await Ticket.find(filter).sort({ ticketNumber: -1 }).limit(limit).lean();
const lines = ['# Ticket export ' + new Date().toISOString() + (status ? ` (status=${status})` : '') + ` limit=${limit}`, 'ticketNumber\tstatus\tsenderEmail\tsubject\tcreatedAt\tclaimedBy\tpriority\tescalationTier'];
for (const t of tickets) {
const created = t.createdAt ? new Date(t.createdAt).toISOString() : '';
lines.push([t.ticketNumber, t.status || '', (t.senderEmail || '').replace(/\t/g, ' '), (t.subject || '').replace(/\t/g, ' ').slice(0, 200), created, (t.claimedBy || '').replace(/\t/g, ' '), t.priority || '', t.escalationTier ?? ''].join('\t'));
}
const buf = Buffer.from(lines.join('\n'), 'utf8');
const channel = await interaction.client.channels.fetch(CONFIG.BACKUP_EXPORT_CHANNEL_ID);
await enqueueSend(channel, {
content: `Ticket export by ${interaction.user.tag} (${tickets.length} tickets${status ? ` status=${status}` : ''})`,
files: [new AttachmentBuilder(buf, { name: `ticket-export-${Date.now()}.txt` })]
});
await interaction.editReply(`Export complete. ${tickets.length} tickets sent to the backup channel.`);
} catch (err) {
trackError('export-command', err, interaction);
await interaction.editReply('Failed to export: ' + (err.message || err));
}
}
// /search
if (interaction.commandName === 'search') {
trackInteraction('commands', 'search', interaction.user.tag);
await interaction.deferReply({ ephemeral: true });
try {
const query = interaction.options.getString('query');
const status = interaction.options.getString('status') || 'all';
const regex = new RegExp(escapeRegex(query), 'i');
const filter = {
$or: [
{ senderEmail: regex },
{ subject: regex }
]
};
const ticketNum = parseInt(query, 10);
if (!Number.isNaN(ticketNum) && String(ticketNum) === query.trim()) {
filter.$or.push({ ticketNumber: ticketNum });
}
if (status !== 'all') filter.status = status;
const results = await Ticket.find(filter).sort({ createdAt: -1 }).limit(10).lean();
if (!results || results.length === 0) {
return interaction.editReply('🔍 No tickets found matching your query.');
}
const embed = new EmbedBuilder()
.setTitle(`🔍 Search Results for "${query}"`)
.setDescription(`Found ${results.length} ticket(s)`)
.setColor(CONFIG.EMBED_COLOR_INFO);
for (const ticket of results.slice(0, 5)) {
const priorityEmoji = getPriorityEmoji(ticket.priority || 'normal');
const statusEmoji = ticket.status === 'open' ? '🟢' : '🔴';
embed.addFields({
name: `${priorityEmoji} Ticket #${ticket.ticketNumber} ${statusEmoji}`,
value: `**Subject:** ${ticket.subject || 'No subject'}\n**From:** ${ticket.senderEmail}\n**Status:** ${ticket.status}\n**Claimed:** ${ticket.claimedBy || 'Unclaimed'}`,
inline: false
});
}
if (results.length > 5) {
embed.setFooter({ text: `Showing 5 of ${results.length} results` });
}
await interaction.editReply({ embeds: [embed] });
} catch (err) {
trackError('search-command', err, interaction);
await interaction.editReply('❌ An error occurred while searching.');
}
}
// /fix-stale-tickets
if (interaction.commandName === 'fix-stale-tickets') {
if (interaction.user.id !== CONFIG.ADMIN_ID) {
return interaction.reply({ content: 'You do not have permission to run this command.', ephemeral: true });
}
await interaction.deferReply({ ephemeral: true });
try {
const result = await Ticket.updateMany(
{ status: 'open', lastActivity: null },
[{ $set: { lastActivity: '$createdAt' } }]
);
await interaction.editReply(`Fixed ${result.modifiedCount} ticket(s).`);
} catch (err) {
console.error('fix-stale-tickets:', err);
await interaction.editReply('❌ Failed to backfill tickets.').catch(() => {});
}
}
// /stats
if (interaction.commandName === 'stats') {
trackInteraction('commands', 'stats', interaction.user.tag);
await interaction.deferReply({ ephemeral: true });
try {
const summary = getAnalyticsSummary();
const ticketStats = await Ticket.aggregate([
{ $group: { _id: '$status', count: { $sum: 1 } } }
]);
const openCount = ticketStats.find(s => s._id === 'open')?.count || 0;
const closedCount = ticketStats.find(s => s._id === 'closed')?.count || 0;
const claimedCount = await Ticket.countDocuments({ status: 'open', claimedBy: { $ne: null } });
const embed = new EmbedBuilder()
.setTitle('📊 Bot Statistics & Analytics')
.setColor(CONFIG.EMBED_COLOR_INFO)
.addFields([
{ name: '⏱️ Uptime', value: summary.uptime, inline: true },
{ name: '💬 Total Interactions', value: summary.totalInteractions.toString(), inline: true },
{ name: '📈 Commands Used', value: summary.commandsUsed.toString(), inline: true },
{ name: '🎫 Open Tickets', value: openCount.toString(), inline: true },
{ name: '✅ Closed Tickets', value: closedCount.toString(), inline: true },
{ name: '📌 Claimed Tickets', value: (claimedCount || 0).toString(), inline: true },
{ name: '🔥 Most Used Command', value: summary.mostUsedCommand, inline: true },
{ name: '❌ Errors (Last Hour)', value: summary.errorsLastHour.toString(), inline: true },
{ name: '📉 Error Rate', value: summary.errorRate, inline: true },
{ name: '📋 Top Commands', value: summary.topCommands.join('\n') || 'None', inline: false }
])
.setTimestamp();
await interaction.editReply({ embeds: [embed] });
} catch (err) {
trackError('stats-command', err, interaction);
await interaction.editReply('❌ An error occurred while fetching statistics.');
}
}
} }
/** /**
@@ -1104,7 +907,6 @@ async function handleContextMenu(interaction) {
// Create Ticket From Message // Create Ticket From Message
if (interaction.isMessageContextMenuCommand() && interaction.commandName === 'Create Ticket From Message') { if (interaction.isMessageContextMenuCommand() && interaction.commandName === 'Create Ticket From Message') {
trackInteraction('contextMenus', 'create-ticket-from-message', interaction.user.tag);
await interaction.deferReply({ ephemeral: true }); await interaction.deferReply({ ephemeral: true });
const rateLimit = checkTicketCreationRateLimit(interaction.user.id); const rateLimit = checkTicketCreationRateLimit(interaction.user.id);
@@ -1215,14 +1017,13 @@ async function handleContextMenu(interaction) {
await interaction.editReply(`✅ Ticket created: ${channel}`); await interaction.editReply(`✅ Ticket created: ${channel}`);
} catch (err) { } catch (err) {
trackError('create-ticket-from-message', err, interaction); logError('create-ticket-from-message', err, interaction).catch(() => {});
await interaction.editReply('❌ Failed to create ticket from message.'); await interaction.editReply('❌ Failed to create ticket from message.');
} }
} }
// View User Tickets // View User Tickets
if (interaction.isUserContextMenuCommand() && interaction.commandName === 'View User Tickets') { if (interaction.isUserContextMenuCommand() && interaction.commandName === 'View User Tickets') {
trackInteraction('contextMenus', 'view-user-tickets', interaction.user.tag);
await interaction.deferReply({ ephemeral: true }); await interaction.deferReply({ ephemeral: true });
try { try {
@@ -1258,7 +1059,7 @@ async function handleContextMenu(interaction) {
await interaction.editReply({ embeds: [embed] }); await interaction.editReply({ embeds: [embed] });
} catch (err) { } catch (err) {
trackError('view-user-tickets', err, interaction); logError('view-user-tickets', err, interaction).catch(() => {});
await interaction.editReply('❌ Failed to fetch user tickets.'); await interaction.editReply('❌ Failed to fetch user tickets.');
} }
} }

View File

@@ -30,7 +30,7 @@ const ALLOWED_CONFIG_KEYS = new Set([
'ADMIN_ID', 'ADMIN_ID',
// Channel IDs // Channel IDs
'TRANSCRIPT_CHANNEL_ID', 'LOGGING_CHANNEL_ID', 'DEBUGGING_CHANNEL_ID', 'TRANSCRIPT_CHANNEL_ID', 'LOGGING_CHANNEL_ID', 'DEBUGGING_CHANNEL_ID',
'BACKUP_EXPORT_CHANNEL_ID', 'DISCORD_CHANNEL_ID', 'DISCORD_CHANNEL_ID',
'GMAIL_LOG_CHANNEL_ID', 'AUTOMATION_LOG_CHANNEL_ID', 'RENAME_LOG_CHANNEL_ID', 'GMAIL_LOG_CHANNEL_ID', 'AUTOMATION_LOG_CHANNEL_ID', 'RENAME_LOG_CHANNEL_ID',
'SECURITY_LOG_CHANNEL_ID', 'SYSTEM_LOG_CHANNEL_ID', 'SECURITY_LOG_CHANNEL_ID', 'SYSTEM_LOG_CHANNEL_ID',
// Messages and labels // Messages and labels