/** * 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 { setNotifyDm } = require('../../services/staffSettings'); const { makeTicketName, resolveCreatorNickname } = require('../../services/tickets'); const { enqueueRename, enqueueMove, enqueueOverwrite, enqueueTopic, enqueueSend } = require('../../services/channelQueue'); const { logError, logTicketEvent } = require('../../services/debugLog'); const { findTicketForChannel } = require('../sharedHelpers'); const { requireStaffRole, fetchLoggingChannel } = require('./helpers'); const { runEscalation, runDeescalation, handleEscalate, handleDeescalate } = require('./escalation'); const { handleCloseTimer, handleCancelClose, handleForceClose } = require('./close'); const { handleResponse, handleAutocomplete } = require('./response'); const { handlePanel, handleSignature } = require('./panel'); const { handleCreateTicketFromMessage, handleViewUserTickets } = require('./contextMenu'); 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 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.', flags: MessageFlags.Ephemeral }); } // Defer before the DB write + rename so the interaction token survives. await interaction.deferReply(); try { const claimerLabel = guildMember.displayName || guildMember.user.username; await Ticket.updateOne( { gmailThreadId: ticket.gmailThreadId }, { $set: { claimedBy: claimerLabel, claimerId: guildMember.id } } ); ticket.claimedBy = claimerLabel; ticket.claimerId = guildMember.id; // 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 ${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.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, `Ticket ${interaction.channel} moved to category **${category.name}** by ${interaction.user.tag}` ); } } 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; // 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 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 ` - Set ticket topic/description' }, { name: 'Saved Responses', value: '`/response send ` - 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], 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, 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. 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 };