Compare commits

...

3 Commits

Author SHA1 Message Date
e3b3b8d48c 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>
2026-05-07 18:57:43 +00:00
3ac23466b2 refactor handleCommand into a dispatch table
Each slash command and context-menu entry is now its own named function;
handleCommand looks the name up in COMMAND_HANDLERS and delegates. Finding
where a command lives is now "grep handle<Name>" instead of scrolling 600
lines of sequential ifs.

Other cleanups in the same pass:
- Restore ActionRowBuilder, ModalBuilder, TextInputBuilder, TextInputStyle
  to the top-level discord.js destructure. ActionRowBuilder was used at
  runtime by /response delete and /panel but had been dropped from the
  imports based on a stale "unused" diagnostic — those paths would have
  thrown ReferenceError. Hoisting the previously-inline /signature
  imports as well.
- Hoist `logTicketEvent` to the top imports (was inline-required at three
  callsites).
- Extract findTicketForChannel(), runDeferred(), and fetchLoggingChannel()
  helpers — replaces the lookup-then-defer-then-try-catch boilerplate
  repeated in nearly every branch.
- Pull the force-close timer body into finalizeForceClose() and
  postTranscript() so the timer registration is one line.
- Pull the panel button-row construction into buildPanelButtonRow().
- Split /response into its own RESPONSE_SUBCOMMANDS dispatch + per-sub
  handlers.
- Consolidate the duplicated transcript date formatter into one local fmt().

No behavior change. All 23 modules still load clean; handleCommand,
handleContextMenu, handleAutocomplete, runEscalation, and runDeescalation
exports are preserved (handlers/buttons.js imports the last two).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 18:51:29 +00:00
83b6b4ae0c simplify: rename CONFIG channels, dedup hasStaffRole, drop enforceEmbedLimit
- Rename CONFIG.TRANSCRIPT_CHAN -> CONFIG.TRANSCRIPT_CHANNEL_ID and
  CONFIG.LOG_CHAN -> CONFIG.LOGGING_CHANNEL_ID across 9 callsites so
  CONFIG keys match their .env names — no more "grep .env, find nothing"
  for new readers
- Replace handlers/commands.js#hasStaffRole with utils.js#isStaff
  (was a verbatim copy)
- Delete utils.js#enforceEmbedLimit and its 2 callsites; both inputs are
  bounded well under the 6000-char Discord embed cap, so the trim was
  defensive code that never fired

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 18:45:18 +00:00
7 changed files with 1410 additions and 1440 deletions

View File

@@ -18,8 +18,8 @@ const CONFIG = {
DISCORD_TICKET_CATEGORY_ID: process.env.DISCORD_TICKET_CATEGORY_ID || process.env.TICKET_CATEGORY_ID,
ROLE_ID_TO_PING: process.env.ROLE_ID_TO_PING,
ROLE_TO_PING_ID: process.env.ROLE_ID_TO_PING || process.env.ROLE_TO_PING_ID,
TRANSCRIPT_CHAN: process.env.TRANSCRIPT_CHANNEL_ID,
LOG_CHAN: process.env.LOGGING_CHANNEL_ID,
TRANSCRIPT_CHANNEL_ID: process.env.TRANSCRIPT_CHANNEL_ID,
LOGGING_CHANNEL_ID: process.env.LOGGING_CHANNEL_ID,
DEBUGGING_CHANNEL_ID: process.env.DEBUGGING_CHANNEL_ID || null,
CLIENT_ID: process.env.DISCORD_APPLICATION_ID,
REFRESH_TOKEN: process.env.REFRESH_TOKEN,

View File

@@ -14,7 +14,6 @@ const {
stripEmailQuotes,
stripMobileFooter,
detectGame,
enforceEmbedLimit,
sanitizeEmbedText
} = require('./utils');
const { getGmailClient } = require('./services/gmail');
@@ -225,7 +224,6 @@ async function poll(client) {
{ name: 'Subject', value: `\`\`\`\n${sanitizeEmbedText(subject) || 'No subject'}\n\`\`\``, inline: false }
);
enforceEmbedLimit([ticketInfoEmbed]);
const welcomeMsg = await enqueueSend(ticketChan, {
content: `<@&${CONFIG.ROLE_ID_TO_PING}>`,
embeds: [ticketInfoEmbed],
@@ -251,7 +249,7 @@ async function poll(client) {
if (transcriptRows.length > 0) {
const transcriptChan = await client.channels
.fetch(CONFIG.TRANSCRIPT_CHAN)
.fetch(CONFIG.TRANSCRIPT_CHANNEL_ID)
.catch(() => null);
if (transcriptChan) {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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

View File

@@ -69,7 +69,7 @@ async function logTicketEvent(action, fields, interaction = null) {
if (interaction?.user?.tag) {
embed.setFooter({ text: interaction.user.tag });
}
await sendToChannel(CONFIG.LOG_CHAN, embed, interaction?.client);
await sendToChannel(CONFIG.LOGGING_CHANNEL_ID, embed, interaction?.client);
}
module.exports = {

View File

@@ -264,83 +264,9 @@ function truncateEmbedDescription(str, max = 4096) {
return s.length > max ? s.slice(0, max - 3) + '...' : s;
}
/**
* Enforce the 6 000 char total embed limit across an array of EmbedBuilder
* instances. Mutates in place: trims the largest description first, then
* largest field values, until the total is under 6 000 chars.
* Returns the same array for chaining.
*/
function enforceEmbedLimit(embeds) {
const charCount = (e) => {
const d = e.data || {};
let total = 0;
if (d.title) total += d.title.length;
if (d.description) total += d.description.length;
if (d.footer?.text) total += d.footer.text.length;
if (d.author?.name) total += d.author.name.length;
if (d.fields) {
for (const f of d.fields) {
if (f.name) total += f.name.length;
if (f.value) total += f.value.length;
}
}
return total;
};
const LIMIT = 6000;
const totalChars = () => embeds.reduce((sum, e) => sum + charCount(e), 0);
// Trim largest descriptions first
while (totalChars() > LIMIT) {
let largestIdx = -1;
let largestLen = 0;
for (let i = 0; i < embeds.length; i++) {
const desc = embeds[i].data?.description;
if (desc && desc.length > largestLen) {
largestLen = desc.length;
largestIdx = i;
}
}
if (largestIdx === -1 || largestLen <= 4) break;
const excess = totalChars() - LIMIT;
const newLen = Math.max(1, largestLen - excess - 3);
embeds[largestIdx].setDescription(
embeds[largestIdx].data.description.slice(0, newLen) + '...'
);
if (totalChars() <= LIMIT) break;
// If still over, loop will pick next largest
}
// Trim largest field values
while (totalChars() > LIMIT) {
let targetEmbed = null;
let targetFieldIdx = -1;
let targetLen = 0;
for (const e of embeds) {
const fields = e.data?.fields || [];
for (let fi = 0; fi < fields.length; fi++) {
if (fields[fi].value && fields[fi].value.length > targetLen) {
targetLen = fields[fi].value.length;
targetEmbed = e;
targetFieldIdx = fi;
}
}
}
if (!targetEmbed || targetLen <= 4) break;
const excess = totalChars() - LIMIT;
const newLen = Math.max(1, targetLen - excess - 3);
targetEmbed.data.fields[targetFieldIdx].value =
targetEmbed.data.fields[targetFieldIdx].value.slice(0, newLen) + '...';
}
return embeds;
}
module.exports = {
sanitizeEmbedText,
truncateEmbedDescription,
enforceEmbedLimit,
escapeHtml,
safeEqual,
isStaff,