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:
@@ -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
|
||||
LOGGING_CHANNEL_ID= # Channel for lifecycle log messages
|
||||
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: Ticket copy & buttons ---
|
||||
|
||||
@@ -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()
|
||||
.setName('closetimer')
|
||||
.setDescription('Set the force-close countdown duration')
|
||||
@@ -456,13 +391,6 @@ async function registerCommands() {
|
||||
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
|
||||
.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()
|
||||
.setName('signature')
|
||||
.setDescription('Set your personal email signature (valediction, display name, tagline)')
|
||||
|
||||
@@ -47,7 +47,6 @@ const CONFIG = {
|
||||
TRANSCRIPT_CHAN: process.env.TRANSCRIPT_CHANNEL_ID,
|
||||
LOG_CHAN: process.env.LOGGING_CHANNEL_ID,
|
||||
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,
|
||||
CLIENT_ID: process.env.DISCORD_APPLICATION_ID,
|
||||
REFRESH_TOKEN: process.env.REFRESH_TOKEN,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
@@ -23,7 +23,6 @@ const { sanitizeEmbedText, truncateEmbedDescription, truncateEmbedField, enforce
|
||||
const { setEmailRouting } = require('../services/guildSettings');
|
||||
const { enqueueRename, enqueueSend } = require('../services/channelQueue');
|
||||
const { runEscalation, runDeescalation } = require('./commands');
|
||||
const { trackInteraction, trackError } = require('./analytics');
|
||||
const { pendingCloses } = require('./pendingCloses');
|
||||
const { logError, logSystem } = require('../services/debugLog');
|
||||
|
||||
@@ -90,7 +89,7 @@ async function handleButton(interaction) {
|
||||
ephemeral: true
|
||||
});
|
||||
} catch (err) {
|
||||
trackError('email-routing-button', err, interaction);
|
||||
logError('email-routing-button', err, interaction).catch(() => {});
|
||||
await interaction.reply({
|
||||
content: 'Failed to update email routing.',
|
||||
ephemeral: true
|
||||
@@ -215,7 +214,7 @@ async function handleButton(interaction) {
|
||||
await interaction.deferReply();
|
||||
await runEscalation(interaction, ticket, 1, null);
|
||||
} 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(() =>
|
||||
interaction.followUp({ content: 'Failed to escalate to tier 2.', ephemeral: true }).catch(() => {})
|
||||
);
|
||||
@@ -238,7 +237,7 @@ async function handleButton(interaction) {
|
||||
await interaction.deferReply();
|
||||
await runEscalation(interaction, ticket, 2, null);
|
||||
} 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(() =>
|
||||
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 runDeescalation(interaction, ticket);
|
||||
} catch (err) {
|
||||
trackError('deescalate-button', err, interaction);
|
||||
logError('deescalate-button', err, interaction).catch(() => {});
|
||||
await interaction.editReply({ content: 'Failed to deescalate this ticket.' }).catch(() =>
|
||||
interaction.followUp({ content: 'Failed to deescalate this ticket.', ephemeral: true }).catch(() => {})
|
||||
);
|
||||
@@ -266,7 +265,6 @@ async function handleButton(interaction) {
|
||||
|
||||
// --- TAG DELETE CONFIRM ---
|
||||
if (interaction.customId.startsWith('confirm_delete_tag::')) {
|
||||
trackInteraction('buttons', 'confirm-delete-tag', interaction.user.tag);
|
||||
const tagName = interaction.customId.slice('confirm_delete_tag::'.length);
|
||||
|
||||
try {
|
||||
@@ -284,7 +282,7 @@ async function handleButton(interaction) {
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
trackError('tag-delete-confirm', err, interaction);
|
||||
logError('tag-delete-confirm', err, interaction).catch(() => {});
|
||||
await interaction.update({
|
||||
content: '❌ Failed to delete tag.',
|
||||
components: []
|
||||
|
||||
@@ -12,14 +12,13 @@ const {
|
||||
} = require('discord.js');
|
||||
const { mongoose } = require('../db-connection');
|
||||
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 { sendTicketNotificationEmail } = require('../services/gmail');
|
||||
const { getTicketActionRow } = require('../utils/ticketComponents');
|
||||
const { getEmailRouting } = require('../services/guildSettings');
|
||||
const { enqueueRename, enqueueMove, enqueueSend } = require('../services/channelQueue');
|
||||
const { setNotifyDm } = require('../services/staffSettings');
|
||||
const { trackInteraction, trackError, getAnalyticsSummary } = require('./analytics');
|
||||
const { logTicketEvent, logSecurity, logError } = require('../services/debugLog');
|
||||
const { handleSetupCommand } = require('./setup');
|
||||
const { pendingCloses } = require('./pendingCloses');
|
||||
@@ -249,7 +248,7 @@ async function handleCommand(interaction) {
|
||||
components: [row]
|
||||
});
|
||||
} catch (err) {
|
||||
trackError('email-routing-command', err, interaction);
|
||||
logError('email-routing-command', err, interaction).catch(() => {});
|
||||
await interaction.editReply('Failed to load routing options.').catch(() => {});
|
||||
}
|
||||
return;
|
||||
@@ -631,7 +630,6 @@ async function handleCommand(interaction) {
|
||||
|
||||
// /response – saved response tags (send, create, edit, delete, list)
|
||||
if (interaction.commandName === 'response') {
|
||||
trackInteraction('commands', 'response', interaction.user.tag);
|
||||
const subcommand = interaction.options.getSubcommand();
|
||||
|
||||
try {
|
||||
@@ -671,7 +669,7 @@ async function handleCommand(interaction) {
|
||||
if (err.code === 11000 || err.message?.includes('duplicate')) {
|
||||
await interaction.reply({ content: `❌ Tag "${name}" already exists.`, ephemeral: true });
|
||||
} else {
|
||||
trackError('tag-create', err, interaction);
|
||||
logError('tag-create', err, interaction).catch(() => {});
|
||||
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 });
|
||||
}
|
||||
} catch (err) {
|
||||
trackError('tag-edit', err, interaction);
|
||||
logError('tag-edit', err, interaction).catch(() => {});
|
||||
await interaction.reply({ content: '❌ Failed to edit tag.', ephemeral: true });
|
||||
}
|
||||
}
|
||||
@@ -737,7 +735,7 @@ async function handleCommand(interaction) {
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
}
|
||||
} catch (err) {
|
||||
trackError('response-command', err, interaction);
|
||||
logError('response-command', err, interaction).catch(() => {});
|
||||
const errorMsg = '❌ An error occurred while processing the response command.';
|
||||
if (interaction.deferred) {
|
||||
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
|
||||
if (interaction.isMessageContextMenuCommand() && interaction.commandName === 'Create Ticket From Message') {
|
||||
trackInteraction('contextMenus', 'create-ticket-from-message', interaction.user.tag);
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
|
||||
const rateLimit = checkTicketCreationRateLimit(interaction.user.id);
|
||||
@@ -1215,14 +1017,13 @@ async function handleContextMenu(interaction) {
|
||||
|
||||
await interaction.editReply(`✅ Ticket created: ${channel}`);
|
||||
} 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.');
|
||||
}
|
||||
}
|
||||
|
||||
// View User Tickets
|
||||
if (interaction.isUserContextMenuCommand() && interaction.commandName === 'View User Tickets') {
|
||||
trackInteraction('contextMenus', 'view-user-tickets', interaction.user.tag);
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
|
||||
try {
|
||||
@@ -1258,7 +1059,7 @@ async function handleContextMenu(interaction) {
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
} catch (err) {
|
||||
trackError('view-user-tickets', err, interaction);
|
||||
logError('view-user-tickets', err, interaction).catch(() => {});
|
||||
await interaction.editReply('❌ Failed to fetch user tickets.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ const ALLOWED_CONFIG_KEYS = new Set([
|
||||
'ADMIN_ID',
|
||||
// Channel IDs
|
||||
'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',
|
||||
'SECURITY_LOG_CHANNEL_ID', 'SYSTEM_LOG_CHANNEL_ID',
|
||||
// Messages and labels
|
||||
|
||||
Reference in New Issue
Block a user