The 1028-line handlers/commands.js bundled escalation logic + force-close
flow + /response tag CRUD + /panel + /signature + context-menu handlers +
several config-toggle slash commands. After the dispatch-table refactor it
was still a god module. Split into handlers/commands/ with one file per
topic; require('./commands') resolves to handlers/commands/index.js
(handlers/commands.js is removed).
Layout:
helpers.js — requireStaffRole, fetchLoggingChannel
(cross-submodule, kept here to avoid cycles with index.js)
escalation.js — runEscalation, runDeescalation, handleEscalate, handleDeescalate
(run* are still exported via index.js for handlers/buttons.js)
close.js — handleForceClose, handleCancelClose, handleCloseTimer
+ finalizeForceClose / postTranscript (timer callback)
response.js — handleResponse + send/create/edit/delete/list subcommands
+ handleAutocomplete (only /response autocompletes)
panel.js — handlePanel, buildPanelButtonRow, handleSignature
contextMenu.js — handleCreateTicketFromMessage, handleViewUserTickets
index.js — dispatch tables, handleCommand/handleContextMenu, plus the
short-and-not-thematic handlers (notifydm, add, remove,
transfer, move, topic, staffthread, pinmessages, gmailpoll,
help) and the public re-exports.
No behavior change — every imported name, every Discord call, every DB
write, every embed, every reply payload preserved verbatim. Public surface
of require('./commands') is still { handleCommand, handleContextMenu,
handleAutocomplete, runEscalation, runDeescalation }.
Largest single module is now index.js at 299 lines; others are 33–214.
127 lines
5.1 KiB
JavaScript
127 lines
5.1 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 } = require('../../services/debugLog');
|
|
const { pendingCloses } = require('../pendingCloses');
|
|
const { findTicketForChannel } = require('../sharedHelpers');
|
|
|
|
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, userId: interaction.user.id, username: interaction.user.tag });
|
|
}
|
|
|
|
/** Performs the actual force-close work after the countdown elapses. */
|
|
async function finalizeForceClose(channelRef, clientRef) {
|
|
pendingCloses.delete(channelRef.id);
|
|
const freshTicket = await Ticket.findOne({ discordThreadId: channelRef.id }).lean();
|
|
if (!freshTicket || freshTicket.status === 'closed') return;
|
|
|
|
try {
|
|
await Ticket.updateOne(
|
|
{ gmailThreadId: freshTicket.gmailThreadId },
|
|
{ $set: { status: 'closed' } }
|
|
);
|
|
|
|
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 messages = await channelRef.messages.fetch({ limit: 100 });
|
|
const log =
|
|
`TRANSCRIPT: ${freshTicket.subject}\nUser: ${freshTicket.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-${channelRef.name}.txt`
|
|
});
|
|
|
|
const transcriptChan = await clientRef.channels
|
|
.fetch(CONFIG.TRANSCRIPT_CHANNEL_ID)
|
|
.catch(() => null);
|
|
if (!transcriptChan) return;
|
|
|
|
const fmt = (d) => 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'
|
|
});
|
|
const openedStr = fmt(freshTicket.createdAt);
|
|
const closedStr = fmt(new Date());
|
|
const transcriptContent = CONFIG.DISCORD_TRANSCRIPT_MESSAGE
|
|
.replace(/\{channel_name\}/g, channelRef.name)
|
|
.replace(/\{email\}/g, freshTicket.senderEmail || '')
|
|
.replace(/\{date_opened\}/g, openedStr)
|
|
.replace(/\{date_closed\}/g, closedStr)
|
|
+ `\n\nDate Opened: ${openedStr}\nDate Closed: ${closedStr}`;
|
|
await enqueueSend(transcriptChan, { content: transcriptContent, files: [file] });
|
|
}
|
|
|
|
module.exports = { handleCloseTimer, handleCancelClose, handleForceClose };
|