From adcd9dd9c989d7fe8643b89e6db208d37e6bbf9e Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Fri, 8 May 2026 20:29:44 +0000 Subject: [PATCH] audit week 2 [ARCH-001]: split handlers/commands.js into submodules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 1028-line handlers/commands.js bundled escalation logic + force-close flow + /response tag CRUD + /panel + /signature + context-menu handlers + several config-toggle slash commands. After the dispatch-table refactor it was still a god module. Split into handlers/commands/ with one file per topic; require('./commands') resolves to handlers/commands/index.js (handlers/commands.js is removed). Layout: helpers.js — requireStaffRole, fetchLoggingChannel (cross-submodule, kept here to avoid cycles with index.js) escalation.js — runEscalation, runDeescalation, handleEscalate, handleDeescalate (run* are still exported via index.js for handlers/buttons.js) close.js — handleForceClose, handleCancelClose, handleCloseTimer + finalizeForceClose / postTranscript (timer callback) response.js — handleResponse + send/create/edit/delete/list subcommands + handleAutocomplete (only /response autocompletes) panel.js — handlePanel, buildPanelButtonRow, handleSignature contextMenu.js — handleCreateTicketFromMessage, handleViewUserTickets index.js — dispatch tables, handleCommand/handleContextMenu, plus the short-and-not-thematic handlers (notifydm, add, remove, transfer, move, topic, staffthread, pinmessages, gmailpoll, help) and the public re-exports. No behavior change — every imported name, every Discord call, every DB write, every embed, every reply payload preserved verbatim. Public surface of require('./commands') is still { handleCommand, handleContextMenu, handleAutocomplete, runEscalation, runDeescalation }. Largest single module is now index.js at 299 lines; others are 33–214. --- handlers/commands.js | 1028 ------------------------------ handlers/commands/close.js | 126 ++++ handlers/commands/contextMenu.js | 168 +++++ handlers/commands/escalation.js | 214 +++++++ handlers/commands/helpers.js | 33 + handlers/commands/index.js | 299 +++++++++ handlers/commands/panel.js | 133 ++++ handlers/commands/response.js | 165 +++++ 8 files changed, 1138 insertions(+), 1028 deletions(-) delete mode 100644 handlers/commands.js create mode 100644 handlers/commands/close.js create mode 100644 handlers/commands/contextMenu.js create mode 100644 handlers/commands/escalation.js create mode 100644 handlers/commands/helpers.js create mode 100644 handlers/commands/index.js create mode 100644 handlers/commands/panel.js create mode 100644 handlers/commands/response.js diff --git a/handlers/commands.js b/handlers/commands.js deleted file mode 100644 index 7fe0e45..0000000 --- a/handlers/commands.js +++ /dev/null @@ -1,1028 +0,0 @@ -/** - * Slash command, context menu, and autocomplete handlers. - * - * The dispatcher pattern: handleCommand looks up the command name in - * COMMAND_HANDLERS and delegates. Each handle() owns one slash - * command. To find a command's implementation, search for handle. - */ -const { - ChannelType, - ActionRowBuilder, - ButtonBuilder, - ButtonStyle, - AttachmentBuilder, - EmbedBuilder, - MessageFlags, - 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, enqueueOverwrite, enqueueTopic, 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}).`, - flags: MessageFlags.Ephemeral - }); - 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.', flags: MessageFlags.Ephemeral }); - } - if (nextTier <= currentTier) { - return interaction.reply({ content: 'Ticket is already at or past that tier.', flags: MessageFlags.Ephemeral }); - } - - const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-'); - const categoryId = nextTier === 1 - ? (isDiscordTicket ? CONFIG.DISCORD_ESCALATED2_CHANNEL_ID : CONFIG.EMAIL_ESCALATED2_CHANNEL_ID) - : (isDiscordTicket ? CONFIG.DISCORD_ESCALATED3_CHANNEL_ID : CONFIG.EMAIL_ESCALATED3_CHANNEL_ID); - const configKey = nextTier === 1 ? 'ESCALATED2' : 'ESCALATED3'; - if (!categoryId && !interaction.channel.isThread()) { - return interaction.reply({ - content: `${configKey} is not configured for ${isDiscordTicket ? 'Discord' : 'email'} tickets.`, - flags: MessageFlags.Ephemeral - }); - } - - await runDeferred(interaction, 'escalate', () => - runEscalation(interaction, ticket, nextTier, reason) - ); -} - -async function handleDeescalate(interaction) { - const ticket = await findTicketForChannel(interaction); - if (!ticket) return; - - const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0); - if (currentTier === 0) { - return interaction.reply({ content: 'This ticket is not escalated.', flags: MessageFlags.Ephemeral }); - } - - await runDeferred(interaction, 'de-escalate', - () => runDeescalation(interaction, ticket), - { flags: MessageFlags.Ephemeral } - ); -} - -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; - - try { - await enqueueOverwrite(interaction.channel, 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.', flags: MessageFlags.Ephemeral }); - } -} - -async function handleRemove(interaction) { - const user = interaction.options.getUser('user'); - const ticket = await findTicketForChannel(interaction); - if (!ticket) return; - - try { - await enqueueOverwrite(interaction.channel, user.id, null, 'delete'); - 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.', flags: MessageFlags.Ephemeral }); - } -} - -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 }); - } - - 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.', flags: MessageFlags.Ephemeral }); - } -} - -async function handleMove(interaction) { - const category = interaction.options.getChannel('category'); - const ticket = await findTicketForChannel(interaction); - if (!ticket) return; - - try { - await enqueueMove(interaction.channel, category.id); - 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.', flags: MessageFlags.Ephemeral }); - } -} - -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 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.`, flags: MessageFlags.Ephemeral }); -} - -async function handleCancelClose(interaction) { - const pending = pendingCloses.get(interaction.channel.id); - if (!pending) { - return interaction.reply({ content: 'No pending close for this channel.', flags: MessageFlags.Ephemeral }); - } - 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.', flags: MessageFlags.Ephemeral }); -} - -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.', flags: MessageFlags.Ephemeral }); - } - - 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 { - await enqueueTopic(interaction.channel, text); - await interaction.reply('Topic updated successfully.'); - } catch (err) { - console.error('Topic error:', err); - await interaction.reply({ content: 'Failed to update topic.', flags: MessageFlags.Ephemeral }); - } -} - -// /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, flags: MessageFlags.Ephemeral }); - } - } -} - -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.`, flags: MessageFlags.Ephemeral }); - } - - 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.`, flags: MessageFlags.Ephemeral }); - } catch (err) { - if (err.code === 11000 || err.message?.includes('duplicate')) { - await interaction.reply({ content: `❌ Tag "${name}" already exists.`, flags: MessageFlags.Ephemeral }); - } else { - logError('tag-create', err, interaction).catch(() => {}); - await interaction.reply({ content: '❌ Failed to create tag.', flags: MessageFlags.Ephemeral }); - } - } -} - -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.`, flags: MessageFlags.Ephemeral }); - } else { - await interaction.reply({ content: `✅ Tag "${name}" updated successfully.`, flags: MessageFlags.Ephemeral }); - } - } catch (err) { - logError('tag-edit', err, interaction).catch(() => {}); - await interaction.reply({ content: '❌ Failed to edit tag.', flags: MessageFlags.Ephemeral }); - } -} - -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], - flags: MessageFlags.Ephemeral - }); -} - -async function handleResponseList(interaction) { - await interaction.deferReply({ flags: MessageFlags.Ephemeral }); - - 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.', flags: MessageFlags.Ephemeral }).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 ` - 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 }); -} - -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}!`, flags: MessageFlags.Ephemeral }); - } catch (err) { - console.error('Panel creation error:', err); - await interaction.reply({ content: 'Failed to create panel.', flags: MessageFlags.Ephemeral }); - } -} - -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({ flags: MessageFlags.Ephemeral }); - - 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, - creatorId: message.author.id, - 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({ flags: MessageFlags.Ephemeral }); - - 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 }; diff --git a/handlers/commands/close.js b/handlers/commands/close.js new file mode 100644 index 0000000..05525d7 --- /dev/null +++ b/handlers/commands/close.js @@ -0,0 +1,126 @@ +/** + * Force-close flow: /force-close, /cancel-close, /closetimer, plus the + * countdown-elapses finalize step and transcript renderer that the + * countdown's setTimeout calls back into. + * + * Note: the button-driven close path lives in handlers/buttons.js + * (handleCloseButton / handleConfirmCloseRequest / runFinalClose). + * This module covers the slash-command-driven path only. + */ +const { AttachmentBuilder, MessageFlags } = require('discord.js'); +const { mongoose } = require('../../db-connection'); +const { CONFIG } = require('../../config'); +const { enqueueSend } = require('../../services/channelQueue'); +const { logTicketEvent } = require('../../services/debugLog'); +const { pendingCloses } = require('../pendingCloses'); +const { findTicketForChannel } = require('../sharedHelpers'); + +const Ticket = mongoose.model('Ticket'); + +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.`, flags: MessageFlags.Ephemeral }); +} + +async function handleCancelClose(interaction) { + const pending = pendingCloses.get(interaction.channel.id); + if (!pending) { + return interaction.reply({ content: 'No pending close for this channel.', flags: MessageFlags.Ephemeral }); + } + 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.', flags: MessageFlags.Ephemeral }); +} + +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.', flags: MessageFlags.Ephemeral }); + } + + 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] }); +} + +module.exports = { handleCloseTimer, handleCancelClose, handleForceClose }; diff --git a/handlers/commands/contextMenu.js b/handlers/commands/contextMenu.js new file mode 100644 index 0000000..24a36f3 --- /dev/null +++ b/handlers/commands/contextMenu.js @@ -0,0 +1,168 @@ +/** + * Right-click "Apps" menu commands: + * - "Create Ticket From Message" — turn a Discord message into a ticket. + * - "View User Tickets" — show last 10 tickets for the targeted user. + */ +const { + ChannelType, + EmbedBuilder, + MessageFlags, + PermissionFlagsBits +} = require('discord.js'); +const { mongoose } = require('../../db-connection'); +const { CONFIG } = require('../../config'); +const { getPriorityEmoji } = require('../../utils'); +const { checkTicketCreationRateLimit, getOrCreateTicketCategory } = require('../../services/tickets'); +const { getTicketActionRow } = require('../../utils/ticketComponents'); +const { enqueueSend } = require('../../services/channelQueue'); +const { logError } = require('../../services/debugLog'); + +const Ticket = mongoose.model('Ticket'); + +async function handleCreateTicketFromMessage(interaction) { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + 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, + creatorId: message.author.id, + 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({ flags: MessageFlags.Ephemeral }); + + 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.'); + } +} + +module.exports = { handleCreateTicketFromMessage, handleViewUserTickets }; diff --git a/handlers/commands/escalation.js b/handlers/commands/escalation.js new file mode 100644 index 0000000..eff5323 --- /dev/null +++ b/handlers/commands/escalation.js @@ -0,0 +1,214 @@ +/** + * Escalation flows. + * + * runEscalation / runDeescalation are exported for handlers/buttons.js + * (the tier-pick buttons share this code path). handleEscalate / + * handleDeescalate are the slash-command entry points. + */ +const { EmbedBuilder, MessageFlags } = require('discord.js'); +const { mongoose } = require('../../db-connection'); +const { CONFIG } = require('../../config'); +const { makeTicketName, resolveCreatorNickname } = require('../../services/tickets'); +const { sendTicketNotificationEmail } = require('../../services/gmail'); +const { getTicketActionRow } = require('../../utils/ticketComponents'); +const { enqueueRename, enqueueMove, enqueueSend } = require('../../services/channelQueue'); +const { pinMessage } = require('../../services/pinMessage'); +const { logError } = require('../../services/debugLog'); +const { findTicketForChannel, runDeferred } = require('../sharedHelpers'); +const { fetchLoggingChannel } = require('./helpers'); + +const Ticket = mongoose.model('Ticket'); + +/** + * Run escalation to a target tier (1 = tier 2, 2 = tier 3). Caller must + * validate ticket and currentTier < nextTier, and have already deferred. + */ +async function runEscalation(interaction, ticket, nextTier, reason) { + const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-'); + const categoryId = nextTier === 1 + ? (isDiscordTicket ? CONFIG.DISCORD_ESCALATED2_CHANNEL_ID : CONFIG.EMAIL_ESCALATED2_CHANNEL_ID) + : (isDiscordTicket ? CONFIG.DISCORD_ESCALATED3_CHANNEL_ID : CONFIG.EMAIL_ESCALATED3_CHANNEL_ID); + + // Clear claim on escalation + await Ticket.updateOne( + { gmailThreadId: ticket.gmailThreadId }, + { $set: { escalated: true, escalationTier: nextTier, claimedBy: null, claimerId: null } } + ); + ticket.escalated = true; + ticket.escalationTier = nextTier; + ticket.claimedBy = null; + + const creatorNickname = await resolveCreatorNickname(interaction.guild, ticket); + const newName = makeTicketName('escalated', ticket, creatorNickname); + enqueueRename(interaction.channel, newName).catch(err => logError('rename', err).catch(() => {})); + + if (!interaction.channel.isThread() && categoryId) { + await enqueueMove(interaction.channel, categoryId); + } + + const pendingEmbed = new EmbedBuilder() + .setDescription('Ticket will be escalated in a few seconds.') + .setColor(CONFIG.EMBED_COLOR_INFO); + await interaction.editReply({ embeds: [pendingEmbed] }); + + const creatorId = isDiscordTicket + ? (ticket.gmailThreadId.split('-').pop() || '').trim() + : null; + const creatorMention = creatorId ? `<@${creatorId}>` : ''; + const roleMention = CONFIG.ROLE_ID_TO_PING ? `<@&${CONFIG.ROLE_ID_TO_PING}>` : 'a senior team member'; + const heyLine = creatorMention ? `Hey There ${creatorMention} 🥦` : 'Hey There 🥦'; + // Creator + role pings are intentional; still block @everyone/@here if somehow interpolated. + await enqueueSend(interaction.channel, { + content: `${heyLine}\n**Getting the senior ${roleMention} for you.**`, + allowedMentions: { parse: ['users', 'roles'] } + }); + + const escalationBody = CONFIG.ESCALATION_MESSAGE + .replace(/\\n/g, '\n') + .replace(/\{support_name\}/g, CONFIG.SUPPORT_NAME); + const escalatedEmbed = new EmbedBuilder() + .setTitle(`🚨 Escalated to ${nextTier === 1 ? 'Tier 2' : 'Tier 3'} Support`) + .setDescription(escalationBody) + .setColor(CONFIG.EMBED_COLOR_ESCALATED) + .setFooter({ text: `Escalated by ${interaction.member?.displayName || interaction.user.username}` }); + const updatedTicketForRow = { ...ticket, escalationTier: nextTier, escalated: true }; + const escalationRow = getTicketActionRow(updatedTicketForRow); + const escalationMsg = await enqueueSend(interaction.channel, { + content: null, + embeds: [escalatedEmbed], + components: [escalationRow] + }); + + if (CONFIG.PIN_ESCALATION_MESSAGE_ENABLED && escalationMsg) { + await pinMessage(escalationMsg, interaction.client).catch(() => {}); + } + + if (!isDiscordTicket && ticket.gmailThreadId) { + try { + const escalatorName = interaction.member?.displayName || interaction.user.username; + const tierLabel = nextTier === 1 ? 'tier 2' : 'tier 3'; + const emailBody = `${escalatorName} escalated this ticket to ${tierLabel}.${reason ? `\n\nReason: ${reason}` : ''}`; + await sendTicketNotificationEmail(ticket, null, emailBody, interaction.user.id); + } catch (emailErr) { + console.error('Escalation email failed (non-fatal):', emailErr.message); + } + } + + if (nextTier === 2 && ticket.welcomeMessageId) { + try { + const welcomeMsg = await interaction.channel.messages.fetch(ticket.welcomeMessageId); + await welcomeMsg.edit({ components: [getTicketActionRow(updatedTicketForRow)] }); + } catch (e) { + console.error('Failed to update welcome message after escalate:', e.message); + } + } + + const logChan = await fetchLoggingChannel(interaction.client); + if (logChan) { + const ticketType = isDiscordTicket ? 'Discord' : 'Email'; + const tierLabel = nextTier === 1 ? 'tier 2' : 'tier 3'; + await enqueueSend(logChan, + `${ticketType} ticket ${interaction.channel} escalated to ${tierLabel} by ${interaction.user.tag}.\nReason: ${reason}` + ); + } +} + +/** Run deescalation one step. Caller must validate ticket and currentTier >= 1. */ +async function runDeescalation(interaction, ticket) { + const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0); + const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-'); + const newTier = currentTier - 1; + + await Ticket.updateOne( + { gmailThreadId: ticket.gmailThreadId }, + { $set: { escalated: newTier > 0, escalationTier: newTier, claimedBy: null, claimerId: null } } + ); + ticket.escalated = newTier > 0; + ticket.escalationTier = newTier; + ticket.claimedBy = null; + + const creatorNickname = await resolveCreatorNickname(interaction.guild, ticket); + const state = newTier === 0 ? 'unclaimed' : 'escalated'; + enqueueRename(interaction.channel, makeTicketName(state, ticket, creatorNickname)).catch(err => logError('rename', err).catch(() => {})); + + if (!interaction.channel.isThread()) { + try { + if (newTier === 0) { + const homeCategory = isDiscordTicket ? CONFIG.DISCORD_TICKET_CATEGORY_ID : CONFIG.TICKET_CATEGORY_ID; + if (homeCategory) await enqueueMove(interaction.channel, homeCategory); + } else if (newTier === 1) { + const t2Category = isDiscordTicket + ? CONFIG.DISCORD_ESCALATED2_CHANNEL_ID + : CONFIG.EMAIL_ESCALATED2_CHANNEL_ID; + if (t2Category) await enqueueMove(interaction.channel, t2Category); + } + } catch (e) { + console.error('Move error (deescalate):', e); + } + } + + const tierLabel = newTier === 0 ? 'normal' : newTier === 1 ? 'tier 2' : 'tier 3'; + const deescalateEmbed = new EmbedBuilder() + .setColor(0x00BFFF) + .setTitle(`✅ De-escalated to ${tierLabel} Support`) + .setFooter({ text: interaction.member?.displayName || interaction.user.username }); + await interaction.editReply({ embeds: [deescalateEmbed] }); + + const logChan = await fetchLoggingChannel(interaction.client); + if (logChan) { + const ticketType = isDiscordTicket ? 'Discord' : 'Email'; + await enqueueSend(logChan, + `${ticketType} ticket ${interaction.channel} de‑escalated to ${tierLabel} by ${interaction.user.tag}.` + ); + } +} + +async function handleEscalate(interaction) { + const reason = null; + const level = interaction.options.getString('level'); + const nextTier = level === '3' ? 2 : 1; + + const ticket = await findTicketForChannel(interaction); + if (!ticket) return; + + const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0); + if (currentTier >= 2) { + return interaction.reply({ content: 'This ticket is already at tier 3 support.', flags: MessageFlags.Ephemeral }); + } + if (nextTier <= currentTier) { + return interaction.reply({ content: 'Ticket is already at or past that tier.', flags: MessageFlags.Ephemeral }); + } + + const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-'); + const categoryId = nextTier === 1 + ? (isDiscordTicket ? CONFIG.DISCORD_ESCALATED2_CHANNEL_ID : CONFIG.EMAIL_ESCALATED2_CHANNEL_ID) + : (isDiscordTicket ? CONFIG.DISCORD_ESCALATED3_CHANNEL_ID : CONFIG.EMAIL_ESCALATED3_CHANNEL_ID); + const configKey = nextTier === 1 ? 'ESCALATED2' : 'ESCALATED3'; + if (!categoryId && !interaction.channel.isThread()) { + return interaction.reply({ + content: `${configKey} is not configured for ${isDiscordTicket ? 'Discord' : 'email'} tickets.`, + flags: MessageFlags.Ephemeral + }); + } + + await runDeferred(interaction, 'escalate', () => + runEscalation(interaction, ticket, nextTier, reason) + ); +} + +async function handleDeescalate(interaction) { + const ticket = await findTicketForChannel(interaction); + if (!ticket) return; + + const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0); + if (currentTier === 0) { + return interaction.reply({ content: 'This ticket is not escalated.', flags: MessageFlags.Ephemeral }); + } + + await runDeferred(interaction, 'de-escalate', + () => runDeescalation(interaction, ticket), + { flags: MessageFlags.Ephemeral } + ); +} + +module.exports = { runEscalation, runDeescalation, handleEscalate, handleDeescalate }; diff --git a/handlers/commands/helpers.js b/handlers/commands/helpers.js new file mode 100644 index 0000000..9a09364 --- /dev/null +++ b/handlers/commands/helpers.js @@ -0,0 +1,33 @@ +/** + * Cross-submodule helpers for handlers/commands/*. + * + * Lives at this level (not in index.js) so escalation.js, close.js, etc. can + * import without creating circular dependencies with index.js. + */ +const { MessageFlags } = require('discord.js'); +const { CONFIG } = require('../../config'); +const { isStaff } = require('../../utils'); + +/** + * 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}).`, + flags: MessageFlags.Ephemeral + }); + 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); +} + +module.exports = { requireStaffRole, fetchLoggingChannel }; diff --git a/handlers/commands/index.js b/handlers/commands/index.js new file mode 100644 index 0000000..3743681 --- /dev/null +++ b/handlers/commands/index.js @@ -0,0 +1,299 @@ +/** + * 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 { enqueueMove, enqueueOverwrite, enqueueTopic, enqueueSend } = require('../../services/channelQueue'); +const { 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; + + try { + await enqueueOverwrite(interaction.channel, 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.', flags: MessageFlags.Ephemeral }); + } +} + +async function handleRemove(interaction) { + const user = interaction.options.getUser('user'); + const ticket = await findTicketForChannel(interaction); + if (!ticket) return; + + try { + await enqueueOverwrite(interaction.channel, user.id, null, 'delete'); + 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.', flags: MessageFlags.Ephemeral }); + } +} + +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 }); + } + + 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.', flags: MessageFlags.Ephemeral }); + } +} + +async function handleMove(interaction) { + const category = interaction.options.getChannel('category'); + const ticket = await findTicketForChannel(interaction); + if (!ticket) return; + + try { + await enqueueMove(interaction.channel, category.id); + 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.', flags: MessageFlags.Ephemeral }); + } +} + +async function handleTopic(interaction) { + const text = interaction.options.getString('text'); + const ticket = await findTicketForChannel(interaction); + if (!ticket) return; + + try { + await enqueueTopic(interaction.channel, text); + await interaction.reply('Topic updated successfully.'); + } catch (err) { + console.error('Topic error:', err); + await interaction.reply({ content: 'Failed to update topic.', flags: MessageFlags.Ephemeral }); + } +} + +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. /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); +} + +module.exports = { + handleCommand, + handleContextMenu, + handleAutocomplete, + runEscalation, + runDeescalation +}; diff --git a/handlers/commands/panel.js b/handlers/commands/panel.js new file mode 100644 index 0000000..559a908 --- /dev/null +++ b/handlers/commands/panel.js @@ -0,0 +1,133 @@ +/** + * /panel — create a ticket-creation panel embed in a chosen channel. + * Also hosts /signature (modal for staff personal email signature) since + * both are user-facing UX-flow commands without their own dedicated module. + */ +const { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + EmbedBuilder, + MessageFlags, + ModalBuilder, + TextInputBuilder, + TextInputStyle +} = require('discord.js'); +const { mongoose } = require('../../db-connection'); +const { CONFIG } = require('../../config'); +const { enqueueSend } = require('../../services/channelQueue'); + +const StaffSignature = mongoose.model('StaffSignature'); + +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}!`, flags: MessageFlags.Ephemeral }); + } catch (err) { + console.error('Panel creation error:', err); + await interaction.reply({ content: 'Failed to create panel.', flags: MessageFlags.Ephemeral }); + } +} + +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('✅') + ); +} + +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.', flags: MessageFlags.Ephemeral }).catch(() => {}); + } + } +} + +module.exports = { handlePanel, handleSignature }; diff --git a/handlers/commands/response.js b/handlers/commands/response.js new file mode 100644 index 0000000..56ca407 --- /dev/null +++ b/handlers/commands/response.js @@ -0,0 +1,165 @@ +/** + * /response (saved tags) and its autocomplete. + * + * /response is itself a router over its subcommands: + * send / create / edit / delete / list + * The autocomplete handler also lives here since the only autocompleting + * slash command is /response. + */ +const { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + EmbedBuilder, + MessageFlags +} = require('discord.js'); +const { mongoose } = require('../../db-connection'); +const { CONFIG } = require('../../config'); +const { replaceVariables } = require('../../utils'); +const { logError } = require('../../services/debugLog'); + +const Tag = mongoose.model('Tag'); +const Ticket = mongoose.model('Ticket'); + +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, flags: MessageFlags.Ephemeral }); + } + } +} + +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.`, flags: MessageFlags.Ephemeral }); + } + + 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.`, flags: MessageFlags.Ephemeral }); + } catch (err) { + if (err.code === 11000 || err.message?.includes('duplicate')) { + await interaction.reply({ content: `❌ Tag "${name}" already exists.`, flags: MessageFlags.Ephemeral }); + } else { + logError('tag-create', err, interaction).catch(() => {}); + await interaction.reply({ content: '❌ Failed to create tag.', flags: MessageFlags.Ephemeral }); + } + } +} + +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.`, flags: MessageFlags.Ephemeral }); + } else { + await interaction.reply({ content: `✅ Tag "${name}" updated successfully.`, flags: MessageFlags.Ephemeral }); + } + } catch (err) { + logError('tag-edit', err, interaction).catch(() => {}); + await interaction.reply({ content: '❌ Failed to edit tag.', flags: MessageFlags.Ephemeral }); + } +} + +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], + flags: MessageFlags.Ephemeral + }); +} + +async function handleResponseList(interaction) { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + 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] }); +} + +const RESPONSE_SUBCOMMANDS = { + send: handleResponseSend, + create: handleResponseCreate, + edit: handleResponseEdit, + delete: handleResponseDelete, + list: handleResponseList +}; + +/** 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 = { handleResponse, handleAutocomplete };