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>
1026 lines
39 KiB
JavaScript
1026 lines
39 KiB
JavaScript
/**
|
||
* 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');
|
||
const { CONFIG } = require('../config');
|
||
const { getPriorityEmoji, replaceVariables, isStaff } = require('../utils');
|
||
const { makeTicketName, resolveCreatorNickname, getOrCreateTicketCategory, checkTicketCreationRateLimit } = require('../services/tickets');
|
||
const { sendTicketNotificationEmail } = require('../services/gmail');
|
||
const { getTicketActionRow } = require('../utils/ticketComponents');
|
||
const { enqueueRename, enqueueMove, enqueueSend } = require('../services/channelQueue');
|
||
const { setNotifyDm } = require('../services/staffSettings');
|
||
const { pinMessage } = require('../services/pinMessage');
|
||
const { logError, logTicketEvent } = require('../services/debugLog');
|
||
const { pendingCloses } = require('./pendingCloses');
|
||
const { findTicketForChannel, runDeferred } = require('./sharedHelpers');
|
||
|
||
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 the caller should bail).
|
||
*/
|
||
async function requireStaffRole(interaction) {
|
||
if (!interaction.guild) return false;
|
||
if (!CONFIG.ROLE_ID_TO_PING && (!CONFIG.ADDITIONAL_STAFF_ROLES || CONFIG.ADDITIONAL_STAFF_ROLES.length === 0)) return false;
|
||
if (isStaff(interaction.member)) return false;
|
||
const roleMention = CONFIG.ROLE_ID_TO_PING ? `<@&${CONFIG.ROLE_ID_TO_PING}>` : 'support';
|
||
await interaction.reply({
|
||
content: `This command is only available to the support team (${roleMention}).`,
|
||
ephemeral: true
|
||
});
|
||
return true;
|
||
}
|
||
|
||
/** 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-');
|
||
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}.`
|
||
);
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// Per-command handlers
|
||
// ============================================================
|
||
|
||
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.', ephemeral: true });
|
||
}
|
||
if (nextTier <= currentTier) {
|
||
return interaction.reply({ content: 'Ticket is already at or past that tier.', ephemeral: true });
|
||
}
|
||
|
||
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.`,
|
||
ephemeral: true
|
||
});
|
||
}
|
||
|
||
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 });
|
||
}
|
||
|
||
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);
|
||
await interaction.reply({
|
||
content: `DM notifications ${setting ? 'enabled ✅' : 'disabled 🔕'}.`,
|
||
ephemeral: true
|
||
});
|
||
} catch (err) {
|
||
console.error('notifydm error:', err);
|
||
await interaction.reply({ content: 'Failed to update notification setting.', ephemeral: true }).catch(() => {});
|
||
}
|
||
}
|
||
|
||
async function handleAdd(interaction) {
|
||
const user = interaction.options.getUser('user');
|
||
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.
|
||
await interaction.channel.permissionOverwrites.create(user.id, {
|
||
ViewChannel: true,
|
||
SendMessages: true,
|
||
ReadMessageHistory: true
|
||
});
|
||
await interaction.reply({ content: `Added ${user} to this ticket.`, allowedMentions: { parse: ['users'] } });
|
||
} catch (err) {
|
||
console.error('Add user error:', err);
|
||
await interaction.reply({ content: 'Failed to add user.', ephemeral: true });
|
||
}
|
||
}
|
||
|
||
async function handleRemove(interaction) {
|
||
const user = interaction.options.getUser('user');
|
||
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.
|
||
await interaction.channel.permissionOverwrites.delete(user.id);
|
||
await interaction.reply({ content: `Removed ${user} from this ticket.`, allowedMentions: { parse: ['users'] } });
|
||
} catch (err) {
|
||
console.error('Remove user error:', err);
|
||
await interaction.reply({ content: 'Failed to remove user.', ephemeral: true });
|
||
}
|
||
}
|
||
|
||
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;
|
||
|
||
const staffRoleId = CONFIG.ROLE_TO_PING_ID;
|
||
const guildMember = await interaction.guild.members.fetch(member.id).catch(() => null);
|
||
|
||
if (!guildMember || !guildMember.roles.cache.has(staffRoleId)) {
|
||
return interaction.reply({ content: 'The target member must have the staff role.', ephemeral: true });
|
||
}
|
||
|
||
try {
|
||
const claimerLabel = guildMember.displayName || guildMember.user.username;
|
||
|
||
await Ticket.updateOne(
|
||
{ gmailThreadId: ticket.gmailThreadId },
|
||
{ $set: { claimedBy: claimerLabel } }
|
||
);
|
||
|
||
// `reason` is staff-supplied freeform text; gate to user pings so @everyone in it can't mass-ping.
|
||
await interaction.reply({
|
||
content: `Ticket transferred to ${member} 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 ${member.tag}.\nReason: ${reason}`,
|
||
allowedMentions: { parse: ['users'] }
|
||
});
|
||
}
|
||
} catch (err) {
|
||
console.error('Transfer error:', err);
|
||
await interaction.reply({ content: 'Failed to transfer ticket.', ephemeral: true });
|
||
}
|
||
}
|
||
|
||
async function handleMove(interaction) {
|
||
const category = interaction.options.getChannel('category');
|
||
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 fetchLoggingChannel(interaction.client);
|
||
if (logChan) {
|
||
await enqueueSend(logChan,
|
||
`Ticket ${interaction.channel} moved to category **${category.name}** by ${interaction.user.tag}`
|
||
);
|
||
}
|
||
} catch (err) {
|
||
console.error('Move error:', err);
|
||
await interaction.reply({ content: 'Failed to move ticket.', ephemeral: true });
|
||
}
|
||
}
|
||
|
||
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'}**.`, ephemeral: true });
|
||
}
|
||
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}**.`, ephemeral: true });
|
||
}
|
||
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'}**.`, ephemeral: true });
|
||
}
|
||
}
|
||
|
||
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'}**.`, ephemeral: true });
|
||
}
|
||
if (sub === 'escalation') {
|
||
CONFIG.PIN_ESCALATION_MESSAGE_ENABLED = enabled;
|
||
return interaction.reply({ content: `Auto-pin escalation message is now **${enabled ? 'enabled' : 'disabled'}**.`, ephemeral: true });
|
||
}
|
||
if (sub === 'suppress') {
|
||
CONFIG.PIN_SUPPRESS_SYSTEM_MESSAGE = enabled;
|
||
return interaction.reply({ content: `Suppress pin system message is now **${enabled ? 'enabled' : 'disabled'}**.`, ephemeral: true });
|
||
}
|
||
}
|
||
|
||
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(() => {});
|
||
return interaction.reply({ content: `Gmail poll interval set to ${seconds} seconds.`, ephemeral: true });
|
||
}
|
||
|
||
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(() => {});
|
||
return interaction.reply({ content: `Force-close timer set to ${seconds} seconds.`, ephemeral: true });
|
||
}
|
||
|
||
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);
|
||
logTicketEvent('Force-close cancelled', [
|
||
{ name: 'Ticket', value: interaction.channel.name || interaction.channel.id },
|
||
{ name: 'Cancelled by', value: interaction.user.tag },
|
||
{ name: 'Original setter', value: pending.username || 'Unknown' }
|
||
], interaction).catch(() => {});
|
||
pendingCloses.delete(interaction.channel.id);
|
||
return interaction.reply({ content: 'Close cancelled.', 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 });
|
||
}
|
||
|
||
const timerSeconds = CONFIG.FORCE_CLOSE_TIMER;
|
||
await interaction.reply(`Closing ticket in ${timerSeconds} seconds. Use \`/cancel-close\` to abort.`);
|
||
|
||
const channelRef = interaction.channel;
|
||
const clientRef = interaction.client;
|
||
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;
|
||
|
||
try {
|
||
await Ticket.updateOne(
|
||
{ gmailThreadId: freshTicket.gmailThreadId },
|
||
{ $set: { status: 'closed' } }
|
||
);
|
||
|
||
await enqueueSend(channelRef, 'Ticket force-closed. Archiving...');
|
||
await postTranscript(channelRef, clientRef, freshTicket).catch(tErr =>
|
||
console.error('Transcript error (force-close):', tErr)
|
||
);
|
||
|
||
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 });
|
||
const log =
|
||
`TRANSCRIPT: ${freshTicket.subject}\nUser: ${freshTicket.senderEmail}\n---\n` +
|
||
messages
|
||
.reverse()
|
||
.map(m => `[${m.createdAt.toLocaleString()}] ${m.author.tag}: ${m.cleanContent}`)
|
||
.join('\n');
|
||
|
||
const file = new AttachmentBuilder(Buffer.from(log), {
|
||
name: `transcript-${channelRef.name}.txt`
|
||
});
|
||
|
||
const transcriptChan = await clientRef.channels
|
||
.fetch(CONFIG.TRANSCRIPT_CHANNEL_ID)
|
||
.catch(() => null);
|
||
if (!transcriptChan) return;
|
||
|
||
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] });
|
||
}
|
||
|
||
async function handleTopic(interaction) {
|
||
const text = interaction.options.getString('text');
|
||
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.
|
||
await interaction.channel.setTopic(text);
|
||
await interaction.reply('Topic updated successfully.');
|
||
} catch (err) {
|
||
console.error('Topic error:', err);
|
||
await interaction.reply({ content: 'Failed to update topic.', ephemeral: true });
|
||
}
|
||
}
|
||
|
||
// /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 {
|
||
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) {
|
||
return interaction.reply({ content: `❌ Tag "${name}" not found.`, ephemeral: true });
|
||
}
|
||
|
||
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
|
||
const context = {
|
||
ticket: ticket || {},
|
||
staff: {
|
||
username: interaction.user.username,
|
||
displayName: interaction.member?.displayName,
|
||
mention: interaction.user.toString()
|
||
},
|
||
guild: interaction.guild
|
||
};
|
||
|
||
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 mention parsing so a `@everyone` in a tag body never pings.
|
||
await interaction.reply({ content, allowedMentions: { parse: [] } });
|
||
}
|
||
|
||
async function handleResponseCreate(interaction) {
|
||
const name = interaction.options.getString('name');
|
||
const content = interaction.options.getString('content');
|
||
|
||
try {
|
||
await Tag.create({ name, content, createdBy: interaction.user.id });
|
||
await interaction.reply({ content: `✅ Tag "${name}" created successfully.`, ephemeral: true });
|
||
} catch (err) {
|
||
if (err.code === 11000 || err.message?.includes('duplicate')) {
|
||
await interaction.reply({ content: `❌ Tag "${name}" already exists.`, ephemeral: true });
|
||
} else {
|
||
logError('tag-create', err, interaction).catch(() => {});
|
||
await interaction.reply({ content: '❌ Failed to create tag.', ephemeral: true });
|
||
}
|
||
}
|
||
}
|
||
|
||
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 {
|
||
await interaction.reply({ content: `✅ Tag "${name}" updated successfully.`, ephemeral: true });
|
||
}
|
||
} catch (err) {
|
||
logError('tag-edit', err, interaction).catch(() => {});
|
||
await interaction.reply({ content: '❌ Failed to edit tag.', ephemeral: true });
|
||
}
|
||
}
|
||
|
||
async function handleResponseDelete(interaction) {
|
||
const name = interaction.options.getString('name');
|
||
// 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()
|
||
.setCustomId(customId)
|
||
.setLabel('Yes, Delete Tag')
|
||
.setStyle(ButtonStyle.Danger),
|
||
new ButtonBuilder()
|
||
.setCustomId('cancel_delete_tag')
|
||
.setLabel('Cancel')
|
||
.setStyle(ButtonStyle.Secondary)
|
||
);
|
||
|
||
return interaction.reply({
|
||
content: `⚠️ Are you sure you want to delete the tag "${name}"? This action cannot be undone.`,
|
||
components: [confirmRow],
|
||
ephemeral: true
|
||
});
|
||
}
|
||
|
||
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.' });
|
||
}
|
||
|
||
const embed = new EmbedBuilder()
|
||
.setTitle('📋 Available Saved Responses')
|
||
.setDescription(
|
||
tags.map((t, i) => `${i + 1}. **${t.name}** (used ${t.useCount || 0}x)`).join('\n')
|
||
)
|
||
.setColor(CONFIG.EMBED_COLOR_INFO)
|
||
.setFooter({ text: `Total: ${tags.length} tags` });
|
||
|
||
await interaction.editReply({ embeds: [embed] });
|
||
}
|
||
|
||
async function handleSignature(interaction) {
|
||
try {
|
||
const existingSignature = await StaffSignature.findOne({ userId: interaction.user.id }).lean();
|
||
|
||
const modal = new ModalBuilder()
|
||
.setCustomId(`signature_modal_${interaction.user.id}`)
|
||
.setTitle('Staff Signature Settings');
|
||
|
||
const valedictionInput = new TextInputBuilder()
|
||
.setCustomId('valediction')
|
||
.setLabel('Valediction (e.g. "Best regards", "Thanks")')
|
||
.setStyle(TextInputStyle.Short)
|
||
.setRequired(false)
|
||
.setValue(existingSignature?.valediction || '');
|
||
|
||
const displayNameInput = new TextInputBuilder()
|
||
.setCustomId('display_name')
|
||
.setLabel('Display Name (e.g. "Support Team")')
|
||
.setStyle(TextInputStyle.Short)
|
||
.setRequired(false)
|
||
.setValue(existingSignature?.displayName || '');
|
||
|
||
const taglineInput = new TextInputBuilder()
|
||
.setCustomId('tagline')
|
||
.setLabel('Tagline (e.g. "Technical Support Specialist")')
|
||
.setStyle(TextInputStyle.Short)
|
||
.setRequired(false)
|
||
.setValue(existingSignature?.tagline || '');
|
||
|
||
modal.addComponents(
|
||
new ActionRowBuilder().addComponents(valedictionInput),
|
||
new ActionRowBuilder().addComponents(displayNameInput),
|
||
new ActionRowBuilder().addComponents(taglineInput)
|
||
);
|
||
|
||
await interaction.showModal(modal);
|
||
} catch (err) {
|
||
console.error('Signature command error:', err);
|
||
if (!interaction.replied && !interaction.deferred) {
|
||
await interaction.reply({ content: 'Failed to open signature settings.', ephemeral: true }).catch(() => {});
|
||
}
|
||
}
|
||
}
|
||
|
||
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` - Transfer ticket to another staff member\n`/move #category` - Move ticket to another category\n`/force-close` - Force close ticket without confirmation\n`/topic <text>` - Set ticket topic/description'
|
||
},
|
||
{
|
||
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 [reason] [tier]` - Escalate ticket (tier 2 or 3, or one step)\n`/deescalate` - De-escalate ticket (tier 3→2 or tier 2→normal)'
|
||
}
|
||
])
|
||
.setFooter({ text: 'Click buttons on ticket messages to claim/close' });
|
||
|
||
await interaction.reply({ embeds: [embed], ephemeral: true });
|
||
}
|
||
|
||
async function handlePanel(interaction) {
|
||
const channel = interaction.options.getChannel('channel');
|
||
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. 🎟';
|
||
|
||
const embed = new EmbedBuilder()
|
||
.setTitle(title)
|
||
.setDescription(description)
|
||
.setColor(0x2ecc71)
|
||
.setThumbnail(CONFIG.LOGO_URL || null)
|
||
.setFooter({ text: 'Indifferent Broccoli Tickets' });
|
||
|
||
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') {
|
||
return new ActionRowBuilder().addComponents(
|
||
new ButtonBuilder()
|
||
.setCustomId('open_ticket_thread')
|
||
.setLabel('Create ticket (thread)')
|
||
.setStyle(ButtonStyle.Secondary)
|
||
.setEmoji('🧵'),
|
||
new ButtonBuilder()
|
||
.setCustomId('open_ticket_channel')
|
||
.setLabel('Create ticket (channel)')
|
||
.setStyle(ButtonStyle.Secondary)
|
||
.setEmoji('📁')
|
||
);
|
||
}
|
||
if (panelType === 'thread') {
|
||
return new ActionRowBuilder().addComponents(
|
||
new ButtonBuilder()
|
||
.setCustomId('open_ticket_thread')
|
||
.setLabel('Create ticket')
|
||
.setStyle(ButtonStyle.Secondary)
|
||
.setEmoji('🧵')
|
||
);
|
||
}
|
||
if (panelType === 'category') {
|
||
return new ActionRowBuilder().addComponents(
|
||
new ButtonBuilder()
|
||
.setCustomId('open_ticket_channel')
|
||
.setLabel('Create ticket')
|
||
.setStyle(ButtonStyle.Secondary)
|
||
.setEmoji('📁')
|
||
);
|
||
}
|
||
return new ActionRowBuilder().addComponents(
|
||
new ButtonBuilder()
|
||
.setCustomId('open_ticket')
|
||
.setLabel('Create ticket')
|
||
.setStyle(ButtonStyle.Secondary)
|
||
.setEmoji('✅')
|
||
);
|
||
}
|
||
|
||
// ============================================================
|
||
// Context-menu handlers
|
||
// ============================================================
|
||
|
||
async function handleCreateTicketFromMessage(interaction) {
|
||
await interaction.deferReply({ ephemeral: true });
|
||
|
||
const rateLimit = checkTicketCreationRateLimit(interaction.user.id);
|
||
if (!rateLimit.allowed) {
|
||
const mins = Math.ceil((rateLimit.retryAfterMs || 0) / 60000);
|
||
return interaction.editReply(`You can only create ${CONFIG.RATE_LIMIT_TICKETS_PER_USER} ticket(s) per ${CONFIG.RATE_LIMIT_WINDOW_MINUTES} minutes. Try again in ${mins} minute(s).`);
|
||
}
|
||
|
||
try {
|
||
const message = interaction.targetMessage;
|
||
const subject = `Message from ${message.author.tag}`;
|
||
const description = message.content || 'No content';
|
||
|
||
const guild = interaction.guild;
|
||
const lastTicket = await Ticket.findOne().sort({ ticketNumber: -1 }).select('ticketNumber').lean();
|
||
const ticketNumber = (lastTicket?.ticketNumber || 0) + 1;
|
||
|
||
let parentCategoryIdForTicket;
|
||
try {
|
||
parentCategoryIdForTicket = await getOrCreateTicketCategory(
|
||
guild,
|
||
CONFIG.DISCORD_TICKET_CATEGORY_ID,
|
||
CONFIG.TICKET_CATEGORY_NAME
|
||
);
|
||
} catch (err) {
|
||
console.error('getOrCreateTicketCategory (context menu ticket):', err);
|
||
return interaction.editReply('❌ Discord ticket category could not be resolved. Contact an administrator.');
|
||
}
|
||
|
||
let channel;
|
||
try {
|
||
channel = await guild.channels.create({
|
||
name: `ticket-${ticketNumber}`,
|
||
type: ChannelType.GuildText,
|
||
parent: parentCategoryIdForTicket,
|
||
permissionOverwrites: [
|
||
{ id: guild.id, deny: [PermissionFlagsBits.ViewChannel] },
|
||
{
|
||
id: message.author.id,
|
||
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory]
|
||
},
|
||
{
|
||
id: CONFIG.ROLE_ID_TO_PING,
|
||
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory]
|
||
}
|
||
]
|
||
});
|
||
} catch (err) {
|
||
console.error('guild.channels.create (context menu ticket):', err);
|
||
return interaction.editReply('❌ Failed to create ticket channel. Contact an administrator.');
|
||
}
|
||
|
||
const gmailThreadId = `discord-msg-${Date.now()}-${message.id}`;
|
||
const now = new Date();
|
||
await Ticket.create({
|
||
gmailThreadId,
|
||
discordThreadId: channel.id,
|
||
senderEmail: message.author.tag,
|
||
subject,
|
||
createdAt: now,
|
||
status: 'open',
|
||
ticketNumber,
|
||
priority: 'normal',
|
||
lastActivity: now,
|
||
parentCategoryId: parentCategoryIdForTicket
|
||
});
|
||
|
||
const welcomeEmbed = new EmbedBuilder()
|
||
.setDescription(CONFIG.TICKET_WELCOME_MESSAGE)
|
||
.setColor(CONFIG.EMBED_COLOR_INFO);
|
||
|
||
const infoEmbed = new EmbedBuilder()
|
||
.setColor(CONFIG.EMBED_COLOR_INFO)
|
||
.addFields(
|
||
{ name: 'From message', value: `[Jump to message](${message.url})` },
|
||
{ name: 'Creator', value: message.author.toString(), inline: true },
|
||
{ name: 'Created by Staff', value: interaction.user.toString(), inline: true },
|
||
{ name: 'Content', value: description.slice(0, 1000) || 'No content', inline: false }
|
||
);
|
||
|
||
const row = getTicketActionRow({ escalationTier: 0 });
|
||
|
||
try {
|
||
const welcomeMsg = await enqueueSend(channel, {
|
||
content: `<@&${CONFIG.ROLE_ID_TO_PING}>\nHey There ${message.author} 🥦`,
|
||
embeds: [welcomeEmbed, infoEmbed],
|
||
components: [row]
|
||
});
|
||
|
||
await Ticket.updateOne(
|
||
{ discordThreadId: channel.id },
|
||
{ $set: { welcomeMessageId: welcomeMsg.id } }
|
||
);
|
||
} catch (err) {
|
||
console.error('welcomeMessageId-save', err);
|
||
}
|
||
|
||
await interaction.editReply(`✅ Ticket created: ${channel}`);
|
||
} catch (err) {
|
||
logError('create-ticket-from-message', err, interaction).catch(() => {});
|
||
await interaction.editReply('❌ Failed to create ticket from message.');
|
||
}
|
||
}
|
||
|
||
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)
|
||
.lean();
|
||
|
||
if (!tickets || tickets.length === 0) {
|
||
return interaction.editReply(`📋 No tickets found for ${targetUser.tag}`);
|
||
}
|
||
|
||
const embed = new EmbedBuilder()
|
||
.setTitle(`📋 Tickets for ${targetUser.tag}`)
|
||
.setDescription(`Found ${tickets.length} ticket(s)`)
|
||
.setColor(CONFIG.EMBED_COLOR_INFO);
|
||
|
||
for (const ticket of tickets.slice(0, 5)) {
|
||
const priorityEmoji = getPriorityEmoji(ticket.priority || 'normal');
|
||
const statusEmoji = ticket.status === 'open' ? '🟢' : '🔴';
|
||
embed.addFields({
|
||
name: `${priorityEmoji} Ticket #${ticket.ticketNumber} ${statusEmoji}`,
|
||
value: `**Subject:** ${ticket.subject || 'No subject'}\n**Status:** ${ticket.status}\n**Claimed:** ${ticket.claimedBy || 'Unclaimed'}`,
|
||
inline: false
|
||
});
|
||
}
|
||
|
||
if (tickets.length > 5) {
|
||
embed.setFooter({ text: `Showing 5 of ${tickets.length} tickets` });
|
||
}
|
||
|
||
await interaction.editReply({ embeds: [embed] });
|
||
} catch (err) {
|
||
logError('view-user-tickets', err, interaction).catch(() => {});
|
||
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
|
||
};
|
||
|
||
/**
|
||
* 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') return;
|
||
const subcommand = interaction.options.getSubcommand();
|
||
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)
|
||
.map(t => ({ name: t.name, value: t.name }));
|
||
|
||
await interaction.respond(filtered);
|
||
}
|
||
|
||
module.exports = { handleCommand, handleContextMenu, handleAutocomplete, runEscalation, runDeescalation };
|