/** * 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 } = 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 { const { transitioned, ticket: closedTicket } = await attemptCloseTransition(freshTicket.gmailThreadId, {}, { 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) ); setTimeout(() => { channelRef.delete('Ticket force-closed').catch(e => console.error('Failed to delete channel:', e) ); }, 5000); } 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 };