Files
broccolini-bot/handlers/commands/escalation.js
indifferentketchup adcd9dd9c9 audit week 2 [ARCH-001]: split handlers/commands.js into submodules
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.
2026-05-08 20:29:44 +00:00

215 lines
9.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Escalation flows.
*
* runEscalation / runDeescalation are exported for handlers/buttons.js
* (the tier-pick buttons share this code path). handleEscalate /
* handleDeescalate are the slash-command entry points.
*/
const { EmbedBuilder, MessageFlags } = require('discord.js');
const { mongoose } = require('../../db-connection');
const { CONFIG } = require('../../config');
const { makeTicketName, resolveCreatorNickname } = require('../../services/tickets');
const { sendTicketNotificationEmail } = require('../../services/gmail');
const { getTicketActionRow } = require('../../utils/ticketComponents');
const { enqueueRename, enqueueMove, enqueueSend } = require('../../services/channelQueue');
const { pinMessage } = require('../../services/pinMessage');
const { logError } = require('../../services/debugLog');
const { findTicketForChannel, runDeferred } = require('../sharedHelpers');
const { fetchLoggingChannel } = require('./helpers');
const Ticket = mongoose.model('Ticket');
/**
* Run escalation to a target tier (1 = tier 2, 2 = tier 3). Caller must
* validate ticket and currentTier < nextTier, and have already deferred.
*/
async function runEscalation(interaction, ticket, nextTier, reason) {
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
const categoryId = nextTier === 1
? (isDiscordTicket ? CONFIG.DISCORD_ESCALATED2_CHANNEL_ID : CONFIG.EMAIL_ESCALATED2_CHANNEL_ID)
: (isDiscordTicket ? CONFIG.DISCORD_ESCALATED3_CHANNEL_ID : CONFIG.EMAIL_ESCALATED3_CHANNEL_ID);
// Clear claim on escalation
await Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
{ $set: { escalated: true, escalationTier: nextTier, claimedBy: null, claimerId: null } }
);
ticket.escalated = true;
ticket.escalationTier = nextTier;
ticket.claimedBy = null;
const creatorNickname = await resolveCreatorNickname(interaction.guild, ticket);
const newName = makeTicketName('escalated', ticket, creatorNickname);
enqueueRename(interaction.channel, newName).catch(err => logError('rename', err).catch(() => {}));
if (!interaction.channel.isThread() && categoryId) {
await enqueueMove(interaction.channel, categoryId);
}
const pendingEmbed = new EmbedBuilder()
.setDescription('Ticket will be escalated in a few seconds.')
.setColor(CONFIG.EMBED_COLOR_INFO);
await interaction.editReply({ embeds: [pendingEmbed] });
const creatorId = isDiscordTicket
? (ticket.gmailThreadId.split('-').pop() || '').trim()
: null;
const creatorMention = creatorId ? `<@${creatorId}>` : '';
const roleMention = CONFIG.ROLE_ID_TO_PING ? `<@&${CONFIG.ROLE_ID_TO_PING}>` : 'a senior team member';
const heyLine = creatorMention ? `Hey There ${creatorMention} 🥦` : 'Hey There 🥦';
// Creator + role pings are intentional; still block @everyone/@here if somehow interpolated.
await enqueueSend(interaction.channel, {
content: `${heyLine}\n**Getting the senior ${roleMention} for you.**`,
allowedMentions: { parse: ['users', 'roles'] }
});
const escalationBody = CONFIG.ESCALATION_MESSAGE
.replace(/\\n/g, '\n')
.replace(/\{support_name\}/g, CONFIG.SUPPORT_NAME);
const escalatedEmbed = new EmbedBuilder()
.setTitle(`🚨 Escalated to ${nextTier === 1 ? 'Tier 2' : 'Tier 3'} Support`)
.setDescription(escalationBody)
.setColor(CONFIG.EMBED_COLOR_ESCALATED)
.setFooter({ text: `Escalated by ${interaction.member?.displayName || interaction.user.username}` });
const updatedTicketForRow = { ...ticket, escalationTier: nextTier, escalated: true };
const escalationRow = getTicketActionRow(updatedTicketForRow);
const escalationMsg = await enqueueSend(interaction.channel, {
content: null,
embeds: [escalatedEmbed],
components: [escalationRow]
});
if (CONFIG.PIN_ESCALATION_MESSAGE_ENABLED && escalationMsg) {
await pinMessage(escalationMsg, interaction.client).catch(() => {});
}
if (!isDiscordTicket && ticket.gmailThreadId) {
try {
const escalatorName = interaction.member?.displayName || interaction.user.username;
const tierLabel = nextTier === 1 ? 'tier 2' : 'tier 3';
const emailBody = `${escalatorName} escalated this ticket to ${tierLabel}.${reason ? `\n\nReason: ${reason}` : ''}`;
await sendTicketNotificationEmail(ticket, null, emailBody, interaction.user.id);
} catch (emailErr) {
console.error('Escalation email failed (non-fatal):', emailErr.message);
}
}
if (nextTier === 2 && ticket.welcomeMessageId) {
try {
const welcomeMsg = await interaction.channel.messages.fetch(ticket.welcomeMessageId);
await welcomeMsg.edit({ components: [getTicketActionRow(updatedTicketForRow)] });
} catch (e) {
console.error('Failed to update welcome message after escalate:', e.message);
}
}
const logChan = await fetchLoggingChannel(interaction.client);
if (logChan) {
const ticketType = isDiscordTicket ? 'Discord' : 'Email';
const tierLabel = nextTier === 1 ? 'tier 2' : 'tier 3';
await enqueueSend(logChan,
`${ticketType} ticket ${interaction.channel} escalated to ${tierLabel} by ${interaction.user.tag}.\nReason: ${reason}`
);
}
}
/** Run deescalation one step. Caller must validate ticket and currentTier >= 1. */
async function runDeescalation(interaction, ticket) {
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
const newTier = currentTier - 1;
await Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
{ $set: { escalated: newTier > 0, escalationTier: newTier, claimedBy: null, claimerId: null } }
);
ticket.escalated = newTier > 0;
ticket.escalationTier = newTier;
ticket.claimedBy = null;
const creatorNickname = await resolveCreatorNickname(interaction.guild, ticket);
const state = newTier === 0 ? 'unclaimed' : 'escalated';
enqueueRename(interaction.channel, makeTicketName(state, ticket, creatorNickname)).catch(err => logError('rename', err).catch(() => {}));
if (!interaction.channel.isThread()) {
try {
if (newTier === 0) {
const homeCategory = isDiscordTicket ? CONFIG.DISCORD_TICKET_CATEGORY_ID : CONFIG.TICKET_CATEGORY_ID;
if (homeCategory) await enqueueMove(interaction.channel, homeCategory);
} else if (newTier === 1) {
const t2Category = isDiscordTicket
? CONFIG.DISCORD_ESCALATED2_CHANNEL_ID
: CONFIG.EMAIL_ESCALATED2_CHANNEL_ID;
if (t2Category) await enqueueMove(interaction.channel, t2Category);
}
} catch (e) {
console.error('Move error (deescalate):', e);
}
}
const tierLabel = newTier === 0 ? 'normal' : newTier === 1 ? 'tier 2' : 'tier 3';
const deescalateEmbed = new EmbedBuilder()
.setColor(0x00BFFF)
.setTitle(`✅ De-escalated to ${tierLabel} Support`)
.setFooter({ text: interaction.member?.displayName || interaction.user.username });
await interaction.editReply({ embeds: [deescalateEmbed] });
const logChan = await fetchLoggingChannel(interaction.client);
if (logChan) {
const ticketType = isDiscordTicket ? 'Discord' : 'Email';
await enqueueSend(logChan,
`${ticketType} ticket ${interaction.channel} deescalated to ${tierLabel} by ${interaction.user.tag}.`
);
}
}
async function handleEscalate(interaction) {
const reason = null;
const level = interaction.options.getString('level');
const nextTier = level === '3' ? 2 : 1;
const ticket = await findTicketForChannel(interaction);
if (!ticket) return;
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
if (currentTier >= 2) {
return interaction.reply({ content: 'This ticket is already at tier 3 support.', flags: MessageFlags.Ephemeral });
}
if (nextTier <= currentTier) {
return interaction.reply({ content: 'Ticket is already at or past that tier.', flags: MessageFlags.Ephemeral });
}
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
const categoryId = nextTier === 1
? (isDiscordTicket ? CONFIG.DISCORD_ESCALATED2_CHANNEL_ID : CONFIG.EMAIL_ESCALATED2_CHANNEL_ID)
: (isDiscordTicket ? CONFIG.DISCORD_ESCALATED3_CHANNEL_ID : CONFIG.EMAIL_ESCALATED3_CHANNEL_ID);
const configKey = nextTier === 1 ? 'ESCALATED2' : 'ESCALATED3';
if (!categoryId && !interaction.channel.isThread()) {
return interaction.reply({
content: `${configKey} is not configured for ${isDiscordTicket ? 'Discord' : 'email'} tickets.`,
flags: MessageFlags.Ephemeral
});
}
await runDeferred(interaction, 'escalate', () =>
runEscalation(interaction, ticket, nextTier, reason)
);
}
async function handleDeescalate(interaction) {
const ticket = await findTicketForChannel(interaction);
if (!ticket) return;
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
if (currentTier === 0) {
return interaction.reply({ content: 'This ticket is not escalated.', flags: MessageFlags.Ephemeral });
}
await runDeferred(interaction, 'de-escalate',
() => runDeescalation(interaction, ticket),
{ flags: MessageFlags.Ephemeral }
);
}
module.exports = { runEscalation, runDeescalation, handleEscalate, handleDeescalate };