/** * Slash command, context menu, and autocomplete handlers. */ const { ChannelType, ButtonBuilder, ButtonStyle, AttachmentBuilder, EmbedBuilder, PermissionFlagsBits } = require('discord.js'); const { mongoose } = require('../db-connection'); const { CONFIG } = require('../config'); const { getPriorityEmoji, replaceVariables } = require('../utils'); const { makeTicketName, resolveCreatorNickname, getOrCreateTicketCategory, checkTicketCreationRateLimit } = require('../services/tickets'); const { sendTicketNotificationEmail } = require('../services/gmail'); const { getTicketActionRow } = require('../utils/ticketComponents'); const { enqueueRename, enqueueMove, enqueueSend } = require('../services/channelQueue'); const { setNotifyDm } = require('../services/staffSettings'); const { logError } = require('../services/debugLog'); const { pendingCloses } = require('./pendingCloses'); const Ticket = mongoose.model('Ticket'); const Tag = mongoose.model('Tag'); /** * True if member has the support role (ROLE_ID_TO_PING) or any ADDITIONAL_STAFF_ROLES. * Used to restrict commands to staff only; customers cannot use bot commands. * @param {import('discord.js').GuildMember|null} member * @returns {boolean} */ function hasStaffRole(member) { if (!member?.roles?.cache) return false; if (CONFIG.ROLE_ID_TO_PING && member.roles.cache.has(CONFIG.ROLE_ID_TO_PING)) return true; const additional = CONFIG.ADDITIONAL_STAFF_ROLES || []; return additional.some(roleId => member.roles.cache.has(roleId)); } /** * Reply ephemeral and return true if the interaction is in a guild and the user is not staff (so caller should return). * @param {import('discord.js').CommandInteraction|import('discord.js').ContextMenuCommandInteraction} interaction * @returns {Promise} true if caller should return (user is not allowed) */ 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 (hasStaffRole(interaction.member)) return false; const roleMention = CONFIG.ROLE_ID_TO_PING ? `<@&${CONFIG.ROLE_ID_TO_PING}>` : 'support'; await interaction.reply({ content: `This command is only available to the support team (${roleMention}).`, ephemeral: true }); return true; } /** * Run escalation to a target DB tier (1 = tier 2, 2 = tier 3). Caller must validate ticket and currentTier < nextTier. */ 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) { const { pinMessage } = require('../services/pinMessage'); 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) { if (!ticket.welcomeMessageId) { console.warn('welcomeMessageId is null/undefined; skipping welcome-message update for escalation'); } else { 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 interaction.client.channels .fetch(CONFIG.LOG_CHAN) .catch(() => null); 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 interaction.client.channels.fetch(CONFIG.LOG_CHAN).catch(() => null); if (logChan) { const ticketType = isDiscordTicket ? 'Discord' : 'Email'; await enqueueSend(logChan, `${ticketType} ticket ${interaction.channel} de‑escalated to ${tierLabel} by ${interaction.user.tag}.` ); } } /** * Main slash-command handler. */ async function handleCommand(interaction) { // Only /help can be used by everyone; all other commands require staff role (ROLE_ID_TO_PING / ADDITIONAL_STAFF_ROLES) if (interaction.commandName !== 'help' && (await requireStaffRole(interaction))) return; // /escalate (tier 2 or 3 via level; works for both email and Discord). Always unclaims on escalate. if (interaction.commandName === 'escalate') { const reason = null; const level = interaction.options.getString('level'); const nextTier = level === '3' ? 2 : 1; const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean(); if (!ticket) { return interaction.reply({ content: 'This channel is not linked to a ticket.', ephemeral: true }); } const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0); if (currentTier >= 2) { return interaction.reply({ content: 'This ticket is already at tier 3 support.', ephemeral: true }); } if (nextTier <= currentTier) { return interaction.reply({ content: 'Ticket is already at or past that tier.', ephemeral: true }); } const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-'); const categoryId = nextTier === 1 ? (isDiscordTicket ? CONFIG.DISCORD_ESCALATED2_CHANNEL_ID : CONFIG.EMAIL_ESCALATED2_CHANNEL_ID) : (isDiscordTicket ? CONFIG.DISCORD_ESCALATED3_CHANNEL_ID : CONFIG.EMAIL_ESCALATED3_CHANNEL_ID); const configKey = nextTier === 1 ? 'ESCALATED2' : 'ESCALATED3'; if (!categoryId && !interaction.channel.isThread()) { return interaction.reply({ content: `${configKey} is not configured for ${isDiscordTicket ? 'Discord' : 'email'} tickets.`, ephemeral: true }); } try { await interaction.deferReply(); await runEscalation(interaction, ticket, nextTier, reason); } catch (err) { console.error('Escalate error:', err); await interaction.editReply({ content: 'Failed to escalate this ticket.' }).catch(() => interaction.followUp({ content: 'Failed to escalate this ticket.', ephemeral: true }).catch(() => {}) ); } } if (interaction.commandName === 'notifydm') { try { const setting = interaction.options.getString('setting') === 'on'; await setNotifyDm(interaction.user.id, interaction.guildId, setting); await interaction.reply({ content: `DM notifications ${setting ? 'enabled βœ…' : 'disabled πŸ”•'}.`, ephemeral: true }); } catch (err) { console.error('notifydm error:', err); await interaction.reply({ content: 'Failed to update notification setting.', ephemeral: true }).catch(() => {}); } return; } // /deescalate (tier 3 β†’ tier 2, tier 2 β†’ normal) if (interaction.commandName === 'deescalate') { const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean(); if (!ticket) { return interaction.reply({ content: 'This channel is not linked to a ticket.', ephemeral: true }); } const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0); if (currentTier === 0) { return interaction.reply({ content: 'This ticket is not escalated.', ephemeral: true }); } try { await interaction.deferReply({ ephemeral: true }); await runDeescalation(interaction, ticket); } catch (err) { console.error('Deescalate error:', err); await interaction.editReply({ content: 'Failed to deescalate this ticket.' }).catch(() => interaction.followUp({ content: 'Failed to deescalate this ticket.', ephemeral: true }).catch(() => {}) ); } } // /add if (interaction.commandName === 'add') { const user = interaction.options.getUser('user'); const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean(); if (!ticket) { return interaction.reply({ content: 'This channel is not linked to a ticket.', ephemeral: true }); } try { // TODO(queue-migrate): permissionOverwrites mutation bypasses channelQueue β€” could race a pending rename/send on the same channel. await interaction.channel.permissionOverwrites.create(user.id, { ViewChannel: true, SendMessages: true, ReadMessageHistory: true }); await interaction.reply({ content: `Added ${user} to this ticket.`, allowedMentions: { parse: ['users'] } }); } catch (err) { console.error('Add user error:', err); await interaction.reply({ content: 'Failed to add user.', ephemeral: true }); } } // /remove if (interaction.commandName === 'remove') { const user = interaction.options.getUser('user'); const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean(); if (!ticket) { return interaction.reply({ content: 'This channel is not linked to a ticket.', ephemeral: true }); } try { // TODO(queue-migrate): permissionOverwrites mutation bypasses channelQueue β€” could race a pending rename/send on the same channel. await interaction.channel.permissionOverwrites.delete(user.id); await interaction.reply({ content: `Removed ${user} from this ticket.`, allowedMentions: { parse: ['users'] } }); } catch (err) { console.error('Remove user error:', err); await interaction.reply({ content: 'Failed to remove user.', ephemeral: true }); } } // /transfer if (interaction.commandName === 'transfer') { const member = interaction.options.getUser('member'); const reason = interaction.options.getString('reason') || 'No reason provided'; const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean(); if (!ticket) { return interaction.reply({ content: 'This channel is not linked to a ticket.', ephemeral: true }); } const staffRoleId = CONFIG.ROLE_TO_PING_ID; const guildMember = await interaction.guild.members.fetch(member.id).catch(() => null); if (!guildMember || !guildMember.roles.cache.has(staffRoleId)) { return interaction.reply({ content: 'The target member must have the staff role.', ephemeral: true }); } try { const claimerLabel = guildMember.displayName || guildMember.user.username; await Ticket.updateOne( { gmailThreadId: ticket.gmailThreadId }, { $set: { claimedBy: claimerLabel } } ); // `reason` is staff-supplied freeform text; gate to user pings so @everyone in it can't mass-ping. await interaction.reply({ content: `Ticket transferred to ${member} by ${interaction.user}.\nReason: ${reason}`, allowedMentions: { parse: ['users'] } }); const logChan = await interaction.client.channels.fetch(CONFIG.LOG_CHAN).catch(() => null); if (logChan) { await enqueueSend(logChan, { content: `Ticket ${interaction.channel} transferred from ${interaction.user.tag} to ${member.tag}.\nReason: ${reason}`, allowedMentions: { parse: ['users'] } }); } } catch (err) { console.error('Transfer error:', err); await interaction.reply({ content: 'Failed to transfer ticket.', ephemeral: true }); } } // /move if (interaction.commandName === 'move') { const category = interaction.options.getChannel('category'); const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean(); if (!ticket) { return interaction.reply({ content: 'This channel is not linked to a ticket.', ephemeral: true }); } try { // TODO(queue-migrate): setParent bypasses channelQueue (enqueueMove) β€” use enqueueMove so moves serialize with pending renames/sends. await interaction.channel.setParent(category.id, { lockPermissions: true }); await interaction.reply(`Moved ticket to **${category.name}**.`); const logChan = await interaction.client.channels.fetch(CONFIG.LOG_CHAN).catch(() => null); if (logChan) { await enqueueSend(logChan, `Ticket ${interaction.channel} moved to category **${category.name}** by ${interaction.user.tag}` ); } } catch (err) { console.error('Move error:', err); await interaction.reply({ content: 'Failed to move ticket.', ephemeral: true }); } } // /gmailpoll // /staffthread if (interaction.commandName === 'staffthread') { const sub = interaction.options.getSubcommand(); if (sub === 'toggle') { CONFIG.STAFF_THREAD_ENABLED = !CONFIG.STAFF_THREAD_ENABLED; return interaction.reply({ content: `Staff threads are now **${CONFIG.STAFF_THREAD_ENABLED ? 'enabled' : 'disabled'}**.`, ephemeral: true }); } if (sub === 'name') { const name = interaction.options.getString('thread_name').slice(0, 100); CONFIG.STAFF_THREAD_NAME = name; return interaction.reply({ content: `Staff thread name set to **${name}**.`, ephemeral: true }); } if (sub === 'autorole') { const enabled = interaction.options.getBoolean('enabled'); CONFIG.STAFF_THREAD_AUTO_ADD_ROLE = enabled; return interaction.reply({ content: `Auto-add role to staff thread is now **${enabled ? 'enabled' : 'disabled'}**.`, ephemeral: true }); } return; } // /pinmessages if (interaction.commandName === 'pinmessages') { const sub = interaction.options.getSubcommand(); const enabled = interaction.options.getBoolean('enabled'); if (sub === 'initial') { CONFIG.PIN_INITIAL_MESSAGE_ENABLED = enabled; return interaction.reply({ content: `Auto-pin initial message is now **${enabled ? 'enabled' : 'disabled'}**.`, ephemeral: true }); } if (sub === 'escalation') { CONFIG.PIN_ESCALATION_MESSAGE_ENABLED = enabled; return interaction.reply({ content: `Auto-pin escalation message is now **${enabled ? 'enabled' : 'disabled'}**.`, ephemeral: true }); } if (sub === 'suppress') { CONFIG.PIN_SUPPRESS_SYSTEM_MESSAGE = enabled; return interaction.reply({ content: `Suppress pin system message is now **${enabled ? 'enabled' : 'disabled'}**.`, ephemeral: true }); } return; } if (interaction.commandName === 'gmailpoll') { const seconds = parseInt(interaction.options.getString('interval'), 10); const { setGmailPollInterval } = require('../broccolini-discord'); setGmailPollInterval(seconds * 1000); logTicketEvent('Gmail poll interval updated', [{ name: 'Interval', value: `${seconds}s` }, { name: 'Set by', value: interaction.user.tag }], interaction).catch(() => {}); return interaction.reply({ content: `Gmail poll interval set to ${seconds} seconds.`, ephemeral: true }); } // /closetimer if (interaction.commandName === 'closetimer') { const seconds = parseInt(interaction.options.getString('seconds'), 10); CONFIG.FORCE_CLOSE_TIMER = seconds; logTicketEvent('Close timer updated', [{ name: 'Duration', value: `${seconds}s` }, { name: 'Set by', value: interaction.user.tag }], interaction).catch(() => {}); return interaction.reply({ content: `Force-close timer set to ${seconds} seconds.`, ephemeral: true }); } // /cancel-close if (interaction.commandName === 'cancel-close') { const pending = pendingCloses.get(interaction.channel.id); if (!pending) { return interaction.reply({ content: 'No pending close for this channel.', ephemeral: true }); } clearTimeout(pending.timeout); const { logTicketEvent } = require('../services/debugLog'); logTicketEvent('Force-close cancelled', [ { name: 'Ticket', value: interaction.channel.name || interaction.channel.id }, { name: 'Cancelled by', value: interaction.user.tag }, { name: 'Original setter', value: pending.username || 'Unknown' } ], interaction).catch(() => {}); pendingCloses.delete(interaction.channel.id); return interaction.reply({ content: 'Close cancelled.', ephemeral: true }); } // /force-close if (interaction.commandName === 'force-close') { const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean(); if (!ticket) { return interaction.reply({ content: 'This channel is not linked to a ticket.', ephemeral: true }); } if (pendingCloses.has(interaction.channel.id)) { return interaction.reply({ content: 'A close is already pending for this ticket.', ephemeral: true }); } const timerSeconds = CONFIG.FORCE_CLOSE_TIMER; await interaction.reply(`Closing ticket in ${timerSeconds} seconds. Use \`/cancel-close\` to abort.`); const channelRef = interaction.channel; const clientRef = interaction.client; const timerId = setTimeout(async () => { 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...'); try { 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_CHAN) .catch(() => null); if (transcriptChan) { const closedAt = new Date(); const openedStr = new Date(freshTicket.createdAt).toLocaleString('en-US', { month: '2-digit', day: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: true, timeZoneName: 'short' }); const closedStr = closedAt.toLocaleString('en-US', { month: '2-digit', day: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: true, timeZoneName: 'short' }); const transcriptContent = CONFIG.DISCORD_TRANSCRIPT_MESSAGE .replace(/\{channel_name\}/g, channelRef.name) .replace(/\{email\}/g, freshTicket.senderEmail || '') .replace(/\{date_opened\}/g, openedStr) .replace(/\{date_closed\}/g, closedStr) + `\n\nDate Opened: ${openedStr}\nDate Closed: ${closedStr}`; await enqueueSend(transcriptChan, { content: transcriptContent, files: [file] }); } } catch (tErr) { console.error('Transcript error (force-close):', tErr); } setTimeout(async () => { try { await channelRef.delete('Ticket force-closed'); } catch (e) { console.error('Failed to delete channel:', e); } }, 5000); } catch (err) { console.error('Force close error:', err); } }, timerSeconds * 1000); pendingCloses.set(channelRef.id, { timeout: timerId, userId: interaction.user.id, username: interaction.user.tag }); } // /topic if (interaction.commandName === 'topic') { const text = interaction.options.getString('text'); const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean(); if (!ticket) { return interaction.reply({ content: 'This channel is not linked to a ticket.', ephemeral: true }); } try { // TODO(queue-migrate): setTopic bypasses channelQueue β€” could race a pending rename/send on the same channel. await interaction.channel.setTopic(text); await interaction.reply('Topic updated successfully.'); } catch (err) { console.error('Topic error:', err); await interaction.reply({ content: 'Failed to update topic.', ephemeral: true }); } } // /response – saved response tags (send, create, edit, delete, list) if (interaction.commandName === 'response') { const subcommand = interaction.options.getSubcommand(); try { if (subcommand === 'send') { const name = interaction.options.getString('name'); const tag = await Tag.findOne({ name }).lean(); if (!tag) { return interaction.reply({ content: `❌ Tag "${name}" not found.`, ephemeral: true }); } const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean(); const context = { ticket: ticket || {}, staff: { username: interaction.user.username, displayName: interaction.member?.displayName, mention: interaction.user.toString() }, guild: interaction.guild }; const content = replaceVariables(tag.content, context); await Tag.updateOne({ name }, { $inc: { useCount: 1 } }); // Tag bodies are staff-authored but may include variable substitutions from user/ticket data. // Disable all mention parsing so a `@everyone` in a tag body never pings. await interaction.reply({ content, allowedMentions: { parse: [] } }); } else if (subcommand === 'create') { const name = interaction.options.getString('name'); const content = interaction.options.getString('content'); try { await Tag.create({ name, content, createdBy: interaction.user.id }); await interaction.reply({ content: `βœ… Tag "${name}" created successfully.`, ephemeral: true }); } catch (err) { if (err.code === 11000 || err.message?.includes('duplicate')) { await interaction.reply({ content: `❌ Tag "${name}" already exists.`, ephemeral: true }); } else { logError('tag-create', err, interaction).catch(() => {}); await interaction.reply({ content: '❌ Failed to create tag.', ephemeral: true }); } } } else if (subcommand === 'edit') { const name = interaction.options.getString('name'); const content = interaction.options.getString('content'); try { const result = await Tag.updateOne({ name }, { $set: { content } }); if (result.matchedCount === 0) { await interaction.reply({ content: `❌ Tag "${name}" not found.`, ephemeral: true }); } else { await interaction.reply({ content: `βœ… Tag "${name}" updated successfully.`, ephemeral: true }); } } catch (err) { logError('tag-edit', err, interaction).catch(() => {}); await interaction.reply({ content: '❌ Failed to edit tag.', ephemeral: true }); } } else if (subcommand === 'delete') { const name = interaction.options.getString('name'); // Use :: delimiter so tag names with underscores are parsed correctly (Discord customId max 100 chars) const customId = `confirm_delete_tag::${name}`.slice(0, 100); const confirmRow = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(customId) .setLabel('Yes, Delete Tag') .setStyle(ButtonStyle.Danger), new ButtonBuilder() .setCustomId('cancel_delete_tag') .setLabel('Cancel') .setStyle(ButtonStyle.Secondary) ); return interaction.reply({ content: `⚠️ Are you sure you want to delete the tag "${name}"? This action cannot be undone.`, components: [confirmRow], ephemeral: true }); } else if (subcommand === 'list') { await interaction.deferReply({ ephemeral: true }); const tags = await Tag.find().sort({ useCount: -1 }).select('name useCount').lean(); if (!tags || tags.length === 0) { return interaction.editReply({ content: 'πŸ“‹ No tags available.' }); } const embed = new EmbedBuilder() .setTitle('πŸ“‹ Available Saved Responses') .setDescription( tags.map((t, i) => `${i + 1}. **${t.name}** (used ${t.useCount || 0}x)`).join('\n') ) .setColor(CONFIG.EMBED_COLOR_INFO) .setFooter({ text: `Total: ${tags.length} tags` }); await interaction.editReply({ embeds: [embed] }); } } catch (err) { logError('response-command', err, interaction).catch(() => {}); const errorMsg = '❌ An error occurred while processing the response command.'; if (interaction.deferred) { await interaction.editReply(errorMsg); } else { await interaction.reply({ content: errorMsg, ephemeral: true }); } } } // /signature if (interaction.commandName === 'signature') { try { // Fetch existing signature data if it exists const StaffSignature = mongoose.model('StaffSignature'); const existingSignature = await StaffSignature.findOne({ userId: interaction.user.id }).lean(); // Create modal const { ModalBuilder, ActionRowBuilder, TextInputBuilder, TextInputStyle } = require('discord.js'); const modal = new ModalBuilder() .setCustomId(`signature_modal_${interaction.user.id}`) .setTitle('Staff Signature Settings'); // Add text inputs to modal const valedictionInput = new TextInputBuilder() .setCustomId('valediction') .setLabel('Valediction (e.g. "Best regards", "Thanks")') .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 || ''); const valedictionRow = new ActionRowBuilder().addComponents(valedictionInput); const displayNameRow = new ActionRowBuilder().addComponents(displayNameInput); const taglineRow = new ActionRowBuilder().addComponents(taglineInput); modal.addComponents(valedictionRow, displayNameRow, taglineRow); await interaction.showModal(modal); } catch (err) { console.error('Signature command error:', err); if (!interaction.replied && !interaction.deferred) { await interaction.reply({ content: 'Failed to open signature settings.', ephemeral: true }).catch(() => {}); } } return; } // /help if (interaction.commandName === 'help') { 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], ephemeral: true }); } // /panel if (interaction.commandName === 'panel') { const channel = interaction.options.getChannel('channel'); const panelType = interaction.options.getString('type') || null; // 'thread' | 'category' | 'both' or null (use CONFIG default) 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' }); let row; if (panelType === 'both') { row = 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('πŸ“') ); } else if (panelType === 'thread') { row = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId('open_ticket_thread') .setLabel('Create ticket') .setStyle(ButtonStyle.Secondary) .setEmoji('🧡') ); } else if (panelType === 'category') { row = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId('open_ticket_channel') .setLabel('Create ticket') .setStyle(ButtonStyle.Secondary) .setEmoji('πŸ“') ); } else { row = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId('open_ticket') .setLabel('Create ticket') .setStyle(ButtonStyle.Secondary) .setEmoji('βœ…') ); } try { await enqueueSend(channel, { embeds: [embed], components: [row] }); await interaction.reply({ content: `Panel created in ${channel}!`, ephemeral: true }); } catch (err) { console.error('Panel creation error:', err); await interaction.reply({ content: 'Failed to create panel.', ephemeral: true }); } } } /** * Context menu interaction handler. */ async function handleContextMenu(interaction) { // Restrict all guild context menus to staff role only if (await requireStaffRole(interaction)) return; // Create Ticket From Message if (interaction.isMessageContextMenuCommand() && interaction.commandName === 'Create Ticket From Message') { await interaction.deferReply({ ephemeral: true }); const rateLimit = checkTicketCreationRateLimit(interaction.user.id); if (!rateLimit.allowed) { const mins = Math.ceil((rateLimit.retryAfterMs || 0) / 60000); return interaction.editReply(`You can only create ${CONFIG.RATE_LIMIT_TICKETS_PER_USER} ticket(s) per ${CONFIG.RATE_LIMIT_WINDOW_MINUTES} minutes. Try again in ${mins} minute(s).`); } try { const message = interaction.targetMessage; const subject = `Message from ${message.author.tag}`; const description = message.content || 'No content'; const guild = interaction.guild; const lastTicket = await Ticket.findOne().sort({ ticketNumber: -1 }).select('ticketNumber').lean(); const ticketNumber = (lastTicket?.ticketNumber || 0) + 1; let channel; let parentCategoryIdForTicket = null; let parentId; try { parentId = 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.'); } parentCategoryIdForTicket = parentId; try { channel = await guild.channels.create({ name: `ticket-${ticketNumber}`, type: ChannelType.GuildText, parent: parentId, permissionOverwrites: [ { id: guild.id, deny: [PermissionFlagsBits.ViewChannel] }, { id: message.author.id, allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory] }, { id: CONFIG.ROLE_ID_TO_PING, allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory] } ] }); } catch (err) { console.error('guild.channels.create (context menu ticket):', err); return interaction.editReply('❌ Failed to create ticket channel. Contact an administrator.'); } const gmailThreadId = `discord-msg-${Date.now()}-${message.id}`; const now = new Date(); await Ticket.create({ gmailThreadId, discordThreadId: channel.id, senderEmail: message.author.tag, subject, createdAt: now, status: 'open', ticketNumber, priority: 'normal', lastActivity: now, parentCategoryId: parentCategoryIdForTicket }); const welcomeEmbed = new EmbedBuilder() .setDescription(CONFIG.TICKET_WELCOME_MESSAGE) .setColor(CONFIG.EMBED_COLOR_INFO); const infoEmbed = new EmbedBuilder() .setColor(CONFIG.EMBED_COLOR_INFO) .addFields( { name: 'From message', value: `[Jump to message](${message.url})` }, { name: 'Creator', value: message.author.toString(), inline: true }, { name: 'Created by Staff', value: interaction.user.toString(), inline: true }, { name: 'Content', value: description.slice(0, 1000) || 'No content', inline: false } ); const row = getTicketActionRow({ escalationTier: 0 }); try { const welcomeMsg = await enqueueSend(channel, { content: `<@&${CONFIG.ROLE_ID_TO_PING}>\nHey There ${message.author} πŸ₯¦`, embeds: [welcomeEmbed, infoEmbed], components: [row] }); await Ticket.updateOne( { discordThreadId: channel.id }, { $set: { welcomeMessageId: welcomeMsg.id } } ); } catch (err) { console.error('welcomeMessageId-save', err); } await interaction.editReply(`βœ… Ticket created: ${channel}`); } catch (err) { logError('create-ticket-from-message', err, interaction).catch(() => {}); await interaction.editReply('❌ Failed to create ticket from message.'); } } // View User Tickets if (interaction.isUserContextMenuCommand() && interaction.commandName === 'View User Tickets') { await interaction.deferReply({ ephemeral: true }); try { const targetUser = interaction.targetUser; const tickets = await Ticket.find({ senderEmail: targetUser.tag }) .sort({ createdAt: -1 }) .limit(10) .lean(); if (!tickets || tickets.length === 0) { return interaction.editReply(`πŸ“‹ No tickets found for ${targetUser.tag}`); } const embed = new EmbedBuilder() .setTitle(`πŸ“‹ Tickets for ${targetUser.tag}`) .setDescription(`Found ${tickets.length} ticket(s)`) .setColor(CONFIG.EMBED_COLOR_INFO); for (const ticket of tickets.slice(0, 5)) { const priorityEmoji = getPriorityEmoji(ticket.priority || 'normal'); const statusEmoji = ticket.status === 'open' ? '🟒' : 'πŸ”΄'; embed.addFields({ name: `${priorityEmoji} Ticket #${ticket.ticketNumber} ${statusEmoji}`, value: `**Subject:** ${ticket.subject || 'No subject'}\n**Status:** ${ticket.status}\n**Claimed:** ${ticket.claimedBy || 'Unclaimed'}`, inline: false }); } if (tickets.length > 5) { embed.setFooter({ text: `Showing 5 of ${tickets.length} tickets` }); } await interaction.editReply({ embeds: [embed] }); } catch (err) { logError('view-user-tickets', err, interaction).catch(() => {}); await interaction.editReply('❌ Failed to fetch user tickets.'); } } } /** * Autocomplete handler. */ async function handleAutocomplete(interaction) { if (interaction.commandName === 'response') { const subcommand = interaction.options.getSubcommand(); if (['send', 'edit', 'delete'].includes(subcommand)) { 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 };