Close: make side-effects best-effort so none can abort the commit+delete
runFinalClose ran the transcript archive, creator DM, close log, and closure email in the same try as the close transition and channel delete, with the transcript posted *before* the commit. A failure in any of them (notably a DiscordAPIError 50001 Missing Access when posting the transcript to the archive channel) aborted the whole close: the customer saw 'ticket closed' but the DB stayed open and the channel was never deleted. Rewrite so the close transition + pendingDelete commit FIRST, each side-effect is individually best-effort via a closeStep wrapper, and scheduleTicketChannelDelete always runs. finalizeForceClose was already commit-first; wrap its remaining unguarded archiving send too.
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user