diff --git a/handlers/buttons.js b/handlers/buttons.js index e3d6cb6..ab1bb5d 100644 --- a/handlers/buttons.js +++ b/handlers/buttons.js @@ -404,8 +404,20 @@ async function handleDeescalateButton(interaction, ticket) { * posts to the transcript channel and optionally DMs the creator, sends the * customer closure email (email tickets only), then deletes the channel. */ +// Run one best-effort close side-effect. A failure is logged but never propagates, +// so it cannot abort the close — the transition and channel delete still happen. +async function closeStep(label, fn) { + try { + await fn(); + } catch (e) { + logError(`runFinalClose:${label}`, e).catch(() => {}); + } +} + async function runFinalClose(interaction, ticket, sendEmail = true) { const closedAt = new Date(); + const channel = interaction.channel; + const channelName = channel.name; try { await interaction.update({ content: 'Archiving and closing...', components: [] }); @@ -414,90 +426,95 @@ async function runFinalClose(interaction, ticket, sendEmail = true) { 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` - }); + // Build the transcript up front — it needs the channel's history, before delete. + // Best-effort: a failure here must not block the close. + let transcriptText = null; + await closeStep('buildTranscript', async () => { transcriptText = await buildTranscriptText(channel, ticket); }); + // CRITICAL #1 — commit the close and mark pendingDelete (discordThreadId stays + // set for restart recovery). Done BEFORE the fallible side-effects below so none + // of them can leave a "closed"-looking but still-open, undeleted ticket. + let transitioned = false; + let closedTicket = null; + await closeStep('transition', async () => { + ({ transitioned, ticket: closedTicket } = + await attemptCloseTransition(ticket.gmailThreadId, { pendingDelete: true }, { welcomeMessageId: '' })); + }); + + // Customer-facing close notice (best-effort). + await closeStep('closeMessage', () => enqueueSend(channel, CONFIG.DISCORD_CLOSE_MESSAGE)); + + // Archive the transcript to the transcript channel (best-effort — a Missing + // Access here previously aborted the whole close). + let transcriptMsg = null; + if (transcriptText != null) { 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], - allowedMentions: { parse: [] } - }); - } - - // 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); - } - - // Keep discordThreadId set and mark pendingDelete so a restart during the - // grace window before the channel delete is recovered by resumePendingDeletes(). - const { transitioned, ticket: closedTicket } = await attemptCloseTransition(ticket.gmailThreadId, { pendingDelete: true }, { welcomeMessageId: '' }); - if (transitioned) { - const closerType = isStaff(interaction.member) ? 'staff' : 'user'; - recordAction(interaction.user.id, 'close', { - ticket: closedTicket, - guildId: interaction.guild?.id, - closerType, - resolverId: closedTicket.claimerId ?? null, - wasClaimed: Boolean(closedTicket.claimerId) - }); - } - - // File the email thread into the Resolved folder — non-fatal, email tickets only. - if (!ticket.gmailThreadId?.startsWith('discord-')) { - moveThreadToFolder(ticket.gmailThreadId, 'RESOLVED') - .catch(err => logError('gmailLabels: resolved move', err).catch(() => {})); - } - - if (transcriptMsg?.id) { - await Transcript.create({ - gmailThreadId: ticket.gmailThreadId, - transcriptMessageId: transcriptMsg.id, - createdAt: new Date() - }); - } - - const parentCatId = ticket.parentCategoryId; - const guildRef = interaction.guild; - - // Queue-routed, pendingDelete-guarded delete (shared with auto-close + slash - // close) so a mid-close restart can't orphan the channel. - scheduleTicketChannelDelete(interaction.channel, ticket.gmailThreadId); - - // Lazy require — same cycle reason as in handleConfirmCloseRequest above. - const { trackTimeout } = require('../broccolini-discord'); - trackTimeout(setTimeout(() => { - if (parentCatId && guildRef) { - cleanupEmptyOverflowCategory(guildRef, parentCatId, CONFIG.TICKET_CATEGORY_NAME).catch(() => {}); + await closeStep('transcriptArchive', async () => { + const transcriptContent = renderTranscriptHeader(channelName, ticket.senderEmail, openedStr, closedStr); + const file = new AttachmentBuilder(Buffer.from(transcriptText), { name: `transcript-${channelName}.txt` }); + const transcriptChan = await interaction.client.channels.fetch(CONFIG.TRANSCRIPT_CHANNEL_ID).catch(() => null); + if (transcriptChan) { + transcriptMsg = await enqueueSend(transcriptChan, { + content: transcriptContent, files: [file], allowedMentions: { parse: [] } + }); } - }, 6000)); - } catch (e) { - console.error('Close ticket error:', e); + }); + + // DM the transcript to the creator (Discord-origin tickets only). Best-effort — + // many users have member DMs disabled (50007). + if (CONFIG.TRANSCRIPT_DM_TO_CREATOR && ticket.gmailThreadId?.startsWith('discord-')) { + await closeStep('dmCreator', () => + dmTranscriptToCreator(interaction.client, ticket, channelName, transcriptText, openedStr, closedStr)); + } } + + await closeStep('closeLog', () => postCloseLogEntry(interaction, ticket, channelName)); + + if (sendEmail && !ticket.gmailThreadId?.startsWith('discord-')) { + const closerDisplayName = interaction.member?.displayName || interaction.user.username; + await closeStep('closeEmail', () => sendTicketClosedEmail(ticket, closerDisplayName, interaction.user.id)); + } + + // File the email thread into the Resolved folder — non-fatal, email tickets only. + if (!ticket.gmailThreadId?.startsWith('discord-')) { + moveThreadToFolder(ticket.gmailThreadId, 'RESOLVED') + .catch(err => logError('gmailLabels: resolved move', err).catch(() => {})); + } + + if (transitioned && closedTicket) { + const closerType = isStaff(interaction.member) ? 'staff' : 'user'; + recordAction(interaction.user.id, 'close', { + ticket: closedTicket, + guildId: interaction.guild?.id, + closerType, + resolverId: closedTicket.claimerId ?? null, + wasClaimed: Boolean(closedTicket.claimerId) + }); + } + + if (transcriptMsg?.id) { + await closeStep('transcriptRecord', () => Transcript.create({ + gmailThreadId: ticket.gmailThreadId, + transcriptMessageId: transcriptMsg.id, + createdAt: new Date() + })); + } + + // CRITICAL #2 — schedule the channel delete. Always runs, regardless of any + // side-effect failure above. + scheduleTicketChannelDelete(channel, ticket.gmailThreadId); + + // Best-effort overflow-category cleanup after the channel is gone. + const parentCatId = ticket.parentCategoryId; + const guildRef = interaction.guild; + // Lazy require — same cycle reason as in handleConfirmCloseRequest above. + const { trackTimeout } = require('../broccolini-discord'); + trackTimeout(setTimeout(() => { + if (parentCatId && guildRef) { + cleanupEmptyOverflowCategory(guildRef, parentCatId, CONFIG.TICKET_CATEGORY_NAME).catch(() => {}); + } + }, 6000)); } async function dmTranscriptToCreator(client, ticket, channelName, transcriptText, openedStr, closedStr) { diff --git a/handlers/commands/close.js b/handlers/commands/close.js index 091ca7e..bac46ad 100644 --- a/handlers/commands/close.js +++ b/handlers/commands/close.js @@ -94,7 +94,8 @@ async function finalizeForceClose(channelRef, clientRef, _TicketModel, _recordAc .catch(err => logError('gmailLabels: resolved move', err).catch(() => {})); } - await enqueueSend(channelRef, 'Ticket force-closed. Archiving...'); + // Both best-effort — a failure here must not skip the channel delete below. + await enqueueSend(channelRef, 'Ticket force-closed. Archiving...').catch(() => {}); await postTranscript(channelRef, clientRef, freshTicket).catch(tErr => console.error('Transcript error (force-close):', tErr) );