/** * Button interaction handlers – claim, close, priority, tag delete, * open-ticket panel button, and ticket_modal submission. */ const { ChannelType, ActionRowBuilder, ButtonBuilder, ButtonStyle, AttachmentBuilder, EmbedBuilder, PermissionFlagsBits, ModalBuilder, TextInputBuilder, TextInputStyle } = require('discord.js'); const { mongoose } = require('../db-connection'); const { CONFIG } = require('../config'); const { canRename, makeTicketName, minutesFromMs, getOrCreateTicketCategory, cleanupEmptyOverflowCategory, createDiscordTicketAsThread, checkTicketCreationRateLimit, getSenderLocal } = require('../services/tickets'); const { sendTicketClosedEmail } = require('../services/gmail'); const { getTicketActionRow } = require('../utils/ticketComponents'); const { setEmailRouting } = require('../services/guildSettings'); const { enqueueRename } = require('../services/channelQueue'); const { runEscalation, runDeescalation } = require('./commands'); const { trackInteraction, trackError } = require('./analytics'); const Ticket = mongoose.model('Ticket'); const Transcript = mongoose.model('Transcript'); const Tag = mongoose.model('Tag'); const User = mongoose.model('User'); /** * Main button/modal handler – called from interactionCreate. */ async function handleButton(interaction) { // --- "Open Ticket" panel buttons → show modal --- if (interaction.customId === 'open_ticket' || interaction.customId === 'open_ticket_thread' || interaction.customId === 'open_ticket_channel') { const modalCustomId = interaction.customId === 'open_ticket' ? 'ticket_modal' : interaction.customId === 'open_ticket_thread' ? 'ticket_modal_thread' : 'ticket_modal_channel'; const modal = new ModalBuilder() .setCustomId(modalCustomId) .setTitle('Please Enter Your Information'); const emailInput = new TextInputBuilder() .setCustomId('ticket_email') .setLabel('Account Email:') .setStyle(TextInputStyle.Short) .setPlaceholder('Example: broccoli@indifferentbroccoli.com') .setRequired(true) .setMaxLength(100); const gameInput = new TextInputBuilder() .setCustomId('ticket_game') .setLabel('What game do you need help with?') .setStyle(TextInputStyle.Short) .setPlaceholder('Example: Project Zomboid, Minecraft') .setRequired(true) .setMaxLength(100); const descriptionInput = new TextInputBuilder() .setCustomId('ticket_description') .setLabel('What do you need help with?') .setStyle(TextInputStyle.Paragraph) .setPlaceholder("Example: I can't connect to my server.") .setRequired(true) .setMaxLength(1000); modal.addComponents( new ActionRowBuilder().addComponents(emailInput), new ActionRowBuilder().addComponents(gameInput), new ActionRowBuilder().addComponents(descriptionInput) ); return await interaction.showModal(modal); } // --- Email routing (no ticket required) --- if (interaction.customId === 'email_routing_thread' || interaction.customId === 'email_routing_category') { const value = interaction.customId === 'email_routing_thread' ? 'thread' : 'category'; try { await setEmailRouting(interaction.guild.id, value); const label = value === 'thread' ? '**threads**' : '**channels in a category**'; await interaction.reply({ content: `Done. New email tickets will now be created as ${label}.`, ephemeral: true }); } catch (err) { trackError('email-routing-button', err, interaction); await interaction.reply({ content: 'Failed to update email routing.', ephemeral: true }).catch(() => {}); } return; } // --- Ticket-scoped buttons (need ticket lookup) --- const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean(); if (!ticket) { return interaction.reply({ content: 'This channel is not linked to a ticket, or the ticket could not be found.', ephemeral: true }); } // --- CLAIM / UNCLAIM --- if (interaction.customId === 'claim_ticket') { return handleClaim(interaction, ticket); } // --- CLOSE --- if (interaction.customId === 'close_ticket') { const confirmRow = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId('confirm_close') .setLabel('Confirm Close') .setStyle(ButtonStyle.Danger), new ButtonBuilder() .setCustomId('cancel_close') .setLabel('Cancel') .setStyle(ButtonStyle.Secondary) ); return interaction.reply({ content: 'Are you sure you want to close this ticket?', components: [confirmRow] }); } if (interaction.customId === 'confirm_close') { return handleConfirmClose(interaction, ticket); } if (interaction.customId === 'cancel_close') { return interaction.update({ content: 'Close cancelled.', components: [] }); } // --- ESCALATE (prompt for tier 2 or 3) --- if (interaction.customId === 'escalate_ticket') { 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 choiceRow = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId('escalate_to_tier2') .setLabel('To Tier 2') .setStyle(ButtonStyle.Secondary), new ButtonBuilder() .setCustomId('escalate_to_tier3') .setLabel('To Tier 3') .setStyle(ButtonStyle.Secondary) ); return interaction.reply({ content: 'Escalate to which tier?', components: [choiceRow], ephemeral: true }); } if (interaction.customId === 'escalate_to_tier2') { const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0); if (currentTier >= 1) { return interaction.reply({ content: 'This ticket is already at tier 2.', ephemeral: true }); } const categoryId = ticket.gmailThreadId.startsWith('discord-') ? (CONFIG.DISCORD_ESCALATED2_CHANNEL_ID || CONFIG.DISCORD_ESCALATED_CATEGORY_ID) : (CONFIG.EMAIL_ESCALATED2_CHANNEL_ID || CONFIG.EMAIL_ESCALATED_CATEGORY_ID); if (!categoryId && !interaction.channel.isThread()) { return interaction.reply({ content: 'Tier 2 (ESCALATED2) is not configured for this ticket type.', ephemeral: true }); } try { await interaction.deferReply(); await runEscalation(interaction, ticket, 1, 'Escalated via button (Tier 2)'); } catch (err) { trackError('escalate-button-tier2', err, interaction); await interaction.editReply({ content: 'Failed to escalate to tier 2.' }).catch(() => interaction.followUp({ content: 'Failed to escalate to tier 2.', ephemeral: true }).catch(() => {}) ); } return; } if (interaction.customId === 'escalate_to_tier3') { const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0); if (currentTier >= 2) { return interaction.reply({ content: 'This ticket is already at tier 3.', ephemeral: true }); } const categoryId = ticket.gmailThreadId.startsWith('discord-') ? CONFIG.DISCORD_ESCALATED3_CHANNEL_ID : CONFIG.EMAIL_ESCALATED3_CHANNEL_ID; if (!categoryId && !interaction.channel.isThread()) { return interaction.reply({ content: 'Tier 3 (ESCALATED3) is not configured for this ticket type.', ephemeral: true }); } try { await interaction.deferReply(); await runEscalation(interaction, ticket, 2, 'Escalated via button (Tier 3)'); } catch (err) { trackError('escalate-button-tier3', err, interaction); await interaction.editReply({ content: 'Failed to escalate to tier 3.' }).catch(() => interaction.followUp({ content: 'Failed to escalate to tier 3.', ephemeral: true }).catch(() => {}) ); } return; } // --- DEESCALATE --- if (interaction.customId === 'deescalate_ticket') { 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) { trackError('deescalate-button', err, interaction); await interaction.editReply({ content: 'Failed to deescalate this ticket.' }).catch(() => interaction.followUp({ content: 'Failed to deescalate this ticket.', ephemeral: true }).catch(() => {}) ); } return; } // --- TAG DELETE CONFIRM --- if (interaction.customId.startsWith('confirm_delete_tag::')) { trackInteraction('buttons', 'confirm-delete-tag', interaction.user.tag); const tagName = interaction.customId.slice('confirm_delete_tag::'.length); try { const result = await Tag.deleteOne({ name: tagName }); if (result.deletedCount === 0) { await interaction.update({ content: `❌ Tag "${tagName}" not found.`, components: [] }); } else { await interaction.update({ content: `✅ Tag "${tagName}" deleted successfully.`, components: [] }); } } catch (err) { trackError('tag-delete-confirm', err, interaction); await interaction.update({ content: '❌ Failed to delete tag.', components: [] }); } } if (interaction.customId === 'cancel_delete_tag') { return interaction.update({ content: 'Tag deletion cancelled.', components: [] }); } // Priority is set via /priority slash command only; no priority buttons in tickets. } // --- CLAIM LOGIC --- async function handleClaim(interaction, ticket) { const freshTicket = await Ticket.findOne({ gmailThreadId: ticket.gmailThreadId }).lean(); if (!freshTicket) { return interaction.reply({ content: 'Ticket data missing.', ephemeral: true }); } const isClaimed = !!freshTicket.claimedBy; const claimerLabel = interaction.member?.displayName || interaction.user.username; const guild = interaction.guild; const isClaimedByMe = freshTicket.claimedBy === claimerLabel; const [row0] = interaction.message.components; if (!row0) { return interaction.reply({ content: 'No components to update.', ephemeral: true }); } const row = ActionRowBuilder.from(row0); const [btnClose, btnClaim] = row.components; if (!btnClose || !btnClaim) { return interaction.reply({ content: 'Buttons missing.', ephemeral: true }); } if (isClaimed && !isClaimedByMe && !CONFIG.ALLOW_CLAIM_OVERWRITE) { return interaction.reply({ content: `This ticket is already claimed by **${freshTicket.claimedBy}**.`, ephemeral: true }); } if (!isClaimed || (isClaimed && !isClaimedByMe && CONFIG.ALLOW_CLAIM_OVERWRITE)) { await Ticket.updateOne( { gmailThreadId: freshTicket.gmailThreadId }, { $set: { claimedBy: claimerLabel, claimerId: interaction.user.id } } ); freshTicket.claimedBy = claimerLabel; freshTicket.claimerId = interaction.user.id; // Resolve claimerEmoji from STAFF_EMOJIS map (fallback to CLAIMER_EMOJI_FALLBACK) const claimerEmoji = CONFIG.STAFF_EMOJIS.get(interaction.user.id) || CONFIG.CLAIMER_EMOJI_FALLBACK; // Resolve creatorNickname: displayName for Discord tickets, senderLocal for email tickets let creatorNickname; if (freshTicket.gmailThreadId.startsWith('discord-')) { const creatorUserId = freshTicket.gmailThreadId.split('-').pop(); try { const creatorMember = await guild.members.fetch(creatorUserId); creatorNickname = creatorMember.displayName; } catch { creatorNickname = freshTicket.senderEmail; } } else { creatorNickname = getSenderLocal(freshTicket.senderEmail); } const renameInfo = await canRename(freshTicket); if (renameInfo.ok) { const newName = makeTicketName( { escalated: !!freshTicket.escalated, claimed: true }, freshTicket, guild, claimerEmoji, creatorNickname ); try { await enqueueRename(interaction.channel, newName); } catch (e) { console.error('Rename error (claim):', e); } } else { const unlockAtMs = Date.now() + renameInfo.waitMs; const unlockAtUnix = Math.floor(unlockAtMs / 1000); await interaction.channel.send( `Channel renamed too quickly. Try again .` ); } const baseLabel = `Unclaim (${claimerLabel})`; const label = renameInfo.ok ? baseLabel : `${baseLabel} – rename in ${minutesFromMs(renameInfo.waitMs)}m`; btnClose .setCustomId('close_ticket') .setLabel(CONFIG.BUTTON_LABEL_CLOSE) .setEmoji(CONFIG.BUTTON_EMOJI_CLOSE) .setStyle(ButtonStyle.Secondary) .setDisabled(false); btnClaim .setCustomId('claim_ticket') .setEmoji(CONFIG.BUTTON_EMOJI_UNCLAIM) .setStyle(ButtonStyle.Secondary) .setDisabled(false) .setLabel(label); await interaction.update({ components: [row] }); const claimText = CONFIG.TICKET_CLAIMED_MESSAGE .replace(/\{staff_mention\}/g, interaction.user.toString()) .replace(/\{staff_name\}/g, interaction.member?.displayName || interaction.user.username); const claimEmbed = new EmbedBuilder() .setTitle('✅ Ticket Claimed') .setDescription(claimText) .setColor(CONFIG.EMBED_COLOR_CLAIMED) .setFooter({ text: `Claimed by ${claimerLabel}` }); await interaction.followUp({ embeds: [claimEmbed] }); } else { // Unclaim await Ticket.updateOne( { gmailThreadId: freshTicket.gmailThreadId }, { $set: { claimedBy: null, claimerId: null, staffChannelId: null } } ); freshTicket.claimedBy = null; freshTicket.claimerId = null; freshTicket.staffChannelId = null; const renameInfo = await canRename(freshTicket); if (renameInfo.ok) { try { await enqueueRename(interaction.channel, `ticket-${freshTicket.ticketNumber}`); } catch (e) { console.error('Rename error (unclaim):', e); } } else { const unlockAtMs = Date.now() + renameInfo.waitMs; const unlockAtUnix = Math.floor(unlockAtMs / 1000); await interaction.channel.send( `Channel renamed too quickly. Try again .` ); } btnClose .setCustomId('close_ticket') .setLabel(CONFIG.BUTTON_LABEL_CLOSE) .setEmoji(CONFIG.BUTTON_EMOJI_CLOSE) .setStyle(ButtonStyle.Secondary) .setDisabled(false); btnClaim .setCustomId('claim_ticket') .setEmoji(CONFIG.BUTTON_EMOJI_CLAIM) .setStyle(ButtonStyle.Secondary) .setDisabled(false) .setLabel(CONFIG.BUTTON_LABEL_CLAIM); await interaction.update({ components: [row] }); const unclaimText = CONFIG.TICKET_UNCLAIMED_MESSAGE .replace(/\{staff_mention\}/g, interaction.user.toString()) .replace(/\{staff_name\}/g, interaction.member?.displayName || interaction.user.username); const unclaimEmbed = new EmbedBuilder() .setTitle('🔓 Ticket Unclaimed') .setDescription(unclaimText) .setColor(0x808080) .setFooter({ text: `Unclaimed by ${claimerLabel}` }); await interaction.followUp({ embeds: [unclaimEmbed] }); } } // --- CONFIRM CLOSE --- async function handleConfirmClose(interaction, ticket) { const closedAt = new Date(); try { await interaction.update({ content: 'Archiving and closing...', components: [] }); } catch { // Already acknowledged – fall back to editReply await interaction.editReply({ content: 'Archiving and closing...', components: [] }).catch(() => {}); } 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 channelName = interaction.channel.name; const opened = new Date(ticket.createdAt); const openedStr = opened.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' }); // In-ticket message before transcript is posted (Discord close message) const discordCloseContent = CONFIG.DISCORD_CLOSE_MESSAGE; await interaction.channel.send(discordCloseContent); const transcriptChan = await interaction.client.channels .fetch(CONFIG.TRANSCRIPT_CHAN) .catch(() => null); let transcriptMsg = null; const transcriptContent = CONFIG.DISCORD_TRANSCRIPT_MESSAGE .replace(/\{channel_name\}/g, channelName) .replace(/\{email\}/g, ticket.senderEmail || '') .replace(/\{date_opened\}/g, openedStr) .replace(/\{date_closed\}/g, closedStr) + `\n\nDate Opened: ${openedStr}\nDate Closed: ${closedStr}`; if (transcriptChan) { transcriptMsg = await transcriptChan.send({ content: transcriptContent, files: [file] }); } // DM the transcript to the ticket creator (Discord-originated tickets) if (ticket.gmailThreadId?.startsWith('discord-')) { const creatorId = ticket.gmailThreadId.split('-').pop(); try { const creator = await interaction.client.users.fetch(creatorId); const dmFile = new AttachmentBuilder(Buffer.from(log), { name: `transcript-${channelName}.txt` }); const dmContent = CONFIG.DISCORD_TRANSCRIPT_MESSAGE .replace(/\{channel_name\}/g, channelName) .replace(/\{email\}/g, ticket.senderEmail || '') .replace(/\{date_opened\}/g, openedStr) .replace(/\{date_closed\}/g, closedStr); await creator.send({ content: dmContent, files: [dmFile] }); } catch (dmErr) { console.warn(`Could not DM transcript to user ${creatorId}:`, dmErr.message); } } const logChan = await interaction.client.channels .fetch(CONFIG.LOG_CHAN) .catch(() => null); if (logChan) { const closerMention = interaction.user.toString(); const closerDisplayName = interaction.member?.displayName || interaction.user.username; let logMsg; if (ticket.gmailThreadId?.startsWith('discord-')) { const creatorId = ticket.gmailThreadId.split('-').pop(); try { const creator = await interaction.client.users.fetch(creatorId); const creatorMention = creator.toString(); logMsg = `Closed ${creatorMention}'s **${channelName}** by ${closerMention} (${closerDisplayName})`; } catch { logMsg = `Closed **${channelName}** by ${closerMention} (${closerDisplayName})`; } } else { logMsg = `Closed **${channelName}** (${ticket.senderEmail}) by ${closerMention} (${closerDisplayName})`; } await logChan.send(logMsg); } const closerDisplayName = interaction.member?.displayName || interaction.user.username; if (!ticket.gmailThreadId?.startsWith('discord-')) { await sendTicketClosedEmail(ticket, closerDisplayName); } await Ticket.updateOne( { gmailThreadId: ticket.gmailThreadId }, { $set: { discordThreadId: null, status: 'closed' } } ); try { const { deleteStaffChannel } = require('../services/staffChannel'); await deleteStaffChannel(interaction.guild, ticket.staffChannelId); } catch (e) { console.error('Delete staff channel (close):', e); } if (transcriptMsg?.id) { await Transcript.create({ gmailThreadId: ticket.gmailThreadId, transcriptMessageId: transcriptMsg.id, createdAt: new Date() }); } const parentCatId = ticket.parentCategoryId; const guildRef = interaction.guild; setTimeout( () => interaction.channel.delete().catch(() => {}), 5000 ); setTimeout(() => { (async () => { if (parentCatId && guildRef) { await cleanupEmptyOverflowCategory(guildRef, parentCatId, CONFIG.TICKET_CATEGORY_NAME); } })(); }, 6000); } catch (e) { console.error('Close ticket error:', e); } } /** * Handle the ticket_modal submission (from the open-ticket panel button). */ async function handleTicketModal(interaction) { await interaction.deferReply({ ephemeral: true }); const email = interaction.fields.getTextInputValue('ticket_email').trim(); const game = interaction.fields.getTextInputValue('ticket_game').trim(); const description = interaction.fields.getTextInputValue('ticket_description'); const subject = game ? `[${game}] ${description.slice(0, 60)}` : description.slice(0, 80); const priority = CONFIG.PRIORITY_ENABLED ? CONFIG.DEFAULT_PRIORITY : 'normal'; const useThread = interaction.customId === 'ticket_modal_thread' || (interaction.customId === 'ticket_modal' && !!CONFIG.DISCORD_THREAD_CHANNEL_ID); 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 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 (useThread && CONFIG.DISCORD_THREAD_CHANNEL_ID) { try { channel = await createDiscordTicketAsThread(guild, ticketNumber, interaction.user.id); parentCategoryIdForTicket = channel.parent?.parentId ?? null; } catch (err) { console.error('Discord ticket thread create failed:', err.message); return interaction.editReply('Could not create ticket thread. Check DISCORD_THREAD_CHANNEL_ID and try again.'); } } else if (useThread && !CONFIG.DISCORD_THREAD_CHANNEL_ID) { return interaction.editReply('Thread tickets are not configured (DISCORD_THREAD_CHANNEL_ID is not set). Use a channel panel or set the env variable.'); } else { let parentId; try { parentId = await getOrCreateTicketCategory( guild, CONFIG.DISCORD_TICKET_CATEGORY_ID, CONFIG.TICKET_CATEGORY_NAME ); } catch (err) { console.error('getOrCreateTicketCategory (ticket modal):', 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: interaction.user.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 (ticket modal):', err); return interaction.editReply('Failed to create ticket channel. Contact an administrator.'); } } const gmailThreadId = `discord-${Date.now()}-${interaction.user.id}`; const now = new Date(); await Ticket.create({ gmailThreadId, discordThreadId: channel.id, senderEmail: email, subject, game: game || null, createdAt: now, status: 'open', ticketNumber, priority, lastActivity: now, parentCategoryId: parentCategoryIdForTicket }); const displayName = interaction.member?.displayName || interaction.user.username; // Welcome embed (dark grey #1e2124) const welcomeEmbed = new EmbedBuilder() .setDescription(CONFIG.TICKET_WELCOME_MESSAGE) .setColor(CONFIG.EMBED_COLOR_INFO) .setFooter({ text: 'Indifferent Broccoli Tickets' }); // Ticket details embed (dark) – short labels, trimmed description const descTrimmed = description.length > 500 ? description.slice(0, 497) + '…' : description; const infoEmbed = new EmbedBuilder() .setColor(CONFIG.EMBED_COLOR_INFO) .addFields( { name: 'Email', value: email, inline: true }, { name: 'Game', value: game || 'Not specified', inline: true }, { name: 'Description', value: descTrimmed, inline: false } ) .setTimestamp(); const actionRow = getTicketActionRow({ escalationTier: 0 }); const welcomeMsg = await channel.send({ content: `Hey There ${interaction.user} 🥦`, embeds: [welcomeEmbed, infoEmbed], components: [actionRow] }); await Ticket.updateOne( { discordThreadId: channel.id }, { $set: { welcomeMessageId: welcomeMsg.id } } ); await interaction.deleteReply().catch(() => {}); const logChan = await interaction.client.channels.fetch(CONFIG.LOG_CHAN).catch(() => null); if (logChan) { await logChan.send( `📝 ${channel.name} created by ${interaction.user.tag}` ); } } catch (err) { console.error('Ticket creation error:', err); await interaction.editReply('Failed to create ticket. Please contact an administrator.'); } } module.exports = { handleButton, handleTicketModal };