1208 lines
48 KiB
JavaScript
1208 lines
48 KiB
JavaScript
/**
|
||
* 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, getOrCreateTicketCategory, createDiscordTicketAsThread, checkTicketCreationRateLimit } = require('../services/tickets');
|
||
const { sendTicketNotificationEmail } = require('../services/gmail');
|
||
const { getTicketActionRow } = require('../utils/ticketComponents');
|
||
const { getEmailRouting } = require('../services/guildSettings');
|
||
const { enqueueRename, enqueueMove } = require('../services/channelQueue');
|
||
const { moveStaffChannel } = require('../services/staffChannel');
|
||
const { setNotifyDm } = require('../services/staffSettings');
|
||
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');
|
||
|
||
/**
|
||
* 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
|
||
});
|
||
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.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 } }
|
||
);
|
||
ticket.escalated = true;
|
||
ticket.escalationTier = nextTier;
|
||
|
||
const renameInfo = await canRename(ticket);
|
||
if (renameInfo.ok) {
|
||
const newName = makeTicketName(
|
||
{ escalated: true, claimed: false },
|
||
ticket,
|
||
interaction.guild
|
||
);
|
||
try {
|
||
await enqueueRename(interaction.channel, 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 enqueueMove(interaction.channel, categoryId);
|
||
}
|
||
|
||
if (!interaction.channel.isThread()) {
|
||
try {
|
||
const emoji = nextTier === 1 ? CONFIG.PRIORITY_MEDIUM_EMOJI : CONFIG.PRIORITY_HIGH_EMOJI;
|
||
const baseName = interaction.channel.name.replace(/^[🟢🟡🔴]/, '');
|
||
const renameInfoEsc = await canRename(ticket);
|
||
if (renameInfoEsc.ok) await enqueueRename(interaction.channel, `${emoji}${baseName}`);
|
||
const tierCategory = nextTier === 1 ? CONFIG.STAFF_T2_CATEGORY : CONFIG.STAFF_T3_CATEGORY;
|
||
if (tierCategory) await enqueueMove(interaction.channel, tierCategory);
|
||
if (ticket.staffChannelId) {
|
||
const staffChan = await interaction.guild.channels.fetch(ticket.staffChannelId).catch(() => null);
|
||
if (staffChan) await moveStaffChannel(staffChan, tierCategory);
|
||
}
|
||
} catch (e) {
|
||
console.error('Staff tier category (escalate):', e);
|
||
}
|
||
}
|
||
|
||
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 interaction.channel.send(
|
||
`${heyLine}\n**Getting the senior ${roleMention} for you.**`
|
||
);
|
||
|
||
const escalationBody = CONFIG.ESCALATION_MESSAGE
|
||
.replace(/\{support_name\}/g, CONFIG.SUPPORT_NAME)
|
||
+ (reason ? `\n\n**Reason:** ${reason}` : '');
|
||
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);
|
||
await interaction.channel.send({
|
||
content: null,
|
||
embeds: [escalatedEmbed],
|
||
components: [escalationRow]
|
||
});
|
||
|
||
if (!isDiscordTicket && ticket.gmailThreadId) {
|
||
const emailBody = CONFIG.ESCALATION_MESSAGE.replace(/\{support_name\}/g, CONFIG.SUPPORT_NAME) + (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;
|
||
|
||
await Ticket.updateOne(
|
||
{ gmailThreadId: ticket.gmailThreadId },
|
||
{ $set: { escalated: newTier > 0, escalationTier: newTier, claimedBy: null } }
|
||
);
|
||
ticket.escalated = newTier > 0;
|
||
ticket.escalationTier = newTier;
|
||
|
||
const baseName = interaction.channel.name.replace(/^[🟢🟡🔴]/, '');
|
||
const renameInfo = await canRename(ticket);
|
||
if (renameInfo.ok) {
|
||
try {
|
||
const emoji = newTier === 0 ? CONFIG.PRIORITY_LOW_EMOJI : CONFIG.PRIORITY_MEDIUM_EMOJI;
|
||
await enqueueRename(
|
||
interaction.channel,
|
||
newTier === 0 ? baseName : `${emoji}${baseName}`
|
||
);
|
||
} 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()) {
|
||
try {
|
||
if (newTier === 0 && CONFIG.STAFF_T1_CATEGORY) await enqueueMove(interaction.channel, CONFIG.STAFF_T1_CATEGORY);
|
||
if (newTier === 1 && CONFIG.STAFF_T2_CATEGORY) await enqueueMove(interaction.channel, CONFIG.STAFF_T2_CATEGORY);
|
||
} 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 logChan.send(
|
||
`${ticketType} ticket ${interaction.channel} de‑escalated 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.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 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(() => {})
|
||
);
|
||
}
|
||
}
|
||
|
||
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 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...');
|
||
|
||
try {
|
||
await interaction.channel.send(CONFIG.DISCORD_CLOSE_MESSAGE);
|
||
|
||
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) {
|
||
const closedAt = new Date();
|
||
const openedStr = new Date(ticket.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, interaction.channel.name)
|
||
.replace(/\{email\}/g, ticket.senderEmail || '')
|
||
.replace(/\{date_opened\}/g, openedStr)
|
||
.replace(/\{date_closed\}/g, closedStr)
|
||
+ `\n\nDate Opened: ${openedStr}\nDate Closed: ${closedStr}`;
|
||
await transcriptChan.send({
|
||
content: transcriptContent,
|
||
files: [file]
|
||
});
|
||
}
|
||
} catch (tErr) {
|
||
console.error('Transcript error (force-close):', tErr);
|
||
}
|
||
|
||
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');
|
||
// 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 });
|
||
}
|
||
}
|
||
}
|
||
|
||
// /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 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) {
|
||
// 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 });
|
||
|
||
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 };
|