refactor handleButton into a dispatch table
Each customId now maps to a named handler in one of two tables:
FREE_BUTTON_HANDLERS (open-ticket panel, tag-delete cancel — no ticket
lookup) or TICKET_BUTTON_HANDLERS (anything fired inside a ticket channel
— the dispatcher does the lookup once before delegating). The dynamic
`confirm_delete_tag::*` id is matched by prefix.
To find a button's logic, search handle<Name>Button or handleTagDelete*.
Other cleanups in the same pass:
- Move findTicketForChannel and runDeferred from handlers/commands.js to
the new handlers/sharedHelpers.js so both files share one source of
truth. runDeferred now also calls logError(verb, ...) — was logged ad
hoc in buttons.js, missing in commands.js. Strictly additive.
- Hoist three inline `require('../services/...')` calls (staffThread,
pinMessage, debugLog) to top imports.
- Collapse escalate_to_tier2 and escalate_to_tier3 into one
handleEscalateButton(interaction, ticket) that derives the tier from
customId. Same for confirm_close / confirm_close_with_email /
confirm_close_no_email — one handleConfirmCloseRequest deriving
sendEmail from customId.
- Decompose the 156-line handleConfirmClose into runFinalClose +
buildTranscriptText + formatDateForTranscript + renderTranscriptHeader
+ dmTranscriptToCreator + postCloseLogEntry. Each piece is testable in
isolation.
- Decompose handleClaim into applyClaim + applyUnclaim.
- Extract buildOpenTicketModal() and postTicketWelcomeEmbeds() so the
ticket-creation modal flow is readable top-to-bottom.
No behavior change. handleButton + handleTicketModal exports preserved;
24/24 modules load clean (sharedHelpers.js is the new one).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,12 @@
|
||||
/**
|
||||
* Button interaction handlers – claim, close, priority, tag delete,
|
||||
* open-ticket panel button, and ticket_modal submission.
|
||||
* Button interaction handlers and the ticket-creation modal submit.
|
||||
*
|
||||
* The dispatcher pattern: handleButton splits buttons into two tables —
|
||||
* FREE_BUTTON_HANDLERS for buttons that don't need a ticket (open-ticket
|
||||
* panel, tag-delete cancel) and TICKET_BUTTON_HANDLERS for buttons fired
|
||||
* inside a ticket channel. The dispatcher does one ticket lookup before
|
||||
* delegating to a TICKET_BUTTON_HANDLERS entry. To find a button's
|
||||
* implementation, search for handle<Name>Button (or handleTagDelete*).
|
||||
*/
|
||||
const {
|
||||
ChannelType,
|
||||
@@ -23,23 +29,33 @@ const { sanitizeEmbedText, truncateEmbedDescription } = require('../utils');
|
||||
const { enqueueRename, enqueueSend } = require('../services/channelQueue');
|
||||
const { runEscalation, runDeescalation } = require('./commands');
|
||||
const { pendingCloses } = require('./pendingCloses');
|
||||
const { logError } = require('../services/debugLog');
|
||||
const { addMemberToStaffThread, createStaffThread } = require('../services/staffThread');
|
||||
const { pinMessage } = require('../services/pinMessage');
|
||||
const { logError, logTicketEvent } = require('../services/debugLog');
|
||||
const { findTicketForChannel, runDeferred } = require('./sharedHelpers');
|
||||
|
||||
const Ticket = mongoose.model('Ticket');
|
||||
const Transcript = mongoose.model('Transcript');
|
||||
const Tag = mongoose.model('Tag');
|
||||
|
||||
// ============================================================
|
||||
// Free-standing button handlers (no ticket lookup)
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Main button/modal handler – called from interactionCreate.
|
||||
* Open-ticket panel button (any of `open_ticket`, `open_ticket_thread`,
|
||||
* `open_ticket_channel`). Shows the ticket-creation modal.
|
||||
*/
|
||||
async function handleButton(interaction) {
|
||||
// --- "Open Ticket" panel buttons → show modal ---
|
||||
if (interaction.customId === 'open_ticket' || interaction.customId === 'open_ticket_thread' || interaction.customId === 'open_ticket_channel') {
|
||||
async function handleOpenTicketModal(interaction) {
|
||||
const modalCustomId = interaction.customId === 'open_ticket'
|
||||
? 'ticket_modal'
|
||||
: interaction.customId === 'open_ticket_thread'
|
||||
? 'ticket_modal_thread'
|
||||
: 'ticket_modal_channel';
|
||||
return interaction.showModal(buildOpenTicketModal(modalCustomId));
|
||||
}
|
||||
|
||||
function buildOpenTicketModal(modalCustomId) {
|
||||
const modal = new ModalBuilder()
|
||||
.setCustomId(modalCustomId)
|
||||
.setTitle('Please Enter Your Information');
|
||||
@@ -74,244 +90,42 @@ async function handleButton(interaction) {
|
||||
new ActionRowBuilder().addComponents(descriptionInput)
|
||||
);
|
||||
|
||||
return await interaction.showModal(modal);
|
||||
return modal;
|
||||
}
|
||||
|
||||
// --- Ticket-scoped buttons (need ticket lookup) ---
|
||||
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
|
||||
if (!ticket) {
|
||||
return interaction.reply({
|
||||
content: 'This channel is not linked to a ticket, or the ticket could not be found.',
|
||||
ephemeral: true
|
||||
});
|
||||
async function handleTagDeleteCancel(interaction) {
|
||||
return interaction.update({ content: 'Tag deletion cancelled.', components: [] });
|
||||
}
|
||||
|
||||
// --- CLAIM / UNCLAIM ---
|
||||
if (interaction.customId === 'claim_ticket') {
|
||||
return handleClaim(interaction, ticket);
|
||||
}
|
||||
|
||||
// --- CLOSE ---
|
||||
if (interaction.customId === 'close_ticket') {
|
||||
const isEmailTicket = ticket.gmailThreadId && !ticket.gmailThreadId.startsWith('discord-');
|
||||
const confirmRow = new ActionRowBuilder();
|
||||
if (isEmailTicket) {
|
||||
confirmRow.addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId('confirm_close_with_email')
|
||||
.setLabel('Confirm Close With Email')
|
||||
.setStyle(ButtonStyle.Danger),
|
||||
new ButtonBuilder()
|
||||
.setCustomId('confirm_close_no_email')
|
||||
.setLabel('Confirm Close Without Email')
|
||||
.setStyle(ButtonStyle.Danger),
|
||||
new ButtonBuilder()
|
||||
.setCustomId('cancel_close')
|
||||
.setLabel('Cancel')
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
);
|
||||
} else {
|
||||
confirmRow.addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId('confirm_close')
|
||||
.setLabel('Confirm Close')
|
||||
.setStyle(ButtonStyle.Danger),
|
||||
new ButtonBuilder()
|
||||
.setCustomId('cancel_close')
|
||||
.setLabel('Cancel')
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
);
|
||||
}
|
||||
|
||||
return interaction.reply({
|
||||
content: 'Are you sure you want to close this ticket?',
|
||||
components: [confirmRow]
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
interaction.customId === 'confirm_close' ||
|
||||
interaction.customId === 'confirm_close_with_email' ||
|
||||
interaction.customId === 'confirm_close_no_email'
|
||||
) {
|
||||
const sendEmail = interaction.customId !== 'confirm_close_no_email';
|
||||
const timerSeconds = CONFIG.FORCE_CLOSE_TIMER;
|
||||
if (pendingCloses.has(interaction.channel.id)) {
|
||||
return interaction.reply({ content: 'A close is already pending for this ticket.', ephemeral: true });
|
||||
}
|
||||
const cancelRow = new ActionRowBuilder().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId('cancel_close')
|
||||
.setLabel('Cancel Close')
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
);
|
||||
await interaction.update({ content: `Closing ticket in ${timerSeconds} seconds.`, components: [cancelRow] });
|
||||
const timerId = setTimeout(async () => {
|
||||
const pending = pendingCloses.get(interaction.channel.id);
|
||||
pendingCloses.delete(interaction.channel.id);
|
||||
const freshTicket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
|
||||
if (!freshTicket || freshTicket.status === 'closed') return;
|
||||
const { logTicketEvent } = require('../services/debugLog');
|
||||
logTicketEvent('Force-close timer fired', [
|
||||
{ name: 'Ticket', value: interaction.channel.name || interaction.channel.id },
|
||||
{ name: 'Set by', value: interaction.user.tag },
|
||||
{ name: 'Duration', value: `${timerSeconds}s` }
|
||||
]).catch(() => {});
|
||||
const effectiveSendEmail = pending?.sendEmail ?? true;
|
||||
await handleConfirmClose(interaction, freshTicket, effectiveSendEmail);
|
||||
}, timerSeconds * 1000);
|
||||
pendingCloses.set(interaction.channel.id, { timeout: timerId, userId: interaction.user.id, username: interaction.user.tag, sendEmail });
|
||||
return;
|
||||
}
|
||||
|
||||
if (interaction.customId === 'cancel_close') {
|
||||
const pending = pendingCloses.get(interaction.channel.id);
|
||||
if (pending) {
|
||||
clearTimeout(pending.timeout);
|
||||
pendingCloses.delete(interaction.channel.id);
|
||||
}
|
||||
return interaction.update({ content: 'Close cancelled.', components: [] });
|
||||
}
|
||||
|
||||
// --- ESCALATE (prompt for tier 2 or 3) ---
|
||||
if (interaction.customId === 'escalate_ticket') {
|
||||
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
|
||||
if (currentTier >= 2) {
|
||||
return interaction.reply({ content: 'This ticket is already at tier 3 support.', ephemeral: true });
|
||||
}
|
||||
const escalateButtons = [];
|
||||
if (currentTier < 1) {
|
||||
escalateButtons.push(
|
||||
new ButtonBuilder()
|
||||
.setCustomId('escalate_to_tier2')
|
||||
.setLabel('To Tier 2')
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
);
|
||||
}
|
||||
if (currentTier < 2) {
|
||||
escalateButtons.push(
|
||||
new ButtonBuilder()
|
||||
.setCustomId('escalate_to_tier3')
|
||||
.setLabel('To Tier 3')
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
);
|
||||
}
|
||||
const choiceRow = new ActionRowBuilder().addComponents(escalateButtons);
|
||||
return interaction.reply({
|
||||
content: 'Escalate to which tier?',
|
||||
components: [choiceRow],
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
|
||||
if (interaction.customId === 'escalate_to_tier2') {
|
||||
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
|
||||
if (currentTier >= 1) {
|
||||
return interaction.reply({ content: 'This ticket is already at tier 2.', ephemeral: true });
|
||||
}
|
||||
const categoryId = ticket.gmailThreadId.startsWith('discord-')
|
||||
? CONFIG.DISCORD_ESCALATED2_CHANNEL_ID
|
||||
: CONFIG.EMAIL_ESCALATED2_CHANNEL_ID;
|
||||
if (!categoryId && !interaction.channel.isThread()) {
|
||||
return interaction.reply({ content: 'Tier 2 (ESCALATED2) is not configured for this ticket type.', ephemeral: true });
|
||||
}
|
||||
try {
|
||||
await interaction.deferReply();
|
||||
await runEscalation(interaction, ticket, 1, null);
|
||||
} catch (err) {
|
||||
logError('escalate-button-tier2', err, interaction).catch(() => {});
|
||||
await interaction.editReply({ content: 'Failed to escalate to tier 2.' }).catch(() =>
|
||||
interaction.followUp({ content: 'Failed to escalate to tier 2.', ephemeral: true }).catch(() => {})
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (interaction.customId === 'escalate_to_tier3') {
|
||||
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
|
||||
if (currentTier >= 2) {
|
||||
return interaction.reply({ content: 'This ticket is already at tier 3.', ephemeral: true });
|
||||
}
|
||||
const categoryId = ticket.gmailThreadId.startsWith('discord-')
|
||||
? CONFIG.DISCORD_ESCALATED3_CHANNEL_ID
|
||||
: CONFIG.EMAIL_ESCALATED3_CHANNEL_ID;
|
||||
if (!categoryId && !interaction.channel.isThread()) {
|
||||
return interaction.reply({ content: 'Tier 3 (ESCALATED3) is not configured for this ticket type.', ephemeral: true });
|
||||
}
|
||||
try {
|
||||
await interaction.deferReply();
|
||||
await runEscalation(interaction, ticket, 2, null);
|
||||
} catch (err) {
|
||||
logError('escalate-button-tier3', err, interaction).catch(() => {});
|
||||
await interaction.editReply({ content: 'Failed to escalate to tier 3.' }).catch(() =>
|
||||
interaction.followUp({ content: 'Failed to escalate to tier 3.', ephemeral: true }).catch(() => {})
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// --- DEESCALATE ---
|
||||
if (interaction.customId === 'deescalate_ticket') {
|
||||
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
|
||||
if (currentTier === 0) {
|
||||
return interaction.reply({ content: 'This ticket is not escalated.', ephemeral: true });
|
||||
}
|
||||
try {
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
await runDeescalation(interaction, ticket);
|
||||
} catch (err) {
|
||||
logError('deescalate-button', err, interaction).catch(() => {});
|
||||
await interaction.editReply({ content: 'Failed to deescalate this ticket.' }).catch(() =>
|
||||
interaction.followUp({ content: 'Failed to deescalate this ticket.', ephemeral: true }).catch(() => {})
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// --- TAG DELETE CONFIRM ---
|
||||
if (interaction.customId.startsWith('confirm_delete_tag::')) {
|
||||
async function handleTagDeleteConfirm(interaction) {
|
||||
const tagName = interaction.customId.slice('confirm_delete_tag::'.length);
|
||||
|
||||
try {
|
||||
const result = await Tag.deleteOne({ name: tagName });
|
||||
|
||||
if (result.deletedCount === 0) {
|
||||
await interaction.update({
|
||||
content: `❌ Tag "${tagName}" not found.`,
|
||||
components: []
|
||||
});
|
||||
await interaction.update({ content: `❌ Tag "${tagName}" not found.`, components: [] });
|
||||
} else {
|
||||
await interaction.update({
|
||||
content: `✅ Tag "${tagName}" deleted successfully.`,
|
||||
components: []
|
||||
});
|
||||
await interaction.update({ content: `✅ Tag "${tagName}" deleted successfully.`, components: [] });
|
||||
}
|
||||
} catch (err) {
|
||||
logError('tag-delete-confirm', err, interaction).catch(() => {});
|
||||
await interaction.update({
|
||||
content: '❌ Failed to delete tag.',
|
||||
components: []
|
||||
});
|
||||
await interaction.update({ content: '❌ Failed to delete tag.', components: [] });
|
||||
}
|
||||
}
|
||||
|
||||
if (interaction.customId === 'cancel_delete_tag') {
|
||||
return interaction.update({ content: 'Tag deletion cancelled.', components: [] });
|
||||
}
|
||||
// ============================================================
|
||||
// Ticket-scoped button handlers
|
||||
// ============================================================
|
||||
|
||||
// Priority is set via /priority slash command only; no priority buttons in tickets.
|
||||
}
|
||||
|
||||
// --- CLAIM LOGIC ---
|
||||
async function handleClaim(interaction, ticket) {
|
||||
/** Toggle claim/unclaim on the current ticket and rewrite the action row. */
|
||||
async function handleClaimButton(interaction, ticket) {
|
||||
const freshTicket = await Ticket.findOne({ gmailThreadId: ticket.gmailThreadId }).lean();
|
||||
if (!freshTicket) {
|
||||
return interaction.reply({ content: 'Ticket data missing.', ephemeral: true });
|
||||
}
|
||||
|
||||
const isClaimed = !!freshTicket.claimedBy;
|
||||
const claimerLabel =
|
||||
interaction.member?.displayName || interaction.user.username;
|
||||
const claimerLabel = interaction.member?.displayName || interaction.user.username;
|
||||
const guild = interaction.guild;
|
||||
const isClaimedByMe = freshTicket.claimedBy === claimerLabel;
|
||||
|
||||
@@ -322,7 +136,6 @@ async function handleClaim(interaction, ticket) {
|
||||
|
||||
const row = ActionRowBuilder.from(row0);
|
||||
const [btnClose, btnClaim] = row.components;
|
||||
|
||||
if (!btnClose || !btnClaim) {
|
||||
return interaction.reply({ content: 'Buttons missing.', ephemeral: true });
|
||||
}
|
||||
@@ -334,7 +147,15 @@ async function handleClaim(interaction, ticket) {
|
||||
});
|
||||
}
|
||||
|
||||
if (!isClaimed || (isClaimed && !isClaimedByMe && CONFIG.ALLOW_CLAIM_OVERWRITE)) {
|
||||
const isClaiming = !isClaimed || (isClaimed && !isClaimedByMe && CONFIG.ALLOW_CLAIM_OVERWRITE);
|
||||
if (isClaiming) {
|
||||
await applyClaim(interaction, freshTicket, row, btnClose, btnClaim, claimerLabel, guild);
|
||||
} else {
|
||||
await applyUnclaim(interaction, freshTicket, row, btnClose, btnClaim, claimerLabel, guild);
|
||||
}
|
||||
}
|
||||
|
||||
async function applyClaim(interaction, freshTicket, row, btnClose, btnClaim, claimerLabel, guild) {
|
||||
await Ticket.updateOne(
|
||||
{ gmailThreadId: freshTicket.gmailThreadId },
|
||||
{ $set: { claimedBy: claimerLabel, claimerId: interaction.user.id } }
|
||||
@@ -344,12 +165,9 @@ async function handleClaim(interaction, ticket) {
|
||||
|
||||
const claimerEmoji = CONFIG.STAFF_EMOJIS[interaction.user.id] || CONFIG.CLAIMER_EMOJI_FALLBACK;
|
||||
const creatorNickname = await resolveCreatorNickname(guild, freshTicket);
|
||||
|
||||
const state = freshTicket.escalated ? 'escalated-claimed' : 'claimed';
|
||||
const newName = makeTicketName(state, freshTicket, creatorNickname, claimerEmoji);
|
||||
enqueueRename(interaction.channel, newName).catch(err => logError('rename', err).catch(() => {}));
|
||||
|
||||
const label = `Unclaim (${claimerLabel})`;
|
||||
enqueueRename(interaction.channel, makeTicketName(state, freshTicket, creatorNickname, claimerEmoji))
|
||||
.catch(err => logError('rename', err).catch(() => {}));
|
||||
|
||||
btnClose
|
||||
.setCustomId('close_ticket')
|
||||
@@ -363,9 +181,10 @@ async function handleClaim(interaction, ticket) {
|
||||
.setEmoji(CONFIG.BUTTON_EMOJI_UNCLAIM)
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setDisabled(false)
|
||||
.setLabel(label);
|
||||
.setLabel(`Unclaim (${claimerLabel})`);
|
||||
|
||||
await interaction.update({ components: [row] });
|
||||
|
||||
const claimText = CONFIG.TICKET_CLAIMED_MESSAGE
|
||||
.replace(/\{staff_mention\}/g, interaction.user.toString())
|
||||
.replace(/\{staff_name\}/g, interaction.member?.displayName || interaction.user.username);
|
||||
@@ -375,10 +194,11 @@ async function handleClaim(interaction, ticket) {
|
||||
.setColor(CONFIG.EMBED_COLOR_CLAIMED)
|
||||
.setFooter({ text: `Claimed by ${claimerLabel}` });
|
||||
await interaction.followUp({ embeds: [claimEmbed] });
|
||||
const { addMemberToStaffThread } = require('../services/staffThread');
|
||||
|
||||
await addMemberToStaffThread(interaction.channel, interaction.user.id).catch(() => {});
|
||||
} else {
|
||||
// Unclaim
|
||||
}
|
||||
|
||||
async function applyUnclaim(interaction, freshTicket, row, btnClose, btnClaim, claimerLabel, guild) {
|
||||
await Ticket.updateOne(
|
||||
{ gmailThreadId: freshTicket.gmailThreadId },
|
||||
{ $set: { claimedBy: null, claimerId: null } }
|
||||
@@ -386,9 +206,10 @@ async function handleClaim(interaction, ticket) {
|
||||
freshTicket.claimedBy = null;
|
||||
freshTicket.claimerId = null;
|
||||
|
||||
const creatorNicknameUnclaim = await resolveCreatorNickname(guild, freshTicket);
|
||||
const unclaimState = (freshTicket.escalationTier ?? 0) >= 1 ? 'escalated' : 'unclaimed';
|
||||
enqueueRename(interaction.channel, makeTicketName(unclaimState, freshTicket, creatorNicknameUnclaim)).catch(err => logError('rename', err).catch(() => {}));
|
||||
const creatorNickname = await resolveCreatorNickname(guild, freshTicket);
|
||||
const state = (freshTicket.escalationTier ?? 0) >= 1 ? 'escalated' : 'unclaimed';
|
||||
enqueueRename(interaction.channel, makeTicketName(state, freshTicket, creatorNickname))
|
||||
.catch(err => logError('rename', err).catch(() => {}));
|
||||
|
||||
btnClose
|
||||
.setCustomId('close_ticket')
|
||||
@@ -405,6 +226,7 @@ async function handleClaim(interaction, ticket) {
|
||||
.setLabel(CONFIG.BUTTON_LABEL_CLAIM);
|
||||
|
||||
await interaction.update({ components: [row] });
|
||||
|
||||
const unclaimText = CONFIG.TICKET_UNCLAIMED_MESSAGE
|
||||
.replace(/\{staff_mention\}/g, interaction.user.toString())
|
||||
.replace(/\{staff_name\}/g, interaction.member?.displayName || interaction.user.username);
|
||||
@@ -415,72 +237,187 @@ async function handleClaim(interaction, ticket) {
|
||||
.setFooter({ text: `Unclaimed by ${claimerLabel}` });
|
||||
await interaction.followUp({ embeds: [unclaimEmbed] });
|
||||
}
|
||||
|
||||
/**
|
||||
* First-stage Close button: prompt the staff member with confirm/cancel
|
||||
* variants. Email tickets get a "Confirm Close With Email" / "Without Email"
|
||||
* choice; Discord-only tickets get a single "Confirm Close".
|
||||
*/
|
||||
async function handleCloseButton(interaction, ticket) {
|
||||
const isEmailTicket = ticket.gmailThreadId && !ticket.gmailThreadId.startsWith('discord-');
|
||||
const buttons = [];
|
||||
|
||||
if (isEmailTicket) {
|
||||
buttons.push(
|
||||
new ButtonBuilder().setCustomId('confirm_close_with_email').setLabel('Confirm Close With Email').setStyle(ButtonStyle.Danger),
|
||||
new ButtonBuilder().setCustomId('confirm_close_no_email').setLabel('Confirm Close Without Email').setStyle(ButtonStyle.Danger)
|
||||
);
|
||||
} else {
|
||||
buttons.push(
|
||||
new ButtonBuilder().setCustomId('confirm_close').setLabel('Confirm Close').setStyle(ButtonStyle.Danger)
|
||||
);
|
||||
}
|
||||
buttons.push(
|
||||
new ButtonBuilder().setCustomId('cancel_close').setLabel('Cancel').setStyle(ButtonStyle.Secondary)
|
||||
);
|
||||
|
||||
return interaction.reply({
|
||||
content: 'Are you sure you want to close this ticket?',
|
||||
components: [new ActionRowBuilder().addComponents(...buttons)]
|
||||
});
|
||||
}
|
||||
|
||||
// --- CONFIRM CLOSE ---
|
||||
async function handleConfirmClose(interaction, ticket, sendEmail = true) {
|
||||
/**
|
||||
* Confirm-close button (any of `confirm_close`, `confirm_close_with_email`,
|
||||
* `confirm_close_no_email`). Starts a countdown; staff can hit `cancel_close`
|
||||
* to abort. After the timer elapses, runFinalClose() does the archive+delete.
|
||||
*/
|
||||
async function handleConfirmCloseRequest(interaction, ticket) {
|
||||
const sendEmail = interaction.customId !== 'confirm_close_no_email';
|
||||
const timerSeconds = CONFIG.FORCE_CLOSE_TIMER;
|
||||
|
||||
if (pendingCloses.has(interaction.channel.id)) {
|
||||
return interaction.reply({ content: 'A close is already pending for this ticket.', ephemeral: true });
|
||||
}
|
||||
|
||||
const cancelRow = new ActionRowBuilder().addComponents(
|
||||
new ButtonBuilder().setCustomId('cancel_close').setLabel('Cancel Close').setStyle(ButtonStyle.Secondary)
|
||||
);
|
||||
await interaction.update({ content: `Closing ticket in ${timerSeconds} seconds.`, components: [cancelRow] });
|
||||
|
||||
const channelId = interaction.channel.id;
|
||||
const channelName = interaction.channel.name;
|
||||
const userTag = interaction.user.tag;
|
||||
|
||||
const timerId = setTimeout(async () => {
|
||||
const pending = pendingCloses.get(channelId);
|
||||
pendingCloses.delete(channelId);
|
||||
const freshTicket = await Ticket.findOne({ discordThreadId: channelId }).lean();
|
||||
if (!freshTicket || freshTicket.status === 'closed') return;
|
||||
|
||||
logTicketEvent('Force-close timer fired', [
|
||||
{ name: 'Ticket', value: channelName || channelId },
|
||||
{ name: 'Set by', value: userTag },
|
||||
{ name: 'Duration', value: `${timerSeconds}s` }
|
||||
]).catch(() => {});
|
||||
|
||||
const effectiveSendEmail = pending?.sendEmail ?? true;
|
||||
await runFinalClose(interaction, freshTicket, effectiveSendEmail);
|
||||
}, timerSeconds * 1000);
|
||||
|
||||
pendingCloses.set(channelId, { timeout: timerId, userId: interaction.user.id, username: userTag, sendEmail });
|
||||
}
|
||||
|
||||
async function handleCancelCloseRequest(interaction) {
|
||||
const pending = pendingCloses.get(interaction.channel.id);
|
||||
if (pending) {
|
||||
clearTimeout(pending.timeout);
|
||||
pendingCloses.delete(interaction.channel.id);
|
||||
}
|
||||
return interaction.update({ content: 'Close cancelled.', components: [] });
|
||||
}
|
||||
|
||||
/**
|
||||
* Escalate button: shows a tier 2 / tier 3 picker. The picker buttons are
|
||||
* `escalate_to_tier2` / `escalate_to_tier3`, handled by handleEscalateButton.
|
||||
*/
|
||||
async function handleEscalatePrompt(interaction, ticket) {
|
||||
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
|
||||
if (currentTier >= 2) {
|
||||
return interaction.reply({ content: 'This ticket is already at tier 3 support.', ephemeral: true });
|
||||
}
|
||||
|
||||
const buttons = [];
|
||||
if (currentTier < 1) {
|
||||
buttons.push(new ButtonBuilder().setCustomId('escalate_to_tier2').setLabel('To Tier 2').setStyle(ButtonStyle.Secondary));
|
||||
}
|
||||
if (currentTier < 2) {
|
||||
buttons.push(new ButtonBuilder().setCustomId('escalate_to_tier3').setLabel('To Tier 3').setStyle(ButtonStyle.Secondary));
|
||||
}
|
||||
|
||||
return interaction.reply({
|
||||
content: 'Escalate to which tier?',
|
||||
components: [new ActionRowBuilder().addComponents(buttons)],
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Tier-pick button (`escalate_to_tier2` or `escalate_to_tier3`). Validates
|
||||
* the target tier, then delegates to runEscalation() in handlers/commands.js.
|
||||
*/
|
||||
async function handleEscalateButton(interaction, ticket) {
|
||||
const tier = interaction.customId === 'escalate_to_tier3' ? 2 : 1;
|
||||
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
|
||||
|
||||
if (currentTier >= tier) {
|
||||
return interaction.reply({ content: `This ticket is already at tier ${tier + 1}.`, ephemeral: true });
|
||||
}
|
||||
|
||||
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
|
||||
const categoryId = tier === 1
|
||||
? (isDiscordTicket ? CONFIG.DISCORD_ESCALATED2_CHANNEL_ID : CONFIG.EMAIL_ESCALATED2_CHANNEL_ID)
|
||||
: (isDiscordTicket ? CONFIG.DISCORD_ESCALATED3_CHANNEL_ID : CONFIG.EMAIL_ESCALATED3_CHANNEL_ID);
|
||||
|
||||
if (!categoryId && !interaction.channel.isThread()) {
|
||||
return interaction.reply({
|
||||
content: `Tier ${tier + 1} (ESCALATED${tier + 1}) is not configured for this ticket type.`,
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
|
||||
await runDeferred(interaction, 'escalate', () => runEscalation(interaction, ticket, tier, null));
|
||||
}
|
||||
|
||||
async function handleDeescalateButton(interaction, ticket) {
|
||||
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
|
||||
if (currentTier === 0) {
|
||||
return interaction.reply({ content: 'This ticket is not escalated.', ephemeral: true });
|
||||
}
|
||||
|
||||
await runDeferred(interaction, 'deescalate',
|
||||
() => runDeescalation(interaction, ticket),
|
||||
{ ephemeral: true }
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Final close: archive → transcript → delete
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Runs after the force-close countdown elapses (or the staff member
|
||||
* confirmed without a countdown). Archives the channel into a transcript,
|
||||
* posts to the transcript channel and optionally DMs the creator, sends the
|
||||
* customer closure email (email tickets only), then deletes the channel.
|
||||
*/
|
||||
async function runFinalClose(interaction, ticket, sendEmail = true) {
|
||||
const closedAt = new Date();
|
||||
|
||||
try {
|
||||
await interaction.update({ content: 'Archiving and closing...', components: [] });
|
||||
} catch {
|
||||
// Already acknowledged – fall back to editReply
|
||||
await interaction.editReply({ content: 'Archiving and closing...', components: [] }).catch(() => {});
|
||||
}
|
||||
|
||||
try {
|
||||
const messages = await interaction.channel.messages.fetch({ limit: 100 });
|
||||
const log =
|
||||
`TRANSCRIPT: ${ticket.subject}\nUser: ${ticket.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-${interaction.channel.name}.txt`
|
||||
});
|
||||
|
||||
const channelName = interaction.channel.name;
|
||||
const opened = new Date(ticket.createdAt);
|
||||
const openedStr = opened.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 closedStr = closedAt.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 transcriptText = await buildTranscriptText(interaction.channel, ticket);
|
||||
const file = new AttachmentBuilder(Buffer.from(transcriptText), {
|
||||
name: `transcript-${channelName}.txt`
|
||||
});
|
||||
|
||||
// In-ticket message before transcript is posted (Discord close message)
|
||||
const discordCloseContent = CONFIG.DISCORD_CLOSE_MESSAGE;
|
||||
await enqueueSend(interaction.channel, discordCloseContent);
|
||||
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);
|
||||
let transcriptMsg = null;
|
||||
|
||||
const transcriptContent = CONFIG.DISCORD_TRANSCRIPT_MESSAGE
|
||||
.replace(/\{channel_name\}/g, channelName)
|
||||
.replace(/\{email\}/g, ticket.senderEmail || '')
|
||||
.replace(/\{date_opened\}/g, openedStr)
|
||||
.replace(/\{date_closed\}/g, closedStr)
|
||||
+ `\n\nDate Opened: ${openedStr}\nDate Closed: ${closedStr}`;
|
||||
|
||||
if (transcriptChan) {
|
||||
transcriptMsg = await enqueueSend(transcriptChan, {
|
||||
content: transcriptContent,
|
||||
@@ -488,62 +425,20 @@ async function handleConfirmClose(interaction, ticket, sendEmail = true) {
|
||||
});
|
||||
}
|
||||
|
||||
// DM the transcript to the ticket creator (Discord-originated tickets).
|
||||
// Gated because many users have DMs from server members disabled — the send
|
||||
// then 50007s and generates noise. Default off; enable via env when desired.
|
||||
// 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-')) {
|
||||
const creatorId = ticket.gmailThreadId.split('-').pop();
|
||||
try {
|
||||
const creator = await interaction.client.users.fetch(creatorId);
|
||||
const dmFile = new AttachmentBuilder(Buffer.from(log), {
|
||||
name: `transcript-${channelName}.txt`
|
||||
});
|
||||
const dmContent = CONFIG.DISCORD_TRANSCRIPT_MESSAGE
|
||||
.replace(/\{channel_name\}/g, channelName)
|
||||
.replace(/\{email\}/g, ticket.senderEmail || '')
|
||||
.replace(/\{date_opened\}/g, openedStr)
|
||||
.replace(/\{date_closed\}/g, closedStr);
|
||||
await creator.send({
|
||||
content: dmContent,
|
||||
files: [dmFile]
|
||||
});
|
||||
} catch (dmErr) {
|
||||
// 50007 = "Cannot send messages to this user" — user has DMs off. Expected; ignore.
|
||||
if (dmErr?.code !== 50007) {
|
||||
logError('transcript-dm', dmErr).catch(() => {});
|
||||
}
|
||||
}
|
||||
await dmTranscriptToCreator(interaction.client, ticket, channelName, transcriptText, openedStr, closedStr);
|
||||
}
|
||||
|
||||
const logChan = await interaction.client.channels
|
||||
.fetch(CONFIG.LOGGING_CHANNEL_ID)
|
||||
.catch(() => null);
|
||||
if (logChan) {
|
||||
const closerMention = interaction.user.toString();
|
||||
await postCloseLogEntry(interaction, ticket, channelName);
|
||||
|
||||
const closerDisplayName = interaction.member?.displayName || interaction.user.username;
|
||||
|
||||
let logMsg;
|
||||
if (ticket.gmailThreadId?.startsWith('discord-')) {
|
||||
const creatorId = ticket.gmailThreadId.split('-').pop();
|
||||
try {
|
||||
const creator = await interaction.client.users.fetch(creatorId);
|
||||
const creatorMention = creator.toString();
|
||||
logMsg = `Closed ${creatorMention}'s **${channelName}** by ${closerMention} (${closerDisplayName})`;
|
||||
} catch {
|
||||
logMsg = `Closed **${channelName}** by ${closerMention} (${closerDisplayName})`;
|
||||
}
|
||||
} else {
|
||||
logMsg = `Closed **${channelName}** (${ticket.senderEmail}) by ${closerMention} (${closerDisplayName})`;
|
||||
}
|
||||
await enqueueSend(logChan, logMsg);
|
||||
}
|
||||
|
||||
const closerDisplayName =
|
||||
interaction.member?.displayName || interaction.user.username;
|
||||
|
||||
if (sendEmail && !ticket.gmailThreadId?.startsWith('discord-')) {
|
||||
await sendTicketClosedEmail(ticket, closerDisplayName, interaction.user.id);
|
||||
}
|
||||
|
||||
await Ticket.updateOne(
|
||||
{ gmailThreadId: ticket.gmailThreadId },
|
||||
{ $set: { discordThreadId: null, status: 'closed' } }
|
||||
@@ -560,25 +455,92 @@ async function handleConfirmClose(interaction, ticket, sendEmail = true) {
|
||||
const parentCatId = ticket.parentCategoryId;
|
||||
const guildRef = interaction.guild;
|
||||
|
||||
setTimeout(
|
||||
() => interaction.channel.delete().catch(() => {}),
|
||||
5000
|
||||
);
|
||||
setTimeout(() => interaction.channel.delete().catch(() => {}), 5000);
|
||||
setTimeout(() => {
|
||||
(async () => {
|
||||
if (parentCatId && guildRef) {
|
||||
await cleanupEmptyOverflowCategory(guildRef, parentCatId, CONFIG.TICKET_CATEGORY_NAME);
|
||||
cleanupEmptyOverflowCategory(guildRef, parentCatId, CONFIG.TICKET_CATEGORY_NAME).catch(() => {});
|
||||
}
|
||||
})();
|
||||
}, 6000);
|
||||
} catch (e) {
|
||||
console.error('Close ticket error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the ticket_modal submission (from the open-ticket panel button).
|
||||
*/
|
||||
/** Render the last 100 messages of a channel as a plaintext transcript. */
|
||||
async function buildTranscriptText(channel, ticket) {
|
||||
const messages = await channel.messages.fetch({ limit: 100 });
|
||||
return `TRANSCRIPT: ${ticket.subject}\nUser: ${ticket.senderEmail}\n---\n` +
|
||||
messages
|
||||
.reverse()
|
||||
.map(m => `[${m.createdAt.toLocaleString()}] ${m.author.tag}: ${m.cleanContent}`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function formatDateForTranscript(d) {
|
||||
return 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'
|
||||
});
|
||||
}
|
||||
|
||||
function renderTranscriptHeader(channelName, senderEmail, openedStr, closedStr) {
|
||||
return CONFIG.DISCORD_TRANSCRIPT_MESSAGE
|
||||
.replace(/\{channel_name\}/g, channelName)
|
||||
.replace(/\{email\}/g, senderEmail || '')
|
||||
.replace(/\{date_opened\}/g, openedStr)
|
||||
.replace(/\{date_closed\}/g, closedStr)
|
||||
+ `\n\nDate Opened: ${openedStr}\nDate Closed: ${closedStr}`;
|
||||
}
|
||||
|
||||
async function dmTranscriptToCreator(client, ticket, channelName, transcriptText, openedStr, closedStr) {
|
||||
const creatorId = ticket.gmailThreadId.split('-').pop();
|
||||
try {
|
||||
const creator = await client.users.fetch(creatorId);
|
||||
const dmFile = new AttachmentBuilder(Buffer.from(transcriptText), {
|
||||
name: `transcript-${channelName}.txt`
|
||||
});
|
||||
const dmContent = CONFIG.DISCORD_TRANSCRIPT_MESSAGE
|
||||
.replace(/\{channel_name\}/g, channelName)
|
||||
.replace(/\{email\}/g, ticket.senderEmail || '')
|
||||
.replace(/\{date_opened\}/g, openedStr)
|
||||
.replace(/\{date_closed\}/g, closedStr);
|
||||
await creator.send({ content: dmContent, files: [dmFile] });
|
||||
} catch (dmErr) {
|
||||
// 50007 = "Cannot send messages to this user" — user has DMs off. Expected; ignore.
|
||||
if (dmErr?.code !== 50007) {
|
||||
logError('transcript-dm', dmErr).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function postCloseLogEntry(interaction, ticket, channelName) {
|
||||
if (!CONFIG.LOGGING_CHANNEL_ID) return;
|
||||
const logChan = await interaction.client.channels.fetch(CONFIG.LOGGING_CHANNEL_ID).catch(() => null);
|
||||
if (!logChan) return;
|
||||
|
||||
const closerMention = interaction.user.toString();
|
||||
const closerDisplayName = interaction.member?.displayName || interaction.user.username;
|
||||
|
||||
let logMsg;
|
||||
if (ticket.gmailThreadId?.startsWith('discord-')) {
|
||||
const creatorId = ticket.gmailThreadId.split('-').pop();
|
||||
try {
|
||||
const creator = await interaction.client.users.fetch(creatorId);
|
||||
logMsg = `Closed ${creator.toString()}'s **${channelName}** by ${closerMention} (${closerDisplayName})`;
|
||||
} catch {
|
||||
logMsg = `Closed **${channelName}** by ${closerMention} (${closerDisplayName})`;
|
||||
}
|
||||
} else {
|
||||
logMsg = `Closed **${channelName}** (${ticket.senderEmail}) by ${closerMention} (${closerDisplayName})`;
|
||||
}
|
||||
await enqueueSend(logChan, logMsg);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Ticket-creation modal submit (open-ticket panel → modal → ticket channel)
|
||||
// ============================================================
|
||||
|
||||
async function handleTicketModal(interaction) {
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
|
||||
@@ -599,14 +561,12 @@ async function handleTicketModal(interaction) {
|
||||
const lastTicket = await Ticket.findOne().sort({ ticketNumber: -1 }).select('ticketNumber').lean();
|
||||
const ticketNumber = (lastTicket?.ticketNumber || 0) + 1;
|
||||
|
||||
const creatorNicknameModal = interaction.member?.displayName || interaction.user.username;
|
||||
const unclaimedName = toDiscordSafeName(`unclaimed-${creatorNicknameModal}-${ticketNumber}`);
|
||||
const creatorNickname = interaction.member?.displayName || interaction.user.username;
|
||||
const unclaimedName = toDiscordSafeName(`unclaimed-${creatorNickname}-${ticketNumber}`);
|
||||
|
||||
let channel;
|
||||
let parentCategoryIdForTicket = null;
|
||||
let parentId;
|
||||
let parentCategoryIdForTicket;
|
||||
try {
|
||||
parentId = await getOrCreateTicketCategory(
|
||||
parentCategoryIdForTicket = await getOrCreateTicketCategory(
|
||||
guild,
|
||||
CONFIG.DISCORD_TICKET_CATEGORY_ID,
|
||||
CONFIG.TICKET_CATEGORY_NAME
|
||||
@@ -615,13 +575,14 @@ async function handleTicketModal(interaction) {
|
||||
console.error('getOrCreateTicketCategory (ticket modal):', err);
|
||||
return interaction.editReply('Discord ticket category could not be resolved. Contact an administrator.');
|
||||
}
|
||||
parentCategoryIdForTicket = parentId;
|
||||
|
||||
let channel;
|
||||
try {
|
||||
// TODO(queue-migrate): initial permissionOverwrites here are fine since the channel is just being created, but any later permissionOverwrites mutation on this channel should go through channelQueue.
|
||||
channel = await guild.channels.create({
|
||||
name: unclaimedName,
|
||||
type: ChannelType.GuildText,
|
||||
parent: parentId,
|
||||
parent: parentCategoryIdForTicket,
|
||||
permissionOverwrites: [
|
||||
{ id: guild.id, deny: [PermissionFlagsBits.ViewChannel] },
|
||||
{
|
||||
@@ -655,6 +616,29 @@ async function handleTicketModal(interaction) {
|
||||
parentCategoryId: parentCategoryIdForTicket
|
||||
});
|
||||
|
||||
const welcomeMsg = await postTicketWelcomeEmbeds(channel, interaction, email, game, description);
|
||||
await createStaffThread(channel, interaction.client).catch(() => {});
|
||||
|
||||
if (CONFIG.PIN_INITIAL_MESSAGE_ENABLED && welcomeMsg) {
|
||||
await pinMessage(welcomeMsg, interaction.client).catch(() => {});
|
||||
}
|
||||
|
||||
await interaction.deleteReply().catch(() => {});
|
||||
|
||||
if (CONFIG.LOGGING_CHANNEL_ID) {
|
||||
const logChan = await interaction.client.channels.fetch(CONFIG.LOGGING_CHANNEL_ID).catch(() => null);
|
||||
if (logChan) {
|
||||
await enqueueSend(logChan, `📝 ${channel.name} created by ${interaction.user.tag}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ticket creation error:', err);
|
||||
await interaction.editReply('Failed to create ticket. Please contact an administrator.');
|
||||
}
|
||||
}
|
||||
|
||||
/** Build and send the welcome / info / resources embeds when a ticket is created via the modal. */
|
||||
async function postTicketWelcomeEmbeds(channel, interaction, email, game, description) {
|
||||
const descTrimmed = description.length > 500 ? description.slice(0, 497) + '…' : description;
|
||||
|
||||
const welcomeEmbed = new EmbedBuilder()
|
||||
@@ -697,27 +681,55 @@ async function handleTicketModal(interaction) {
|
||||
} catch (err) {
|
||||
console.error('welcomeMessageId-save', err);
|
||||
}
|
||||
|
||||
const { createStaffThread } = require('../services/staffThread');
|
||||
await createStaffThread(channel, interaction.client).catch(() => {});
|
||||
|
||||
if (CONFIG.PIN_INITIAL_MESSAGE_ENABLED && welcomeMsg) {
|
||||
const { pinMessage } = require('../services/pinMessage');
|
||||
await pinMessage(welcomeMsg, interaction.client).catch(() => {});
|
||||
return welcomeMsg;
|
||||
}
|
||||
|
||||
await interaction.deleteReply().catch(() => {});
|
||||
// ============================================================
|
||||
// Dispatch tables
|
||||
// ============================================================
|
||||
|
||||
const logChan = await interaction.client.channels.fetch(CONFIG.LOGGING_CHANNEL_ID).catch(() => null);
|
||||
if (logChan) {
|
||||
await enqueueSend(logChan,
|
||||
`📝 ${channel.name} created by ${interaction.user.tag}`
|
||||
/** Buttons that don't depend on a ticket-bound channel. */
|
||||
const FREE_BUTTON_HANDLERS = {
|
||||
open_ticket: handleOpenTicketModal,
|
||||
open_ticket_thread: handleOpenTicketModal,
|
||||
open_ticket_channel: handleOpenTicketModal,
|
||||
cancel_delete_tag: handleTagDeleteCancel
|
||||
};
|
||||
|
||||
/** Buttons that fire inside a ticket channel. The dispatcher does the lookup. */
|
||||
const TICKET_BUTTON_HANDLERS = {
|
||||
claim_ticket: handleClaimButton,
|
||||
close_ticket: handleCloseButton,
|
||||
confirm_close: handleConfirmCloseRequest,
|
||||
confirm_close_with_email: handleConfirmCloseRequest,
|
||||
confirm_close_no_email: handleConfirmCloseRequest,
|
||||
cancel_close: handleCancelCloseRequest,
|
||||
escalate_ticket: handleEscalatePrompt,
|
||||
escalate_to_tier2: handleEscalateButton,
|
||||
escalate_to_tier3: handleEscalateButton,
|
||||
deescalate_ticket: handleDeescalateButton
|
||||
};
|
||||
|
||||
async function handleButton(interaction) {
|
||||
const { customId } = interaction;
|
||||
|
||||
// Tag-delete confirm has a dynamic id (`confirm_delete_tag::<name>`).
|
||||
if (customId.startsWith('confirm_delete_tag::')) {
|
||||
return handleTagDeleteConfirm(interaction);
|
||||
}
|
||||
|
||||
const freeHandler = FREE_BUTTON_HANDLERS[customId];
|
||||
if (freeHandler) return freeHandler(interaction);
|
||||
|
||||
const ticketHandler = TICKET_BUTTON_HANDLERS[customId];
|
||||
if (!ticketHandler) return;
|
||||
|
||||
const ticket = await findTicketForChannel(
|
||||
interaction,
|
||||
'This channel is not linked to a ticket, or the ticket could not be found.'
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ticket creation error:', err);
|
||||
await interaction.editReply('Failed to create ticket. Please contact an administrator.');
|
||||
}
|
||||
if (!ticket) return;
|
||||
return ticketHandler(interaction, ticket);
|
||||
}
|
||||
|
||||
module.exports = { handleButton, handleTicketModal };
|
||||
|
||||
@@ -28,6 +28,7 @@ const { setNotifyDm } = require('../services/staffSettings');
|
||||
const { pinMessage } = require('../services/pinMessage');
|
||||
const { logError, logTicketEvent } = require('../services/debugLog');
|
||||
const { pendingCloses } = require('./pendingCloses');
|
||||
const { findTicketForChannel, runDeferred } = require('./sharedHelpers');
|
||||
|
||||
const Ticket = mongoose.model('Ticket');
|
||||
const Tag = mongoose.model('Tag');
|
||||
@@ -53,37 +54,6 @@ async function requireStaffRole(interaction) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up the ticket linked to this channel; reply with a friendly message
|
||||
* and return null if the channel is not a ticket. Returns the ticket on
|
||||
* success.
|
||||
*/
|
||||
async function findTicketForChannel(interaction) {
|
||||
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
|
||||
if (!ticket) {
|
||||
await interaction.reply({ content: 'This channel is not linked to a ticket.', ephemeral: true });
|
||||
return null;
|
||||
}
|
||||
return ticket;
|
||||
}
|
||||
|
||||
/**
|
||||
* Defer + run + log + reply on error. `verb` is the user-facing verb (e.g.
|
||||
* "escalate"); error messages render as "Failed to <verb> this ticket."
|
||||
*/
|
||||
async function runDeferred(interaction, verb, fn, { ephemeral = false } = {}) {
|
||||
try {
|
||||
await interaction.deferReply({ ephemeral });
|
||||
await fn();
|
||||
} catch (err) {
|
||||
console.error(`${verb} error:`, err);
|
||||
const msg = `Failed to ${verb} this ticket.`;
|
||||
await interaction.editReply({ content: msg }).catch(() =>
|
||||
interaction.followUp({ content: msg, ephemeral: true }).catch(() => {})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch the configured logging channel, or null if unset/missing. */
|
||||
async function fetchLoggingChannel(client) {
|
||||
if (!CONFIG.LOGGING_CHANNEL_ID) return null;
|
||||
|
||||
53
handlers/sharedHelpers.js
Normal file
53
handlers/sharedHelpers.js
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Shared helpers for slash-command and button handlers.
|
||||
*
|
||||
* Both handlers/commands.js and handlers/buttons.js use these to avoid
|
||||
* repeating the lookup-and-defer-and-try-catch pattern across 30+ branches.
|
||||
*/
|
||||
const { mongoose } = require('../db-connection');
|
||||
const { logError } = require('../services/debugLog');
|
||||
|
||||
const Ticket = mongoose.model('Ticket');
|
||||
|
||||
/**
|
||||
* Look up the ticket linked to this channel; reply with `missingMessage`
|
||||
* (default: "This channel is not linked to a ticket.") and return null if
|
||||
* the channel is not a ticket. Returns the ticket on success.
|
||||
*
|
||||
* @param {import('discord.js').Interaction} interaction
|
||||
* @param {string} [missingMessage]
|
||||
*/
|
||||
async function findTicketForChannel(interaction, missingMessage = 'This channel is not linked to a ticket.') {
|
||||
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
|
||||
if (!ticket) {
|
||||
await interaction.reply({ content: missingMessage, ephemeral: true });
|
||||
return null;
|
||||
}
|
||||
return ticket;
|
||||
}
|
||||
|
||||
/**
|
||||
* Defer + run + log + reply on error. `verb` is the user-facing verb
|
||||
* (e.g. "escalate"); error messages render as "Failed to <verb> this ticket."
|
||||
* Errors are logged to console + DEBUGGING_CHANNEL_ID via logError(verb, ...).
|
||||
*
|
||||
* @param {import('discord.js').Interaction} interaction
|
||||
* @param {string} verb
|
||||
* @param {() => Promise<void>} fn
|
||||
* @param {{ ephemeral?: boolean }} [opts]
|
||||
*/
|
||||
async function runDeferred(interaction, verb, fn, { ephemeral = false } = {}) {
|
||||
try {
|
||||
await interaction.deferReply({ ephemeral });
|
||||
await fn();
|
||||
} catch (err) {
|
||||
console.error(`${verb} error:`, err);
|
||||
logError(verb, err, interaction).catch(() => {});
|
||||
const msg = `Failed to ${verb} this ticket.`;
|
||||
await interaction.editReply({ content: msg }).catch(() =>
|
||||
interaction.followUp({ content: msg, ephemeral: true }).catch(() => {})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { findTicketForChannel, runDeferred };
|
||||
Reference in New Issue
Block a user