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:
2026-05-07 18:57:43 +00:00
parent 3ac23466b2
commit e3b3b8d48c
3 changed files with 590 additions and 555 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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
View 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 };