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.
215 lines
9.1 KiB
JavaScript
215 lines
9.1 KiB
JavaScript
/**
|
||
* 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} de‑escalated 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 };
|