The button and slash close paths deleted the channel via a bare setTimeout that never set the pendingDelete flag, so a restart in the 5s grace window orphaned the channel (closed in DB, still present in Discord) with no recovery — only the auto-close path used the flag correctly. Extract scheduleTicketChannelDelete() in services/tickets.js: a grace-delayed, queue-routed (enqueueDelete) delete that clears pendingDelete on success. All three close paths now use it. Button/slash set pendingDelete:true and keep discordThreadId populated so resumePendingDeletes() recovers the delete on the next boot. The button path previously nulled discordThreadId before the delete, which made the channel unrecoverable.
131 lines
6.0 KiB
JavaScript
131 lines
6.0 KiB
JavaScript
/**
|
|
* Force-close flow: /force-close, /cancel-close, /closetimer, plus the
|
|
* countdown-elapses finalize step and transcript renderer that the
|
|
* countdown's setTimeout calls back into.
|
|
*
|
|
* Note: the button-driven close path lives in handlers/buttons.js
|
|
* (handleCloseButton / handleConfirmCloseRequest / runFinalClose).
|
|
* This module covers the slash-command-driven path only.
|
|
*/
|
|
const { AttachmentBuilder, MessageFlags } = require('discord.js');
|
|
const { mongoose } = require('../../db-connection');
|
|
const { CONFIG } = require('../../config');
|
|
const { enqueueSend } = require('../../services/channelQueue');
|
|
const { logTicketEvent, logError } = require('../../services/debugLog');
|
|
const { moveThreadToFolder } = require('../../services/gmailLabels');
|
|
const { pendingCloses } = require('../pendingCloses');
|
|
const { findTicketForChannel } = require('../sharedHelpers');
|
|
const { attemptCloseTransition, scheduleTicketChannelDelete } = require('../../services/tickets');
|
|
const { buildTranscriptText, formatDateForTranscript, renderTranscriptHeader } = require('../../services/transcript');
|
|
const { recordAction } = require('../../services/staffStats');
|
|
|
|
const Ticket = mongoose.model('Ticket');
|
|
|
|
async function handleCloseTimer(interaction) {
|
|
const seconds = parseInt(interaction.options.getString('seconds'), 10);
|
|
CONFIG.FORCE_CLOSE_TIMER = seconds;
|
|
logTicketEvent('Close timer updated', [
|
|
{ name: 'Duration', value: `${seconds}s` },
|
|
{ name: 'Set by', value: interaction.user.tag }
|
|
], interaction).catch(() => {});
|
|
return interaction.reply({ content: `Force-close timer set to ${seconds} seconds.`, flags: MessageFlags.Ephemeral });
|
|
}
|
|
|
|
async function handleCancelClose(interaction) {
|
|
const pending = pendingCloses.get(interaction.channel.id);
|
|
if (!pending) {
|
|
return interaction.reply({ content: 'No pending close for this channel.', flags: MessageFlags.Ephemeral });
|
|
}
|
|
clearTimeout(pending.timeout);
|
|
logTicketEvent('Force-close cancelled', [
|
|
{ name: 'Ticket', value: interaction.channel.name || interaction.channel.id },
|
|
{ name: 'Cancelled by', value: interaction.user.tag },
|
|
{ name: 'Original setter', value: pending.username || 'Unknown' }
|
|
], interaction).catch(() => {});
|
|
pendingCloses.delete(interaction.channel.id);
|
|
return interaction.reply({ content: 'Close cancelled.', flags: MessageFlags.Ephemeral });
|
|
}
|
|
|
|
async function handleForceClose(interaction) {
|
|
const ticket = await findTicketForChannel(interaction);
|
|
if (!ticket) return;
|
|
|
|
if (pendingCloses.has(interaction.channel.id)) {
|
|
return interaction.reply({ content: 'A close is already pending for this ticket.', flags: MessageFlags.Ephemeral });
|
|
}
|
|
|
|
const timerSeconds = CONFIG.FORCE_CLOSE_TIMER;
|
|
await interaction.reply(`Closing ticket in ${timerSeconds} seconds. Use \`/cancel-close\` to abort.`);
|
|
|
|
const channelRef = interaction.channel;
|
|
const clientRef = interaction.client;
|
|
const timerId = setTimeout(() => finalizeForceClose(channelRef, clientRef), timerSeconds * 1000);
|
|
pendingCloses.set(channelRef.id, { timeout: timerId, username: interaction.user.tag, closerId: interaction.user.id });
|
|
}
|
|
|
|
/** Performs the actual force-close work after the countdown elapses. */
|
|
async function finalizeForceClose(channelRef, clientRef, _TicketModel, _recordAction, _pendingCloses) {
|
|
const T = _TicketModel || Ticket;
|
|
const record = _recordAction || recordAction;
|
|
const pc = _pendingCloses || pendingCloses;
|
|
const pending = pc.get(channelRef.id);
|
|
pc.delete(channelRef.id);
|
|
const closerId = pending?.closerId ?? null;
|
|
const freshTicket = await T.findOne({ discordThreadId: channelRef.id }).lean();
|
|
if (!freshTicket || freshTicket.status === 'closed') return;
|
|
|
|
try {
|
|
// pendingDelete (with discordThreadId left set) lets resumePendingDeletes()
|
|
// recover the channel delete if a restart interrupts the grace window.
|
|
const { transitioned, ticket: closedTicket } = await attemptCloseTransition(freshTicket.gmailThreadId, { pendingDelete: true }, { welcomeMessageId: '' }, T);
|
|
if (transitioned) {
|
|
record(closerId ?? 'system', 'close', {
|
|
ticket: closedTicket,
|
|
guildId: channelRef.guild?.id,
|
|
closerType: closerId ? 'staff' : 'system',
|
|
resolverId: closedTicket.claimerId ?? null,
|
|
wasClaimed: Boolean(closedTicket.claimerId)
|
|
});
|
|
}
|
|
|
|
// File the email thread into the Resolved folder — non-fatal, email tickets only.
|
|
if (!freshTicket.gmailThreadId.startsWith('discord-')) {
|
|
moveThreadToFolder(freshTicket.gmailThreadId, 'RESOLVED')
|
|
.catch(err => logError('gmailLabels: resolved move', err).catch(() => {}));
|
|
}
|
|
|
|
await enqueueSend(channelRef, 'Ticket force-closed. Archiving...');
|
|
await postTranscript(channelRef, clientRef, freshTicket).catch(tErr =>
|
|
console.error('Transcript error (force-close):', tErr)
|
|
);
|
|
|
|
// Queue-routed, pendingDelete-guarded delete (shared with auto-close + button
|
|
// close) so a mid-close restart can't orphan the channel.
|
|
scheduleTicketChannelDelete(channelRef, freshTicket.gmailThreadId);
|
|
} catch (err) {
|
|
console.error('Force close error:', err);
|
|
}
|
|
}
|
|
|
|
/** Render and post a closing transcript for a ticket. */
|
|
async function postTranscript(channelRef, clientRef, freshTicket) {
|
|
await enqueueSend(channelRef, CONFIG.DISCORD_CLOSE_MESSAGE);
|
|
|
|
const log = await buildTranscriptText(channelRef, freshTicket);
|
|
const file = new AttachmentBuilder(Buffer.from(log), {
|
|
name: `transcript-${channelRef.name}.txt`
|
|
});
|
|
|
|
const transcriptChan = await clientRef.channels
|
|
.fetch(CONFIG.TRANSCRIPT_CHANNEL_ID)
|
|
.catch(() => null);
|
|
if (!transcriptChan) return;
|
|
|
|
const openedStr = formatDateForTranscript(freshTicket.createdAt);
|
|
const closedStr = formatDateForTranscript(new Date());
|
|
const transcriptContent = renderTranscriptHeader(channelRef.name, freshTicket.senderEmail, openedStr, closedStr);
|
|
await enqueueSend(transcriptChan, { content: transcriptContent, files: [file], allowedMentions: { parse: [] } });
|
|
}
|
|
|
|
module.exports = { handleCloseTimer, handleCancelClose, handleForceClose, finalizeForceClose };
|