diff --git a/handlers/buttons.js b/handlers/buttons.js index 432cc58..2498e85 100644 --- a/handlers/buttons.js +++ b/handlers/buttons.js @@ -1,6 +1,12 @@ /** - * Button interaction handlers – claim, close, priority, tag delete, - * open-ticket panel button, and ticket_modal submission. + * 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, @@ -23,295 +29,103 @@ const { sanitizeEmbedText, truncateEmbedDescription } = require('../utils'); const { enqueueRename, enqueueSend } = require('../services/channelQueue'); const { runEscalation, runDeescalation } = require('./commands'); const { pendingCloses } = require('./pendingCloses'); -const { logError } = require('../services/debugLog'); +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) +// ============================================================ + /** - * Main button/modal handler – called from interactionCreate. + * Open-ticket panel button (any of `open_ticket`, `open_ticket_thread`, + * `open_ticket_channel`). Shows the ticket-creation modal. */ -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); - } - - // --- 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 isEmailTicket = ticket.gmailThreadId && !ticket.gmailThreadId.startsWith('discord-'); - const confirmRow = new ActionRowBuilder(); - if (isEmailTicket) { - confirmRow.addComponents( - 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), - new ButtonBuilder() - .setCustomId('cancel_close') - .setLabel('Cancel') - .setStyle(ButtonStyle.Secondary) - ); - } else { - confirmRow.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' || - interaction.customId === 'confirm_close_with_email' || - interaction.customId === 'confirm_close_no_email' - ) { - 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.', ephemeral: true }); - } - 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 timerId = setTimeout(async () => { - const pending = pendingCloses.get(interaction.channel.id); - pendingCloses.delete(interaction.channel.id); - const freshTicket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean(); - if (!freshTicket || freshTicket.status === 'closed') return; - const { logTicketEvent } = require('../services/debugLog'); - logTicketEvent('Force-close timer fired', [ - { name: 'Ticket', value: interaction.channel.name || interaction.channel.id }, - { name: 'Set by', value: interaction.user.tag }, - { name: 'Duration', value: `${timerSeconds}s` } - ]).catch(() => {}); - const effectiveSendEmail = pending?.sendEmail ?? true; - await handleConfirmClose(interaction, freshTicket, effectiveSendEmail); - }, timerSeconds * 1000); - pendingCloses.set(interaction.channel.id, { timeout: timerId, userId: interaction.user.id, username: interaction.user.tag, sendEmail }); - return; - } - - if (interaction.customId === 'cancel_close') { - 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 (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 escalateButtons = []; - if (currentTier < 1) { - escalateButtons.push( - new ButtonBuilder() - .setCustomId('escalate_to_tier2') - .setLabel('To Tier 2') - .setStyle(ButtonStyle.Secondary) - ); - } - if (currentTier < 2) { - escalateButtons.push( - new ButtonBuilder() - .setCustomId('escalate_to_tier3') - .setLabel('To Tier 3') - .setStyle(ButtonStyle.Secondary) - ); - } - const choiceRow = new ActionRowBuilder().addComponents(escalateButtons); - 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.EMAIL_ESCALATED2_CHANNEL_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, null); - } catch (err) { - logError('escalate-button-tier2', err, interaction).catch(() => {}); - 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, null); - } catch (err) { - logError('escalate-button-tier3', err, interaction).catch(() => {}); - 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) { - logError('deescalate-button', err, interaction).catch(() => {}); - 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::')) { - 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: [] - }); - } - } - - 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. +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)); } -// --- CLAIM LOGIC --- -async function handleClaim(interaction, ticket) { +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.', ephemeral: true }); } const isClaimed = !!freshTicket.claimedBy; - const claimerLabel = - interaction.member?.displayName || interaction.user.username; + const claimerLabel = interaction.member?.displayName || interaction.user.username; const guild = interaction.guild; const isClaimedByMe = freshTicket.claimedBy === claimerLabel; @@ -322,7 +136,6 @@ async function handleClaim(interaction, ticket) { const row = ActionRowBuilder.from(row0); const [btnClose, btnClaim] = row.components; - if (!btnClose || !btnClaim) { return interaction.reply({ content: 'Buttons missing.', ephemeral: true }); } @@ -334,153 +147,277 @@ async function handleClaim(interaction, ticket) { }); } - 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; - - 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'; - const newName = makeTicketName(state, freshTicket, creatorNickname, claimerEmoji); - enqueueRename(interaction.channel, newName).catch(err => logError('rename', err).catch(() => {})); - - const label = `Unclaim (${claimerLabel})`; - - 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] }); - const { addMemberToStaffThread } = require('../services/staffThread'); - await addMemberToStaffThread(interaction.channel, interaction.user.id).catch(() => {}); + const isClaiming = !isClaimed || (isClaimed && !isClaimedByMe && CONFIG.ALLOW_CLAIM_OVERWRITE); + if (isClaiming) { + await applyClaim(interaction, freshTicket, row, btnClose, btnClaim, claimerLabel, guild); } else { - // Unclaim - await Ticket.updateOne( - { gmailThreadId: freshTicket.gmailThreadId }, - { $set: { claimedBy: null, claimerId: null } } - ); - freshTicket.claimedBy = null; - freshTicket.claimerId = null; - - const creatorNicknameUnclaim = await resolveCreatorNickname(guild, freshTicket); - const unclaimState = (freshTicket.escalationTier ?? 0) >= 1 ? 'escalated' : 'unclaimed'; - enqueueRename(interaction.channel, makeTicketName(unclaimState, freshTicket, creatorNicknameUnclaim)).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] }); + await applyUnclaim(interaction, freshTicket, row, btnClose, btnClaim, claimerLabel, guild); } } -// --- CONFIRM CLOSE --- -async function handleConfirmClose(interaction, ticket, sendEmail = true) { +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.', ephemeral: true }); + } + + 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; + + const timerId = 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.', ephemeral: true }); + } + + 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)], + ephemeral: true + }); +} + +/** + * 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}.`, ephemeral: true }); + } + + 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.`, + ephemeral: true + }); + } + + 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.', ephemeral: true }); + } + + await runDeferred(interaction, 'deescalate', + () => runDeescalation(interaction, ticket), + { ephemeral: true } + ); +} + +// ============================================================ +// 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 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' + const transcriptText = await buildTranscriptText(interaction.channel, ticket); + const file = new AttachmentBuilder(Buffer.from(transcriptText), { + name: `transcript-${channelName}.txt` }); - // In-ticket message before transcript is posted (Discord close message) - const discordCloseContent = CONFIG.DISCORD_CLOSE_MESSAGE; - await enqueueSend(interaction.channel, discordCloseContent); + 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); - 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 enqueueSend(transcriptChan, { content: transcriptContent, @@ -488,62 +425,20 @@ async function handleConfirmClose(interaction, ticket, sendEmail = true) { }); } - // DM the transcript to the ticket creator (Discord-originated tickets). - // Gated because many users have DMs from server members disabled — the send - // then 50007s and generates noise. Default off; enable via env when desired. + // 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-')) { - 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) { - // 50007 = "Cannot send messages to this user" — user has DMs off. Expected; ignore. - if (dmErr?.code !== 50007) { - logError('transcript-dm', dmErr).catch(() => {}); - } - } + await dmTranscriptToCreator(interaction.client, ticket, channelName, transcriptText, openedStr, closedStr); } - const logChan = await interaction.client.channels - .fetch(CONFIG.LOGGING_CHANNEL_ID) - .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 enqueueSend(logChan, logMsg); - } - - const closerDisplayName = - interaction.member?.displayName || interaction.user.username; + 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); } + await Ticket.updateOne( { gmailThreadId: ticket.gmailThreadId }, { $set: { discordThreadId: null, status: 'closed' } } @@ -560,25 +455,92 @@ async function handleConfirmClose(interaction, ticket, sendEmail = true) { const parentCatId = ticket.parentCategoryId; const guildRef = interaction.guild; - setTimeout( - () => interaction.channel.delete().catch(() => {}), - 5000 - ); + setTimeout(() => interaction.channel.delete().catch(() => {}), 5000); setTimeout(() => { - (async () => { - if (parentCatId && guildRef) { - await cleanupEmptyOverflowCategory(guildRef, parentCatId, CONFIG.TICKET_CATEGORY_NAME); - } - })(); + if (parentCatId && guildRef) { + cleanupEmptyOverflowCategory(guildRef, parentCatId, CONFIG.TICKET_CATEGORY_NAME).catch(() => {}); + } }, 6000); } catch (e) { console.error('Close ticket error:', e); } } -/** - * Handle the ticket_modal submission (from the open-ticket panel button). - */ +/** 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) { + const creatorId = ticket.gmailThreadId.split('-').pop(); + 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.gmailThreadId.split('-').pop(); + try { + const creator = await interaction.client.users.fetch(creatorId); + logMsg = `Closed ${creator.toString()}'s **${channelName}** by ${closerMention} (${closerDisplayName})`; + } catch { + logMsg = `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({ ephemeral: true }); @@ -599,14 +561,12 @@ async function handleTicketModal(interaction) { const lastTicket = await Ticket.findOne().sort({ ticketNumber: -1 }).select('ticketNumber').lean(); const ticketNumber = (lastTicket?.ticketNumber || 0) + 1; - const creatorNicknameModal = interaction.member?.displayName || interaction.user.username; - const unclaimedName = toDiscordSafeName(`unclaimed-${creatorNicknameModal}-${ticketNumber}`); + const creatorNickname = interaction.member?.displayName || interaction.user.username; + const unclaimedName = toDiscordSafeName(`unclaimed-${creatorNickname}-${ticketNumber}`); - let channel; - let parentCategoryIdForTicket = null; - let parentId; + let parentCategoryIdForTicket; try { - parentId = await getOrCreateTicketCategory( + parentCategoryIdForTicket = await getOrCreateTicketCategory( guild, CONFIG.DISCORD_TICKET_CATEGORY_ID, CONFIG.TICKET_CATEGORY_NAME @@ -615,13 +575,14 @@ async function handleTicketModal(interaction) { console.error('getOrCreateTicketCategory (ticket modal):', err); return interaction.editReply('Discord ticket category could not be resolved. Contact an administrator.'); } - parentCategoryIdForTicket = parentId; + + let channel; try { // TODO(queue-migrate): initial permissionOverwrites here are fine since the channel is just being created, but any later permissionOverwrites mutation on this channel should go through channelQueue. channel = await guild.channels.create({ name: unclaimedName, type: ChannelType.GuildText, - parent: parentId, + parent: parentCategoryIdForTicket, permissionOverwrites: [ { id: guild.id, deny: [PermissionFlagsBits.ViewChannel] }, { @@ -655,64 +616,20 @@ async function handleTicketModal(interaction) { parentCategoryId: parentCategoryIdForTicket }); - 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); - } - - const { createStaffThread } = require('../services/staffThread'); + const welcomeMsg = await postTicketWelcomeEmbeds(channel, interaction, email, game, description); await createStaffThread(channel, interaction.client).catch(() => {}); if (CONFIG.PIN_INITIAL_MESSAGE_ENABLED && welcomeMsg) { - const { pinMessage } = require('../services/pinMessage'); await pinMessage(welcomeMsg, interaction.client).catch(() => {}); } await interaction.deleteReply().catch(() => {}); - 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}` - ); + 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); @@ -720,4 +637,99 @@ async function handleTicketModal(interaction) { } } +/** 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 }; diff --git a/handlers/commands.js b/handlers/commands.js index 57b5979..a49d3ff 100644 --- a/handlers/commands.js +++ b/handlers/commands.js @@ -28,6 +28,7 @@ const { setNotifyDm } = require('../services/staffSettings'); const { pinMessage } = require('../services/pinMessage'); const { logError, logTicketEvent } = require('../services/debugLog'); const { pendingCloses } = require('./pendingCloses'); +const { findTicketForChannel, runDeferred } = require('./sharedHelpers'); const Ticket = mongoose.model('Ticket'); const Tag = mongoose.model('Tag'); @@ -53,37 +54,6 @@ async function requireStaffRole(interaction) { return true; } -/** - * Look up the ticket linked to this channel; reply with a friendly message - * and return null if the channel is not a ticket. Returns the ticket on - * success. - */ -async function findTicketForChannel(interaction) { - const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean(); - if (!ticket) { - await interaction.reply({ content: 'This channel is not linked to a ticket.', ephemeral: true }); - return null; - } - return ticket; -} - -/** - * Defer + run + log + reply on error. `verb` is the user-facing verb (e.g. - * "escalate"); error messages render as "Failed to this ticket." - */ -async function runDeferred(interaction, verb, fn, { ephemeral = false } = {}) { - try { - await interaction.deferReply({ ephemeral }); - await fn(); - } catch (err) { - console.error(`${verb} error:`, err); - const msg = `Failed to ${verb} this ticket.`; - await interaction.editReply({ content: msg }).catch(() => - interaction.followUp({ content: msg, ephemeral: true }).catch(() => {}) - ); - } -} - /** Fetch the configured logging channel, or null if unset/missing. */ async function fetchLoggingChannel(client) { if (!CONFIG.LOGGING_CHANNEL_ID) return null; diff --git a/handlers/sharedHelpers.js b/handlers/sharedHelpers.js new file mode 100644 index 0000000..4cb1fcf --- /dev/null +++ b/handlers/sharedHelpers.js @@ -0,0 +1,53 @@ +/** + * Shared helpers for slash-command and button handlers. + * + * Both handlers/commands.js and handlers/buttons.js use these to avoid + * repeating the lookup-and-defer-and-try-catch pattern across 30+ branches. + */ +const { mongoose } = require('../db-connection'); +const { logError } = require('../services/debugLog'); + +const Ticket = mongoose.model('Ticket'); + +/** + * Look up the ticket linked to this channel; reply with `missingMessage` + * (default: "This channel is not linked to a ticket.") and return null if + * the channel is not a ticket. Returns the ticket on success. + * + * @param {import('discord.js').Interaction} interaction + * @param {string} [missingMessage] + */ +async function findTicketForChannel(interaction, missingMessage = 'This channel is not linked to a ticket.') { + const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean(); + if (!ticket) { + await interaction.reply({ content: missingMessage, ephemeral: true }); + return null; + } + return ticket; +} + +/** + * Defer + run + log + reply on error. `verb` is the user-facing verb + * (e.g. "escalate"); error messages render as "Failed to this ticket." + * Errors are logged to console + DEBUGGING_CHANNEL_ID via logError(verb, ...). + * + * @param {import('discord.js').Interaction} interaction + * @param {string} verb + * @param {() => Promise} fn + * @param {{ ephemeral?: boolean }} [opts] + */ +async function runDeferred(interaction, verb, fn, { ephemeral = false } = {}) { + try { + await interaction.deferReply({ ephemeral }); + await fn(); + } catch (err) { + console.error(`${verb} error:`, err); + logError(verb, err, interaction).catch(() => {}); + const msg = `Failed to ${verb} this ticket.`; + await interaction.editReply({ content: msg }).catch(() => + interaction.followUp({ content: msg, ephemeral: true }).catch(() => {}) + ); + } +} + +module.exports = { findTicketForChannel, runDeferred };