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
|
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 ---
|
||||||
|
|||||||
@@ -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)')
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 { 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: []
|
||||||
|
|||||||
@@ -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.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user