Event-sourced tracking of staff/ticket lifecycle actions, plus a /stats command. Foundation for a future tickets-website analytics dashboard. Data: - StaffAction model (event log) + Ticket.game / Ticket.closedAt - STATS_ADMIN_IDS config (who may view others' stats) Recording (fire-and-forget, idempotent on real state transitions): - claim, response (channel reply + /response send), escalate, de-escalate, transfer, close (4 sites), reopen — each denormalizes ticketType, tier, priority, game, requester (senderEmail / creatorId), guildId - close events carry closerType / resolverId (claimer credit) / wasClaimed; transfer carries fromId / toId; reopen stamps resolverId - conditional close transition helper (atomic open->closed + closedAt) shared by all four close paths Query + command: - pure period parser (presets + free-text) and stats shaper (per-metric keys) - command-aware autocomplete dispatch - /stats: period (autocomplete) + member (admin-gated) + source (all/email/ discord), ManageMessages + staff-role gated, ephemeral, tier-labeled embed 288+ unit tests; timing/busiest-times data is collected but displayed later.
483 lines
20 KiB
JavaScript
483 lines
20 KiB
JavaScript
/**
|
|
* Slash command, context menu, and autocomplete dispatcher.
|
|
*
|
|
* Submodules own command handlers by topic:
|
|
* helpers.js — requireStaffRole, fetchLoggingChannel
|
|
* escalation.js — runEscalation, runDeescalation, handleEscalate, handleDeescalate
|
|
* close.js — handleForceClose, handleCancelClose, handleCloseTimer (+ finalize/transcript)
|
|
* response.js — /response subcommands + handleAutocomplete
|
|
* panel.js — handlePanel, handleSignature
|
|
* contextMenu.js — handleCreateTicketFromMessage, handleViewUserTickets
|
|
*
|
|
* This file holds the dispatchers, the small "remainder" handlers
|
|
* (channel-mod, settings toggles, /help, /notifydm), and the public
|
|
* module.exports surface that handlers/buttons.js + broccolini-discord.js
|
|
* import from `require('./commands')`.
|
|
*/
|
|
const { EmbedBuilder, MessageFlags } = require('discord.js');
|
|
const { mongoose } = require('../../db-connection');
|
|
const { CONFIG } = require('../../config');
|
|
const { isStaff } = require('../../utils');
|
|
const { setNotifyDm } = require('../../services/staffSettings');
|
|
const { recordAction } = require('../../services/staffStats');
|
|
const { makeTicketName, resolveCreatorNickname } = require('../../services/tickets');
|
|
const { enqueueRename, enqueueMove, enqueueOverwrite, enqueueTopic, enqueueSend } = require('../../services/channelQueue');
|
|
const { logError, logTicketEvent } = require('../../services/debugLog');
|
|
const { applyConfigUpdates } = require('../../services/configPersistence');
|
|
const { moveThreadToFolder, folderDisplayName } = require('../../services/gmailLabels');
|
|
const { findTicketForChannel } = require('../sharedHelpers');
|
|
|
|
const { requireStaffRole, fetchLoggingChannel } = require('./helpers');
|
|
const { runEscalation, runDeescalation, handleEscalate, handleDeescalate, resolveEscalationCategoryId } = require('./escalation');
|
|
const { handleCloseTimer, handleCancelClose, handleForceClose } = require('./close');
|
|
const { handleResponse, handleAutocomplete: handleResponseAutocomplete } = require('./response');
|
|
const { handlePanel, handleSignature } = require('./panel');
|
|
const { handleForward } = require('./forward');
|
|
const { handleCreateTicketFromMessage, handleViewUserTickets } = require('./contextMenu');
|
|
const { handleStats, handleStatsAutocomplete } = require('./stats');
|
|
|
|
const Ticket = mongoose.model('Ticket');
|
|
|
|
// ============================================================
|
|
// Remainder handlers — small enough not to deserve their own module.
|
|
// ============================================================
|
|
|
|
async function handleNotifyDm(interaction) {
|
|
try {
|
|
const setting = interaction.options.getString('setting') === 'on';
|
|
await setNotifyDm(interaction.user.id, interaction.guildId, setting);
|
|
await interaction.reply({
|
|
content: `DM notifications ${setting ? 'enabled ✅' : 'disabled 🔕'}.`,
|
|
flags: MessageFlags.Ephemeral
|
|
});
|
|
} catch (err) {
|
|
console.error('notifydm error:', err);
|
|
await interaction.reply({ content: 'Failed to update notification setting.', flags: MessageFlags.Ephemeral }).catch(() => {});
|
|
}
|
|
}
|
|
|
|
async function handleAdd(interaction) {
|
|
const user = interaction.options.getUser('user');
|
|
const ticket = await findTicketForChannel(interaction);
|
|
if (!ticket) return;
|
|
|
|
// Defer up front: enqueueOverwrite serializes behind any pending rename/move
|
|
// on this channel and can exceed Discord's 3s interaction-token window.
|
|
await interaction.deferReply();
|
|
|
|
try {
|
|
await enqueueOverwrite(interaction.channel, user.id, {
|
|
ViewChannel: true,
|
|
SendMessages: true,
|
|
ReadMessageHistory: true
|
|
});
|
|
await interaction.editReply({ content: `Added ${user} to this ticket.`, allowedMentions: { parse: ['users'] } });
|
|
} catch (err) {
|
|
console.error('Add user error:', err);
|
|
await interaction.editReply({ content: 'Failed to add user.' }).catch(() => {});
|
|
}
|
|
}
|
|
|
|
async function handleRemove(interaction) {
|
|
const user = interaction.options.getUser('user');
|
|
const ticket = await findTicketForChannel(interaction);
|
|
if (!ticket) return;
|
|
|
|
// Defer up front — same reason as handleAdd.
|
|
await interaction.deferReply();
|
|
|
|
try {
|
|
await enqueueOverwrite(interaction.channel, user.id, null, 'delete');
|
|
await interaction.editReply({ content: `Removed ${user} from this ticket.`, allowedMentions: { parse: ['users'] } });
|
|
} catch (err) {
|
|
console.error('Remove user error:', err);
|
|
await interaction.editReply({ content: 'Failed to remove user.' }).catch(() => {});
|
|
}
|
|
}
|
|
|
|
async function applyTransfer(interaction, ticket, guildMember, reason, _TicketModel, _recordAction) {
|
|
const T = _TicketModel || Ticket;
|
|
const record = _recordAction || recordAction;
|
|
|
|
const fromId = ticket.claimerId; // capture BEFORE the write
|
|
const toId = guildMember.id;
|
|
const claimerLabel = guildMember.displayName || guildMember.user.username;
|
|
|
|
await T.updateOne(
|
|
{ gmailThreadId: ticket.gmailThreadId },
|
|
{ $set: { claimedBy: claimerLabel, claimerId: toId } }
|
|
);
|
|
ticket.claimedBy = claimerLabel;
|
|
ticket.claimerId = toId;
|
|
|
|
// Gate: transferring to the member who already holds the claim is a no-op.
|
|
if (fromId !== toId) {
|
|
record(interaction.user.id, 'transfer', {
|
|
ticket,
|
|
guildId: interaction.guild?.id,
|
|
fromId,
|
|
toId
|
|
});
|
|
}
|
|
|
|
// Rename the channel to reflect the new claimer — mirrors the /claim
|
|
// button flow (applyClaim in handlers/buttons.js). Picks the new
|
|
// claimer's emoji from STAFF_EMOJIS and uses the escalated-claimed
|
|
// variant when tier >= 1.
|
|
const claimerEmoji = CONFIG.STAFF_EMOJIS[guildMember.id] || CONFIG.CLAIMER_EMOJI_FALLBACK;
|
|
const creatorNickname = await resolveCreatorNickname(interaction.guild, ticket);
|
|
const tier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
|
|
const state = tier >= 1 ? 'escalated-claimed' : 'claimed';
|
|
enqueueRename(interaction.channel, makeTicketName(state, ticket, creatorNickname, claimerEmoji))
|
|
.catch(err => logError('rename', err).catch(() => {}));
|
|
|
|
// `reason` is staff-supplied freeform text; gate to user pings so @everyone in it can't mass-ping.
|
|
await interaction.editReply({
|
|
content: `Ticket transferred to ${guildMember.user} by ${interaction.user}.\nReason: ${reason}`,
|
|
allowedMentions: { parse: ['users'] }
|
|
});
|
|
|
|
const logChan = await fetchLoggingChannel(interaction.client);
|
|
if (logChan) {
|
|
await enqueueSend(logChan, {
|
|
content: `Ticket ${interaction.channel} transferred from ${interaction.user.tag} to ${guildMember.user.tag}.\nReason: ${reason}`,
|
|
allowedMentions: { parse: [] }
|
|
});
|
|
}
|
|
}
|
|
|
|
async function handleTransfer(interaction) {
|
|
const member = interaction.options.getUser('member');
|
|
const reason = interaction.options.getString('reason') || 'No reason provided';
|
|
const ticket = await findTicketForChannel(interaction);
|
|
if (!ticket) return;
|
|
|
|
// Cache-first member resolution; falls back to a fetch if not in cache.
|
|
// GuildMembers intent keeps the cache warm in normal operation.
|
|
const guildMember = interaction.guild.members.cache.get(member.id)
|
|
|| await interaction.guild.members.fetch(member.id).catch(() => null);
|
|
|
|
// Reject self-transfers and bots; require the target to satisfy isStaff(),
|
|
// which covers ROLE_ID_TO_PING + ADDITIONAL_STAFF_ROLES — the same staff
|
|
// definition used by every other gate in the bot. The previous check only
|
|
// looked at ROLE_TO_PING_ID, missing additional staff roles.
|
|
if (!guildMember || guildMember.user.bot || !isStaff(guildMember)) {
|
|
return interaction.reply({
|
|
content: 'The target member must have the staff role.',
|
|
flags: MessageFlags.Ephemeral
|
|
});
|
|
}
|
|
if (guildMember.id === interaction.user.id) {
|
|
return interaction.reply({
|
|
content: 'You cannot transfer the ticket to yourself.',
|
|
flags: MessageFlags.Ephemeral
|
|
});
|
|
}
|
|
|
|
// Defer before the DB write + rename so the interaction token survives.
|
|
await interaction.deferReply();
|
|
|
|
try {
|
|
await applyTransfer(interaction, ticket, guildMember, reason);
|
|
} catch (err) {
|
|
console.error('Transfer error:', err);
|
|
await interaction.editReply({ content: 'Failed to transfer ticket.' }).catch(() => {});
|
|
}
|
|
}
|
|
|
|
async function handleMove(interaction) {
|
|
const category = interaction.options.getChannel('category');
|
|
const ticket = await findTicketForChannel(interaction);
|
|
if (!ticket) return;
|
|
|
|
// Defer up front — enqueueMove serializes behind any pending rename and
|
|
// setParent itself can take a moment on busy channels.
|
|
await interaction.deferReply();
|
|
|
|
try {
|
|
await enqueueMove(interaction.channel, category.id);
|
|
await interaction.editReply(`Moved ticket to **${category.name}**.`);
|
|
|
|
const logChan = await fetchLoggingChannel(interaction.client);
|
|
if (logChan) {
|
|
await enqueueSend(logChan, {
|
|
content: `Ticket ${interaction.channel} moved to category **${category.name}** by ${interaction.user.tag}`,
|
|
allowedMentions: { parse: [] }
|
|
});
|
|
}
|
|
} catch (err) {
|
|
console.error('Move error:', err);
|
|
await interaction.editReply({ content: 'Failed to move ticket.' }).catch(() => {});
|
|
}
|
|
}
|
|
|
|
async function handleTopic(interaction) {
|
|
const text = interaction.options.getString('text');
|
|
const ticket = await findTicketForChannel(interaction);
|
|
if (!ticket) return;
|
|
|
|
// Defer up front — enqueueTopic serializes behind any pending rename/move.
|
|
await interaction.deferReply();
|
|
|
|
try {
|
|
await enqueueTopic(interaction.channel, text);
|
|
await interaction.editReply('Topic updated successfully.');
|
|
} catch (err) {
|
|
console.error('Topic error:', err);
|
|
await interaction.editReply({ content: 'Failed to update topic.' }).catch(() => {});
|
|
}
|
|
}
|
|
|
|
async function handleStaffThread(interaction) {
|
|
const sub = interaction.options.getSubcommand();
|
|
if (sub === 'toggle') {
|
|
CONFIG.STAFF_THREAD_ENABLED = !CONFIG.STAFF_THREAD_ENABLED;
|
|
return interaction.reply({ content: `Staff threads are now **${CONFIG.STAFF_THREAD_ENABLED ? 'enabled' : 'disabled'}**.`, flags: MessageFlags.Ephemeral });
|
|
}
|
|
if (sub === 'name') {
|
|
const name = interaction.options.getString('thread_name').slice(0, 100);
|
|
CONFIG.STAFF_THREAD_NAME = name;
|
|
return interaction.reply({ content: `Staff thread name set to **${name}**.`, flags: MessageFlags.Ephemeral });
|
|
}
|
|
if (sub === 'autorole') {
|
|
const enabled = interaction.options.getBoolean('enabled');
|
|
CONFIG.STAFF_THREAD_AUTO_ADD_ROLE = enabled;
|
|
return interaction.reply({ content: `Auto-add role to staff thread is now **${enabled ? 'enabled' : 'disabled'}**.`, flags: MessageFlags.Ephemeral });
|
|
}
|
|
}
|
|
|
|
async function handlePinMessages(interaction) {
|
|
const sub = interaction.options.getSubcommand();
|
|
const enabled = interaction.options.getBoolean('enabled');
|
|
if (sub === 'initial') {
|
|
CONFIG.PIN_INITIAL_MESSAGE_ENABLED = enabled;
|
|
return interaction.reply({ content: `Auto-pin initial message is now **${enabled ? 'enabled' : 'disabled'}**.`, flags: MessageFlags.Ephemeral });
|
|
}
|
|
if (sub === 'escalation') {
|
|
CONFIG.PIN_ESCALATION_MESSAGE_ENABLED = enabled;
|
|
return interaction.reply({ content: `Auto-pin escalation message is now **${enabled ? 'enabled' : 'disabled'}**.`, flags: MessageFlags.Ephemeral });
|
|
}
|
|
if (sub === 'suppress') {
|
|
CONFIG.PIN_SUPPRESS_SYSTEM_MESSAGE = enabled;
|
|
return interaction.reply({ content: `Suppress pin system message is now **${enabled ? 'enabled' : 'disabled'}**.`, flags: MessageFlags.Ephemeral });
|
|
}
|
|
}
|
|
|
|
async function handleGmailPoll(interaction) {
|
|
const requested = parseInt(interaction.options.getString('interval'), 10);
|
|
// Defense-in-depth: the slash command's addChoices already floors at 30s, but
|
|
// clamp the resolved ms here too so any future caller (or skewed input) can't
|
|
// drop below 30s and trip Gmail's per-user quota under sustained load.
|
|
const ms = Math.max(30000, requested * 1000);
|
|
const seconds = ms / 1000;
|
|
// While the inbound email flow is off, setting an interval must NOT silently
|
|
// restart polling. Record it for this session (matches /gmailpoll's existing
|
|
// runtime-only model) so it applies the next time someone runs /email on.
|
|
if (!CONFIG.GMAIL_POLL_ENABLED) {
|
|
CONFIG.GMAIL_POLL_INTERVAL_MS = ms;
|
|
return interaction.reply({
|
|
content: `Interval saved (${seconds}s), but the inbound email flow is currently **off** — it will apply when you run \`/email on\`.`,
|
|
flags: MessageFlags.Ephemeral
|
|
});
|
|
}
|
|
// Lazy require — broccolini-discord re-exports this and we'd otherwise cycle.
|
|
const { setGmailPollInterval } = require('../../broccolini-discord');
|
|
setGmailPollInterval(ms);
|
|
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.`, flags: MessageFlags.Ephemeral });
|
|
}
|
|
|
|
async function handleEmail(interaction) {
|
|
const sub = interaction.options.getSubcommand();
|
|
|
|
if (sub === 'status') {
|
|
const intervalSec = Math.round(CONFIG.GMAIL_POLL_INTERVAL_MS / 1000);
|
|
return interaction.reply({
|
|
content: `Inbound email flow is **${CONFIG.GMAIL_POLL_ENABLED ? 'on' : 'off'}**.\nPoll interval: ${intervalSec}s.`,
|
|
flags: MessageFlags.Ephemeral
|
|
});
|
|
}
|
|
|
|
const enable = sub === 'on';
|
|
// applyConfigUpdates writes both CONFIG and .env so the state survives restart.
|
|
const { applied, errors } = applyConfigUpdates({ GMAIL_POLL_ENABLED: enable });
|
|
if (!applied.includes('GMAIL_POLL_ENABLED')) {
|
|
const reason = (errors.find(e => e.key === 'GMAIL_POLL_ENABLED') || {}).error || 'unknown error';
|
|
return interaction.reply({
|
|
content: `Failed to turn email flow ${enable ? 'on' : 'off'}: ${reason}`,
|
|
flags: MessageFlags.Ephemeral
|
|
});
|
|
}
|
|
|
|
// Lazy require — broccolini-discord re-exports these and we'd otherwise cycle.
|
|
const { setGmailPollInterval, clearGmailPollInterval } = require('../../broccolini-discord');
|
|
if (enable) {
|
|
// Clear any auth-suspend latch so a prior invalid_grant doesn't keep polling
|
|
// dead. If auth is still broken, the next cycle re-suspends and DMs admin.
|
|
try { require('../../gmail-poll').setPollSuspended(false); } catch (_) {}
|
|
setGmailPollInterval(CONFIG.GMAIL_POLL_INTERVAL_MS);
|
|
} else {
|
|
clearGmailPollInterval();
|
|
}
|
|
|
|
logTicketEvent('Email flow toggled', [
|
|
{ name: 'State', value: enable ? 'on' : 'off' },
|
|
{ name: 'Set by', value: interaction.user.tag }
|
|
], interaction).catch(() => {});
|
|
|
|
return interaction.reply({
|
|
content: enable
|
|
? 'Inbound email flow is now **on** — the inbox will be polled.'
|
|
: 'Inbound email flow is now **off** — the inbox will not be polled. Outbound emails still send.',
|
|
flags: MessageFlags.Ephemeral
|
|
});
|
|
}
|
|
|
|
async function handleFolder(interaction) {
|
|
const folderKey = interaction.options.getString('destination');
|
|
const ticket = await findTicketForChannel(interaction);
|
|
if (!ticket) return;
|
|
|
|
// Discord-origin tickets have no Gmail thread to file.
|
|
if (ticket.gmailThreadId.startsWith('discord-')) {
|
|
return interaction.reply({
|
|
content: "This ticket has no email thread, so it can't be moved to a Gmail folder.",
|
|
flags: MessageFlags.Ephemeral
|
|
});
|
|
}
|
|
|
|
const label = folderDisplayName(folderKey) || 'Spam';
|
|
// Defer: resolving/creating labels + threads.modify can exceed the 3s window.
|
|
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
|
|
|
try {
|
|
await moveThreadToFolder(ticket.gmailThreadId, folderKey);
|
|
logTicketEvent('Email thread filed', [
|
|
{ name: 'Folder', value: label },
|
|
{ name: 'Filed by', value: interaction.user.tag }
|
|
], interaction).catch(() => {});
|
|
return interaction.editReply({ content: `Moved this ticket's email thread to **${label}**.` });
|
|
} catch (err) {
|
|
logError('handleFolder', err, interaction).catch(() => {});
|
|
return interaction.editReply({ content: `Failed to move the email thread: ${err.message}` });
|
|
}
|
|
}
|
|
|
|
async function handleHelp(interaction) {
|
|
const embed = new EmbedBuilder()
|
|
.setTitle('Ticket System - Commands')
|
|
.setColor(CONFIG.EMBED_COLOR_OPEN)
|
|
.addFields([
|
|
{
|
|
name: 'User Management',
|
|
value: '`/add @user` - Add user to ticket\n`/remove @user` - Remove user from ticket'
|
|
},
|
|
{
|
|
name: 'Ticket Management',
|
|
value: '`/transfer @staff [reason]` - Transfer ticket to another staff member\n`/move #category` - Move ticket to another category\n`/force-close` - Force close ticket without confirmation\n`/cancel-close` - Abort a pending force-close countdown\n`/topic <text>` - Set ticket topic/description\n`/folder <destination>` - File this ticket\'s email into a Gmail folder\n`/forward <email> [note]` - Forward this ticket\'s email thread to another address'
|
|
},
|
|
{
|
|
name: 'Saved Responses',
|
|
value: '`/response send <name>` - Send saved response\n`/response create|edit|delete|list` - Manage saved responses'
|
|
},
|
|
{
|
|
name: 'Variables (for responses)',
|
|
value: '`{ticket.user}`, `{ticket.email}`, `{ticket.number}`, `{ticket.subject}`, `{staff.name}`, `{server.name}`, `{date}`, `{time}`'
|
|
},
|
|
{
|
|
name: 'Panel System',
|
|
value: '`/panel #channel` - Create a ticket panel for Discord-side tickets'
|
|
},
|
|
{
|
|
name: 'Escalation',
|
|
value: '`/escalate <level>` - Escalate ticket (tier 2 or 3, or one step)\n`/deescalate` - De-escalate ticket (tier 3→2 or tier 2→normal)'
|
|
},
|
|
{
|
|
name: 'Staff Configuration',
|
|
value: '`/notifydm` - Toggle DM notifications for your claimed tickets\n`/signature` - Set your email signature\n`/closetimer <seconds>` - Set the force-close countdown\n`/staffthread` - Toggle/configure per-ticket staff threads\n`/pinmessages` - Toggle auto-pinning of ticket messages\n`/gmailpoll <interval>` - Set the Gmail poll interval\n`/email on|off|status` - Turn the inbound email flow on/off'
|
|
},
|
|
{
|
|
name: 'Right-click (Apps menu)',
|
|
value: '`Create Ticket From Message` - Turn a message into a ticket\n`View User Tickets` - Show a user\'s recent tickets'
|
|
}
|
|
])
|
|
.setFooter({ text: 'Click buttons on ticket messages to claim/close. Config changes via slash commands apply until the next restart.' });
|
|
|
|
await interaction.reply({ embeds: [embed], flags: MessageFlags.Ephemeral });
|
|
}
|
|
|
|
// ============================================================
|
|
// 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,
|
|
email: handleEmail,
|
|
folder: handleFolder,
|
|
closetimer: handleCloseTimer,
|
|
forward: handleForward,
|
|
'cancel-close': handleCancelClose,
|
|
'force-close': handleForceClose,
|
|
topic: handleTopic,
|
|
response: handleResponse,
|
|
signature: handleSignature,
|
|
help: handleHelp,
|
|
panel: handlePanel,
|
|
stats: handleStats
|
|
};
|
|
|
|
const CONTEXT_MENU_HANDLERS = {
|
|
'Create Ticket From Message': handleCreateTicketFromMessage,
|
|
'View User Tickets': handleViewUserTickets
|
|
};
|
|
|
|
const AUTOCOMPLETE_HANDLERS = {
|
|
response: handleResponseAutocomplete,
|
|
stats: handleStatsAutocomplete
|
|
};
|
|
|
|
async function handleAutocomplete(interaction, _handlers) {
|
|
const handlers = _handlers || AUTOCOMPLETE_HANDLERS;
|
|
const handler = handlers[interaction.commandName];
|
|
if (handler) await handler(interaction);
|
|
}
|
|
|
|
/**
|
|
* Slash-command dispatcher. Every command is staff-only — including /help,
|
|
* which previously bypassed the role check.
|
|
*/
|
|
async function handleCommand(interaction) {
|
|
if (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);
|
|
}
|
|
|
|
module.exports = {
|
|
handleCommand,
|
|
handleContextMenu,
|
|
handleAutocomplete,
|
|
runEscalation,
|
|
runDeescalation,
|
|
resolveEscalationCategoryId,
|
|
applyTransfer
|
|
};
|