/** * Button interaction handlers and the ticket-creation modal submit. * * The dispatcher pattern: handleButton splits buttons into two tables — * FREE_BUTTON_HANDLERS for buttons that don't need a ticket (open-ticket * panel, tag-delete cancel) and TICKET_BUTTON_HANDLERS for buttons fired * inside a ticket channel. The dispatcher does one ticket lookup before * delegating to a TICKET_BUTTON_HANDLERS entry. To find a button's * implementation, search for handleButton (or handleTagDelete*). */ const { ChannelType, ActionRowBuilder, ButtonBuilder, ButtonStyle, AttachmentBuilder, EmbedBuilder, MessageFlags, PermissionFlagsBits, ModalBuilder, TextInputBuilder, TextInputStyle } = require('discord.js'); const { mongoose } = require('../db-connection'); const { CONFIG } = require('../config'); const { makeTicketName, resolveCreatorNickname, getOrCreateTicketCategory, cleanupEmptyOverflowCategory, checkTicketCreationRateLimit, toDiscordSafeName } = require('../services/tickets'); const { sendTicketClosedEmail } = require('../services/gmail'); const { getTicketActionRow } = require('../utils/ticketComponents'); const { sanitizeEmbedText, truncateEmbedDescription } = require('../utils'); const { enqueueRename, enqueueSend } = require('../services/channelQueue'); const { runEscalation, runDeescalation } = require('./commands'); const { pendingCloses } = require('./pendingCloses'); const { addMemberToStaffThread, createStaffThread } = require('../services/staffThread'); const { pinMessage } = require('../services/pinMessage'); const { logError, logTicketEvent } = require('../services/debugLog'); const { findTicketForChannel, runDeferred } = require('./sharedHelpers'); const Ticket = mongoose.model('Ticket'); const Transcript = mongoose.model('Transcript'); const Tag = mongoose.model('Tag'); // ============================================================ // Free-standing button handlers (no ticket lookup) // ============================================================ /** * Open-ticket panel button (any of `open_ticket`, `open_ticket_thread`, * `open_ticket_channel`). Shows the ticket-creation modal. */ async function handleOpenTicketModal(interaction) { const modalCustomId = interaction.customId === 'open_ticket' ? 'ticket_modal' : interaction.customId === 'open_ticket_thread' ? 'ticket_modal_thread' : 'ticket_modal_channel'; return interaction.showModal(buildOpenTicketModal(modalCustomId)); } function buildOpenTicketModal(modalCustomId) { 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 modal; } async function handleTagDeleteCancel(interaction) { return interaction.update({ content: 'Tag deletion cancelled.', components: [] }); } async function handleTagDeleteConfirm(interaction) { 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) { logError('tag-delete-confirm', err, interaction).catch(() => {}); await interaction.update({ content: '❌ Failed to delete tag.', components: [] }); } } // ============================================================ // Ticket-scoped button handlers // ============================================================ /** Toggle claim/unclaim on the current ticket and rewrite the action row. */ async function handleClaimButton(interaction, ticket) { const freshTicket = await Ticket.findOne({ gmailThreadId: ticket.gmailThreadId }).lean(); if (!freshTicket) { return interaction.reply({ content: 'Ticket data missing.', flags: MessageFlags.Ephemeral }); } 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.', flags: MessageFlags.Ephemeral }); } const row = ActionRowBuilder.from(row0); const [btnClose, btnClaim] = row.components; if (!btnClose || !btnClaim) { return interaction.reply({ content: 'Buttons missing.', flags: MessageFlags.Ephemeral }); } if (isClaimed && !isClaimedByMe && !CONFIG.ALLOW_CLAIM_OVERWRITE) { return interaction.reply({ content: `This ticket is already claimed by **${freshTicket.claimedBy}**.`, flags: MessageFlags.Ephemeral }); } const isClaiming = !isClaimed || (isClaimed && !isClaimedByMe && CONFIG.ALLOW_CLAIM_OVERWRITE); if (isClaiming) { await applyClaim(interaction, freshTicket, row, btnClose, btnClaim, claimerLabel, guild); } else { await applyUnclaim(interaction, freshTicket, row, btnClose, btnClaim, claimerLabel, guild); } } async function applyClaim(interaction, freshTicket, row, btnClose, btnClaim, claimerLabel, guild) { await Ticket.updateOne( { gmailThreadId: freshTicket.gmailThreadId }, { $set: { claimedBy: claimerLabel, claimerId: interaction.user.id } } ); freshTicket.claimedBy = claimerLabel; freshTicket.claimerId = interaction.user.id; const claimerEmoji = CONFIG.STAFF_EMOJIS[interaction.user.id] || CONFIG.CLAIMER_EMOJI_FALLBACK; const creatorNickname = await resolveCreatorNickname(guild, freshTicket); const state = freshTicket.escalated ? 'escalated-claimed' : 'claimed'; enqueueRename(interaction.channel, makeTicketName(state, freshTicket, creatorNickname, claimerEmoji)) .catch(err => logError('rename', err).catch(() => {})); 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(`Unclaim (${claimerLabel})`); 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] }); await addMemberToStaffThread(interaction.channel, interaction.user.id).catch(() => {}); } async function applyUnclaim(interaction, freshTicket, row, btnClose, btnClaim, claimerLabel, guild) { await Ticket.updateOne( { gmailThreadId: freshTicket.gmailThreadId }, { $set: { claimedBy: null, claimerId: null } } ); freshTicket.claimedBy = null; freshTicket.claimerId = null; const creatorNickname = await resolveCreatorNickname(guild, freshTicket); const state = (freshTicket.escalationTier ?? 0) >= 1 ? 'escalated' : 'unclaimed'; enqueueRename(interaction.channel, makeTicketName(state, freshTicket, creatorNickname)) .catch(err => logError('rename', err).catch(() => {})); 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] }); } /** * First-stage Close button: prompt the staff member with confirm/cancel * variants. Email tickets get a "Confirm Close With Email" / "Without Email" * choice; Discord-only tickets get a single "Confirm Close". */ async function handleCloseButton(interaction, ticket) { const isEmailTicket = ticket.gmailThreadId && !ticket.gmailThreadId.startsWith('discord-'); const buttons = []; if (isEmailTicket) { buttons.push( new ButtonBuilder().setCustomId('confirm_close_with_email').setLabel('Confirm Close With Email').setStyle(ButtonStyle.Danger), new ButtonBuilder().setCustomId('confirm_close_no_email').setLabel('Confirm Close Without Email').setStyle(ButtonStyle.Danger) ); } else { buttons.push( new ButtonBuilder().setCustomId('confirm_close').setLabel('Confirm Close').setStyle(ButtonStyle.Danger) ); } buttons.push( new ButtonBuilder().setCustomId('cancel_close').setLabel('Cancel').setStyle(ButtonStyle.Secondary) ); return interaction.reply({ content: 'Are you sure you want to close this ticket?', components: [new ActionRowBuilder().addComponents(...buttons)] }); } /** * Confirm-close button (any of `confirm_close`, `confirm_close_with_email`, * `confirm_close_no_email`). Starts a countdown; staff can hit `cancel_close` * to abort. After the timer elapses, runFinalClose() does the archive+delete. */ async function handleConfirmCloseRequest(interaction, ticket) { const sendEmail = interaction.customId !== 'confirm_close_no_email'; const timerSeconds = CONFIG.FORCE_CLOSE_TIMER; if (pendingCloses.has(interaction.channel.id)) { return interaction.reply({ content: 'A close is already pending for this ticket.', flags: MessageFlags.Ephemeral }); } const cancelRow = new ActionRowBuilder().addComponents( new ButtonBuilder().setCustomId('cancel_close').setLabel('Cancel Close').setStyle(ButtonStyle.Secondary) ); await interaction.update({ content: `Closing ticket in ${timerSeconds} seconds.`, components: [cancelRow] }); const channelId = interaction.channel.id; const channelName = interaction.channel.name; const userTag = interaction.user.tag; // Lazy require — broccolini-discord re-exports trackTimeout and we'd otherwise cycle. const { trackTimeout } = require('../broccolini-discord'); const timerId = trackTimeout(setTimeout(async () => { const pending = pendingCloses.get(channelId); pendingCloses.delete(channelId); const freshTicket = await Ticket.findOne({ discordThreadId: channelId }).lean(); if (!freshTicket || freshTicket.status === 'closed') return; logTicketEvent('Force-close timer fired', [ { name: 'Ticket', value: channelName || channelId }, { name: 'Set by', value: userTag }, { name: 'Duration', value: `${timerSeconds}s` } ]).catch(() => {}); const effectiveSendEmail = pending?.sendEmail ?? true; await runFinalClose(interaction, freshTicket, effectiveSendEmail); }, timerSeconds * 1000)); pendingCloses.set(channelId, { timeout: timerId, userId: interaction.user.id, username: userTag, sendEmail }); } async function handleCancelCloseRequest(interaction) { const pending = pendingCloses.get(interaction.channel.id); if (pending) { clearTimeout(pending.timeout); pendingCloses.delete(interaction.channel.id); } return interaction.update({ content: 'Close cancelled.', components: [] }); } /** * Escalate button: shows a tier 2 / tier 3 picker. The picker buttons are * `escalate_to_tier2` / `escalate_to_tier3`, handled by handleEscalateButton. */ async function handleEscalatePrompt(interaction, ticket) { const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0); if (currentTier >= 2) { return interaction.reply({ content: 'This ticket is already at tier 3 support.', flags: MessageFlags.Ephemeral }); } const buttons = []; if (currentTier < 1) { buttons.push(new ButtonBuilder().setCustomId('escalate_to_tier2').setLabel('To Tier 2').setStyle(ButtonStyle.Secondary)); } if (currentTier < 2) { buttons.push(new ButtonBuilder().setCustomId('escalate_to_tier3').setLabel('To Tier 3').setStyle(ButtonStyle.Secondary)); } return interaction.reply({ content: 'Escalate to which tier?', components: [new ActionRowBuilder().addComponents(buttons)], flags: MessageFlags.Ephemeral }); } /** * Tier-pick button (`escalate_to_tier2` or `escalate_to_tier3`). Validates * the target tier, then delegates to runEscalation() in handlers/commands.js. */ async function handleEscalateButton(interaction, ticket) { const tier = interaction.customId === 'escalate_to_tier3' ? 2 : 1; const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0); if (currentTier >= tier) { return interaction.reply({ content: `This ticket is already at tier ${tier + 1}.`, flags: MessageFlags.Ephemeral }); } const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-'); const categoryId = tier === 1 ? (isDiscordTicket ? CONFIG.DISCORD_ESCALATED2_CHANNEL_ID : CONFIG.EMAIL_ESCALATED2_CHANNEL_ID) : (isDiscordTicket ? CONFIG.DISCORD_ESCALATED3_CHANNEL_ID : CONFIG.EMAIL_ESCALATED3_CHANNEL_ID); if (!categoryId && !interaction.channel.isThread()) { return interaction.reply({ content: `Tier ${tier + 1} (ESCALATED${tier + 1}) is not configured for this ticket type.`, flags: MessageFlags.Ephemeral }); } await runDeferred(interaction, 'escalate', () => runEscalation(interaction, ticket, tier, null)); } async function handleDeescalateButton(interaction, ticket) { const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0); if (currentTier === 0) { return interaction.reply({ content: 'This ticket is not escalated.', flags: MessageFlags.Ephemeral }); } await runDeferred(interaction, 'deescalate', () => runDeescalation(interaction, ticket), { flags: MessageFlags.Ephemeral } ); } // ============================================================ // Final close: archive → transcript → delete // ============================================================ /** * Runs after the force-close countdown elapses (or the staff member * confirmed without a countdown). Archives the channel into a transcript, * posts to the transcript channel and optionally DMs the creator, sends the * customer closure email (email tickets only), then deletes the channel. */ async function runFinalClose(interaction, ticket, sendEmail = true) { 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 channelName = interaction.channel.name; const transcriptText = await buildTranscriptText(interaction.channel, ticket); const file = new AttachmentBuilder(Buffer.from(transcriptText), { name: `transcript-${channelName}.txt` }); const openedStr = formatDateForTranscript(ticket.createdAt); const closedStr = formatDateForTranscript(closedAt); const transcriptContent = renderTranscriptHeader(channelName, ticket.senderEmail, openedStr, closedStr); await enqueueSend(interaction.channel, CONFIG.DISCORD_CLOSE_MESSAGE); let transcriptMsg = null; const transcriptChan = await interaction.client.channels .fetch(CONFIG.TRANSCRIPT_CHANNEL_ID) .catch(() => null); if (transcriptChan) { transcriptMsg = await enqueueSend(transcriptChan, { content: transcriptContent, files: [file] }); } // Optionally DM the transcript to the ticket creator. Many users have // server-member DMs disabled; gated to avoid 50007 noise. Discord-origin // tickets only. if (CONFIG.TRANSCRIPT_DM_TO_CREATOR && ticket.gmailThreadId?.startsWith('discord-')) { await dmTranscriptToCreator(interaction.client, ticket, channelName, transcriptText, openedStr, closedStr); } await postCloseLogEntry(interaction, ticket, channelName); const closerDisplayName = interaction.member?.displayName || interaction.user.username; if (sendEmail && !ticket.gmailThreadId?.startsWith('discord-')) { await sendTicketClosedEmail(ticket, closerDisplayName, interaction.user.id); } // $unset welcomeMessageId so a future reopen on this thread doesn't carry // a stale message ID pointing into the now-deleted channel. await Ticket.updateOne( { gmailThreadId: ticket.gmailThreadId }, { $set: { discordThreadId: null, status: 'closed' }, $unset: { welcomeMessageId: '' } } ); if (transcriptMsg?.id) { await Transcript.create({ gmailThreadId: ticket.gmailThreadId, transcriptMessageId: transcriptMsg.id, createdAt: new Date() }); } const parentCatId = ticket.parentCategoryId; const guildRef = interaction.guild; // Lazy require — same cycle reason as in handleConfirmCloseRequest above. const { trackTimeout } = require('../broccolini-discord'); trackTimeout(setTimeout(() => interaction.channel.delete().catch(() => {}), 5000)); trackTimeout(setTimeout(() => { if (parentCatId && guildRef) { cleanupEmptyOverflowCategory(guildRef, parentCatId, CONFIG.TICKET_CATEGORY_NAME).catch(() => {}); } }, 6000)); } catch (e) { console.error('Close ticket error:', e); } } /** Render the last 100 messages of a channel as a plaintext transcript. */ async function buildTranscriptText(channel, ticket) { const messages = await channel.messages.fetch({ limit: 100 }); return `TRANSCRIPT: ${ticket.subject}\nUser: ${ticket.senderEmail}\n---\n` + messages .reverse() .map(m => `[${m.createdAt.toLocaleString()}] ${m.author.tag}: ${m.cleanContent}`) .join('\n'); } function formatDateForTranscript(d) { return new Date(d).toLocaleString('en-US', { month: '2-digit', day: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: true, timeZoneName: 'short' }); } function renderTranscriptHeader(channelName, senderEmail, openedStr, closedStr) { return CONFIG.DISCORD_TRANSCRIPT_MESSAGE .replace(/\{channel_name\}/g, channelName) .replace(/\{email\}/g, senderEmail || '') .replace(/\{date_opened\}/g, openedStr) .replace(/\{date_closed\}/g, closedStr) + `\n\nDate Opened: ${openedStr}\nDate Closed: ${closedStr}`; } async function dmTranscriptToCreator(client, ticket, channelName, transcriptText, openedStr, closedStr) { // Prefer ticket.creatorId (stored on creation). Fall back to legacy parsing for // pre-creatorId modal tickets only — split-pop returns the wrong value for // discord-msg-* tickets (it yields the message ID, not the user ID). const creatorId = ticket.creatorId || (ticket.gmailThreadId.startsWith('discord-msg-') ? null : ticket.gmailThreadId.split('-').pop()); if (!creatorId) return; try { const creator = await client.users.fetch(creatorId); const dmFile = new AttachmentBuilder(Buffer.from(transcriptText), { 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) { // 50007 = "Cannot send messages to this user" — user has DMs off. Expected; ignore. if (dmErr?.code !== 50007) { logError('transcript-dm', dmErr).catch(() => {}); } } } async function postCloseLogEntry(interaction, ticket, channelName) { if (!CONFIG.LOGGING_CHANNEL_ID) return; const logChan = await interaction.client.channels.fetch(CONFIG.LOGGING_CHANNEL_ID).catch(() => null); if (!logChan) return; const closerMention = interaction.user.toString(); const closerDisplayName = interaction.member?.displayName || interaction.user.username; let logMsg; if (ticket.gmailThreadId?.startsWith('discord-')) { const creatorId = ticket.creatorId || (ticket.gmailThreadId.startsWith('discord-msg-') ? null : ticket.gmailThreadId.split('-').pop()); let creator = null; if (creatorId) { creator = await interaction.client.users.fetch(creatorId).catch(() => null); } logMsg = creator ? `Closed ${creator.toString()}'s **${channelName}** by ${closerMention} (${closerDisplayName})` : `Closed **${channelName}** by ${closerMention} (${closerDisplayName})`; } else { logMsg = `Closed **${channelName}** (${ticket.senderEmail}) by ${closerMention} (${closerDisplayName})`; } await enqueueSend(logChan, logMsg); } // ============================================================ // Ticket-creation modal submit (open-ticket panel → modal → ticket channel) // ============================================================ async function handleTicketModal(interaction) { await interaction.deferReply({ flags: MessageFlags.Ephemeral }); const email = interaction.fields.getTextInputValue('ticket_email').trim().toLowerCase(); 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 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; const creatorNickname = interaction.member?.displayName || interaction.user.username; const unclaimedName = toDiscordSafeName(`unclaimed-${creatorNickname}-${ticketNumber}`); let parentCategoryIdForTicket; try { parentCategoryIdForTicket = 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.'); } let channel; try { // Initial permissionOverwrites on guild.channels.create are safe-by-construction: // the channel doesn't exist yet, so there's no in-flight rename/send/move to race // against. Any *subsequent* mutation on this channel (add/remove user, move, // topic, rename) must go through services/channelQueue.js. channel = await guild.channels.create({ name: unclaimedName, type: ChannelType.GuildText, parent: parentCategoryIdForTicket, 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, creatorId: interaction.user.id, parentCategoryId: parentCategoryIdForTicket }); const welcomeMsg = await postTicketWelcomeEmbeds(channel, interaction, email, game, description); await createStaffThread(channel, interaction.client).catch(() => {}); if (CONFIG.PIN_INITIAL_MESSAGE_ENABLED && welcomeMsg) { await pinMessage(welcomeMsg, interaction.client).catch(() => {}); } await interaction.deleteReply().catch(() => {}); if (CONFIG.LOGGING_CHANNEL_ID) { const logChan = await interaction.client.channels.fetch(CONFIG.LOGGING_CHANNEL_ID).catch(() => null); if (logChan) { await enqueueSend(logChan, `📝 ${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.'); } } /** Build and send the welcome / info / resources embeds when a ticket is created via the modal. */ async function postTicketWelcomeEmbeds(channel, interaction, email, game, description) { const descTrimmed = description.length > 500 ? description.slice(0, 497) + '…' : description; const welcomeEmbed = new EmbedBuilder() .setTitle("We got your ticket.") .setDescription("We'll be with you as soon as possible.") .setColor(5763719) .setThumbnail("https://indifferentbroccoli.com/img/broccoli_shadow_square.png"); const infoEmbed = new EmbedBuilder() .setColor(5763719) .setDescription(truncateEmbedDescription( `**Account Email:**\n\`\`\`\n${sanitizeEmbedText(email)}\n\`\`\`\n` + `**Game:**\n\`\`\`\n${sanitizeEmbedText(game) || "Not specified"}\n\`\`\`\n` + `**What do you need help with?**\n\`\`\`\n${sanitizeEmbedText(descTrimmed)}\n\`\`\`` )); const resourcesEmbed = new EmbedBuilder() .setTitle("We're ~~happy~~ indifferent to help. :indifferentbroccoli:") .setDescription("Please feel free to add any additional information to the ticket, including recent changes to the server, if any.") .setColor(5763719) .addFields( { name: "Check out our wiki for guides:", value: "[Indifferent Broccolipedia](https://wiki.indifferentbroccoli.com)", inline: false } ) .setFooter({ text: "indifferent broccoli tickets (:|)", iconURL: "https://i.ibb.co/sJdytfFM/Untitled-design-6.png" }); const actionRow = getTicketActionRow({ escalationTier: 0 }); let welcomeMsg; try { welcomeMsg = await enqueueSend(channel, { content: `Hey There ${interaction.user} 🥦`, embeds: [welcomeEmbed, infoEmbed, resourcesEmbed], components: [actionRow] }); await Ticket.updateOne( { discordThreadId: channel.id }, { $set: { welcomeMessageId: welcomeMsg.id } } ); } catch (err) { console.error('welcomeMessageId-save', err); } return welcomeMsg; } // ============================================================ // Dispatch tables // ============================================================ /** Buttons that don't depend on a ticket-bound channel. */ const FREE_BUTTON_HANDLERS = { open_ticket: handleOpenTicketModal, open_ticket_thread: handleOpenTicketModal, open_ticket_channel: handleOpenTicketModal, cancel_delete_tag: handleTagDeleteCancel }; /** Buttons that fire inside a ticket channel. The dispatcher does the lookup. */ const TICKET_BUTTON_HANDLERS = { claim_ticket: handleClaimButton, close_ticket: handleCloseButton, confirm_close: handleConfirmCloseRequest, confirm_close_with_email: handleConfirmCloseRequest, confirm_close_no_email: handleConfirmCloseRequest, cancel_close: handleCancelCloseRequest, escalate_ticket: handleEscalatePrompt, escalate_to_tier2: handleEscalateButton, escalate_to_tier3: handleEscalateButton, deescalate_ticket: handleDeescalateButton }; async function handleButton(interaction) { const { customId } = interaction; // Tag-delete confirm has a dynamic id (`confirm_delete_tag::`). if (customId.startsWith('confirm_delete_tag::')) { return handleTagDeleteConfirm(interaction); } const freeHandler = FREE_BUTTON_HANDLERS[customId]; if (freeHandler) return freeHandler(interaction); const ticketHandler = TICKET_BUTTON_HANDLERS[customId]; if (!ticketHandler) return; const ticket = await findTicketForChannel( interaction, 'This channel is not linked to a ticket, or the ticket could not be found.' ); if (!ticket) return; return ticketHandler(interaction, ticket); } module.exports = { handleButton, handleTicketModal };