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>
This commit is contained in:
@@ -1,12 +1,20 @@
|
||||
/**
|
||||
* Slash command, context menu, and autocomplete handlers.
|
||||
*
|
||||
* The dispatcher pattern: handleCommand looks up the command name in
|
||||
* COMMAND_HANDLERS and delegates. Each handle<Command>() owns one slash
|
||||
* command. To find a command's implementation, search for handle<Name>.
|
||||
*/
|
||||
const {
|
||||
ChannelType,
|
||||
ActionRowBuilder,
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
AttachmentBuilder,
|
||||
EmbedBuilder,
|
||||
ModalBuilder,
|
||||
TextInputBuilder,
|
||||
TextInputStyle,
|
||||
PermissionFlagsBits
|
||||
} = require('discord.js');
|
||||
const { mongoose } = require('../db-connection');
|
||||
@@ -17,16 +25,21 @@ const { sendTicketNotificationEmail } = require('../services/gmail');
|
||||
const { getTicketActionRow } = require('../utils/ticketComponents');
|
||||
const { enqueueRename, enqueueMove, enqueueSend } = require('../services/channelQueue');
|
||||
const { setNotifyDm } = require('../services/staffSettings');
|
||||
const { logError } = require('../services/debugLog');
|
||||
const { pinMessage } = require('../services/pinMessage');
|
||||
const { logError, logTicketEvent } = require('../services/debugLog');
|
||||
const { pendingCloses } = require('./pendingCloses');
|
||||
|
||||
const Ticket = mongoose.model('Ticket');
|
||||
const Tag = mongoose.model('Tag');
|
||||
const StaffSignature = mongoose.model('StaffSignature');
|
||||
|
||||
// ============================================================
|
||||
// Helpers
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Reply ephemeral and return true if the interaction is in a guild and the user is not staff (so caller should return).
|
||||
* @param {import('discord.js').CommandInteraction|import('discord.js').ContextMenuCommandInteraction} interaction
|
||||
* @returns {Promise<boolean>} true if caller should return (user is not allowed)
|
||||
* Reply ephemeral and return true if the interaction is in a guild and the
|
||||
* user is not staff (so the caller should bail).
|
||||
*/
|
||||
async function requireStaffRole(interaction) {
|
||||
if (!interaction.guild) return false;
|
||||
@@ -41,7 +54,49 @@ async function requireStaffRole(interaction) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Run escalation to a target DB tier (1 = tier 2, 2 = tier 3). Caller must validate ticket and currentTier < nextTier.
|
||||
* 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;
|
||||
return client.channels.fetch(CONFIG.LOGGING_CHANNEL_ID).catch(() => null);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Escalation flows (reused by buttons via the module exports)
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 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-');
|
||||
@@ -76,9 +131,7 @@ async function runEscalation(interaction, ticket, nextTier, reason) {
|
||||
: 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 🥦';
|
||||
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.**`,
|
||||
@@ -102,7 +155,6 @@ async function runEscalation(interaction, ticket, nextTier, reason) {
|
||||
});
|
||||
|
||||
if (CONFIG.PIN_ESCALATION_MESSAGE_ENABLED && escalationMsg) {
|
||||
const { pinMessage } = require('../services/pinMessage');
|
||||
await pinMessage(escalationMsg, interaction.client).catch(() => {});
|
||||
}
|
||||
|
||||
@@ -111,21 +163,13 @@ async function runEscalation(interaction, ticket, nextTier, reason) {
|
||||
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
|
||||
);
|
||||
await sendTicketNotificationEmail(ticket, null, emailBody, interaction.user.id);
|
||||
} catch (emailErr) {
|
||||
console.error('Escalation email failed (non-fatal):', emailErr.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (nextTier === 2) {
|
||||
if (!ticket.welcomeMessageId) {
|
||||
console.warn('welcomeMessageId is null/undefined; skipping welcome-message update for escalation');
|
||||
} else {
|
||||
if (nextTier === 2 && ticket.welcomeMessageId) {
|
||||
try {
|
||||
const welcomeMsg = await interaction.channel.messages.fetch(ticket.welcomeMessageId);
|
||||
await welcomeMsg.edit({ components: [getTicketActionRow(updatedTicketForRow)] });
|
||||
@@ -133,11 +177,8 @@ async function runEscalation(interaction, ticket, nextTier, reason) {
|
||||
console.error('Failed to update welcome message after escalate:', e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const logChan = await interaction.client.channels
|
||||
.fetch(CONFIG.LOGGING_CHANNEL_ID)
|
||||
.catch(() => null);
|
||||
const logChan = await fetchLoggingChannel(interaction.client);
|
||||
if (logChan) {
|
||||
const ticketType = isDiscordTicket ? 'Discord' : 'Email';
|
||||
const tierLabel = nextTier === 1 ? 'tier 2' : 'tier 3';
|
||||
@@ -147,9 +188,7 @@ async function runEscalation(interaction, ticket, nextTier, reason) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run deescalation one step. Caller must validate ticket and currentTier >= 1.
|
||||
*/
|
||||
/** 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-');
|
||||
@@ -190,7 +229,7 @@ async function runDeescalation(interaction, ticket) {
|
||||
.setFooter({ text: interaction.member?.displayName || interaction.user.username });
|
||||
await interaction.editReply({ embeds: [deescalateEmbed] });
|
||||
|
||||
const logChan = await interaction.client.channels.fetch(CONFIG.LOGGING_CHANNEL_ID).catch(() => null);
|
||||
const logChan = await fetchLoggingChannel(interaction.client);
|
||||
if (logChan) {
|
||||
const ticketType = isDiscordTicket ? 'Discord' : 'Email';
|
||||
await enqueueSend(logChan,
|
||||
@@ -199,29 +238,22 @@ async function runDeescalation(interaction, ticket) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main slash-command handler.
|
||||
*/
|
||||
async function handleCommand(interaction) {
|
||||
// Only /help can be used by everyone; all other commands require staff role (ROLE_ID_TO_PING / ADDITIONAL_STAFF_ROLES)
|
||||
if (interaction.commandName !== 'help' && (await requireStaffRole(interaction))) return;
|
||||
// ============================================================
|
||||
// Per-command handlers
|
||||
// ============================================================
|
||||
|
||||
// /escalate (tier 2 or 3 via level; works for both email and Discord). Always unclaims on escalate.
|
||||
if (interaction.commandName === 'escalate') {
|
||||
async function handleEscalate(interaction) {
|
||||
const reason = null;
|
||||
const level = interaction.options.getString('level');
|
||||
const nextTier = level === '3' ? 2 : 1;
|
||||
|
||||
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
|
||||
if (!ticket) {
|
||||
return interaction.reply({ content: 'This channel is not linked to a ticket.', ephemeral: true });
|
||||
}
|
||||
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.', ephemeral: true });
|
||||
}
|
||||
|
||||
if (nextTier <= currentTier) {
|
||||
return interaction.reply({ content: 'Ticket is already at or past that tier.', ephemeral: true });
|
||||
}
|
||||
@@ -238,18 +270,27 @@ async function handleCommand(interaction) {
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await interaction.deferReply();
|
||||
await runEscalation(interaction, ticket, nextTier, reason);
|
||||
} catch (err) {
|
||||
console.error('Escalate error:', err);
|
||||
await interaction.editReply({ content: 'Failed to escalate this ticket.' }).catch(() =>
|
||||
interaction.followUp({ content: 'Failed to escalate this ticket.', ephemeral: true }).catch(() => {})
|
||||
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.', ephemeral: true });
|
||||
}
|
||||
|
||||
if (interaction.commandName === 'notifydm') {
|
||||
await runDeferred(interaction, 'de-escalate',
|
||||
() => runDeescalation(interaction, ticket),
|
||||
{ ephemeral: true }
|
||||
);
|
||||
}
|
||||
|
||||
async function handleNotifyDm(interaction) {
|
||||
try {
|
||||
const setting = interaction.options.getString('setting') === 'on';
|
||||
await setNotifyDm(interaction.user.id, interaction.guildId, setting);
|
||||
@@ -261,40 +302,12 @@ async function handleCommand(interaction) {
|
||||
console.error('notifydm error:', err);
|
||||
await interaction.reply({ content: 'Failed to update notification setting.', ephemeral: true }).catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// /deescalate (tier 3 → tier 2, tier 2 → normal)
|
||||
if (interaction.commandName === 'deescalate') {
|
||||
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
|
||||
if (!ticket) {
|
||||
return interaction.reply({ content: 'This channel is not linked to a ticket.', ephemeral: true });
|
||||
}
|
||||
|
||||
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) {
|
||||
console.error('Deescalate error:', err);
|
||||
await interaction.editReply({ content: 'Failed to deescalate this ticket.' }).catch(() =>
|
||||
interaction.followUp({ content: 'Failed to deescalate this ticket.', ephemeral: true }).catch(() => {})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// /add
|
||||
if (interaction.commandName === 'add') {
|
||||
async function handleAdd(interaction) {
|
||||
const user = interaction.options.getUser('user');
|
||||
|
||||
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
|
||||
if (!ticket) {
|
||||
return interaction.reply({ content: 'This channel is not linked to a ticket.', ephemeral: true });
|
||||
}
|
||||
const ticket = await findTicketForChannel(interaction);
|
||||
if (!ticket) return;
|
||||
|
||||
try {
|
||||
// TODO(queue-migrate): permissionOverwrites mutation bypasses channelQueue — could race a pending rename/send on the same channel.
|
||||
@@ -310,14 +323,10 @@ async function handleCommand(interaction) {
|
||||
}
|
||||
}
|
||||
|
||||
// /remove
|
||||
if (interaction.commandName === 'remove') {
|
||||
async function handleRemove(interaction) {
|
||||
const user = interaction.options.getUser('user');
|
||||
|
||||
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
|
||||
if (!ticket) {
|
||||
return interaction.reply({ content: 'This channel is not linked to a ticket.', ephemeral: true });
|
||||
}
|
||||
const ticket = await findTicketForChannel(interaction);
|
||||
if (!ticket) return;
|
||||
|
||||
try {
|
||||
// TODO(queue-migrate): permissionOverwrites mutation bypasses channelQueue — could race a pending rename/send on the same channel.
|
||||
@@ -329,15 +338,11 @@ async function handleCommand(interaction) {
|
||||
}
|
||||
}
|
||||
|
||||
// /transfer
|
||||
if (interaction.commandName === 'transfer') {
|
||||
async function handleTransfer(interaction) {
|
||||
const member = interaction.options.getUser('member');
|
||||
const reason = interaction.options.getString('reason') || 'No reason provided';
|
||||
|
||||
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
|
||||
if (!ticket) {
|
||||
return interaction.reply({ content: 'This channel is not linked to a ticket.', ephemeral: true });
|
||||
}
|
||||
const ticket = await findTicketForChannel(interaction);
|
||||
if (!ticket) return;
|
||||
|
||||
const staffRoleId = CONFIG.ROLE_TO_PING_ID;
|
||||
const guildMember = await interaction.guild.members.fetch(member.id).catch(() => null);
|
||||
@@ -360,7 +365,7 @@ async function handleCommand(interaction) {
|
||||
allowedMentions: { parse: ['users'] }
|
||||
});
|
||||
|
||||
const logChan = await interaction.client.channels.fetch(CONFIG.LOGGING_CHANNEL_ID).catch(() => null);
|
||||
const logChan = await fetchLoggingChannel(interaction.client);
|
||||
if (logChan) {
|
||||
await enqueueSend(logChan, {
|
||||
content: `Ticket ${interaction.channel} transferred from ${interaction.user.tag} to ${member.tag}.\nReason: ${reason}`,
|
||||
@@ -373,21 +378,17 @@ async function handleCommand(interaction) {
|
||||
}
|
||||
}
|
||||
|
||||
// /move
|
||||
if (interaction.commandName === 'move') {
|
||||
async function handleMove(interaction) {
|
||||
const category = interaction.options.getChannel('category');
|
||||
|
||||
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
|
||||
if (!ticket) {
|
||||
return interaction.reply({ content: 'This channel is not linked to a ticket.', ephemeral: true });
|
||||
}
|
||||
const ticket = await findTicketForChannel(interaction);
|
||||
if (!ticket) return;
|
||||
|
||||
try {
|
||||
// TODO(queue-migrate): setParent bypasses channelQueue (enqueueMove) — use enqueueMove so moves serialize with pending renames/sends.
|
||||
await interaction.channel.setParent(category.id, { lockPermissions: true });
|
||||
await interaction.reply(`Moved ticket to **${category.name}**.`);
|
||||
|
||||
const logChan = await interaction.client.channels.fetch(CONFIG.LOGGING_CHANNEL_ID).catch(() => null);
|
||||
const logChan = await fetchLoggingChannel(interaction.client);
|
||||
if (logChan) {
|
||||
await enqueueSend(logChan,
|
||||
`Ticket ${interaction.channel} moved to category **${category.name}** by ${interaction.user.tag}`
|
||||
@@ -399,9 +400,7 @@ async function handleCommand(interaction) {
|
||||
}
|
||||
}
|
||||
|
||||
// /gmailpoll
|
||||
// /staffthread
|
||||
if (interaction.commandName === 'staffthread') {
|
||||
async function handleStaffThread(interaction) {
|
||||
const sub = interaction.options.getSubcommand();
|
||||
if (sub === 'toggle') {
|
||||
CONFIG.STAFF_THREAD_ENABLED = !CONFIG.STAFF_THREAD_ENABLED;
|
||||
@@ -417,11 +416,9 @@ async function handleCommand(interaction) {
|
||||
CONFIG.STAFF_THREAD_AUTO_ADD_ROLE = enabled;
|
||||
return interaction.reply({ content: `Auto-add role to staff thread is now **${enabled ? 'enabled' : 'disabled'}**.`, ephemeral: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// /pinmessages
|
||||
if (interaction.commandName === 'pinmessages') {
|
||||
async function handlePinMessages(interaction) {
|
||||
const sub = interaction.options.getSubcommand();
|
||||
const enabled = interaction.options.getBoolean('enabled');
|
||||
if (sub === 'initial') {
|
||||
@@ -436,33 +433,36 @@ async function handleCommand(interaction) {
|
||||
CONFIG.PIN_SUPPRESS_SYSTEM_MESSAGE = enabled;
|
||||
return interaction.reply({ content: `Suppress pin system message is now **${enabled ? 'enabled' : 'disabled'}**.`, ephemeral: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (interaction.commandName === 'gmailpoll') {
|
||||
async function handleGmailPoll(interaction) {
|
||||
const seconds = parseInt(interaction.options.getString('interval'), 10);
|
||||
// Lazy require — broccolini-discord re-exports this and we'd otherwise cycle.
|
||||
const { setGmailPollInterval } = require('../broccolini-discord');
|
||||
setGmailPollInterval(seconds * 1000);
|
||||
logTicketEvent('Gmail poll interval updated', [{ name: 'Interval', value: `${seconds}s` }, { name: 'Set by', value: interaction.user.tag }], interaction).catch(() => {});
|
||||
logTicketEvent('Gmail poll interval updated', [
|
||||
{ name: 'Interval', value: `${seconds}s` },
|
||||
{ name: 'Set by', value: interaction.user.tag }
|
||||
], interaction).catch(() => {});
|
||||
return interaction.reply({ content: `Gmail poll interval set to ${seconds} seconds.`, ephemeral: true });
|
||||
}
|
||||
|
||||
// /closetimer
|
||||
if (interaction.commandName === 'closetimer') {
|
||||
async function handleCloseTimer(interaction) {
|
||||
const seconds = parseInt(interaction.options.getString('seconds'), 10);
|
||||
CONFIG.FORCE_CLOSE_TIMER = seconds;
|
||||
logTicketEvent('Close timer updated', [{ name: 'Duration', value: `${seconds}s` }, { name: 'Set by', value: interaction.user.tag }], interaction).catch(() => {});
|
||||
logTicketEvent('Close timer updated', [
|
||||
{ name: 'Duration', value: `${seconds}s` },
|
||||
{ name: 'Set by', value: interaction.user.tag }
|
||||
], interaction).catch(() => {});
|
||||
return interaction.reply({ content: `Force-close timer set to ${seconds} seconds.`, ephemeral: true });
|
||||
}
|
||||
|
||||
// /cancel-close
|
||||
if (interaction.commandName === 'cancel-close') {
|
||||
async function handleCancelClose(interaction) {
|
||||
const pending = pendingCloses.get(interaction.channel.id);
|
||||
if (!pending) {
|
||||
return interaction.reply({ content: 'No pending close for this channel.', ephemeral: true });
|
||||
}
|
||||
clearTimeout(pending.timeout);
|
||||
const { logTicketEvent } = require('../services/debugLog');
|
||||
logTicketEvent('Force-close cancelled', [
|
||||
{ name: 'Ticket', value: interaction.channel.name || interaction.channel.id },
|
||||
{ name: 'Cancelled by', value: interaction.user.tag },
|
||||
@@ -472,12 +472,9 @@ async function handleCommand(interaction) {
|
||||
return interaction.reply({ content: 'Close cancelled.', ephemeral: true });
|
||||
}
|
||||
|
||||
// /force-close
|
||||
if (interaction.commandName === 'force-close') {
|
||||
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
|
||||
if (!ticket) {
|
||||
return interaction.reply({ content: 'This channel is not linked to a ticket.', ephemeral: true });
|
||||
}
|
||||
async function handleForceClose(interaction) {
|
||||
const ticket = await findTicketForChannel(interaction);
|
||||
if (!ticket) return;
|
||||
|
||||
if (pendingCloses.has(interaction.channel.id)) {
|
||||
return interaction.reply({ content: 'A close is already pending for this ticket.', ephemeral: true });
|
||||
@@ -488,7 +485,12 @@ async function handleCommand(interaction) {
|
||||
|
||||
const channelRef = interaction.channel;
|
||||
const clientRef = interaction.client;
|
||||
const timerId = setTimeout(async () => {
|
||||
const timerId = setTimeout(() => finalizeForceClose(channelRef, clientRef), timerSeconds * 1000);
|
||||
pendingCloses.set(channelRef.id, { timeout: timerId, userId: interaction.user.id, username: interaction.user.tag });
|
||||
}
|
||||
|
||||
/** Performs the actual force-close work after the countdown elapses. */
|
||||
async function finalizeForceClose(channelRef, clientRef) {
|
||||
pendingCloses.delete(channelRef.id);
|
||||
const freshTicket = await Ticket.findOne({ discordThreadId: channelRef.id }).lean();
|
||||
if (!freshTicket || freshTicket.status === 'closed') return;
|
||||
@@ -500,8 +502,22 @@ async function handleCommand(interaction) {
|
||||
);
|
||||
|
||||
await enqueueSend(channelRef, 'Ticket force-closed. Archiving...');
|
||||
await postTranscript(channelRef, clientRef, freshTicket).catch(tErr =>
|
||||
console.error('Transcript error (force-close):', tErr)
|
||||
);
|
||||
|
||||
try {
|
||||
setTimeout(() => {
|
||||
channelRef.delete('Ticket force-closed').catch(e =>
|
||||
console.error('Failed to delete channel:', e)
|
||||
);
|
||||
}, 5000);
|
||||
} catch (err) {
|
||||
console.error('Force close error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Render and post a closing transcript for a ticket. */
|
||||
async function postTranscript(channelRef, clientRef, freshTicket) {
|
||||
await enqueueSend(channelRef, CONFIG.DISCORD_CLOSE_MESSAGE);
|
||||
|
||||
const messages = await channelRef.messages.fetch({ limit: 100 });
|
||||
@@ -519,56 +535,28 @@ async function handleCommand(interaction) {
|
||||
const transcriptChan = await clientRef.channels
|
||||
.fetch(CONFIG.TRANSCRIPT_CHANNEL_ID)
|
||||
.catch(() => null);
|
||||
if (!transcriptChan) return;
|
||||
|
||||
if (transcriptChan) {
|
||||
const closedAt = new Date();
|
||||
const openedStr = new Date(freshTicket.createdAt).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', {
|
||||
const fmt = (d) => 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'
|
||||
});
|
||||
const openedStr = fmt(freshTicket.createdAt);
|
||||
const closedStr = fmt(new Date());
|
||||
const transcriptContent = CONFIG.DISCORD_TRANSCRIPT_MESSAGE
|
||||
.replace(/\{channel_name\}/g, channelRef.name)
|
||||
.replace(/\{email\}/g, freshTicket.senderEmail || '')
|
||||
.replace(/\{date_opened\}/g, openedStr)
|
||||
.replace(/\{date_closed\}/g, closedStr)
|
||||
+ `\n\nDate Opened: ${openedStr}\nDate Closed: ${closedStr}`;
|
||||
await enqueueSend(transcriptChan, {
|
||||
content: transcriptContent,
|
||||
files: [file]
|
||||
});
|
||||
}
|
||||
} catch (tErr) {
|
||||
console.error('Transcript error (force-close):', tErr);
|
||||
await enqueueSend(transcriptChan, { content: transcriptContent, files: [file] });
|
||||
}
|
||||
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await channelRef.delete('Ticket force-closed');
|
||||
} catch (e) {
|
||||
console.error('Failed to delete channel:', e);
|
||||
}
|
||||
}, 5000);
|
||||
} catch (err) {
|
||||
console.error('Force close error:', err);
|
||||
}
|
||||
}, timerSeconds * 1000);
|
||||
pendingCloses.set(channelRef.id, { timeout: timerId, userId: interaction.user.id, username: interaction.user.tag });
|
||||
}
|
||||
|
||||
// /topic
|
||||
if (interaction.commandName === 'topic') {
|
||||
async function handleTopic(interaction) {
|
||||
const text = interaction.options.getString('text');
|
||||
|
||||
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
|
||||
if (!ticket) {
|
||||
return interaction.reply({ content: 'This channel is not linked to a ticket.', ephemeral: true });
|
||||
}
|
||||
const ticket = await findTicketForChannel(interaction);
|
||||
if (!ticket) return;
|
||||
|
||||
try {
|
||||
// TODO(queue-migrate): setTopic bypasses channelQueue — could race a pending rename/send on the same channel.
|
||||
@@ -580,12 +568,33 @@ async function handleCommand(interaction) {
|
||||
}
|
||||
}
|
||||
|
||||
// /response – saved response tags (send, create, edit, delete, list)
|
||||
if (interaction.commandName === 'response') {
|
||||
const subcommand = interaction.options.getSubcommand();
|
||||
// /response is itself a router over its subcommands
|
||||
const RESPONSE_SUBCOMMANDS = {
|
||||
send: handleResponseSend,
|
||||
create: handleResponseCreate,
|
||||
edit: handleResponseEdit,
|
||||
delete: handleResponseDelete,
|
||||
list: handleResponseList
|
||||
};
|
||||
|
||||
async function handleResponse(interaction) {
|
||||
const subcommand = interaction.options.getSubcommand();
|
||||
const handler = RESPONSE_SUBCOMMANDS[subcommand];
|
||||
if (!handler) return;
|
||||
try {
|
||||
if (subcommand === 'send') {
|
||||
await handler(interaction);
|
||||
} catch (err) {
|
||||
logError('response-command', err, interaction).catch(() => {});
|
||||
const errorMsg = '❌ An error occurred while processing the response command.';
|
||||
if (interaction.deferred) {
|
||||
await interaction.editReply(errorMsg);
|
||||
} else {
|
||||
await interaction.reply({ content: errorMsg, ephemeral: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResponseSend(interaction) {
|
||||
const name = interaction.options.getString('name');
|
||||
const tag = await Tag.findOne({ name }).lean();
|
||||
if (!tag) {
|
||||
@@ -606,11 +615,11 @@ async function handleCommand(interaction) {
|
||||
const content = replaceVariables(tag.content, context);
|
||||
await Tag.updateOne({ name }, { $inc: { useCount: 1 } });
|
||||
// Tag bodies are staff-authored but may include variable substitutions from user/ticket data.
|
||||
// Disable all mention parsing so a `@everyone` in a tag body never pings.
|
||||
// Disable mention parsing so a `@everyone` in a tag body never pings.
|
||||
await interaction.reply({ content, allowedMentions: { parse: [] } });
|
||||
}
|
||||
|
||||
else if (subcommand === 'create') {
|
||||
async function handleResponseCreate(interaction) {
|
||||
const name = interaction.options.getString('name');
|
||||
const content = interaction.options.getString('content');
|
||||
|
||||
@@ -627,13 +636,12 @@ async function handleCommand(interaction) {
|
||||
}
|
||||
}
|
||||
|
||||
else if (subcommand === 'edit') {
|
||||
async function handleResponseEdit(interaction) {
|
||||
const name = interaction.options.getString('name');
|
||||
const content = interaction.options.getString('content');
|
||||
|
||||
try {
|
||||
const result = await Tag.updateOne({ name }, { $set: { content } });
|
||||
|
||||
if (result.matchedCount === 0) {
|
||||
await interaction.reply({ content: `❌ Tag "${name}" not found.`, ephemeral: true });
|
||||
} else {
|
||||
@@ -645,9 +653,9 @@ async function handleCommand(interaction) {
|
||||
}
|
||||
}
|
||||
|
||||
else if (subcommand === 'delete') {
|
||||
async function handleResponseDelete(interaction) {
|
||||
const name = interaction.options.getString('name');
|
||||
// Use :: delimiter so tag names with underscores are parsed correctly (Discord customId max 100 chars)
|
||||
// Use :: delimiter so tag names with underscores parse correctly (Discord customId max 100 chars).
|
||||
const customId = `confirm_delete_tag::${name}`.slice(0, 100);
|
||||
const confirmRow = new ActionRowBuilder().addComponents(
|
||||
new ButtonBuilder()
|
||||
@@ -667,11 +675,10 @@ async function handleCommand(interaction) {
|
||||
});
|
||||
}
|
||||
|
||||
else if (subcommand === 'list') {
|
||||
async function handleResponseList(interaction) {
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
|
||||
const tags = await Tag.find().sort({ useCount: -1 }).select('name useCount').lean();
|
||||
|
||||
if (!tags || tags.length === 0) {
|
||||
return interaction.editReply({ content: '📋 No tags available.' });
|
||||
}
|
||||
@@ -686,31 +693,15 @@ async function handleCommand(interaction) {
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
}
|
||||
} catch (err) {
|
||||
logError('response-command', err, interaction).catch(() => {});
|
||||
const errorMsg = '❌ An error occurred while processing the response command.';
|
||||
if (interaction.deferred) {
|
||||
await interaction.editReply(errorMsg);
|
||||
} else {
|
||||
await interaction.reply({ content: errorMsg, ephemeral: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// /signature
|
||||
if (interaction.commandName === 'signature') {
|
||||
async function handleSignature(interaction) {
|
||||
try {
|
||||
// Fetch existing signature data if it exists
|
||||
const StaffSignature = mongoose.model('StaffSignature');
|
||||
const existingSignature = await StaffSignature.findOne({ userId: interaction.user.id }).lean();
|
||||
|
||||
// Create modal
|
||||
const { ModalBuilder, ActionRowBuilder, TextInputBuilder, TextInputStyle } = require('discord.js');
|
||||
const modal = new ModalBuilder()
|
||||
.setCustomId(`signature_modal_${interaction.user.id}`)
|
||||
.setTitle('Staff Signature Settings');
|
||||
|
||||
// Add text inputs to modal
|
||||
const valedictionInput = new TextInputBuilder()
|
||||
.setCustomId('valediction')
|
||||
.setLabel('Valediction (e.g. "Best regards", "Thanks")')
|
||||
@@ -732,11 +723,11 @@ async function handleCommand(interaction) {
|
||||
.setRequired(false)
|
||||
.setValue(existingSignature?.tagline || '');
|
||||
|
||||
const valedictionRow = new ActionRowBuilder().addComponents(valedictionInput);
|
||||
const displayNameRow = new ActionRowBuilder().addComponents(displayNameInput);
|
||||
const taglineRow = new ActionRowBuilder().addComponents(taglineInput);
|
||||
|
||||
modal.addComponents(valedictionRow, displayNameRow, taglineRow);
|
||||
modal.addComponents(
|
||||
new ActionRowBuilder().addComponents(valedictionInput),
|
||||
new ActionRowBuilder().addComponents(displayNameInput),
|
||||
new ActionRowBuilder().addComponents(taglineInput)
|
||||
);
|
||||
|
||||
await interaction.showModal(modal);
|
||||
} catch (err) {
|
||||
@@ -745,11 +736,9 @@ async function handleCommand(interaction) {
|
||||
await interaction.reply({ content: 'Failed to open signature settings.', ephemeral: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// /help
|
||||
if (interaction.commandName === 'help') {
|
||||
async function handleHelp(interaction) {
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle('Ticket System - Commands')
|
||||
.setColor(CONFIG.EMBED_COLOR_OPEN)
|
||||
@@ -784,10 +773,9 @@ async function handleCommand(interaction) {
|
||||
await interaction.reply({ embeds: [embed], ephemeral: true });
|
||||
}
|
||||
|
||||
// /panel
|
||||
if (interaction.commandName === 'panel') {
|
||||
async function handlePanel(interaction) {
|
||||
const channel = interaction.options.getChannel('channel');
|
||||
const panelType = interaction.options.getString('type') || null; // 'thread' | 'category' | 'both' or null (use CONFIG default)
|
||||
const panelType = interaction.options.getString('type') || null; // 'thread' | 'category' | 'both' or null
|
||||
const title = interaction.options.getString('title') || 'Indifferent Broccoli Tickets';
|
||||
const description = interaction.options.getString('description') ||
|
||||
'Need help? Click below to create a ticket. 🎟';
|
||||
@@ -799,9 +787,20 @@ async function handleCommand(interaction) {
|
||||
.setThumbnail(CONFIG.LOGO_URL || null)
|
||||
.setFooter({ text: 'Indifferent Broccoli Tickets' });
|
||||
|
||||
let row;
|
||||
const row = buildPanelButtonRow(panelType);
|
||||
|
||||
try {
|
||||
await enqueueSend(channel, { embeds: [embed], components: [row] });
|
||||
await interaction.reply({ content: `Panel created in ${channel}!`, ephemeral: true });
|
||||
} catch (err) {
|
||||
console.error('Panel creation error:', err);
|
||||
await interaction.reply({ content: 'Failed to create panel.', ephemeral: true });
|
||||
}
|
||||
}
|
||||
|
||||
function buildPanelButtonRow(panelType) {
|
||||
if (panelType === 'both') {
|
||||
row = new ActionRowBuilder().addComponents(
|
||||
return new ActionRowBuilder().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId('open_ticket_thread')
|
||||
.setLabel('Create ticket (thread)')
|
||||
@@ -813,24 +812,26 @@ async function handleCommand(interaction) {
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setEmoji('📁')
|
||||
);
|
||||
} else if (panelType === 'thread') {
|
||||
row = new ActionRowBuilder().addComponents(
|
||||
}
|
||||
if (panelType === 'thread') {
|
||||
return new ActionRowBuilder().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId('open_ticket_thread')
|
||||
.setLabel('Create ticket')
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setEmoji('🧵')
|
||||
);
|
||||
} else if (panelType === 'category') {
|
||||
row = new ActionRowBuilder().addComponents(
|
||||
}
|
||||
if (panelType === 'category') {
|
||||
return new ActionRowBuilder().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId('open_ticket_channel')
|
||||
.setLabel('Create ticket')
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setEmoji('📁')
|
||||
);
|
||||
} else {
|
||||
row = new ActionRowBuilder().addComponents(
|
||||
}
|
||||
return new ActionRowBuilder().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId('open_ticket')
|
||||
.setLabel('Create ticket')
|
||||
@@ -839,26 +840,11 @@ async function handleCommand(interaction) {
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await enqueueSend(channel, { embeds: [embed], components: [row] });
|
||||
await interaction.reply({ content: `Panel created in ${channel}!`, ephemeral: true });
|
||||
} catch (err) {
|
||||
console.error('Panel creation error:', err);
|
||||
await interaction.reply({ content: 'Failed to create panel.', ephemeral: true });
|
||||
}
|
||||
}
|
||||
// ============================================================
|
||||
// Context-menu handlers
|
||||
// ============================================================
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Context menu interaction handler.
|
||||
*/
|
||||
async function handleContextMenu(interaction) {
|
||||
// Restrict all guild context menus to staff role only
|
||||
if (await requireStaffRole(interaction)) return;
|
||||
|
||||
// Create Ticket From Message
|
||||
if (interaction.isMessageContextMenuCommand() && interaction.commandName === 'Create Ticket From Message') {
|
||||
async function handleCreateTicketFromMessage(interaction) {
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
|
||||
const rateLimit = checkTicketCreationRateLimit(interaction.user.id);
|
||||
@@ -876,11 +862,9 @@ async function handleContextMenu(interaction) {
|
||||
const lastTicket = await Ticket.findOne().sort({ ticketNumber: -1 }).select('ticketNumber').lean();
|
||||
const ticketNumber = (lastTicket?.ticketNumber || 0) + 1;
|
||||
|
||||
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
|
||||
@@ -889,12 +873,13 @@ async function handleContextMenu(interaction) {
|
||||
console.error('getOrCreateTicketCategory (context menu ticket):', err);
|
||||
return interaction.editReply('❌ Discord ticket category could not be resolved. Contact an administrator.');
|
||||
}
|
||||
parentCategoryIdForTicket = parentId;
|
||||
|
||||
let channel;
|
||||
try {
|
||||
channel = await guild.channels.create({
|
||||
name: `ticket-${ticketNumber}`,
|
||||
type: ChannelType.GuildText,
|
||||
parent: parentId,
|
||||
parent: parentCategoryIdForTicket,
|
||||
permissionOverwrites: [
|
||||
{ id: guild.id, deny: [PermissionFlagsBits.ViewChannel] },
|
||||
{
|
||||
@@ -964,13 +949,11 @@ async function handleContextMenu(interaction) {
|
||||
}
|
||||
}
|
||||
|
||||
// View User Tickets
|
||||
if (interaction.isUserContextMenuCommand() && interaction.commandName === 'View User Tickets') {
|
||||
async function handleViewUserTickets(interaction) {
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
|
||||
try {
|
||||
const targetUser = interaction.targetUser;
|
||||
|
||||
const tickets = await Ticket.find({ senderEmail: targetUser.tag })
|
||||
.sort({ createdAt: -1 })
|
||||
.limit(10)
|
||||
@@ -1005,18 +988,62 @@ async function handleContextMenu(interaction) {
|
||||
await interaction.editReply('❌ Failed to fetch user tickets.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Dispatch tables
|
||||
// ============================================================
|
||||
|
||||
const COMMAND_HANDLERS = {
|
||||
escalate: handleEscalate,
|
||||
deescalate: handleDeescalate,
|
||||
notifydm: handleNotifyDm,
|
||||
add: handleAdd,
|
||||
remove: handleRemove,
|
||||
transfer: handleTransfer,
|
||||
move: handleMove,
|
||||
staffthread: handleStaffThread,
|
||||
pinmessages: handlePinMessages,
|
||||
gmailpoll: handleGmailPoll,
|
||||
closetimer: handleCloseTimer,
|
||||
'cancel-close': handleCancelClose,
|
||||
'force-close': handleForceClose,
|
||||
topic: handleTopic,
|
||||
response: handleResponse,
|
||||
signature: handleSignature,
|
||||
help: handleHelp,
|
||||
panel: handlePanel
|
||||
};
|
||||
|
||||
const CONTEXT_MENU_HANDLERS = {
|
||||
'Create Ticket From Message': handleCreateTicketFromMessage,
|
||||
'View User Tickets': handleViewUserTickets
|
||||
};
|
||||
|
||||
/**
|
||||
* Autocomplete handler.
|
||||
* Slash-command dispatcher. /help is open to everyone; everything else
|
||||
* requires the staff role.
|
||||
*/
|
||||
async function handleCommand(interaction) {
|
||||
if (interaction.commandName !== 'help' && (await requireStaffRole(interaction))) return;
|
||||
const handler = COMMAND_HANDLERS[interaction.commandName];
|
||||
if (handler) await handler(interaction);
|
||||
}
|
||||
|
||||
/** Context-menu dispatcher. All entries are staff-only. */
|
||||
async function handleContextMenu(interaction) {
|
||||
if (await requireStaffRole(interaction)) return;
|
||||
const handler = CONTEXT_MENU_HANDLERS[interaction.commandName];
|
||||
if (handler) await handler(interaction);
|
||||
}
|
||||
|
||||
/** Autocomplete handler. Currently only /response uses it. */
|
||||
async function handleAutocomplete(interaction) {
|
||||
if (interaction.commandName === 'response') {
|
||||
if (interaction.commandName !== 'response') return;
|
||||
const subcommand = interaction.options.getSubcommand();
|
||||
if (['send', 'edit', 'delete'].includes(subcommand)) {
|
||||
if (!['send', 'edit', 'delete'].includes(subcommand)) return;
|
||||
|
||||
const focusedValue = interaction.options.getFocused();
|
||||
const tags = await Tag.find().sort({ name: 1 }).select('name').lean();
|
||||
|
||||
const filtered = tags
|
||||
.filter(t => t.name.toLowerCase().includes(focusedValue.toLowerCase()))
|
||||
.slice(0, 25)
|
||||
@@ -1024,7 +1051,5 @@ async function handleAutocomplete(interaction) {
|
||||
|
||||
await interaction.respond(filtered);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { handleCommand, handleContextMenu, handleAutocomplete, runEscalation, runDeescalation };
|
||||
Reference in New Issue
Block a user