Files
broccolini-bot/handlers/commands.js
root 519788c633 Initial commit
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-10 08:22:19 -06:00

1103 lines
43 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, ZAMMAD, TICKET_TAGS } = require('../config');
const { getPriorityEmoji, replaceVariables, escapeRegex } = require('../utils');
const { canRename, makeTicketName, pickTicketCategoryId, createDiscordTicketAsThread, checkTicketCreationRateLimit } = require('../services/tickets');
const { closeZammadTicket, ensureZammadUserForDiscordUser } = require('../services/zammad');
const { sendTicketNotificationEmail } = require('../services/gmail');
const { getTicketActionRow } = require('../utils/ticketComponents');
const { getEmailRouting } = require('../services/guildSettings');
const { trackInteraction, trackError, getAnalyticsSummary } = require('./analytics');
const { handleAccountInfoCommand } = require('./accountinfo');
const { handleSetupCommand } = require('./setup');
const Ticket = mongoose.model('Ticket');
const Tag = mongoose.model('Tag');
const User = mongoose.model('User');
/**
* 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.DISCORD_ESCALATED_CATEGORY_ID) : (CONFIG.EMAIL_ESCALATED2_CHANNEL_ID || CONFIG.EMAIL_ESCALATED_CATEGORY_ID))
: (isDiscordTicket ? CONFIG.DISCORD_ESCALATED3_CHANNEL_ID : CONFIG.EMAIL_ESCALATED3_CHANNEL_ID);
await Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
{ $set: { escalated: true, escalationTier: nextTier, claimedBy: null } }
);
ticket.escalated = true;
ticket.escalationTier = nextTier;
ticket.claimedBy = null;
const renameInfo = await canRename(ticket);
if (renameInfo.ok) {
const newName = makeTicketName(
{ escalated: true, claimed: false },
ticket,
interaction.guild
);
try {
await interaction.channel.setName(newName);
} catch (e) {
console.error('Rename error (escalate):', e);
}
} else {
const unlockAtMs = Date.now() + renameInfo.waitMs;
const unlockAtUnix = Math.floor(unlockAtMs / 1000);
await interaction.channel.send(
`Channel renamed too quickly. Try again <t:${unlockAtUnix}:R>.`
);
}
if (!interaction.channel.isThread() && categoryId) {
await interaction.channel.setParent(categoryId, { lockPermissions: true });
}
const pendingEmbed = new EmbedBuilder()
.setDescription('Ticket will be escalated in a few seconds.')
.setColor(0xe74c3c);
await interaction.reply({ content: null, 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 interaction.channel.send(
`${heyLine}\n**Getting the senior ${roleMention} for you.**`
);
const seniorLine = `A senior ${CONFIG.SUPPORT_NAME} will be here to assist as soon as possible.`;
const escalatedEmbed = new EmbedBuilder()
.setDescription(
`${CONFIG.ESCALATION_MESSAGE}\n\n${seniorLine}${reason ? `\n\n**Reason:** ${reason}` : ''}`
)
.setColor(0x2ecc71);
const updatedTicketForRow = { ...ticket, escalationTier: nextTier, escalated: true };
const escalationRow = getTicketActionRow(updatedTicketForRow);
await interaction.channel.send({
content: null,
embeds: [escalatedEmbed],
components: [escalationRow]
});
if (!isDiscordTicket && ticket.gmailThreadId) {
const emailBody = `${CONFIG.ESCALATION_MESSAGE}\n\n${seniorLine}${reason ? `\n\nReason: ${reason}` : ''}`;
await sendTicketNotificationEmail(
ticket,
`Ticket escalated to ${nextTier === 1 ? 'tier 2' : 'tier 3'}`,
emailBody,
interaction.member?.displayName || interaction.user.username
);
}
if (nextTier === 2 && ticket.welcomeMessageId) {
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 logChan.send(
`${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;
let categoryId = null;
if (!interaction.channel.isThread()) {
if (newTier === 0) {
const categoryIds = isDiscordTicket
? [CONFIG.DISCORD_TICKET_CATEGORY_ID, ...(CONFIG.DISCORD_TICKET_OVERFLOW_CATEGORY_IDS || [])]
: [CONFIG.TICKET_CATEGORY_ID, ...(CONFIG.EMAIL_TICKET_OVERFLOW_CATEGORY_IDS || [])];
categoryId = pickTicketCategoryId(interaction.guild, categoryIds);
} else {
categoryId = isDiscordTicket ? CONFIG.DISCORD_ESCALATED2_CHANNEL_ID : CONFIG.EMAIL_ESCALATED2_CHANNEL_ID;
if (!categoryId) categoryId = isDiscordTicket ? CONFIG.DISCORD_ESCALATED_CATEGORY_ID : CONFIG.EMAIL_ESCALATED_CATEGORY_ID;
}
}
await Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
{ $set: { escalated: newTier > 0, escalationTier: newTier, claimedBy: null } }
);
ticket.escalated = newTier > 0;
ticket.escalationTier = newTier;
const renameInfo = await canRename(ticket);
if (renameInfo.ok) {
const newName = makeTicketName(
{ escalated: newTier > 0, claimed: false },
ticket,
interaction.guild
);
try {
await interaction.channel.setName(newName);
} catch (e) {
console.error('Rename error (deescalate):', e);
}
} else {
const unlockAtMs = Date.now() + renameInfo.waitMs;
const unlockAtUnix = Math.floor(unlockAtMs / 1000);
await interaction.channel.send(
`Channel renamed too quickly. Try again <t:${unlockAtUnix}:R>.`
);
}
if (!interaction.channel.isThread() && categoryId) {
await interaction.channel.setParent(categoryId, { lockPermissions: true });
}
const tierLabel = newTier === 0 ? 'normal' : newTier === 1 ? 'tier 2' : 'tier 3';
await interaction.reply({
content: `Ticket deescalated to **${tierLabel}** support.`,
ephemeral: true
});
const logChan = await interaction.client.channels.fetch(CONFIG.LOG_CHAN).catch(() => null);
if (logChan) {
const ticketType = isDiscordTicket ? 'Discord' : 'Email';
await logChan.send(
`${ticketType} ticket ${interaction.channel} deescalated to ${tierLabel} by ${interaction.user.tag}.`
);
}
}
/**
* Main slash-command handler.
*/
async function handleCommand(interaction) {
// /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 (optionally to a target tier; works for both email and Discord)
if (interaction.commandName === 'escalate') {
const reason = interaction.options.getString('reason') || 'No reason provided.';
const tierOption = interaction.options.getInteger('tier'); // 2 or 3 from choice, or null
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 });
}
const nextTier = tierOption != null
? (tierOption === 3 ? 2 : 1) // 3 → DB tier 2, 2 → DB tier 1
: currentTier + 1;
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.DISCORD_ESCALATED_CATEGORY_ID) : (CONFIG.EMAIL_ESCALATED2_CHANNEL_ID || CONFIG.EMAIL_ESCALATED_CATEGORY_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 runEscalation(interaction, ticket, nextTier, reason);
} catch (err) {
console.error('Escalate error:', err);
await interaction.reply({ content: 'Failed to escalate this ticket.', ephemeral: true });
}
}
// /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 runDeescalation(interaction, ticket);
} catch (err) {
console.error('Deescalate error:', err);
await interaction.reply({ content: 'Failed to deescalate this ticket.', ephemeral: true });
}
}
// /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 logChan.send(
`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 logChan.send(
`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 });
}
}
// /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 });
}
try {
await Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
{ $set: { status: 'closed' } }
);
await interaction.reply('Ticket force-closed. Archiving...');
// Generate transcript inline (same as confirm_close)
try {
const messages = await interaction.channel.messages.fetch({ limit: 100 });
const log =
`TRANSCRIPT: ${ticket.subject}\nUser: ${ticket.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-${interaction.channel.name}.txt`
});
const transcriptChan = await interaction.client.channels
.fetch(CONFIG.TRANSCRIPT_CHAN)
.catch(() => null);
if (transcriptChan) {
await transcriptChan.send({
content: `Force-closed transcript: \`${ticket.senderEmail}\``,
files: [file]
});
}
} catch (tErr) {
console.error('Transcript error (force-close):', tErr);
}
if (ticket.zammadTicketId && ZAMMAD.URL && ZAMMAD.TOKEN) {
await closeZammadTicket(ticket.zammadTicketId);
}
setTimeout(async () => {
try {
await interaction.channel.delete('Ticket force-closed');
} catch (e) {
console.error('Failed to delete channel:', e);
}
}, 5000);
} catch (err) {
console.error('Force close error:', err);
await interaction.reply({ content: 'Failed to close ticket.', ephemeral: true });
}
}
// /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);
} 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');
const confirmRow = new ActionRowBuilder().addComponents(
new ButtonBuilder()
.setCustomId(`confirm_delete_tag_${name}`)
.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 });
}
}
}
// /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 } }
);
await interaction.reply(channelMessage);
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.Success)
.setEmoji('🧵'),
new ButtonBuilder()
.setCustomId('open_ticket_channel')
.setLabel('Create ticket (channel)')
.setStyle(ButtonStyle.Success)
.setEmoji('📁')
);
} else if (panelType === 'thread') {
row = new ActionRowBuilder().addComponents(
new ButtonBuilder()
.setCustomId('open_ticket_thread')
.setLabel('Create ticket')
.setStyle(ButtonStyle.Success)
.setEmoji('🧵')
);
} else if (panelType === 'category') {
row = new ActionRowBuilder().addComponents(
new ButtonBuilder()
.setCustomId('open_ticket_channel')
.setLabel('Create ticket')
.setStyle(ButtonStyle.Success)
.setEmoji('📁')
);
} else {
row = new ActionRowBuilder().addComponents(
new ButtonBuilder()
.setCustomId('open_ticket')
.setLabel('Create ticket')
.setStyle(ButtonStyle.Success)
.setEmoji('✅')
);
}
try {
await channel.send({ 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 channel.send({
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 channel.send({
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.');
}
}
// /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) {
// 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;
if (CONFIG.DISCORD_THREAD_CHANNEL_ID) {
try {
channel = await createDiscordTicketAsThread(guild, ticketNumber, message.author.id);
} 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 {
const categoryIds = [CONFIG.DISCORD_TICKET_CATEGORY_ID, ...(CONFIG.DISCORD_TICKET_OVERFLOW_CATEGORY_IDS || [])];
const parentId = pickTicketCategoryId(guild, categoryIds);
if (!parentId) {
return interaction.editReply('❌ Discord ticket category not found or all categories full (50 channels max). Contact an administrator.');
}
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]
}
]
});
}
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
});
try {
const websiteUser = await User.findOne({ discordID: String(message.author.id) })
.select('email discordID firstname lastname')
.lean();
if (websiteUser?.email) {
await ensureZammadUserForDiscordUser(websiteUser);
}
} catch (zErr) {
console.error('Zammad user ensure (Discord ticket from message) failed:', zErr.message);
}
const welcomeEmbed = new EmbedBuilder()
.setDescription("We got your ticket. We'll be with you as soon as possible.\nFeel free to add any additional information to your ticket.")
.setColor(0x2ecc71);
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 });
const welcomeMsg = await channel.send({
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 } }
);
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 };