Files
broccolini-bot/handlers/commands.js
2026-04-18 11:10:41 +00:00

1450 lines
60 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Slash command, context menu, and autocomplete handlers.
*/
const {
ChannelType,
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
AttachmentBuilder,
EmbedBuilder,
PermissionFlagsBits
} = require('discord.js');
const { mongoose } = require('../db-connection');
const { CONFIG, TICKET_TAGS } = require('../config');
const { getPriorityEmoji, getPriorityColor, replaceVariables, escapeRegex } = require('../utils');
const { canRename, makeTicketName, resolveCreatorNickname, getSenderLocal, toDiscordSafeName, 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 } = require('../services/debugLog');
const { handleAccountInfoCommand } = require('./accountinfo');
const { handleSetupCommand } = require('./setup');
const { pendingCloses } = require('./pendingCloses');
const { increment } = require('../services/patternStore');
const Ticket = mongoose.model('Ticket');
const Tag = mongoose.model('Tag');
const User = mongoose.model('User');
const StaffNotification = mongoose.model('StaffNotification');
/**
* True if member has the support role (ROLE_ID_TO_PING) or any ADDITIONAL_STAFF_ROLES.
* Used to restrict commands to staff only; customers cannot use bot commands.
* @param {import('discord.js').GuildMember|null} member
* @returns {boolean}
*/
function hasStaffRole(member) {
if (!member?.roles?.cache) return false;
if (CONFIG.ROLE_ID_TO_PING && member.roles.cache.has(CONFIG.ROLE_ID_TO_PING)) return true;
const additional = CONFIG.ADDITIONAL_STAFF_ROLES || [];
return additional.some(roleId => member.roles.cache.has(roleId));
}
/**
* Reply ephemeral and return true if the interaction is in a guild and the user is not staff (so caller should return).
* @param {import('discord.js').CommandInteraction|import('discord.js').ContextMenuCommandInteraction} interaction
* @returns {Promise<boolean>} true if caller should return (user is not allowed)
*/
async function requireStaffRole(interaction) {
if (!interaction.guild) return false;
if (!CONFIG.ROLE_ID_TO_PING && (!CONFIG.ADDITIONAL_STAFF_ROLES || CONFIG.ADDITIONAL_STAFF_ROLES.length === 0)) return false;
if (hasStaffRole(interaction.member)) return false;
const roleMention = CONFIG.ROLE_ID_TO_PING ? `<@&${CONFIG.ROLE_ID_TO_PING}>` : 'support';
await interaction.reply({
content: `This command is only available to the support team (${roleMention}).`,
ephemeral: true
});
logSecurity('Unauthorized command attempt', interaction.user, interaction.commandName).catch(() => {});
return true;
}
/**
* Run escalation to a target DB tier (1 = tier 2, 2 = tier 3). Caller must validate ticket and currentTier < nextTier.
*/
async function runEscalation(interaction, ticket, nextTier, reason) {
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
const categoryId = nextTier === 1
? (isDiscordTicket ? CONFIG.DISCORD_ESCALATED2_CHANNEL_ID : CONFIG.EMAIL_ESCALATED2_CHANNEL_ID)
: (isDiscordTicket ? CONFIG.DISCORD_ESCALATED3_CHANNEL_ID : CONFIG.EMAIL_ESCALATED3_CHANNEL_ID);
// Clear claim on escalation
await Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
{ $set: { escalated: true, escalationTier: nextTier, claimedBy: null, claimerId: null, unclaimedRemindersSent: [] } }
);
ticket.escalated = true;
ticket.escalationTier = nextTier;
ticket.claimedBy = null;
increment('escalations', ticket.game || 'unknown', 'today');
increment('escalations', ticket.game || 'unknown', 'week');
increment('user_escalations', ticket.senderEmail, 'week');
increment('staff_escalations', interaction.user.id, 'today');
increment('staff_escalations', interaction.user.id, 'week');
if (ticket.game) increment(`staff_game_escalations:${interaction.user.id}`, ticket.game, 'week');
const creatorNickname = await resolveCreatorNickname(interaction.guild, ticket);
const renameInfo = await canRename(ticket);
if (renameInfo.ok) {
const newName = makeTicketName('escalated', ticket, creatorNickname);
enqueueRename(interaction.channel, newName).catch(() => {});
} else {
const unlockAtMs = Date.now() + renameInfo.waitMs;
const unlockAtUnix = Math.floor(unlockAtMs / 1000);
await enqueueSend(interaction.channel,
`Channel renamed too quickly. Try again <t:${unlockAtUnix}:R>.`
);
}
if (!interaction.channel.isThread() && categoryId) {
await enqueueMove(interaction.channel, categoryId);
}
const pendingEmbed = new EmbedBuilder()
.setDescription('Ticket will be escalated in a few seconds.')
.setColor(CONFIG.EMBED_COLOR_INFO);
await interaction.editReply({ embeds: [pendingEmbed] });
const creatorId = isDiscordTicket
? (ticket.gmailThreadId.split('-').pop() || '').trim()
: null;
const creatorMention = creatorId ? `<@${creatorId}>` : '';
const roleMention = CONFIG.ROLE_ID_TO_PING ? `<@&${CONFIG.ROLE_ID_TO_PING}>` : 'a senior team member';
const heyLine = creatorMention
? `Hey There ${creatorMention} 🥦`
: 'Hey There 🥦';
await enqueueSend(interaction.channel,
`${heyLine}\n**Getting the senior ${roleMention} for you.**`
);
const escalationBody = CONFIG.ESCALATION_MESSAGE
.replace(/\\n/g, '\n')
.replace(/\{support_name\}/g, CONFIG.SUPPORT_NAME);
const escalatedEmbed = new EmbedBuilder()
.setTitle(`🚨 Escalated to ${nextTier === 1 ? 'Tier 2' : 'Tier 3'} Support`)
.setDescription(escalationBody)
.setColor(CONFIG.EMBED_COLOR_ESCALATED)
.setFooter({ text: `Escalated by ${interaction.member?.displayName || interaction.user.username}` });
const updatedTicketForRow = { ...ticket, escalationTier: nextTier, escalated: true };
const escalationRow = getTicketActionRow(updatedTicketForRow);
const escalationMsg = await enqueueSend(interaction.channel, {
content: null,
embeds: [escalatedEmbed],
components: [escalationRow]
});
if (CONFIG.PIN_ESCALATION_MESSAGE_ENABLED && escalationMsg) {
const { pinMessage } = require('../services/pinMessage');
await pinMessage(escalationMsg, interaction.client).catch(() => {});
}
if (!isDiscordTicket && ticket.gmailThreadId) {
try {
const emailBody = CONFIG.ESCALATION_MESSAGE.replace(/\\n/g, '\n').replace(/\{support_name\}/g, CONFIG.SUPPORT_NAME);
await sendTicketNotificationEmail(
ticket,
`Ticket escalated to ${nextTier === 1 ? 'tier 2' : 'tier 3'}`,
emailBody,
interaction.member?.displayName || interaction.user.username
);
} catch (emailErr) {
console.error('Escalation email failed (non-fatal):', emailErr.message);
}
}
if (nextTier === 2) {
if (!ticket.welcomeMessageId) {
console.warn('welcomeMessageId is null/undefined; skipping welcome-message update for escalation');
} else {
try {
const welcomeMsg = await interaction.channel.messages.fetch(ticket.welcomeMessageId);
await welcomeMsg.edit({ components: [getTicketActionRow(updatedTicketForRow)] });
} catch (e) {
console.error('Failed to update welcome message after escalate:', e.message);
}
}
}
const logChan = await interaction.client.channels
.fetch(CONFIG.LOG_CHAN)
.catch(() => null);
if (logChan) {
const ticketType = isDiscordTicket ? 'Discord' : 'Email';
const tierLabel = nextTier === 1 ? 'tier 2' : 'tier 3';
await enqueueSend(logChan,
`${ticketType} ticket ${interaction.channel} escalated to ${tierLabel} by ${interaction.user.tag}.\nReason: ${reason}`
);
}
}
/**
* Run deescalation one step. Caller must validate ticket and currentTier >= 1.
*/
async function runDeescalation(interaction, ticket) {
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
const newTier = currentTier - 1;
await Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
{ $set: { escalated: newTier > 0, escalationTier: newTier, claimedBy: null, claimerId: null } }
);
ticket.escalated = newTier > 0;
ticket.escalationTier = newTier;
ticket.claimedBy = null;
const creatorNickname = await resolveCreatorNickname(interaction.guild, ticket);
const state = newTier === 0 ? 'unclaimed' : 'escalated';
const renameInfo = await canRename(ticket);
if (renameInfo.ok) {
enqueueRename(interaction.channel, makeTicketName(state, ticket, creatorNickname)).catch(() => {});
} else {
const unlockAtMs = Date.now() + renameInfo.waitMs;
const unlockAtUnix = Math.floor(unlockAtMs / 1000);
await enqueueSend(interaction.channel,
`Channel renamed too quickly. Try again <t:${unlockAtUnix}:R>.`
);
}
if (!interaction.channel.isThread()) {
try {
if (newTier === 0) {
const homeCategory = isDiscordTicket ? CONFIG.DISCORD_TICKET_CATEGORY_ID : CONFIG.TICKET_CATEGORY_ID;
if (homeCategory) await enqueueMove(interaction.channel, homeCategory);
} else if (newTier === 1) {
const t2Category = isDiscordTicket
? CONFIG.DISCORD_ESCALATED2_CHANNEL_ID
: CONFIG.EMAIL_ESCALATED2_CHANNEL_ID;
if (t2Category) await enqueueMove(interaction.channel, t2Category);
}
} catch (e) {
console.error('Move error (deescalate):', e);
}
}
const tierLabel = newTier === 0 ? 'normal' : newTier === 1 ? 'tier 2' : 'tier 3';
const deescalateEmbed = new EmbedBuilder()
.setColor(0x00BFFF)
.setTitle(`✅ De-escalated to ${tierLabel} Support`)
.setFooter({ text: interaction.member?.displayName || interaction.user.username });
await interaction.editReply({ embeds: [deescalateEmbed] });
const logChan = await interaction.client.channels.fetch(CONFIG.LOG_CHAN).catch(() => null);
if (logChan) {
const ticketType = isDiscordTicket ? 'Discord' : 'Email';
await enqueueSend(logChan,
`${ticketType} ticket ${interaction.channel} deescalated to ${tierLabel} by ${interaction.user.tag}.`
);
}
}
/**
* Main slash-command handler.
*/
async function handleCommand(interaction) {
// Only /help can be used by everyone; all other commands require staff role (ROLE_ID_TO_PING / ADDITIONAL_STAFF_ROLES)
if (interaction.commandName !== 'help' && (await requireStaffRole(interaction))) return;
// /setup
if (interaction.commandName === 'setup') {
return handleSetupCommand(interaction);
}
// /email-routing switch where new email tickets are created (thread vs category)
if (interaction.commandName === 'email-routing') {
await interaction.deferReply({ ephemeral: true });
try {
const current = await getEmailRouting(interaction.guild.id);
const row = new ActionRowBuilder().addComponents(
new ButtonBuilder()
.setCustomId('email_routing_thread')
.setLabel('Threads')
.setStyle(ButtonStyle.Primary)
.setEmoji('🧵'),
new ButtonBuilder()
.setCustomId('email_routing_category')
.setLabel('Category channels')
.setStyle(ButtonStyle.Primary)
.setEmoji('📁')
);
await interaction.editReply({
content: `Email ticket routing: **${current}**. Choose where new email tickets should be created:`,
components: [row]
});
} catch (err) {
trackError('email-routing-command', err, interaction);
await interaction.editReply('Failed to load routing options.').catch(() => {});
}
return;
}
// /escalate (tier 2 or 3 via level; works for both email and Discord)
if (interaction.commandName === 'escalate') {
const reason = null;
const level = interaction.options.getString('level');
const nextTier = level === '3' ? 2 : 1;
const action = interaction.options.getString('action');
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
if (!ticket) {
return interaction.reply({ content: 'This channel is not linked to a ticket.', ephemeral: true });
}
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
if (currentTier >= 2) {
return interaction.reply({ content: 'This ticket is already at tier 3 support.', ephemeral: true });
}
if (nextTier <= currentTier) {
return interaction.reply({ content: 'Ticket is already at or past that tier.', ephemeral: true });
}
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
const categoryId = nextTier === 1
? (isDiscordTicket ? CONFIG.DISCORD_ESCALATED2_CHANNEL_ID : CONFIG.EMAIL_ESCALATED2_CHANNEL_ID)
: (isDiscordTicket ? CONFIG.DISCORD_ESCALATED3_CHANNEL_ID : CONFIG.EMAIL_ESCALATED3_CHANNEL_ID);
const configKey = nextTier === 1 ? 'ESCALATED2' : 'ESCALATED3';
if (!categoryId && !interaction.channel.isThread()) {
return interaction.reply({
content: `${configKey} is not configured for ${isDiscordTicket ? 'Discord' : 'email'} tickets.`,
ephemeral: true
});
}
try {
await interaction.deferReply();
await runEscalation(interaction, ticket, nextTier, reason);
if (action === 'unclaim') {
await Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
{ $set: { claimedBy: null, claimerId: null } }
);
}
} catch (err) {
console.error('Escalate error:', err);
await interaction.editReply({ content: 'Failed to escalate this ticket.' }).catch(() =>
interaction.followUp({ content: 'Failed to escalate this ticket.', ephemeral: true }).catch(() => {})
);
}
}
// /notification set | /notification add
if (interaction.commandName === 'notification') {
const sub = interaction.options.getSubcommand();
if (sub === 'set') {
const hours = interaction.options.getInteger('hours');
try {
await StaffNotification.findOneAndUpdate(
{ userId: interaction.user.id },
{ $set: { cooldownHours: hours, updatedAt: new Date() } },
{ upsert: true }
);
return interaction.reply({ content: `Notification cooldown set to ${hours} hour(s).`, ephemeral: true });
} catch (err) {
console.error('notification set error:', err);
return interaction.reply({ content: 'Failed to update notification setting.', ephemeral: true }).catch(() => {});
}
}
if (sub === 'add') {
if (!CONFIG.STAFF_NOTIFICATION_CATEGORY_ID) {
return interaction.reply({ content: 'STAFF_NOTIFICATION_CATEGORY_ID is not configured.', ephemeral: true });
}
const member = interaction.options.getMember('member');
if (!member) {
return interaction.reply({ content: 'Could not resolve that member.', ephemeral: true });
}
const displayName = member.displayName;
const emoji = CONFIG.STAFF_EMOJIS.get(member.id) || '';
const chanName = toDiscordSafeName(`${displayName}${emoji}`);
try {
const newChannel = await interaction.guild.channels.create({
name: chanName,
type: ChannelType.GuildText,
parent: CONFIG.STAFF_NOTIFICATION_CATEGORY_ID,
permissionOverwrites: [
{ id: interaction.guild.id, deny: [PermissionFlagsBits.ViewChannel] },
{ id: member.id, allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory] },
...(CONFIG.ROLE_ID_TO_PING ? [{ id: CONFIG.ROLE_ID_TO_PING, allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory] }] : [])
]
});
await StaffNotification.findOneAndUpdate(
{ userId: member.id },
{ $set: { channelId: newChannel.id, guildId: interaction.guild.id, updatedAt: new Date() } },
{ upsert: true }
);
return interaction.reply({ content: `Notification channel created: ${newChannel}`, ephemeral: true });
} catch (err) {
console.error('notification add error:', err);
return interaction.reply({ content: 'Failed to create notification channel.', ephemeral: true }).catch(() => {});
}
}
return;
}
// /staffnotification (admin only)
if (interaction.commandName === 'staffnotification') {
if (interaction.user.id !== CONFIG.ADMIN_ID) {
logSecurity('Unauthorized command attempt', interaction.user, interaction.commandName).catch(() => {});
return interaction.reply({ content: 'This command is restricted to the bot admin.', ephemeral: true });
}
const member = interaction.options.getMember('member');
const hours = interaction.options.getInteger('hours');
if (!member) {
return interaction.reply({ content: 'Could not resolve that member.', ephemeral: true });
}
try {
await StaffNotification.findOneAndUpdate(
{ userId: member.id },
{ $set: { cooldownHours: hours, updatedAt: new Date() } },
{ upsert: true }
);
return interaction.reply({ content: `Notification cooldown for ${member.displayName} set to ${hours} hour(s).`, ephemeral: true });
} catch (err) {
console.error('staffnotification error:', err);
return interaction.reply({ content: 'Failed to update notification setting.', ephemeral: true }).catch(() => {});
}
}
if (interaction.commandName === 'notifydm') {
try {
const setting = interaction.options.getString('setting') === 'on';
await setNotifyDm(interaction.user.id, interaction.guildId, setting);
await interaction.reply({
content: `DM notifications ${setting ? 'enabled ✅' : 'disabled 🔕'}.`,
ephemeral: true
});
} catch (err) {
console.error('notifydm error:', err);
await interaction.reply({ content: 'Failed to update notification setting.', ephemeral: true }).catch(() => {});
}
return;
}
// /deescalate (tier 3 → tier 2, tier 2 → normal)
if (interaction.commandName === 'deescalate') {
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
if (!ticket) {
return interaction.reply({ content: 'This channel is not linked to a ticket.', ephemeral: true });
}
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
if (currentTier === 0) {
return interaction.reply({ content: 'This ticket is not escalated.', ephemeral: true });
}
try {
await interaction.deferReply({ ephemeral: true });
await runDeescalation(interaction, ticket);
} catch (err) {
console.error('Deescalate error:', err);
await interaction.editReply({ content: 'Failed to deescalate this ticket.' }).catch(() =>
interaction.followUp({ content: 'Failed to deescalate this ticket.', ephemeral: true }).catch(() => {})
);
}
}
// /add
if (interaction.commandName === 'add') {
const user = interaction.options.getUser('user');
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
if (!ticket) {
return interaction.reply({ content: 'This channel is not linked to a ticket.', ephemeral: true });
}
try {
await interaction.channel.permissionOverwrites.create(user.id, {
ViewChannel: true,
SendMessages: true,
ReadMessageHistory: true
});
await interaction.reply(`Added ${user} to this ticket.`);
} catch (err) {
console.error('Add user error:', err);
await interaction.reply({ content: 'Failed to add user.', ephemeral: true });
}
}
// /remove
if (interaction.commandName === 'remove') {
const user = interaction.options.getUser('user');
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
if (!ticket) {
return interaction.reply({ content: 'This channel is not linked to a ticket.', ephemeral: true });
}
try {
await interaction.channel.permissionOverwrites.delete(user.id);
await interaction.reply(`Removed ${user} from this ticket.`);
} catch (err) {
console.error('Remove user error:', err);
await interaction.reply({ content: 'Failed to remove user.', ephemeral: true });
}
}
// /transfer
if (interaction.commandName === 'transfer') {
const member = interaction.options.getUser('member');
const reason = interaction.options.getString('reason') || 'No reason provided';
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
if (!ticket) {
return interaction.reply({ content: 'This channel is not linked to a ticket.', ephemeral: true });
}
const staffRoleId = CONFIG.ROLE_TO_PING_ID;
const guildMember = await interaction.guild.members.fetch(member.id).catch(() => null);
if (!guildMember || !guildMember.roles.cache.has(staffRoleId)) {
return interaction.reply({ content: 'The target member must have the staff role.', ephemeral: true });
}
try {
const claimerLabel = guildMember.displayName || guildMember.user.username;
await Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
{ $set: { claimedBy: claimerLabel } }
);
await interaction.reply(
`Ticket transferred to ${member} by ${interaction.user}.\nReason: ${reason}`
);
const logChan = await interaction.client.channels.fetch(CONFIG.LOG_CHAN).catch(() => null);
if (logChan) {
await enqueueSend(logChan,
`Ticket ${interaction.channel} transferred from ${interaction.user.tag} to ${member.tag}.\nReason: ${reason}`
);
}
} catch (err) {
console.error('Transfer error:', err);
await interaction.reply({ content: 'Failed to transfer ticket.', ephemeral: true });
}
}
// /move
if (interaction.commandName === 'move') {
const category = interaction.options.getChannel('category');
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
if (!ticket) {
return interaction.reply({ content: 'This channel is not linked to a ticket.', ephemeral: true });
}
try {
await interaction.channel.setParent(category.id, { lockPermissions: true });
await interaction.reply(`Moved ticket to **${category.name}**.`);
const logChan = await interaction.client.channels.fetch(CONFIG.LOG_CHAN).catch(() => null);
if (logChan) {
await enqueueSend(logChan,
`Ticket ${interaction.channel} moved to category **${category.name}** by ${interaction.user.tag}`
);
}
} catch (err) {
console.error('Move error:', err);
await interaction.reply({ content: 'Failed to move ticket.', ephemeral: true });
}
}
// /gmailpoll
// /staffthread
if (interaction.commandName === 'staffthread') {
const sub = interaction.options.getSubcommand();
if (sub === 'toggle') {
CONFIG.STAFF_THREAD_ENABLED = !CONFIG.STAFF_THREAD_ENABLED;
return interaction.reply({ content: `Staff threads are now **${CONFIG.STAFF_THREAD_ENABLED ? 'enabled' : 'disabled'}**.`, ephemeral: true });
}
if (sub === 'name') {
const name = interaction.options.getString('thread_name').slice(0, 100);
CONFIG.STAFF_THREAD_NAME = name;
return interaction.reply({ content: `Staff thread name set to **${name}**.`, ephemeral: true });
}
if (sub === 'autorole') {
const enabled = interaction.options.getBoolean('enabled');
CONFIG.STAFF_THREAD_AUTO_ADD_ROLE = enabled;
return interaction.reply({ content: `Auto-add role to staff thread is now **${enabled ? 'enabled' : 'disabled'}**.`, ephemeral: true });
}
return;
}
// /pinmessages
if (interaction.commandName === 'pinmessages') {
const sub = interaction.options.getSubcommand();
const enabled = interaction.options.getBoolean('enabled');
if (sub === 'initial') {
CONFIG.PIN_INITIAL_MESSAGE_ENABLED = enabled;
return interaction.reply({ content: `Auto-pin initial message is now **${enabled ? 'enabled' : 'disabled'}**.`, ephemeral: true });
}
if (sub === 'escalation') {
CONFIG.PIN_ESCALATION_MESSAGE_ENABLED = enabled;
return interaction.reply({ content: `Auto-pin escalation message is now **${enabled ? 'enabled' : 'disabled'}**.`, ephemeral: true });
}
if (sub === 'suppress') {
CONFIG.PIN_SUPPRESS_SYSTEM_MESSAGE = enabled;
return interaction.reply({ content: `Suppress pin system message is now **${enabled ? 'enabled' : 'disabled'}**.`, ephemeral: true });
}
return;
}
if (interaction.commandName === 'gmailpoll') {
const seconds = parseInt(interaction.options.getString('interval'), 10);
const { setGmailPollInterval } = require('../broccolini-discord');
setGmailPollInterval(seconds * 1000);
logTicketEvent('Gmail poll interval updated', [{ name: 'Interval', value: `${seconds}s` }, { name: 'Set by', value: interaction.user.tag }], interaction).catch(() => {});
return interaction.reply({ content: `Gmail poll interval set to ${seconds} seconds.`, ephemeral: true });
}
// /closetimer
if (interaction.commandName === 'closetimer') {
const seconds = parseInt(interaction.options.getString('seconds'), 10);
CONFIG.FORCE_CLOSE_TIMER = seconds;
logTicketEvent('Close timer updated', [{ name: 'Duration', value: `${seconds}s` }, { name: 'Set by', value: interaction.user.tag }], interaction).catch(() => {});
return interaction.reply({ content: `Force-close timer set to ${seconds} seconds.`, ephemeral: true });
}
// /cancel-close
if (interaction.commandName === 'cancel-close') {
const pending = pendingCloses.get(interaction.channel.id);
if (!pending) {
return interaction.reply({ content: 'No pending close for this channel.', ephemeral: true });
}
clearTimeout(pending.timeout);
const { logTicketEvent } = require('../services/debugLog');
logTicketEvent('Force-close cancelled', [
{ name: 'Ticket', value: interaction.channel.name || interaction.channel.id },
{ name: 'Cancelled by', value: interaction.user.tag },
{ name: 'Original setter', value: pending.username || 'Unknown' }
], interaction).catch(() => {});
pendingCloses.delete(interaction.channel.id);
return interaction.reply({ content: 'Close cancelled.', ephemeral: true });
}
// /force-close
if (interaction.commandName === 'force-close') {
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
if (!ticket) {
return interaction.reply({ content: 'This channel is not linked to a ticket.', ephemeral: true });
}
if (pendingCloses.has(interaction.channel.id)) {
return interaction.reply({ content: 'A close is already pending for this ticket.', ephemeral: true });
}
const timerSeconds = CONFIG.FORCE_CLOSE_TIMER;
await interaction.reply(`Closing ticket in ${timerSeconds} seconds. Use \`/cancel-close\` to abort.`);
const channelRef = interaction.channel;
const clientRef = interaction.client;
const timerId = setTimeout(async () => {
pendingCloses.delete(channelRef.id);
const freshTicket = await Ticket.findOne({ discordThreadId: channelRef.id }).lean();
if (!freshTicket || freshTicket.status === 'closed') return;
try {
await Ticket.updateOne(
{ gmailThreadId: freshTicket.gmailThreadId },
{ $set: { status: 'closed' } }
);
await enqueueSend(channelRef, 'Ticket force-closed. Archiving...');
try {
await enqueueSend(channelRef, CONFIG.DISCORD_CLOSE_MESSAGE);
const messages = await channelRef.messages.fetch({ limit: 100 });
const log =
`TRANSCRIPT: ${freshTicket.subject}\nUser: ${freshTicket.senderEmail}\n---\n` +
messages
.reverse()
.map(m => `[${m.createdAt.toLocaleString()}] ${m.author.tag}: ${m.cleanContent}`)
.join('\n');
const file = new AttachmentBuilder(Buffer.from(log), {
name: `transcript-${channelRef.name}.txt`
});
const transcriptChan = await clientRef.channels
.fetch(CONFIG.TRANSCRIPT_CHAN)
.catch(() => null);
if (transcriptChan) {
const closedAt = new Date();
const openedStr = new Date(freshTicket.createdAt).toLocaleString('en-US', {
month: '2-digit', day: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit', second: '2-digit',
hour12: true, timeZoneName: 'short'
});
const closedStr = closedAt.toLocaleString('en-US', {
month: '2-digit', day: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit', second: '2-digit',
hour12: true, timeZoneName: 'short'
});
const transcriptContent = CONFIG.DISCORD_TRANSCRIPT_MESSAGE
.replace(/\{channel_name\}/g, channelRef.name)
.replace(/\{email\}/g, freshTicket.senderEmail || '')
.replace(/\{date_opened\}/g, openedStr)
.replace(/\{date_closed\}/g, closedStr)
+ `\n\nDate Opened: ${openedStr}\nDate Closed: ${closedStr}`;
await enqueueSend(transcriptChan, {
content: transcriptContent,
files: [file]
});
}
} catch (tErr) {
console.error('Transcript error (force-close):', tErr);
}
setTimeout(async () => {
try {
await channelRef.delete('Ticket force-closed');
} catch (e) {
console.error('Failed to delete channel:', e);
}
}, 5000);
} catch (err) {
console.error('Force close error:', err);
}
}, timerSeconds * 1000);
pendingCloses.set(channelRef.id, { timeout: timerId, userId: interaction.user.id, username: interaction.user.tag });
}
// /topic
if (interaction.commandName === 'topic') {
const text = interaction.options.getString('text');
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
if (!ticket) {
return interaction.reply({ content: 'This channel is not linked to a ticket.', ephemeral: true });
}
try {
await interaction.channel.setTopic(text);
await interaction.reply('Topic updated successfully.');
} catch (err) {
console.error('Topic error:', err);
await interaction.reply({ content: 'Failed to update topic.', ephemeral: true });
}
}
// /tag ticket category dropdown only
if (interaction.commandName === 'tag') {
trackInteraction('commands', 'tag', interaction.user.tag);
const categoryValue = interaction.options.getString('category');
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
if (!ticket) {
return interaction.reply({ content: 'This channel is not linked to a ticket.', ephemeral: true });
}
try {
await Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
{ $set: { ticketTag: categoryValue } }
);
const tagEntry = (TICKET_TAGS || []).find(t => t.value === categoryValue);
const emoji = tagEntry ? tagEntry.emoji : '';
const channelMessage = `Your ticket has been categorized as ${emoji} **${tagEntry ? tagEntry.name : categoryValue}** ${emoji}.`;
await interaction.reply(channelMessage);
increment('tag_usage', categoryValue, 'today');
increment('tag_usage', categoryValue, 'week');
if (ticket.game) increment(`tag_game:${categoryValue}`, ticket.game, 'week');
} catch (err) {
trackError('tag-command', err, interaction);
await interaction.reply({ content: 'Failed to set ticket category.', ephemeral: true });
}
}
// /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 {
if (subcommand === 'send') {
const name = interaction.options.getString('name');
const tag = await Tag.findOne({ name }).lean();
if (!tag) {
return interaction.reply({ content: `❌ Tag "${name}" not found.`, ephemeral: true });
}
const ticket = await Ticket.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 Tag.updateOne({ name }, { $inc: { useCount: 1 } });
await interaction.reply(content);
}
else if (subcommand === 'create') {
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.`, ephemeral: true });
} catch (err) {
if (err.code === 11000 || err.message?.includes('duplicate')) {
await interaction.reply({ content: `❌ Tag "${name}" already exists.`, ephemeral: true });
} else {
trackError('tag-create', err, interaction);
await interaction.reply({ content: '❌ Failed to create tag.', ephemeral: true });
}
}
}
else if (subcommand === 'edit') {
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.`, ephemeral: true });
} else {
await interaction.reply({ content: `✅ Tag "${name}" updated successfully.`, ephemeral: true });
}
} catch (err) {
trackError('tag-edit', err, interaction);
await interaction.reply({ content: '❌ Failed to edit tag.', ephemeral: true });
}
}
else if (subcommand === 'delete') {
const name = interaction.options.getString('name');
// Use :: delimiter so tag names with underscores are parsed 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],
ephemeral: true
});
}
else if (subcommand === 'list') {
await interaction.deferReply({ ephemeral: true });
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] });
}
} catch (err) {
trackError('response-command', err, interaction);
const errorMsg = '❌ An error occurred while processing the response command.';
if (interaction.deferred) {
await interaction.editReply(errorMsg);
} else {
await interaction.reply({ content: errorMsg, ephemeral: true });
}
}
}
// /signature
if (interaction.commandName === 'signature') {
try {
// Fetch existing signature data if it exists
const StaffSignature = mongoose.model('StaffSignature');
const existingSignature = await StaffSignature.findOne({ userId: interaction.user.id }).lean();
// Create modal
const { ModalBuilder, ActionRowBuilder, TextInputBuilder, TextInputStyle } = require('discord.js');
const modal = new ModalBuilder()
.setCustomId(`signature_modal_${interaction.user.id}`)
.setTitle('Staff Signature Settings');
// Add text inputs to modal
const valedictionInput = new TextInputBuilder()
.setCustomId('valediction')
.setLabel('Valediction (e.g. "Best regards", "Thanks")')
.setStyle(TextInputStyle.Short)
.setRequired(false)
.setValue(existingSignature?.valediction || '');
const displayNameInput = new TextInputBuilder()
.setCustomId('display_name')
.setLabel('Display Name (e.g. "Support Team")')
.setStyle(TextInputStyle.Short)
.setRequired(false)
.setValue(existingSignature?.displayName || '');
const taglineInput = new TextInputBuilder()
.setCustomId('tagline')
.setLabel('Tagline (e.g. "Technical Support Specialist")')
.setStyle(TextInputStyle.Short)
.setRequired(false)
.setValue(existingSignature?.tagline || '');
const valedictionRow = new ActionRowBuilder().addComponents(valedictionInput);
const displayNameRow = new ActionRowBuilder().addComponents(displayNameInput);
const taglineRow = new ActionRowBuilder().addComponents(taglineInput);
modal.addComponents(valedictionRow, displayNameRow, taglineRow);
await interaction.showModal(modal);
} catch (err) {
console.error('Signature command error:', err);
if (!interaction.replied && !interaction.deferred) {
await interaction.reply({ content: 'Failed to open signature settings.', ephemeral: true }).catch(() => {});
}
}
return;
}
// /accountinfo
if (interaction.commandName === 'accountinfo') {
await handleAccountInfoCommand(interaction);
return;
}
// /help
if (interaction.commandName === 'help') {
const embed = new EmbedBuilder()
.setTitle('Ticket System - Commands')
.setColor(CONFIG.EMBED_COLOR_OPEN)
.addFields([
{
name: 'User Management',
value: '`/add @user` - Add user to ticket\n`/remove @user` - Remove user from ticket'
},
{
name: 'Ticket Management',
value: '`/transfer @staff` - Transfer ticket to another staff member\n`/move #category` - Move ticket to another category\n`/force-close` - Force close ticket without confirmation\n`/topic <text>` - Set ticket topic/description\n`/priority <level>` - Set ticket priority\n`/accountinfo email` - Look up website account by email\n`/accountinfo discord @user` - Look up website account by Discord user'
},
{
name: 'Tags & Responses',
value: '`/tag` - Set ticket category (dropdown)\n`/response send <name>` - Send saved response\n`/response create|edit|delete|list` - Manage saved responses'
},
{
name: 'Variables (for tags)',
value: '`{ticket.user}`, `{ticket.email}`, `{ticket.number}`, `{ticket.subject}`, `{staff.name}`, `{server.name}`, `{date}`, `{time}`'
},
{
name: 'Panel System',
value: '`/panel #channel` - Create a ticket panel for Discord-side tickets'
},
{
name: 'Escalation',
value: '`/escalate [reason] [tier]` - Escalate ticket (tier 2 or 3, or one step)\n`/deescalate` - De-escalate ticket (tier 3→2 or tier 2→normal)'
}
])
.setFooter({ text: 'Click buttons on ticket messages to claim/close' });
await interaction.reply({ embeds: [embed], ephemeral: true });
}
// /priority
if (interaction.commandName === 'priority') {
const level = interaction.options.getString('level');
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
if (!ticket) {
return interaction.reply({ content: 'This channel is not linked to a ticket.', ephemeral: true });
}
const priorityOrder = ['low', 'normal', 'medium', 'high'];
const oldIdx = priorityOrder.indexOf((ticket.priority || 'normal').toLowerCase());
const newIdx = priorityOrder.indexOf(level.toLowerCase());
const emoji = getPriorityEmoji(level);
const levelLabel = level.charAt(0).toUpperCase() + level.slice(1).toLowerCase();
let channelMessage;
if (level === 'normal') {
channelMessage = 'Your ticket priority has returned to Normal.';
} else if (newIdx > oldIdx) {
channelMessage = `Your ticket has been upgraded to ${emoji} **${levelLabel}** ${emoji}.`;
} else if (newIdx < oldIdx) {
channelMessage = `Your ticket has been downgraded to ${emoji} **${levelLabel}** ${emoji}.`;
} else {
channelMessage = `Priority set to ${emoji} **${levelLabel}** ${emoji}.`;
}
try {
await Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
{ $set: { priority: level } }
);
const priorityTitle =
newIdx === oldIdx
? 'Priority Set'
: `Priority ${newIdx > oldIdx ? 'Upgraded' : 'Downgraded'}${levelLabel}`;
const priorityEmbed = new EmbedBuilder()
.setTitle(priorityTitle)
.setDescription(channelMessage)
.setColor(getPriorityColor(level))
.setFooter({ text: interaction.member?.displayName || interaction.user.username });
await interaction.reply({ embeds: [priorityEmbed] });
if (level === 'high' && ticket.gmailThreadId && !ticket.gmailThreadId.startsWith('discord-')) {
await sendTicketNotificationEmail(
ticket,
`Priority updated: ${levelLabel}`,
channelMessage,
interaction.member?.displayName || interaction.user.username
);
}
} catch (err) {
console.error('Priority update error:', err);
await interaction.reply({ content: 'Failed to update priority.', ephemeral: true });
}
}
// /panel
if (interaction.commandName === 'panel') {
const channel = interaction.options.getChannel('channel');
const panelType = interaction.options.getString('type') || null; // 'thread' | 'category' | 'both' or null (use CONFIG default)
const title = interaction.options.getString('title') || 'Indifferent Broccoli Tickets';
const description = interaction.options.getString('description') ||
'Need help? Click below to create a ticket. 🎟';
const embed = new EmbedBuilder()
.setTitle(title)
.setDescription(description)
.setColor(0x2ecc71)
.setThumbnail(CONFIG.LOGO_URL || null)
.setFooter({ text: 'Indifferent Broccoli Tickets' });
let row;
if (panelType === 'both') {
row = new ActionRowBuilder().addComponents(
new ButtonBuilder()
.setCustomId('open_ticket_thread')
.setLabel('Create ticket (thread)')
.setStyle(ButtonStyle.Secondary)
.setEmoji('🧵'),
new ButtonBuilder()
.setCustomId('open_ticket_channel')
.setLabel('Create ticket (channel)')
.setStyle(ButtonStyle.Secondary)
.setEmoji('📁')
);
} else if (panelType === 'thread') {
row = new ActionRowBuilder().addComponents(
new ButtonBuilder()
.setCustomId('open_ticket_thread')
.setLabel('Create ticket')
.setStyle(ButtonStyle.Secondary)
.setEmoji('🧵')
);
} else if (panelType === 'category') {
row = new ActionRowBuilder().addComponents(
new ButtonBuilder()
.setCustomId('open_ticket_channel')
.setLabel('Create ticket')
.setStyle(ButtonStyle.Secondary)
.setEmoji('📁')
);
} else {
row = new ActionRowBuilder().addComponents(
new ButtonBuilder()
.setCustomId('open_ticket')
.setLabel('Create ticket')
.setStyle(ButtonStyle.Secondary)
.setEmoji('✅')
);
}
try {
await enqueueSend(channel, { embeds: [embed], components: [row] });
await interaction.reply({ content: `Panel created in ${channel}!`, ephemeral: true });
} catch (err) {
console.error('Panel creation error:', err);
await interaction.reply({ content: 'Failed to create panel.', ephemeral: true });
}
}
// /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 {
const tickets = await Ticket.find().sort({ ticketNumber: 1 }).lean();
const lines = ['# Ticket backup ' + new Date().toISOString(), '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 backup by ${interaction.user.tag} (${tickets.length} tickets)`,
files: [new AttachmentBuilder(buf, { name: `ticket-backup-${Date.now()}.txt` })]
});
await interaction.editReply(`Backup complete. ${tickets.length} tickets sent to the backup channel.`);
} 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.');
}
}
}
/**
* Context menu interaction handler.
*/
async function handleContextMenu(interaction) {
// Restrict all guild context menus to staff role only
if (await requireStaffRole(interaction)) return;
// 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);
if (!rateLimit.allowed) {
const mins = Math.ceil((rateLimit.retryAfterMs || 0) / 60000);
return interaction.editReply(`You can only create ${CONFIG.RATE_LIMIT_TICKETS_PER_USER} ticket(s) per ${CONFIG.RATE_LIMIT_WINDOW_MINUTES} minutes. Try again in ${mins} minute(s).`);
}
try {
const message = interaction.targetMessage;
const subject = `Message from ${message.author.tag}`;
const description = message.content || 'No content';
const guild = interaction.guild;
const lastTicket = await Ticket.findOne().sort({ ticketNumber: -1 }).select('ticketNumber').lean();
const ticketNumber = (lastTicket?.ticketNumber || 0) + 1;
let channel;
let parentCategoryIdForTicket = null;
if (CONFIG.DISCORD_THREAD_CHANNEL_ID) {
try {
channel = await createDiscordTicketAsThread(guild, ticketNumber, message.author.id);
parentCategoryIdForTicket = channel.parent?.parentId ?? null;
} catch (err) {
console.error('Discord ticket thread create (from message) failed:', err.message);
return interaction.editReply('❌ Could not create ticket thread. Check DISCORD_THREAD_CHANNEL_ID.');
}
} else {
let parentId;
try {
parentId = await getOrCreateTicketCategory(
guild,
CONFIG.DISCORD_TICKET_CATEGORY_ID,
CONFIG.TICKET_CATEGORY_NAME
);
} catch (err) {
console.error('getOrCreateTicketCategory (context menu ticket):', err);
return interaction.editReply('❌ Discord ticket category could not be resolved. Contact an administrator.');
}
parentCategoryIdForTicket = parentId;
try {
channel = await guild.channels.create({
name: `ticket-${ticketNumber}`,
type: ChannelType.GuildText,
parent: parentId,
permissionOverwrites: [
{ id: guild.id, deny: [PermissionFlagsBits.ViewChannel] },
{
id: message.author.id,
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory]
},
{
id: CONFIG.ROLE_ID_TO_PING,
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory]
}
]
});
} catch (err) {
console.error('guild.channels.create (context menu ticket):', err);
return interaction.editReply('❌ Failed to create ticket channel. Contact an administrator.');
}
}
const gmailThreadId = `discord-msg-${Date.now()}-${message.id}`;
const now = new Date();
await Ticket.create({
gmailThreadId,
discordThreadId: channel.id,
senderEmail: message.author.tag,
subject,
createdAt: now,
status: 'open',
ticketNumber,
priority: 'normal',
lastActivity: now,
parentCategoryId: parentCategoryIdForTicket
});
const welcomeEmbed = new EmbedBuilder()
.setDescription(CONFIG.TICKET_WELCOME_MESSAGE)
.setColor(CONFIG.EMBED_COLOR_INFO);
const infoEmbed = new EmbedBuilder()
.setColor(CONFIG.EMBED_COLOR_INFO)
.addFields(
{ name: 'From message', value: `[Jump to message](${message.url})` },
{ name: 'Creator', value: message.author.toString(), inline: true },
{ name: 'Created by Staff', value: interaction.user.toString(), inline: true },
{ name: 'Content', value: description.slice(0, 1000) || 'No content', inline: false }
);
const row = getTicketActionRow({ escalationTier: 0 });
try {
const welcomeMsg = await enqueueSend(channel, {
content: `<@&${CONFIG.ROLE_ID_TO_PING}>\nHey There ${message.author} 🥦`,
embeds: [welcomeEmbed, infoEmbed],
components: [row]
});
await Ticket.updateOne(
{ discordThreadId: channel.id },
{ $set: { welcomeMessageId: welcomeMsg.id } }
);
} catch (err) {
console.error('welcomeMessageId-save', err);
}
await interaction.editReply(`✅ Ticket created: ${channel}`);
} catch (err) {
trackError('create-ticket-from-message', err, interaction);
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 {
const targetUser = interaction.targetUser;
const tickets = await Ticket.find({ senderEmail: targetUser.tag })
.sort({ createdAt: -1 })
.limit(10)
.lean();
if (!tickets || tickets.length === 0) {
return interaction.editReply(`📋 No tickets found for ${targetUser.tag}`);
}
const embed = new EmbedBuilder()
.setTitle(`📋 Tickets for ${targetUser.tag}`)
.setDescription(`Found ${tickets.length} ticket(s)`)
.setColor(CONFIG.EMBED_COLOR_INFO);
for (const ticket of tickets.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**Status:** ${ticket.status}\n**Claimed:** ${ticket.claimedBy || 'Unclaimed'}`,
inline: false
});
}
if (tickets.length > 5) {
embed.setFooter({ text: `Showing 5 of ${tickets.length} tickets` });
}
await interaction.editReply({ embeds: [embed] });
} catch (err) {
trackError('view-user-tickets', err, interaction);
await interaction.editReply('❌ Failed to fetch user tickets.');
}
}
}
/**
* Autocomplete handler.
*/
async function handleAutocomplete(interaction) {
if (interaction.commandName === 'response') {
const subcommand = interaction.options.getSubcommand();
if (['send', 'edit', 'delete'].includes(subcommand)) {
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 = { handleCommand, handleContextMenu, handleAutocomplete, runEscalation, runDeescalation };