From 1a46fb696a68402b9fa522c5313f5edd98dc4ffe Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Tue, 21 Apr 2026 15:57:51 +0000 Subject: [PATCH] cleanup: remove strip backup files --- .claude/settings.local.json | 4 +- handlers/buttons.js.bak-20260421 | 770 --------- handlers/commands.js.bak-20260421 | 1470 ----------------- handlers/messages.js.bak-20260421 | 120 -- services/configSchema.js.bak-20260421 | 262 --- services/tickets.js.bak-20260421 | 675 -------- .../public/css/main.css.bak-20260421 | 1026 ------------ settings-site/public/index.html.bak-20260421 | 484 ------ settings-site/public/js/app.js.bak-20260421 | 162 -- .../public/js/router.js.bak-20260421 | 52 - 10 files changed, 3 insertions(+), 5022 deletions(-) delete mode 100644 handlers/buttons.js.bak-20260421 delete mode 100644 handlers/commands.js.bak-20260421 delete mode 100644 handlers/messages.js.bak-20260421 delete mode 100644 services/configSchema.js.bak-20260421 delete mode 100644 services/tickets.js.bak-20260421 delete mode 100644 settings-site/public/css/main.css.bak-20260421 delete mode 100644 settings-site/public/index.html.bak-20260421 delete mode 100644 settings-site/public/js/app.js.bak-20260421 delete mode 100644 settings-site/public/js/router.js.bak-20260421 diff --git a/.claude/settings.local.json b/.claude/settings.local.json index cfb5de1..76f3bcb 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -45,7 +45,9 @@ "Bash(curl -sI http://100.114.205.53:12752/api/notifications/alerts)", "Bash(git log *)", "Bash(curl *)", - "Bash(docker inspect *)" + "Bash(docker inspect *)", + "Bash(git -C /opt/broccolini-bot tag)", + "Bash(git -C /opt/broccolini-bot branch)" ] } } diff --git a/handlers/buttons.js.bak-20260421 b/handlers/buttons.js.bak-20260421 deleted file mode 100644 index 60d9da3..0000000 --- a/handlers/buttons.js.bak-20260421 +++ /dev/null @@ -1,770 +0,0 @@ -/** - * Button interaction handlers – claim, close, priority, tag delete, - * open-ticket panel button, and ticket_modal submission. - */ -const { - ChannelType, - ActionRowBuilder, - ButtonBuilder, - ButtonStyle, - AttachmentBuilder, - EmbedBuilder, - PermissionFlagsBits, - ModalBuilder, - TextInputBuilder, - TextInputStyle -} = require('discord.js'); -const { mongoose } = require('../db-connection'); -const { CONFIG } = require('../config'); -const { makeTicketName, resolveCreatorNickname, getOrCreateTicketCategory, cleanupEmptyOverflowCategory, createDiscordTicketAsThread, checkTicketCreationRateLimit, getSenderLocal, toDiscordSafeName } = require('../services/tickets'); -const { sendTicketClosedEmail } = require('../services/gmail'); -const { getTicketActionRow } = require('../utils/ticketComponents'); -const { sanitizeEmbedText, truncateEmbedDescription, truncateEmbedField, enforceEmbedLimit } = require('../utils'); -const { setEmailRouting } = require('../services/guildSettings'); -const { enqueueRename, enqueueSend } = require('../services/channelQueue'); -const { runEscalation, runDeescalation } = require('./commands'); -const { trackInteraction, trackError } = require('./analytics'); -const { pendingCloses } = require('./pendingCloses'); -const { increment } = require('../services/patternStore'); -const { logError, logSystem } = require('../services/debugLog'); - -const Ticket = mongoose.model('Ticket'); -const Transcript = mongoose.model('Transcript'); -const Tag = mongoose.model('Tag'); -const User = mongoose.model('User'); - -/** - * Main button/modal handler – called from interactionCreate. - */ -async function handleButton(interaction) { - // --- "Open Ticket" panel buttons → show modal --- - if (interaction.customId === 'open_ticket' || interaction.customId === 'open_ticket_thread' || interaction.customId === 'open_ticket_channel') { - const modalCustomId = interaction.customId === 'open_ticket' - ? 'ticket_modal' - : interaction.customId === 'open_ticket_thread' - ? 'ticket_modal_thread' - : 'ticket_modal_channel'; - const modal = new ModalBuilder() - .setCustomId(modalCustomId) - .setTitle('Please Enter Your Information'); - - const emailInput = new TextInputBuilder() - .setCustomId('ticket_email') - .setLabel('Account Email:') - .setStyle(TextInputStyle.Short) - .setPlaceholder('Example: broccoli@indifferentbroccoli.com') - .setRequired(true) - .setMaxLength(100); - - const gameInput = new TextInputBuilder() - .setCustomId('ticket_game') - .setLabel('What game do you need help with?') - .setStyle(TextInputStyle.Short) - .setPlaceholder('Example: Project Zomboid, Minecraft') - .setRequired(true) - .setMaxLength(100); - - const descriptionInput = new TextInputBuilder() - .setCustomId('ticket_description') - .setLabel('What do you need help with?') - .setStyle(TextInputStyle.Paragraph) - .setPlaceholder("Example: I can't connect to my server.") - .setRequired(true) - .setMaxLength(1000); - - modal.addComponents( - new ActionRowBuilder().addComponents(emailInput), - new ActionRowBuilder().addComponents(gameInput), - new ActionRowBuilder().addComponents(descriptionInput) - ); - - return await interaction.showModal(modal); - } - - // --- Email routing (no ticket required) --- - if (interaction.customId === 'email_routing_thread' || interaction.customId === 'email_routing_category') { - const value = interaction.customId === 'email_routing_thread' ? 'thread' : 'category'; - try { - await setEmailRouting(interaction.guild.id, value); - const label = value === 'thread' ? '**threads**' : '**channels in a category**'; - await interaction.reply({ - content: `Done. New email tickets will now be created as ${label}.`, - ephemeral: true - }); - } catch (err) { - trackError('email-routing-button', err, interaction); - await interaction.reply({ - content: 'Failed to update email routing.', - ephemeral: true - }).catch(() => {}); - } - return; - } - - // --- Ticket-scoped buttons (need ticket lookup) --- - const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean(); - if (!ticket) { - return interaction.reply({ - content: 'This channel is not linked to a ticket, or the ticket could not be found.', - ephemeral: true - }); - } - - // --- CLAIM / UNCLAIM --- - if (interaction.customId === 'claim_ticket') { - return handleClaim(interaction, ticket); - } - - // --- CLOSE --- - if (interaction.customId === 'close_ticket') { - const confirmRow = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId('confirm_close') - .setLabel('Confirm Close') - .setStyle(ButtonStyle.Danger), - new ButtonBuilder() - .setCustomId('cancel_close') - .setLabel('Cancel') - .setStyle(ButtonStyle.Secondary) - ); - - return interaction.reply({ - content: 'Are you sure you want to close this ticket?', - components: [confirmRow] - }); - } - - if (interaction.customId === 'confirm_close') { - const timerSeconds = CONFIG.FORCE_CLOSE_TIMER; - if (pendingCloses.has(interaction.channel.id)) { - return interaction.reply({ content: 'A close is already pending for this ticket.', ephemeral: true }); - } - const cancelRow = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId('cancel_close') - .setLabel('Cancel Close') - .setStyle(ButtonStyle.Secondary) - ); - await interaction.update({ content: `Closing ticket in ${timerSeconds} seconds.`, components: [cancelRow] }); - const timerId = setTimeout(async () => { - pendingCloses.delete(interaction.channel.id); - const freshTicket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean(); - if (!freshTicket || freshTicket.status === 'closed') return; - const { logTicketEvent } = require('../services/debugLog'); - logTicketEvent('Force-close timer fired', [ - { name: 'Ticket', value: interaction.channel.name || interaction.channel.id }, - { name: 'Set by', value: interaction.user.tag }, - { name: 'Duration', value: `${timerSeconds}s` } - ]).catch(() => {}); - await handleConfirmClose(interaction, freshTicket); - }, timerSeconds * 1000); - pendingCloses.set(interaction.channel.id, { timeout: timerId, userId: interaction.user.id, username: interaction.user.tag }); - return; - } - - if (interaction.customId === 'cancel_close') { - const pending = pendingCloses.get(interaction.channel.id); - if (pending) { - clearTimeout(pending.timeout); - pendingCloses.delete(interaction.channel.id); - } - return interaction.update({ content: 'Close cancelled.', components: [] }); - } - - // --- ESCALATE (prompt for tier 2 or 3) --- - if (interaction.customId === 'escalate_ticket') { - 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 }); - } - const escalateButtons = []; - if (currentTier < 1) { - escalateButtons.push( - new ButtonBuilder() - .setCustomId('escalate_to_tier2') - .setLabel('To Tier 2') - .setStyle(ButtonStyle.Secondary) - ); - } - if (currentTier < 2) { - escalateButtons.push( - new ButtonBuilder() - .setCustomId('escalate_to_tier3') - .setLabel('To Tier 3') - .setStyle(ButtonStyle.Secondary) - ); - } - const choiceRow = new ActionRowBuilder().addComponents(escalateButtons); - return interaction.reply({ - content: 'Escalate to which tier?', - components: [choiceRow], - ephemeral: true - }); - } - - if (interaction.customId === 'escalate_to_tier2') { - const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0); - if (currentTier >= 1) { - return interaction.reply({ content: 'This ticket is already at tier 2.', ephemeral: true }); - } - const categoryId = ticket.gmailThreadId.startsWith('discord-') - ? CONFIG.DISCORD_ESCALATED2_CHANNEL_ID - : CONFIG.EMAIL_ESCALATED2_CHANNEL_ID; - if (!categoryId && !interaction.channel.isThread()) { - return interaction.reply({ content: 'Tier 2 (ESCALATED2) is not configured for this ticket type.', ephemeral: true }); - } - try { - await interaction.deferReply(); - await runEscalation(interaction, ticket, 1, null); - } catch (err) { - trackError('escalate-button-tier2', err, interaction); - await interaction.editReply({ content: 'Failed to escalate to tier 2.' }).catch(() => - interaction.followUp({ content: 'Failed to escalate to tier 2.', ephemeral: true }).catch(() => {}) - ); - } - return; - } - - if (interaction.customId === 'escalate_to_tier3') { - const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0); - if (currentTier >= 2) { - return interaction.reply({ content: 'This ticket is already at tier 3.', ephemeral: true }); - } - const categoryId = ticket.gmailThreadId.startsWith('discord-') - ? CONFIG.DISCORD_ESCALATED3_CHANNEL_ID - : CONFIG.EMAIL_ESCALATED3_CHANNEL_ID; - if (!categoryId && !interaction.channel.isThread()) { - return interaction.reply({ content: 'Tier 3 (ESCALATED3) is not configured for this ticket type.', ephemeral: true }); - } - try { - await interaction.deferReply(); - await runEscalation(interaction, ticket, 2, null); - } catch (err) { - trackError('escalate-button-tier3', err, interaction); - await interaction.editReply({ content: 'Failed to escalate to tier 3.' }).catch(() => - interaction.followUp({ content: 'Failed to escalate to tier 3.', ephemeral: true }).catch(() => {}) - ); - } - return; - } - - // --- DEESCALATE --- - if (interaction.customId === 'deescalate_ticket') { - 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) { - trackError('deescalate-button', err, interaction); - await interaction.editReply({ content: 'Failed to deescalate this ticket.' }).catch(() => - interaction.followUp({ content: 'Failed to deescalate this ticket.', ephemeral: true }).catch(() => {}) - ); - } - return; - } - - // --- TAG DELETE CONFIRM --- - if (interaction.customId.startsWith('confirm_delete_tag::')) { - trackInteraction('buttons', 'confirm-delete-tag', interaction.user.tag); - const tagName = interaction.customId.slice('confirm_delete_tag::'.length); - - try { - const result = await Tag.deleteOne({ name: tagName }); - - if (result.deletedCount === 0) { - await interaction.update({ - content: `❌ Tag "${tagName}" not found.`, - components: [] - }); - } else { - await interaction.update({ - content: `✅ Tag "${tagName}" deleted successfully.`, - components: [] - }); - } - } catch (err) { - trackError('tag-delete-confirm', err, interaction); - await interaction.update({ - content: '❌ Failed to delete tag.', - components: [] - }); - } - } - - if (interaction.customId === 'cancel_delete_tag') { - return interaction.update({ content: 'Tag deletion cancelled.', components: [] }); - } - - // Priority is set via /priority slash command only; no priority buttons in tickets. -} - -// --- CLAIM LOGIC --- -async function handleClaim(interaction, ticket) { - const freshTicket = await Ticket.findOne({ gmailThreadId: ticket.gmailThreadId }).lean(); - if (!freshTicket) { - return interaction.reply({ content: 'Ticket data missing.', ephemeral: true }); - } - - const isClaimed = !!freshTicket.claimedBy; - const claimerLabel = - interaction.member?.displayName || interaction.user.username; - const guild = interaction.guild; - const isClaimedByMe = freshTicket.claimedBy === claimerLabel; - - const [row0] = interaction.message.components; - if (!row0) { - return interaction.reply({ content: 'No components to update.', ephemeral: true }); - } - - const row = ActionRowBuilder.from(row0); - const [btnClose, btnClaim] = row.components; - - if (!btnClose || !btnClaim) { - return interaction.reply({ content: 'Buttons missing.', ephemeral: true }); - } - - if (isClaimed && !isClaimedByMe && !CONFIG.ALLOW_CLAIM_OVERWRITE) { - const { logSecurity } = require('../services/debugLog'); - logSecurity('Unauthorized button attempt', interaction.user, interaction.customId).catch(() => {}); - return interaction.reply({ - content: `This ticket is already claimed by **${freshTicket.claimedBy}**.`, - ephemeral: true - }); - } - - if (!isClaimed || (isClaimed && !isClaimedByMe && CONFIG.ALLOW_CLAIM_OVERWRITE)) { - await Ticket.updateOne( - { gmailThreadId: freshTicket.gmailThreadId }, - { $set: { claimedBy: claimerLabel, claimerId: interaction.user.id } } - ); - freshTicket.claimedBy = claimerLabel; - freshTicket.claimerId = interaction.user.id; - increment('staff_claims', interaction.user.id, 'today'); - increment('staff_claims', interaction.user.id, 'week'); - - // Resolve claimerEmoji from STAFF_EMOJIS map (fallback to CLAIMER_EMOJI_FALLBACK) - const claimerEmoji = CONFIG.STAFF_EMOJIS.get(interaction.user.id) || CONFIG.CLAIMER_EMOJI_FALLBACK; - const creatorNickname = await resolveCreatorNickname(guild, freshTicket); - - const state = freshTicket.escalated ? 'escalated-claimed' : 'claimed'; - const newName = makeTicketName(state, freshTicket, creatorNickname, claimerEmoji); - enqueueRename(interaction.channel, newName).catch(err => logError('rename', err).catch(() => {})); - - const label = `Unclaim (${claimerLabel})`; - - btnClose - .setCustomId('close_ticket') - .setLabel(CONFIG.BUTTON_LABEL_CLOSE) - .setEmoji(CONFIG.BUTTON_EMOJI_CLOSE) - .setStyle(ButtonStyle.Secondary) - .setDisabled(false); - - btnClaim - .setCustomId('claim_ticket') - .setEmoji(CONFIG.BUTTON_EMOJI_UNCLAIM) - .setStyle(ButtonStyle.Secondary) - .setDisabled(false) - .setLabel(label); - - await interaction.update({ components: [row] }); - const claimText = CONFIG.TICKET_CLAIMED_MESSAGE - .replace(/\{staff_mention\}/g, interaction.user.toString()) - .replace(/\{staff_name\}/g, interaction.member?.displayName || interaction.user.username); - const claimEmbed = new EmbedBuilder() - .setTitle('✅ Ticket Claimed') - .setDescription(claimText) - .setColor(CONFIG.EMBED_COLOR_CLAIMED) - .setFooter({ text: `Claimed by ${claimerLabel}` }); - await interaction.followUp({ embeds: [claimEmbed] }); - const { addMemberToStaffThread } = require('../services/staffThread'); - await addMemberToStaffThread(interaction.channel, interaction.user.id).catch(() => {}); - } else { - // Unclaim - await Ticket.updateOne( - { gmailThreadId: freshTicket.gmailThreadId }, - { $set: { claimedBy: null, claimerId: null, staffChannelId: null } } - ); - freshTicket.claimedBy = null; - freshTicket.claimerId = null; - freshTicket.staffChannelId = null; - - const creatorNicknameUnclaim = await resolveCreatorNickname(guild, freshTicket); - const unclaimState = (freshTicket.escalationTier ?? 0) >= 1 ? 'escalated' : 'unclaimed'; - enqueueRename(interaction.channel, makeTicketName(unclaimState, freshTicket, creatorNicknameUnclaim)).catch(err => logError('rename', err).catch(() => {})); - - btnClose - .setCustomId('close_ticket') - .setLabel(CONFIG.BUTTON_LABEL_CLOSE) - .setEmoji(CONFIG.BUTTON_EMOJI_CLOSE) - .setStyle(ButtonStyle.Secondary) - .setDisabled(false); - - btnClaim - .setCustomId('claim_ticket') - .setEmoji(CONFIG.BUTTON_EMOJI_CLAIM) - .setStyle(ButtonStyle.Secondary) - .setDisabled(false) - .setLabel(CONFIG.BUTTON_LABEL_CLAIM); - - await interaction.update({ components: [row] }); - const unclaimText = CONFIG.TICKET_UNCLAIMED_MESSAGE - .replace(/\{staff_mention\}/g, interaction.user.toString()) - .replace(/\{staff_name\}/g, interaction.member?.displayName || interaction.user.username); - const unclaimEmbed = new EmbedBuilder() - .setTitle('🔓 Ticket Unclaimed') - .setDescription(unclaimText) - .setColor(0x808080) - .setFooter({ text: `Unclaimed by ${claimerLabel}` }); - await interaction.followUp({ embeds: [unclaimEmbed] }); - } -} - -// --- CONFIRM CLOSE --- -async function handleConfirmClose(interaction, ticket) { - const closedAt = new Date(); - increment('staff_closes', interaction.user.id, 'today'); - if (!ticket.ticketTag) { - increment('untagged_closes', 'total', 'today'); - } - try { - await interaction.update({ content: 'Archiving and closing...', components: [] }); - } catch { - // Already acknowledged – fall back to editReply - await interaction.editReply({ content: 'Archiving and closing...', components: [] }).catch(() => {}); - } - try { - const messages = await interaction.channel.messages.fetch({ limit: 100 }); - const log = - `TRANSCRIPT: ${ticket.subject}\nUser: ${ticket.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-${interaction.channel.name}.txt` - }); - - const channelName = interaction.channel.name; - const opened = new Date(ticket.createdAt); - const openedStr = opened.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' - }); - - // In-ticket message before transcript is posted (Discord close message) - const discordCloseContent = CONFIG.DISCORD_CLOSE_MESSAGE; - await enqueueSend(interaction.channel, discordCloseContent); - - const transcriptChan = await interaction.client.channels - .fetch(CONFIG.TRANSCRIPT_CHAN) - .catch(() => null); - let transcriptMsg = null; - - const transcriptContent = CONFIG.DISCORD_TRANSCRIPT_MESSAGE - .replace(/\{channel_name\}/g, channelName) - .replace(/\{email\}/g, ticket.senderEmail || '') - .replace(/\{date_opened\}/g, openedStr) - .replace(/\{date_closed\}/g, closedStr) - + `\n\nDate Opened: ${openedStr}\nDate Closed: ${closedStr}`; - - if (transcriptChan) { - transcriptMsg = await enqueueSend(transcriptChan, { - content: transcriptContent, - files: [file] - }); - } - - // DM the transcript to the ticket creator (Discord-originated tickets). - // Gated because many users have DMs from server members disabled — the send - // then 50007s and generates noise. Default off; enable via env when desired. - if (CONFIG.TRANSCRIPT_DM_TO_CREATOR && ticket.gmailThreadId?.startsWith('discord-')) { - const creatorId = ticket.gmailThreadId.split('-').pop(); - try { - const creator = await interaction.client.users.fetch(creatorId); - const dmFile = new AttachmentBuilder(Buffer.from(log), { - name: `transcript-${channelName}.txt` - }); - const dmContent = CONFIG.DISCORD_TRANSCRIPT_MESSAGE - .replace(/\{channel_name\}/g, channelName) - .replace(/\{email\}/g, ticket.senderEmail || '') - .replace(/\{date_opened\}/g, openedStr) - .replace(/\{date_closed\}/g, closedStr); - await creator.send({ - content: dmContent, - files: [dmFile] - }); - } catch (dmErr) { - // 50007 = "Cannot send messages to this user" — user has DMs off. Expected class; debug-level only. - if (dmErr?.code === 50007) { - logSystem('Transcript DM skipped (recipient has DMs disabled)', [ - { name: 'User', value: creatorId }, - { name: 'Channel', value: channelName } - ]).catch(() => {}); - } else { - logError('transcript-dm', dmErr).catch(() => {}); - } - } - } - - const logChan = await interaction.client.channels - .fetch(CONFIG.LOG_CHAN) - .catch(() => null); - if (logChan) { - const closerMention = interaction.user.toString(); - const closerDisplayName = interaction.member?.displayName || interaction.user.username; - - let logMsg; - if (ticket.gmailThreadId?.startsWith('discord-')) { - const creatorId = ticket.gmailThreadId.split('-').pop(); - try { - const creator = await interaction.client.users.fetch(creatorId); - const creatorMention = creator.toString(); - logMsg = `Closed ${creatorMention}'s **${channelName}** by ${closerMention} (${closerDisplayName})`; - } catch { - logMsg = `Closed **${channelName}** by ${closerMention} (${closerDisplayName})`; - } - } else { - logMsg = `Closed **${channelName}** (${ticket.senderEmail}) by ${closerMention} (${closerDisplayName})`; - } - await enqueueSend(logChan, logMsg); - } - - const closerDisplayName = - interaction.member?.displayName || interaction.user.username; - - if (!ticket.gmailThreadId?.startsWith('discord-')) { - await sendTicketClosedEmail(ticket, closerDisplayName); - } - await Ticket.updateOne( - { gmailThreadId: ticket.gmailThreadId }, - { $set: { discordThreadId: null, status: 'closed' } } - ); - - try { - const { deleteStaffChannel } = require('../services/staffChannel'); - await deleteStaffChannel(interaction.guild, ticket.staffChannelId); - } catch (e) { - console.error('Delete staff channel (close):', e); - } - - if (transcriptMsg?.id) { - await Transcript.create({ - gmailThreadId: ticket.gmailThreadId, - transcriptMessageId: transcriptMsg.id, - createdAt: new Date() - }); - } - - const parentCatId = ticket.parentCategoryId; - const guildRef = interaction.guild; - - setTimeout( - () => interaction.channel.delete().catch(() => {}), - 5000 - ); - setTimeout(() => { - (async () => { - if (parentCatId && guildRef) { - await cleanupEmptyOverflowCategory(guildRef, parentCatId, CONFIG.TICKET_CATEGORY_NAME); - } - })(); - }, 6000); - } catch (e) { - console.error('Close ticket error:', e); - } -} - -/** - * Handle the ticket_modal submission (from the open-ticket panel button). - */ -async function handleTicketModal(interaction) { - await interaction.deferReply({ ephemeral: true }); - - const email = interaction.fields.getTextInputValue('ticket_email').trim().toLowerCase(); - const game = interaction.fields.getTextInputValue('ticket_game').trim(); - const description = interaction.fields.getTextInputValue('ticket_description'); - const subject = game ? `[${game}] ${description.slice(0, 60)}` : description.slice(0, 80); - const priority = CONFIG.PRIORITY_ENABLED ? CONFIG.DEFAULT_PRIORITY : 'normal'; - - const useThread = - interaction.customId === 'ticket_modal_thread' || - (interaction.customId === 'ticket_modal' && !!CONFIG.DISCORD_THREAD_CHANNEL_ID); - - 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 guild = interaction.guild; - const lastTicket = await Ticket.findOne().sort({ ticketNumber: -1 }).select('ticketNumber').lean(); - const ticketNumber = (lastTicket?.ticketNumber || 0) + 1; - - const creatorNicknameModal = interaction.member?.displayName || interaction.user.username; - const unclaimedName = toDiscordSafeName(`unclaimed-${creatorNicknameModal}-${ticketNumber}`); - - let channel; - let parentCategoryIdForTicket = null; - if (useThread && CONFIG.DISCORD_THREAD_CHANNEL_ID) { - try { - channel = await createDiscordTicketAsThread(guild, ticketNumber, interaction.user.id); - parentCategoryIdForTicket = channel.parent?.parentId ?? null; - } catch (err) { - console.error('Discord ticket thread create failed:', err.message); - return interaction.editReply('Could not create ticket thread. Check DISCORD_THREAD_CHANNEL_ID and try again.'); - } - } else if (useThread && !CONFIG.DISCORD_THREAD_CHANNEL_ID) { - return interaction.editReply('Thread tickets are not configured (DISCORD_THREAD_CHANNEL_ID is not set). Use a channel panel or set the env variable.'); - } else { - let parentId; - try { - parentId = await getOrCreateTicketCategory( - guild, - CONFIG.DISCORD_TICKET_CATEGORY_ID, - CONFIG.TICKET_CATEGORY_NAME - ); - } catch (err) { - console.error('getOrCreateTicketCategory (ticket modal):', err); - return interaction.editReply('Discord ticket category could not be resolved. Contact an administrator.'); - } - parentCategoryIdForTicket = parentId; - try { - // TODO(queue-migrate): initial permissionOverwrites here are fine since the channel is just being created, but any later permissionOverwrites mutation on this channel should go through channelQueue. - channel = await guild.channels.create({ - name: unclaimedName, - type: ChannelType.GuildText, - parent: parentId, - permissionOverwrites: [ - { id: guild.id, deny: [PermissionFlagsBits.ViewChannel] }, - { - id: interaction.user.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 (ticket modal):', err); - return interaction.editReply('Failed to create ticket channel. Contact an administrator.'); - } - } - - const gmailThreadId = `discord-${Date.now()}-${interaction.user.id}`; - const now = new Date(); - await Ticket.create({ - gmailThreadId, - discordThreadId: channel.id, - senderEmail: email, - subject, - game: game || null, - createdAt: now, - status: 'open', - ticketNumber, - priority, - lastActivity: now, - parentCategoryId: parentCategoryIdForTicket - }); - - const displayName = interaction.member?.displayName || interaction.user.username; - - const descTrimmed = description.length > 500 ? description.slice(0, 497) + '…' : description; - - const welcomeEmbed = new EmbedBuilder() - .setTitle("We got your ticket.") - .setDescription("We'll be with you as soon as possible.") - .setColor(5763719) - .setThumbnail("https://indifferentbroccoli.com/img/broccoli_shadow_square.png"); - - const infoEmbed = new EmbedBuilder() - .setColor(5763719) - .setDescription(truncateEmbedDescription( - `**Account Email:**\n\`\`\`\n${sanitizeEmbedText(email)}\n\`\`\`\n` + - `**Game:**\n\`\`\`\n${sanitizeEmbedText(game) || "Not specified"}\n\`\`\`\n` + - `**What do you need help with?**\n\`\`\`\n${sanitizeEmbedText(descTrimmed)}\n\`\`\`` - )); - - const resourcesEmbed = new EmbedBuilder() - .setTitle("We're ~~happy~~ indifferent to help. :indifferentbroccoli:") - .setDescription("Please feel free to add any additional information to the ticket, including recent changes to the server, if any.") - .setColor(5763719) - .addFields( - { name: "Check out our wiki for guides:", value: "[Indifferent Broccolipedia](https://wiki.indifferentbroccoli.com)", inline: false } - ) - .setFooter({ text: "indifferent broccoli tickets (:|)", iconURL: "https://i.ibb.co/sJdytfFM/Untitled-design-6.png" }); - - const actionRow = getTicketActionRow({ escalationTier: 0 }); - - enforceEmbedLimit([welcomeEmbed, infoEmbed, resourcesEmbed]); - let welcomeMsg; - try { - welcomeMsg = await enqueueSend(channel, { - content: `Hey There ${interaction.user} 🥦`, - embeds: [welcomeEmbed, infoEmbed, resourcesEmbed], - components: [actionRow] - }); - - await Ticket.updateOne( - { discordThreadId: channel.id }, - { $set: { welcomeMessageId: welcomeMsg.id } } - ); - } catch (err) { - console.error('welcomeMessageId-save', err); - } - - const { createStaffThread } = require('../services/staffThread'); - await createStaffThread(channel, interaction.client).catch(() => {}); - - if (CONFIG.PIN_INITIAL_MESSAGE_ENABLED && welcomeMsg) { - const { pinMessage } = require('../services/pinMessage'); - await pinMessage(welcomeMsg, interaction.client).catch(() => {}); - } - - increment('user_tickets', interaction.user.id, 'today'); - increment('user_tickets', interaction.user.id, 'week'); - if (game) { - increment('game_tickets', game, 'today'); - increment('game_tickets', game, 'week'); - } - - await interaction.deleteReply().catch(() => {}); - - const logChan = await interaction.client.channels.fetch(CONFIG.LOG_CHAN).catch(() => null); - if (logChan) { - await enqueueSend(logChan, - `📝 ${channel.name} created by ${interaction.user.tag}` - ); - } - } catch (err) { - console.error('Ticket creation error:', err); - await interaction.editReply('Failed to create ticket. Please contact an administrator.'); - } -} - -module.exports = { handleButton, handleTicketModal }; diff --git a/handlers/commands.js.bak-20260421 b/handlers/commands.js.bak-20260421 deleted file mode 100644 index b8dd912..0000000 --- a/handlers/commands.js.bak-20260421 +++ /dev/null @@ -1,1470 +0,0 @@ -/** - * Slash command, context menu, and autocomplete handlers. - */ -const { - ChannelType, - ActionRowBuilder, - ButtonBuilder, - ButtonStyle, - AttachmentBuilder, - EmbedBuilder, - PermissionFlagsBits -} = require('discord.js'); -const { mongoose } = require('../db-connection'); -const { CONFIG, TICKET_TAGS } = require('../config'); -const { getPriorityEmoji, getPriorityColor, replaceVariables, escapeRegex } = require('../utils'); -const { makeTicketName, resolveCreatorNickname, getSenderLocal, toDiscordSafeName, getOrCreateTicketCategory, createDiscordTicketAsThread, checkTicketCreationRateLimit } = require('../services/tickets'); -const { sendTicketNotificationEmail } = require('../services/gmail'); -const { getTicketActionRow } = require('../utils/ticketComponents'); -const { getEmailRouting } = require('../services/guildSettings'); -const { enqueueRename, enqueueMove, enqueueSend } = require('../services/channelQueue'); -const { setNotifyDm } = require('../services/staffSettings'); -const { trackInteraction, trackError, getAnalyticsSummary } = require('./analytics'); -const { logTicketEvent, logSecurity, logError } = require('../services/debugLog'); -const { handleAccountInfoCommand } = require('./accountinfo'); -const { handleSetupCommand } = require('./setup'); -const { pendingCloses } = require('./pendingCloses'); -const { increment } = require('../services/patternStore'); - -const Ticket = mongoose.model('Ticket'); -const Tag = mongoose.model('Tag'); -const User = mongoose.model('User'); -const StaffNotification = mongoose.model('StaffNotification'); - -/** - * 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 - }); - logSecurity('Unauthorized command attempt', interaction.user, interaction.commandName).catch(() => {}); - 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, unclaimedRemindersSent: [] } } - ); - ticket.escalated = true; - ticket.escalationTier = nextTier; - ticket.claimedBy = null; - increment('escalations', ticket.game || 'unknown', 'today'); - increment('escalations', ticket.game || 'unknown', 'week'); - increment('user_escalations', ticket.senderEmail, 'week'); - increment('staff_escalations', interaction.user.id, 'today'); - increment('staff_escalations', interaction.user.id, 'week'); - if (ticket.game) increment(`staff_game_escalations:${interaction.user.id}`, ticket.game, 'week'); - - 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 emailBody = CONFIG.ESCALATION_MESSAGE.replace(/\\n/g, '\n').replace(/\{support_name\}/g, CONFIG.SUPPORT_NAME); - await sendTicketNotificationEmail( - ticket, - `Ticket escalated to ${nextTier === 1 ? 'tier 2' : 'tier 3'}`, - emailBody, - interaction.member?.displayName || interaction.user.username - ); - } 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; - - // /setup - if (interaction.commandName === 'setup') { - return handleSetupCommand(interaction); - } - - // /email-routing – switch where new email tickets are created (thread vs category) - if (interaction.commandName === 'email-routing') { - await interaction.deferReply({ ephemeral: true }); - try { - const current = await getEmailRouting(interaction.guild.id); - const row = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId('email_routing_thread') - .setLabel('Threads') - .setStyle(ButtonStyle.Primary) - .setEmoji('🧵'), - new ButtonBuilder() - .setCustomId('email_routing_category') - .setLabel('Category channels') - .setStyle(ButtonStyle.Primary) - .setEmoji('📁') - ); - await interaction.editReply({ - content: `Email ticket routing: **${current}**. Choose where new email tickets should be created:`, - components: [row] - }); - } catch (err) { - trackError('email-routing-command', err, interaction); - await interaction.editReply('Failed to load routing options.').catch(() => {}); - } - return; - } - - // /escalate (tier 2 or 3 via level; works for both email and Discord) - if (interaction.commandName === 'escalate') { - const reason = null; - const level = interaction.options.getString('level'); - const nextTier = level === '3' ? 2 : 1; - const action = interaction.options.getString('action'); - - 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); - if (action === 'unclaim') { - await Ticket.updateOne( - { gmailThreadId: ticket.gmailThreadId }, - { $set: { claimedBy: null, claimerId: null } } - ); - } - } 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(() => {}) - ); - } - } - - // /notification set | /notification add - if (interaction.commandName === 'notification') { - const sub = interaction.options.getSubcommand(); - if (sub === 'set') { - const hours = interaction.options.getInteger('hours'); - try { - await StaffNotification.findOneAndUpdate( - { userId: interaction.user.id }, - { $set: { cooldownHours: hours, updatedAt: new Date() } }, - { upsert: true } - ); - return interaction.reply({ content: `Notification cooldown set to ${hours} hour(s).`, ephemeral: true }); - } catch (err) { - console.error('notification set error:', err); - return interaction.reply({ content: 'Failed to update notification setting.', ephemeral: true }).catch(() => {}); - } - } - if (sub === 'add') { - if (!CONFIG.STAFF_NOTIFICATION_CATEGORY_ID) { - return interaction.reply({ content: 'STAFF_NOTIFICATION_CATEGORY_ID is not configured.', ephemeral: true }); - } - const member = interaction.options.getMember('member'); - if (!member) { - return interaction.reply({ content: 'Could not resolve that member.', ephemeral: true }); - } - const displayName = member.displayName; - const emoji = CONFIG.STAFF_EMOJIS.get(member.id) || ''; - const chanName = toDiscordSafeName(`${displayName}${emoji}`); - try { - const newChannel = await interaction.guild.channels.create({ - name: chanName, - type: ChannelType.GuildText, - parent: CONFIG.STAFF_NOTIFICATION_CATEGORY_ID, - permissionOverwrites: [ - { id: interaction.guild.id, deny: [PermissionFlagsBits.ViewChannel] }, - { id: member.id, allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory] }, - ...(CONFIG.ROLE_ID_TO_PING ? [{ id: CONFIG.ROLE_ID_TO_PING, allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory] }] : []) - ] - }); - await StaffNotification.findOneAndUpdate( - { userId: member.id }, - { $set: { channelId: newChannel.id, guildId: interaction.guild.id, updatedAt: new Date() } }, - { upsert: true } - ); - return interaction.reply({ content: `Notification channel created: ${newChannel}`, ephemeral: true }); - } catch (err) { - console.error('notification add error:', err); - return interaction.reply({ content: 'Failed to create notification channel.', ephemeral: true }).catch(() => {}); - } - } - return; - } - - // /staffnotification (admin only) - if (interaction.commandName === 'staffnotification') { - if (interaction.user.id !== CONFIG.ADMIN_ID) { - logSecurity('Unauthorized command attempt', interaction.user, interaction.commandName).catch(() => {}); - return interaction.reply({ content: 'This command is restricted to the bot admin.', ephemeral: true }); - } - const member = interaction.options.getMember('member'); - const hours = interaction.options.getInteger('hours'); - if (!member) { - return interaction.reply({ content: 'Could not resolve that member.', ephemeral: true }); - } - try { - await StaffNotification.findOneAndUpdate( - { userId: member.id }, - { $set: { cooldownHours: hours, updatedAt: new Date() } }, - { upsert: true } - ); - return interaction.reply({ content: `Notification cooldown for ${member.displayName} set to ${hours} hour(s).`, ephemeral: true }); - } catch (err) { - console.error('staffnotification error:', err); - return interaction.reply({ content: 'Failed to update notification setting.', 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 }); - } - } - - // /tag – ticket category dropdown only - if (interaction.commandName === 'tag') { - trackInteraction('commands', 'tag', interaction.user.tag); - const categoryValue = interaction.options.getString('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 { - await Ticket.updateOne( - { gmailThreadId: ticket.gmailThreadId }, - { $set: { ticketTag: categoryValue } } - ); - const tagEntry = (TICKET_TAGS || []).find(t => t.value === categoryValue); - const emoji = tagEntry ? tagEntry.emoji : ''; - const channelMessage = `Your ticket has been categorized as ${emoji} **${tagEntry ? tagEntry.name : categoryValue}** ${emoji}.`; - await interaction.reply(channelMessage); - increment('tag_usage', categoryValue, 'today'); - increment('tag_usage', categoryValue, 'week'); - if (ticket.game) increment(`tag_game:${categoryValue}`, ticket.game, 'week'); - } catch (err) { - trackError('tag-command', err, interaction); - await interaction.reply({ content: 'Failed to set ticket category.', ephemeral: true }); - } - } - - // /response – saved response tags (send, create, edit, delete, list) - if (interaction.commandName === 'response') { - trackInteraction('commands', 'response', interaction.user.tag); - 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 { - trackError('tag-create', err, interaction); - 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) { - trackError('tag-edit', err, interaction); - 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) { - trackError('response-command', err, interaction); - 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; - } - - // /accountinfo - if (interaction.commandName === 'accountinfo') { - await handleAccountInfoCommand(interaction); - 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\n`/priority ` - Set ticket priority\n`/accountinfo email` - Look up website account by email\n`/accountinfo discord @user` - Look up website account by Discord user' - }, - { - name: 'Tags & Responses', - value: '`/tag` - Set ticket category (dropdown)\n`/response send ` - Send saved response\n`/response create|edit|delete|list` - Manage saved responses' - }, - { - name: 'Variables (for tags)', - 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 }); - } - - // /priority - if (interaction.commandName === 'priority') { - const level = interaction.options.getString('level'); - - 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 priorityOrder = ['low', 'normal', 'medium', 'high']; - const oldIdx = priorityOrder.indexOf((ticket.priority || 'normal').toLowerCase()); - const newIdx = priorityOrder.indexOf(level.toLowerCase()); - const emoji = getPriorityEmoji(level); - const levelLabel = level.charAt(0).toUpperCase() + level.slice(1).toLowerCase(); - - let channelMessage; - if (level === 'normal') { - channelMessage = 'Your ticket priority has returned to Normal.'; - } else if (newIdx > oldIdx) { - channelMessage = `Your ticket has been upgraded to ${emoji} **${levelLabel}** ${emoji}.`; - } else if (newIdx < oldIdx) { - channelMessage = `Your ticket has been downgraded to ${emoji} **${levelLabel}** ${emoji}.`; - } else { - channelMessage = `Priority set to ${emoji} **${levelLabel}** ${emoji}.`; - } - - try { - await Ticket.updateOne( - { gmailThreadId: ticket.gmailThreadId }, - { $set: { priority: level } } - ); - - const priorityTitle = - newIdx === oldIdx - ? 'Priority Set' - : `Priority ${newIdx > oldIdx ? 'Upgraded' : 'Downgraded'} → ${levelLabel}`; - const priorityEmbed = new EmbedBuilder() - .setTitle(priorityTitle) - .setDescription(channelMessage) - .setColor(getPriorityColor(level)) - .setFooter({ text: interaction.member?.displayName || interaction.user.username }); - await interaction.reply({ embeds: [priorityEmbed] }); - - if (level === 'high' && ticket.gmailThreadId && !ticket.gmailThreadId.startsWith('discord-')) { - await sendTicketNotificationEmail( - ticket, - `Priority updated: ${levelLabel}`, - channelMessage, - interaction.member?.displayName || interaction.user.username - ); - } - } catch (err) { - console.error('Priority update error:', err); - await interaction.reply({ content: 'Failed to update priority.', 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 }); - } - } - - // /backup – export full ticket list to BACKUP_EXPORT_CHANNEL_ID - if (interaction.commandName === 'backup') { - trackInteraction('commands', 'backup', interaction.user.tag); - await interaction.deferReply({ ephemeral: true }); - if (!CONFIG.BACKUP_EXPORT_CHANNEL_ID) { - return interaction.editReply('BACKUP_EXPORT_CHANNEL_ID is not set in .env.'); - } - try { - // Stream every ticket through a Mongoose cursor to a tmp file so peak RSS - // stays bounded regardless of collection size; attach the file, then unlink. - const fs = require('fs'); - const os = require('os'); - const path = require('path'); - const tmpName = `ticket-backup-${Date.now()}-${process.pid}.txt`; - const tmpPath = path.join(os.tmpdir(), tmpName); - const ws = fs.createWriteStream(tmpPath, { encoding: 'utf8' }); - - ws.write('# Ticket backup – ' + new Date().toISOString() + '\n'); - ws.write('ticketNumber\tstatus\tsenderEmail\tsubject\tcreatedAt\tclaimedBy\tpriority\tescalationTier\n'); - - let count = 0; - const cursor = Ticket.find().sort({ ticketNumber: 1 }).lean().cursor(); - for await (const t of cursor) { - const created = t.createdAt ? new Date(t.createdAt).toISOString() : ''; - ws.write([ - t.ticketNumber, - t.status || '', - (t.senderEmail || '').replace(/\t/g, ' '), - (t.subject || '').replace(/\t/g, ' ').slice(0, 200), - created, - (t.claimedBy || '').replace(/\t/g, ' '), - t.priority || '', - t.escalationTier ?? '' - ].join('\t') + '\n'); - count++; - } - await new Promise((resolve, reject) => ws.end(err => err ? reject(err) : resolve())); - - try { - const channel = await interaction.client.channels.fetch(CONFIG.BACKUP_EXPORT_CHANNEL_ID); - await enqueueSend(channel, { - content: `Ticket backup by ${interaction.user.tag} (${count} tickets)`, - files: [new AttachmentBuilder(tmpPath, { name: tmpName })] - }); - await interaction.editReply(`Backup complete. ${count} tickets sent to the backup channel.`); - } finally { - fs.promises.unlink(tmpPath).catch(() => {}); - } - } catch (err) { - trackError('backup-command', err, interaction); - await interaction.editReply('Failed to create backup: ' + (err.message || err)); - } - } - - // /export – export tickets with optional status and limit to BACKUP_EXPORT_CHANNEL_ID - if (interaction.commandName === 'export') { - trackInteraction('commands', 'export', interaction.user.tag); - await interaction.deferReply({ ephemeral: true }); - if (!CONFIG.BACKUP_EXPORT_CHANNEL_ID) { - return interaction.editReply('BACKUP_EXPORT_CHANNEL_ID is not set in .env.'); - } - try { - const status = interaction.options.getString('status') || null; - const limit = interaction.options.getInteger('limit') || 500; - const filter = status ? { status } : {}; - const tickets = await Ticket.find(filter).sort({ ticketNumber: -1 }).limit(limit).lean(); - const lines = ['# Ticket export – ' + new Date().toISOString() + (status ? ` (status=${status})` : '') + ` limit=${limit}`, 'ticketNumber\tstatus\tsenderEmail\tsubject\tcreatedAt\tclaimedBy\tpriority\tescalationTier']; - for (const t of tickets) { - const created = t.createdAt ? new Date(t.createdAt).toISOString() : ''; - lines.push([t.ticketNumber, t.status || '', (t.senderEmail || '').replace(/\t/g, ' '), (t.subject || '').replace(/\t/g, ' ').slice(0, 200), created, (t.claimedBy || '').replace(/\t/g, ' '), t.priority || '', t.escalationTier ?? ''].join('\t')); - } - const buf = Buffer.from(lines.join('\n'), 'utf8'); - const channel = await interaction.client.channels.fetch(CONFIG.BACKUP_EXPORT_CHANNEL_ID); - await enqueueSend(channel, { - content: `Ticket export by ${interaction.user.tag} (${tickets.length} tickets${status ? ` status=${status}` : ''})`, - files: [new AttachmentBuilder(buf, { name: `ticket-export-${Date.now()}.txt` })] - }); - await interaction.editReply(`Export complete. ${tickets.length} tickets sent to the backup channel.`); - } catch (err) { - trackError('export-command', err, interaction); - await interaction.editReply('Failed to export: ' + (err.message || err)); - } - } - - // /search - if (interaction.commandName === 'search') { - trackInteraction('commands', 'search', interaction.user.tag); - await interaction.deferReply({ ephemeral: true }); - - try { - const query = interaction.options.getString('query'); - const status = interaction.options.getString('status') || 'all'; - - const regex = new RegExp(escapeRegex(query), 'i'); - const filter = { - $or: [ - { senderEmail: regex }, - { subject: regex } - ] - }; - const ticketNum = parseInt(query, 10); - if (!Number.isNaN(ticketNum) && String(ticketNum) === query.trim()) { - filter.$or.push({ ticketNumber: ticketNum }); - } - if (status !== 'all') filter.status = status; - - const results = await Ticket.find(filter).sort({ createdAt: -1 }).limit(10).lean(); - - if (!results || results.length === 0) { - return interaction.editReply('🔍 No tickets found matching your query.'); - } - - const embed = new EmbedBuilder() - .setTitle(`🔍 Search Results for "${query}"`) - .setDescription(`Found ${results.length} ticket(s)`) - .setColor(CONFIG.EMBED_COLOR_INFO); - - for (const ticket of results.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**From:** ${ticket.senderEmail}\n**Status:** ${ticket.status}\n**Claimed:** ${ticket.claimedBy || 'Unclaimed'}`, - inline: false - }); - } - - if (results.length > 5) { - embed.setFooter({ text: `Showing 5 of ${results.length} results` }); - } - - await interaction.editReply({ embeds: [embed] }); - } catch (err) { - trackError('search-command', err, interaction); - await interaction.editReply('❌ An error occurred while searching.'); - } - } - - // /fix-stale-tickets - if (interaction.commandName === 'fix-stale-tickets') { - if (interaction.user.id !== CONFIG.ADMIN_ID) { - return interaction.reply({ content: 'You do not have permission to run this command.', ephemeral: true }); - } - await interaction.deferReply({ ephemeral: true }); - try { - const result = await Ticket.updateMany( - { status: 'open', lastActivity: null }, - [{ $set: { lastActivity: '$createdAt' } }] - ); - await interaction.editReply(`Fixed ${result.modifiedCount} ticket(s).`); - } catch (err) { - console.error('fix-stale-tickets:', err); - await interaction.editReply('❌ Failed to backfill tickets.').catch(() => {}); - } - } - - // /stats - if (interaction.commandName === 'stats') { - trackInteraction('commands', 'stats', interaction.user.tag); - await interaction.deferReply({ ephemeral: true }); - - try { - const summary = getAnalyticsSummary(); - - const ticketStats = await Ticket.aggregate([ - { $group: { _id: '$status', count: { $sum: 1 } } } - ]); - - const openCount = ticketStats.find(s => s._id === 'open')?.count || 0; - const closedCount = ticketStats.find(s => s._id === 'closed')?.count || 0; - const claimedCount = await Ticket.countDocuments({ status: 'open', claimedBy: { $ne: null } }); - - const embed = new EmbedBuilder() - .setTitle('📊 Bot Statistics & Analytics') - .setColor(CONFIG.EMBED_COLOR_INFO) - .addFields([ - { name: '⏱️ Uptime', value: summary.uptime, inline: true }, - { name: '💬 Total Interactions', value: summary.totalInteractions.toString(), inline: true }, - { name: '📈 Commands Used', value: summary.commandsUsed.toString(), inline: true }, - { name: '🎫 Open Tickets', value: openCount.toString(), inline: true }, - { name: '✅ Closed Tickets', value: closedCount.toString(), inline: true }, - { name: '📌 Claimed Tickets', value: (claimedCount || 0).toString(), inline: true }, - { name: '🔥 Most Used Command', value: summary.mostUsedCommand, inline: true }, - { name: '❌ Errors (Last Hour)', value: summary.errorsLastHour.toString(), inline: true }, - { name: '📉 Error Rate', value: summary.errorRate, inline: true }, - { name: '📋 Top Commands', value: summary.topCommands.join('\n') || 'None', inline: false } - ]) - .setTimestamp(); - - await interaction.editReply({ embeds: [embed] }); - } catch (err) { - trackError('stats-command', err, interaction); - await interaction.editReply('❌ An error occurred while fetching statistics.'); - } - } -} - -/** - * 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') { - trackInteraction('contextMenus', 'create-ticket-from-message', interaction.user.tag); - 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; - if (CONFIG.DISCORD_THREAD_CHANNEL_ID) { - try { - channel = await createDiscordTicketAsThread(guild, ticketNumber, message.author.id); - parentCategoryIdForTicket = channel.parent?.parentId ?? null; - } catch (err) { - console.error('Discord ticket thread create (from message) failed:', err.message); - return interaction.editReply('❌ Could not create ticket thread. Check DISCORD_THREAD_CHANNEL_ID.'); - } - } else { - 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) { - trackError('create-ticket-from-message', err, interaction); - await interaction.editReply('❌ Failed to create ticket from message.'); - } - } - - // View User Tickets - if (interaction.isUserContextMenuCommand() && interaction.commandName === 'View User Tickets') { - trackInteraction('contextMenus', 'view-user-tickets', interaction.user.tag); - 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) { - trackError('view-user-tickets', err, interaction); - 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 }; \ No newline at end of file diff --git a/handlers/messages.js.bak-20260421 b/handlers/messages.js.bak-20260421 deleted file mode 100644 index 1f9635a..0000000 --- a/handlers/messages.js.bak-20260421 +++ /dev/null @@ -1,120 +0,0 @@ -/** - * Discord messageCreate handler – forwards staff replies to Gmail. - */ -const { mongoose } = require('../db-connection'); -const { CONFIG } = require('../config'); -const { extractRawEmail } = require('../utils'); -const { getGmailClient, sendGmailReply } = require('../services/gmail'); -const { updateTicketActivity } = require('../services/tickets'); -const { getNotifyDm } = require('../services/staffSettings'); -const { pingStaffChannel } = require('../services/staffChannel'); -const { notifyStaffOfReply } = require('../services/staffNotifications'); - -const Ticket = mongoose.model('Ticket'); - -/** - * Handle a Discord message in a ticket channel → relay to Gmail (email tickets only). - */ -async function handleDiscordReply(m) { - if (m.author.bot || m.interaction) return; - - const ticket = await Ticket.findOne({ discordThreadId: m.channel.id }).lean(); - if (!ticket) return; - - if (ticket.claimerId && m.author.id !== ticket.claimerId && ticket.staffChannelId) { - try { - const staffChan = await m.guild.channels.fetch(ticket.staffChannelId).catch(() => null); - if (staffChan) { - await pingStaffChannel(staffChan, ticket.claimerId, m); - } - const dmEnabled = await getNotifyDm(ticket.claimerId); - if (dmEnabled) { - const staffMember = await m.guild.members.fetch(ticket.claimerId).catch(() => null); - if (staffMember) { - const jumpLink = `https://discord.com/channels/${m.guild.id}/${m.channel.id}/${m.id}`; - await staffMember - .send( - `New customer reply in **${m.channel.name}**:\n> ${m.content.slice(0, 300)}\n[Jump to message](${jumpLink})` - ) - .catch(() => {}); - } - } - } catch (e) { - console.error('Staff ping error:', e); - } - } - - // Track whether last message is from staff or customer - const memberForCheck = await m.guild.members.fetch(m.author.id).catch(() => null); - const isStaffMember = memberForCheck && CONFIG.ROLE_ID_TO_PING && memberForCheck.roles.cache.has(CONFIG.ROLE_ID_TO_PING); - Ticket.updateOne( - { discordThreadId: m.channel.id }, - { $set: { lastMessageAuthorIsStaff: !!isStaffMember, lastActivity: new Date() } } - ).catch(() => {}); - - // Notify claiming staff if a non-staff user replied (works for both Discord and email tickets) - if (ticket.claimerId && !isStaffMember) { - const guild = m.guild; - const freshTicket = await Ticket.findOne({ discordThreadId: m.channel.id }).lean(); - if (freshTicket) { - await notifyStaffOfReply(guild, freshTicket, m).catch(e => console.error('notifyStaffOfReply:', e)); - } - } - - const discordUser = m.member?.displayName || m.author.username; - - if (ticket.gmailThreadId.startsWith('discord-')) { - return; - } - - // Email tickets: send reply via Gmail. - try { - const gmail = getGmailClient(); - const thread = await gmail.users.threads.get({ - userId: 'me', - id: ticket.gmailThreadId - }); - - const last = [...thread.data.messages].reverse().find(msg => { - const from = - msg.payload.headers.find(h => h.name === 'From')?.value || ''; - return !from.toLowerCase().includes(CONFIG.MY_EMAIL); - }); - - if (!last) return; - - let recipient = - last.payload.headers.find(h => h.name === 'From')?.value || ''; - const replyTo = - last.payload.headers.find(h => h.name === 'Reply-To')?.value; - if (replyTo) recipient = replyTo; - - const subject = - last.payload.headers.find(h => h.name === 'Subject')?.value || - 'Support'; - const msgId = - last.payload.headers.find(h => h.name === 'Message-ID')?.value; - - const recipientEmail = extractRawEmail(recipient).toLowerCase(); - if (!recipientEmail || recipientEmail === CONFIG.MY_EMAIL) { - console.warn('Bad recipient for reply:', recipientEmail); - return; - } - - await sendGmailReply( - ticket.gmailThreadId, - m.content, - recipientEmail, - subject, - discordUser, - msgId, - m.author.id - ); - - await updateTicketActivity(ticket.gmailThreadId); - } catch (e) { - console.error('REPLY ERROR:', e); - } -} - -module.exports = { handleDiscordReply }; diff --git a/services/configSchema.js.bak-20260421 b/services/configSchema.js.bak-20260421 deleted file mode 100644 index 64a718f..0000000 --- a/services/configSchema.js.bak-20260421 +++ /dev/null @@ -1,262 +0,0 @@ -/** - * Per-key config value validator registry. - * - * Pattern-driven type inference for every key in ALLOWED_CONFIG_KEYS. - * getValidator(key) returns { type, validate(value) }, where validate returns - * { ok: true, coerced } — typed value to assign into CONFIG[key] - * { ok: false, error } — human-readable reason surfaced in the save UI - * - * .env always stores String(coerced); CONFIG gets the typed coerced value so - * downstream consumers that compare === true / === 5 still work. - * - * This file is the canonical source for ALLOWED_CONFIG_KEYS — routes/internalApi - * imports the Set from here. That keeps the require graph acyclic: - * internalApi -> configPersistence -> configSchema - * internalApi -> configSchema - * No side effects beyond a one-line startup log of the fallback-string keys. - */ -'use strict'; - -const ALLOWED_CONFIG_KEYS = new Set([ - // Ticket settings - 'TICKET_CATEGORY_ID', 'TICKET_CATEGORY_NAME', 'TICKET_T2_CATEGORY_NAME', 'TICKET_T3_CATEGORY_NAME', - 'EMAIL_TICKET_OVERFLOW_CATEGORY_IDS', 'DISCORD_TICKET_CATEGORY_ID', 'DISCORD_TICKET_OVERFLOW_CATEGORY_IDS', - 'DISCORD_THREAD_CHANNEL_ID', 'EMAIL_THREAD_CHANNEL_ID', 'THREAD_PARENT_CHANNEL', 'USE_THREADS', - // Escalation categories - 'EMAIL_ESCALATED2_CHANNEL_ID', 'DISCORD_ESCALATED2_CHANNEL_ID', - 'EMAIL_ESCALATED3_CHANNEL_ID', 'DISCORD_ESCALATED3_CHANNEL_ID', - // Roles and staff - 'ROLE_ID_TO_PING', 'ROLE_TO_PING_ID', 'ADDITIONAL_STAFF_ROLES', 'BLACKLISTED_ROLES', - 'STAFF_IDS', 'ADMIN_ID', 'STAFF_EMOJIS', 'CLAIMER_EMOJI_FALLBACK', - // Channel IDs - 'TRANSCRIPT_CHANNEL_ID', 'LOGGING_CHANNEL_ID', 'DEBUGGING_CHANNEL_ID', - 'BACKUP_EXPORT_CHANNEL_ID', 'ACCOUNT_INFO_CHANNEL_ID', 'DISCORD_CHANNEL_ID', - 'GMAIL_LOG_CHANNEL_ID', 'AUTOMATION_LOG_CHANNEL_ID', 'RENAME_LOG_CHANNEL_ID', - 'SECURITY_LOG_CHANNEL_ID', 'SYSTEM_LOG_CHANNEL_ID', - 'ALL_STAFF_CHANNEL_ID', 'ALL_STAFF_CHAT_ALERT_CHANNEL_ID', - 'STAFF_NOTIFICATION_CATEGORY_ID', - // Pattern channel IDs - 'USER_PATTERNS_CHANNEL_ID', 'GAME_PATTERNS_CHANNEL_ID', 'TAG_PATTERNS_CHANNEL_ID', - 'ESCALATION_PATTERNS_CHANNEL_ID', 'STAFF_PATTERNS_CHANNEL_ID', 'COMBINED_PATTERNS_CHANNEL_ID', - // Messages and labels - 'ESCALATION_MESSAGE', 'TICKET_CLOSE_SUBJECT_PREFIX', 'TICKET_CLOSE_MESSAGE', 'TICKET_CLOSE_SIGNATURE', - 'DISCORD_CLOSE_MESSAGE', 'DISCORD_TRANSCRIPT_MESSAGE', 'DISCORD_AUTO_CLOSE_MESSAGE', - 'AUTO_CLOSE_MESSAGE', 'TICKET_WELCOME_MESSAGE', 'TICKET_CLAIMED_MESSAGE', 'TICKET_UNCLAIMED_MESSAGE', - 'REMINDER_MESSAGE', 'BUTTON_LABEL_CLOSE', 'BUTTON_LABEL_CLAIM', 'BUTTON_LABEL_UNCLAIM', - 'BUTTON_EMOJI_CLOSE', 'BUTTON_EMOJI_CLAIM', 'BUTTON_EMOJI_UNCLAIM', - // Branding - 'LOGO_URL', 'SUPPORT_NAME', 'EMAIL_SIGNATURE', 'GAME_LIST', - // Toggles - 'AUTO_CLOSE_ENABLED', 'AUTO_CLOSE_AFTER_HOURS', 'AUTO_UNCLAIM_ENABLED', 'AUTO_UNCLAIM_AFTER_HOURS', - 'CLAIM_TIMEOUT_ENABLED', 'CLAIM_TIMEOUT_HOURS', 'ALLOW_CLAIM_OVERWRITE', - 'REMINDER_ENABLED', 'REMINDER_AFTER_HOURS', 'PRIORITY_ENABLED', 'DEFAULT_PRIORITY', - 'STAFF_THREAD_ENABLED', 'STAFF_THREAD_NAME', 'STAFF_THREAD_AUTO_ADD_ROLE', 'STAFF_THREAD_ROLE_ID', - 'PIN_INITIAL_MESSAGE_ENABLED', 'PIN_ESCALATION_MESSAGE_ENABLED', 'PIN_SUPPRESS_SYSTEM_MESSAGE', - 'STAFF_DND_COUNTS_AS_AVAILABLE', - // Limits and thresholds - 'GLOBAL_TICKET_LIMIT', 'TICKET_LIMIT_PER_CATEGORY', - 'RATE_LIMIT_TICKETS_PER_USER', 'RATE_LIMIT_WINDOW_MINUTES', - 'FORCE_CLOSE_TIMER_SECONDS', 'GMAIL_POLL_INTERVAL_SECONDS', - // Embed colors - 'EMBED_COLOR_OPEN', 'EMBED_COLOR_CLOSED', 'EMBED_COLOR_CLAIMED', 'EMBED_COLOR_ESCALATED', 'EMBED_COLOR_INFO', - 'PRIORITY_HIGH_EMOJI', 'PRIORITY_MEDIUM_EMOJI', 'PRIORITY_LOW_EMOJI', - // Pattern thresholds - 'PATTERN_USER_TICKET_THRESHOLD', 'PATTERN_GAME_TICKET_THRESHOLD', - 'PATTERN_STAFF_STALE_PING_THRESHOLD', 'PATTERN_ESCALATION_THRESHOLD', - 'PATTERN_RAPID_CLOSE_SECONDS', 'PATTERN_UNCLAIMED_HOURS', 'PATTERN_CHECK_INTERVAL_MINUTES', - // Surge settings - 'SURGE_ROLE_ID', 'SURGE_TICKET_COUNT', 'SURGE_TICKET_WINDOW_MINUTES', - 'SURGE_GAME_TICKET_COUNT', 'SURGE_GAME_TICKET_WINDOW_MINUTES', - 'SURGE_STALE_COUNT', 'SURGE_STALE_HOURS', - 'SURGE_NEEDS_RESPONSE_COUNT', 'SURGE_NEEDS_RESPONSE_HOURS', - 'SURGE_UNCLAIMED_COUNT', 'SURGE_UNCLAIMED_MINUTES', 'SURGE_TIER3_UNCLAIMED_MINUTES', - 'SURGE_COOLDOWN_MINUTES', 'SURGE_NO_STAFF_COOLDOWN_MINUTES', 'SURGE_NO_STAFF_OPEN_TICKET_THRESHOLD', - // Chat alerts - 'CHAT_ALERT_CHANNEL_IDS', 'CHAT_ALERT_MESSAGE_COUNT', - 'CHAT_ALERT_HOURS_WITHOUT_RESPONSE', 'CHAT_ALERT_COOLDOWN_MINUTES', - // Notification thresholds - 'NOTIFICATION_THRESHOLDS_JSON', 'UNCLAIMED_REMINDER_THRESHOLDS', - // Notification enable state (Phase 9) - 'NOTIFICATION_ENABLED_JSON', 'NOTIFICATIONS_MASTER_ENABLED' -]); - -// ---------- Regex primitives ---------- - -const SNOWFLAKE_RE = /^[0-9]{17,20}$/; -const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; -const HEX_COLOR_RE = /^(?:0x|#)?([0-9A-Fa-f]{6})$/; -const INT_RE = /^-?\d+$/; -const NUMERIC_COERCE_RE = /^-?\d+(?:\.\d+)?$/; - -function isEmptyInput(v) { - return v === '' || v === null || v === undefined; -} - -// ---------- Validators ---------- - -const VALIDATORS = { - boolean: { - type: 'boolean', - validate(value) { - if (value === true || value === 'true') return { ok: true, coerced: true }; - if (value === false || value === 'false') return { ok: true, coerced: false }; - return { ok: false, error: 'must be true or false' }; - } - }, - integer: { - type: 'integer', - validate(value) { - if (isEmptyInput(value)) return { ok: true, coerced: '' }; - const str = String(value).trim(); - if (!INT_RE.test(str)) return { ok: false, error: 'must be a whole number' }; - const n = parseInt(str, 10); - if (!Number.isFinite(n) || n < 0) return { ok: false, error: 'must be zero or a positive integer' }; - return { ok: true, coerced: n }; - } - }, - hex_color: { - type: 'hex_color', - validate(value) { - if (isEmptyInput(value)) return { ok: true, coerced: '' }; - const str = String(value).trim(); - const m = str.match(HEX_COLOR_RE); - if (!m) return { ok: false, error: 'must be a 6-digit hex color like 0xRRGGBB or #RRGGBB' }; - return { ok: true, coerced: '0x' + m[1].toUpperCase() }; - } - }, - url: { - type: 'url', - validate(value) { - if (isEmptyInput(value)) return { ok: true, coerced: '' }; - const str = String(value).trim(); - try { - new URL(str); - return { ok: true, coerced: str }; - } catch (_) { - return { ok: false, error: 'must be a valid URL (include the protocol)' }; - } - } - }, - email: { - type: 'email', - validate(value) { - if (isEmptyInput(value)) return { ok: true, coerced: '' }; - const str = String(value).trim(); - if (!EMAIL_RE.test(str)) return { ok: false, error: 'must look like a valid email address' }; - return { ok: true, coerced: str }; - } - }, - discord_id: { - type: 'discord_id', - validate(value) { - if (isEmptyInput(value)) return { ok: true, coerced: '' }; - const str = String(value).trim(); - if (!SNOWFLAKE_RE.test(str)) return { ok: false, error: 'must be a Discord ID (17–20 digits) or empty' }; - return { ok: true, coerced: str }; - } - }, - discord_id_list: { - type: 'discord_id_list', - validate(value) { - if (isEmptyInput(value)) return { ok: true, coerced: '' }; - const str = String(value).trim(); - if (str === '') return { ok: true, coerced: '' }; - const parts = str.split(',').map(p => p.trim()).filter(Boolean); - for (const p of parts) { - if (!SNOWFLAKE_RE.test(p)) return { ok: false, error: `"${p}" is not a Discord ID` }; - } - return { ok: true, coerced: parts.join(',') }; - } - }, - json: { - type: 'json', - validate(value) { - if (isEmptyInput(value)) return { ok: true, coerced: '' }; - const str = String(value); - try { - JSON.parse(str); - return { ok: true, coerced: str }; - } catch (_) { - return { ok: false, error: 'must be valid JSON' }; - } - } - }, - string_or_json: { - type: 'string_or_json', - validate(value) { - if (value === null || value === undefined) return { ok: false, error: 'cannot be null' }; - return { ok: true, coerced: String(value) }; - } - }, - // Fallback. Preserves legacy coercion so CONFIG.* values keep their types - // for consumers that compare with === true / === 5 (see old applyConfigUpdates). - string: { - type: 'string', - validate(value) { - if (value === null || value === undefined) return { ok: false, error: 'cannot be null' }; - if (value === 'true' || value === true) return { ok: true, coerced: true }; - if (value === 'false' || value === false) return { ok: true, coerced: false }; - const str = String(value); - if (str !== '' && NUMERIC_COERCE_RE.test(str)) return { ok: true, coerced: Number(str) }; - return { ok: true, coerced: str }; - } - } -}; - -// ---------- Type inference ---------- - -function inferType(key) { - // 1. Explicit overrides - if (key === 'NOTIFICATION_THRESHOLDS_JSON') return 'json'; - if (key === 'NOTIFICATION_ENABLED_JSON') return 'json'; - if (key === 'NOTIFICATIONS_MASTER_ENABLED') return 'boolean'; - if (key === 'LOGO_URL') return 'url'; - if (/_EMAIL$/.test(key)) return 'email'; - if (key.includes('COLOR')) return 'hex_color'; - if (/_EMOJIS$/.test(key)) return 'string_or_json'; - // ROLE_ID_TO_PING has _ID mid-key — standard _ID$ pattern misses it. - if (key === 'ROLE_ID_TO_PING') return 'discord_id'; - - // 2. Name patterns - if (/ENABLED$|^USE_|_ON$/.test(key)) return 'boolean'; - if (/_IDS$/.test(key)) return 'discord_id_list'; - if (/_ID$/.test(key)) return 'discord_id'; - if (/_HOURS$|_MINUTES$|_SECONDS$|_COUNT$|_LIMIT$|_THRESHOLD$/.test(key)) return 'integer'; - - // 3. Fallback - return 'string'; -} - -function getValidator(key) { - return VALIDATORS[inferType(key)]; -} - -// Pre-build per-key validator map for callers that want O(1) lookup -// (and for the smoke test / boot log). -const ALL_VALIDATORS = {}; -for (const key of ALLOWED_CONFIG_KEYS) { - ALL_VALIDATORS[key] = getValidator(key); -} - -// ---------- Startup log (no-op if console.log is suppressed) ---------- - -(function logDistribution() { - const dist = {}; - const fallback = []; - for (const [key, v] of Object.entries(ALL_VALIDATORS)) { - dist[v.type] = (dist[v.type] || 0) + 1; - if (v.type === 'string') fallback.push(key); - } - console.log('[configSchema] type distribution:', JSON.stringify(dist)); - if (fallback.length) { - console.log(`[configSchema] ${fallback.length} keys use fallback 'string' validator:`, fallback.join(', ')); - } -})(); - -module.exports = { - ALLOWED_CONFIG_KEYS, - VALIDATORS, - ALL_VALIDATORS, - getValidator, - inferType -}; diff --git a/services/tickets.js.bak-20260421 b/services/tickets.js.bak-20260421 deleted file mode 100644 index 20ee603..0000000 --- a/services/tickets.js.bak-20260421 +++ /dev/null @@ -1,675 +0,0 @@ -/** - * Ticket database helpers – counters, rename, limits, auto-close, - * reminders, auto-unclaim, channel creation. - */ -const { ChannelType, PermissionFlagsBits } = require('discord.js'); -const { mongoose, withRetry } = require('../db-connection'); -const { CONFIG } = require('../config'); -const { getPriorityEmoji } = require('../utils'); -const { logAutomation } = require('../services/debugLog'); -const { enqueueSend, enqueueDelete } = require('./channelQueue'); - -const Ticket = mongoose.model('Ticket'); -const TicketCounter = mongoose.model('TicketCounter'); - -// --- TICKET NUMBER --- - -async function getNextTicketNumber(senderEmail) { - const senderLocal = senderEmail.split('@')[0].toLowerCase(); - const counter = await TicketCounter.findOneAndUpdate( - { senderLocal }, - { $inc: { counter: 1 } }, - { upsert: true, new: true, setDefaultsOnInsert: true } - ); - return { local: senderLocal, number: counter.counter }; -} - -// --- RENAME + NAMING --- -// Renames flow through utils/renamer.js (RENAMER_BOT secondary token), -// which has its own Discord rate-limit bucket. We no longer gate on the -// primary bot's 2/10min per-channel budget here; 429s from the secondary -// bot surface via utils/renamer.js instead. - -const RENAME_WINDOW_MS = 10 * 60 * 1000; // 10 minutes (unused; kept for back-compat) -const RENAME_LIMIT = 2; - -function getSenderLocal(senderEmail) { - return (senderEmail || 'unknown').split('@')[0].toLowerCase(); -} - -function toDiscordSafeName(str) { - return str - .toLowerCase() - .replace(/\s+/g, '-') - .replace(/[^\p{L}\p{N}\p{Emoji_Presentation}-]/gu, '') - .replace(/-{2,}/g, '-') - .replace(/^-+|-+$/g, '') - .slice(0, 100); -} - -/** - * Resolve a human-friendly creator nickname for channel naming. - * Discord tickets: guild member displayName. Email tickets: senderLocal. - * @param {import('discord.js').Guild} guild - * @param {object} ticket - * @returns {Promise} - */ -async function resolveCreatorNickname(guild, ticket) { - if (ticket.gmailThreadId.startsWith('discord-')) { - const creatorUserId = ticket.gmailThreadId.split('-').pop(); - try { - const member = await guild.members.fetch(creatorUserId); - return member.displayName; - } catch { - return getSenderLocal(ticket.senderEmail); - } - } - return getSenderLocal(ticket.senderEmail); -} - -/** - * Build a channel name from ticket state. - * @param {'unclaimed'|'claimed'|'escalated'|'escalated-claimed'} state - * @param {object} ticket - * @param {string} creatorNickname - pre-resolved via resolveCreatorNickname - * @param {string} [claimerEmoji] - required for claimed / escalated-claimed - * @returns {string} - */ -function makeTicketName(state, ticket, creatorNickname, claimerEmoji) { - const num = ticket.ticketNumber || 1; - switch (state) { - case 'claimed': - return toDiscordSafeName(`${claimerEmoji}-${creatorNickname}-${num}`); - case 'escalated': - return toDiscordSafeName(`escalated-${creatorNickname}-${num}`); - case 'escalated-claimed': - return toDiscordSafeName(`e-${claimerEmoji}-${creatorNickname}-${num}`); - case 'unclaimed': - default: - return toDiscordSafeName(`unclaimed-${creatorNickname}-${num}`); - } -} - -// Retained for external callers (bOSScord, scripts). The gate now lives in -// the secondary bot's rate bucket; this helper no longer touches Mongo. -async function canRename(_ticket) { - return { ok: true, remaining: RENAME_LIMIT, waitMs: 0 }; -} - -function minutesFromMs(ms) { - return Math.max(1, Math.ceil(ms / 60000)); -} - -// --- RATE LIMIT (per-user ticket creation) --- - -const ticketCreationByUser = new Map(); // userId -> { count, resetAt } - -const TICKET_CREATION_SWEEP_TTL_MS = 48 * 60 * 60 * 1000; -const TICKET_CREATION_SWEEP_INTERVAL_MS = 6 * 60 * 60 * 1000; - -function sweepTicketCreationByUser(now = Date.now()) { - // An entry is stale when its window has been expired long enough that no - // legitimate rate-limit decision would still consult it. resetAt is a future - // ms timestamp when the window ends; cutoff is 48h past that. - const cutoff = now - TICKET_CREATION_SWEEP_TTL_MS; - for (const [key, entry] of ticketCreationByUser.entries()) { - if ((entry?.resetAt ?? 0) < cutoff) ticketCreationByUser.delete(key); - } -} - -function startTicketsSweeps(trackInterval) { - const handle = setInterval(() => sweepTicketCreationByUser(), TICKET_CREATION_SWEEP_INTERVAL_MS); - if (typeof handle.unref === 'function') handle.unref(); - if (typeof trackInterval === 'function') trackInterval(handle); - return handle; -} - -/** - * Check if the user can create a ticket (rate limit). If allowed, consumes one slot. - * @param {string} userId - Discord user ID - * @returns {{ allowed: boolean, retryAfterMs?: number }} - */ -function checkTicketCreationRateLimit(userId) { - const limit = CONFIG.RATE_LIMIT_TICKETS_PER_USER; - const windowMs = (CONFIG.RATE_LIMIT_WINDOW_MINUTES || 60) * 60 * 1000; - if (!limit || limit <= 0) return { allowed: true }; - - const now = Date.now(); - let entry = ticketCreationByUser.get(userId); - if (!entry || now >= entry.resetAt) { - entry = { count: 1, resetAt: now + windowMs }; - ticketCreationByUser.set(userId, entry); - return { allowed: true }; - } - if (entry.count >= limit) { - return { allowed: false, retryAfterMs: entry.resetAt - now }; - } - entry.count++; - return { allowed: true }; -} - -// --- CHANNEL CREATION (overflow: Discord limit 50 channels per category) --- - -const CHANNELS_PER_CATEGORY_LIMIT = 50; - -function escapeCategoryNameForRegex(name) { - return String(name).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -/** - * @deprecated Use getOrCreateTicketCategory instead. - * @returns {null} - */ -function pickTicketCategoryId(guild, categoryIds) { - console.warn('[tickets] pickTicketCategoryId is deprecated; use getOrCreateTicketCategory() instead'); - return null; -} - -function countChannelsInCategory(guild, categoryId) { - return guild.channels.cache.filter(c => c.parentId === categoryId).size; -} - -/** - * Resolve or create a ticket category with dynamic overflow (Discord max 50 channels per category). - * @param {import('discord.js').Guild} guild - * @param {string} primaryCategoryId - * @param {string} categoryName Display base name (primary category should match; overflows are "(Overflow N)") - * @returns {Promise} - */ -async function getOrCreateTicketCategory(guild, primaryCategoryId, categoryName) { - if (!guild) { - throw new Error('getOrCreateTicketCategory: guild is required'); - } - if (!primaryCategoryId || !String(primaryCategoryId).trim()) { - throw new Error('getOrCreateTicketCategory: primaryCategoryId is required'); - } - try { - let primary = guild.channels.cache.get(primaryCategoryId); - if (!primary) { - primary = await guild.channels.fetch(primaryCategoryId).catch(() => null); - } - if (!primary || primary.type !== ChannelType.GuildCategory) { - throw new Error(`getOrCreateTicketCategory: primary category ${primaryCategoryId} not found or not a category`); - } - - const escaped = escapeCategoryNameForRegex(categoryName); - const overflowRe = new RegExp(`^${escaped} \\(Overflow (\\d+)\\)$`); - - const overflowMatches = []; - for (const ch of guild.channels.cache.values()) { - if (!ch || ch.type !== ChannelType.GuildCategory) continue; - if (ch.id === primaryCategoryId) continue; - const m = ch.name.match(overflowRe); - if (m) overflowMatches.push({ ch, n: parseInt(m[1], 10) }); - } - overflowMatches.sort((a, b) => a.n - b.n); - - const existingCategories = [primary, ...overflowMatches.map(x => x.ch)]; - - for (const cat of existingCategories) { - if (countChannelsInCategory(guild, cat.id) < CHANNELS_PER_CATEGORY_LIMIT) { - return cat.id; - } - } - - const highestN = overflowMatches.length > 0 ? Math.max(...overflowMatches.map(x => x.n)) : 0; - const nextN = highestN + 1; - const newName = `${categoryName} (Overflow ${nextN})`; - const lastCat = existingCategories[existingCategories.length - 1]; - const position = (lastCat?.rawPosition ?? lastCat?.position ?? 0) + 1; - - let newCat; - try { - newCat = await guild.channels.create({ - name: newName, - type: ChannelType.GuildCategory, - position - }); - } catch (createErr) { - console.error('getOrCreateTicketCategory: failed to create overflow category:', createErr); - throw createErr; - } - return newCat.id; - } catch (err) { - console.error('getOrCreateTicketCategory:', err); - const fallback = guild.channels.cache.get(primaryCategoryId); - if (fallback?.type === ChannelType.GuildCategory) { - return primaryCategoryId; - } - throw err; - } -} - -/** - * Delete an overflow category if it is empty and its name matches "${categoryName} (Overflow N)". - * Never deletes the primary category (exact name match). - * @param {import('discord.js').Guild} guild - * @param {string} categoryId - * @param {string} categoryName - */ -async function cleanupEmptyOverflowCategory(guild, categoryId, categoryName) { - try { - if (!guild || !categoryId) return; - const cached = guild.channels.cache.filter(c => c.parentId === categoryId); - if (cached.size !== 0) return; - - let cat = guild.channels.cache.get(categoryId); - if (!cat) { - cat = await guild.channels.fetch(categoryId).catch(() => null); - } - if (!cat || cat.type !== ChannelType.GuildCategory) return; - if (cat.name === categoryName) return; - - const escaped = escapeCategoryNameForRegex(categoryName); - const overflowRe = new RegExp(`^${escaped} \\(Overflow \\d+\\)$`); - if (!overflowRe.test(cat.name)) return; - - await cat.delete().catch(deleteErr => { - console.error('cleanupEmptyOverflowCategory: delete failed:', deleteErr); - }); - } catch (err) { - console.error('cleanupEmptyOverflowCategory:', err); - } -} - -async function createTicketChannel(guild, ticketNumber, userId, subject, creatorNickname) { - if (CONFIG.USE_THREADS && CONFIG.THREAD_PARENT_CHANNEL) { - const parentChannel = guild.channels.cache.get(CONFIG.THREAD_PARENT_CHANNEL); - if (!parentChannel) { - throw new Error('Thread parent channel not found'); - } - - const thread = await parentChannel.threads.create({ - name: `🎫・ticket-${ticketNumber}`, - autoArchiveDuration: 1440, - type: ChannelType.PrivateThread, - invitable: false, - reason: `Ticket #${ticketNumber}` - }); - - await thread.members.add(userId); - // Add all members with the support role so they can see and reply in the thread - if (CONFIG.ROLE_ID_TO_PING) { - const role = guild.roles.cache.get(CONFIG.ROLE_ID_TO_PING); - if (role?.members?.size) { - for (const [memberId] of role.members) { - if (memberId === userId) continue; // already added - await thread.members.add(memberId).catch(() => {}); - } - } - } - return thread; - } else { - let parentId; - try { - parentId = await getOrCreateTicketCategory(guild, CONFIG.TICKET_CATEGORY_ID, CONFIG.TICKET_CATEGORY_NAME); - } catch (e) { - console.error('getOrCreateTicketCategory (createTicketChannel):', e); - throw new Error('Ticket category not found or could not be allocated'); - } - - let channel; - try { - channel = await guild.channels.create({ - name: creatorNickname ? toDiscordSafeName(`unclaimed-${creatorNickname}-${ticketNumber}`) : `ticket-${ticketNumber}`, - type: ChannelType.GuildText, - parent: parentId, - permissionOverwrites: [ - { - id: guild.id, - deny: [PermissionFlagsBits.ViewChannel] - }, - { - id: userId, - allow: [ - PermissionFlagsBits.ViewChannel, - PermissionFlagsBits.SendMessages, - PermissionFlagsBits.ReadMessageHistory - ] - }, - { - id: CONFIG.ROLE_ID_TO_PING, - allow: [ - PermissionFlagsBits.ViewChannel, - PermissionFlagsBits.SendMessages, - PermissionFlagsBits.ReadMessageHistory - ] - } - ] - }); - } catch (e) { - console.error('guild.channels.create (createTicketChannel):', e); - throw e; - } - - return channel; - } -} - -/** - * Create a private Discord ticket thread under DISCORD_THREAD_CHANNEL_ID. - * Adds creator and all members with ROLE_ID_TO_PING. - * @param {import('discord.js').Guild} guild - * @param {number} ticketNumber - * @param {string} creatorUserId - * @returns {Promise} - */ -async function createDiscordTicketAsThread(guild, ticketNumber, creatorUserId) { - const parentId = CONFIG.DISCORD_THREAD_CHANNEL_ID; - if (!parentId) throw new Error('DISCORD_THREAD_CHANNEL_ID is not set'); - const parentChannel = guild.channels.cache.get(parentId); - if (!parentChannel) throw new Error('Discord thread parent channel not found'); - - const thread = await parentChannel.threads.create({ - name: `🎫・ticket-${ticketNumber}`, - autoArchiveDuration: 1440, - type: ChannelType.PrivateThread, - invitable: false, - reason: `Ticket #${ticketNumber}` - }); - - await thread.members.add(creatorUserId); - if (CONFIG.ROLE_ID_TO_PING) { - const role = guild.roles.cache.get(CONFIG.ROLE_ID_TO_PING); - if (role?.members?.size) { - for (const [memberId] of role.members) { - if (memberId === creatorUserId) continue; - await thread.members.add(memberId).catch(() => {}); - } - } - } - return thread; -} - -/** - * Create a private email ticket thread under EMAIL_THREAD_CHANNEL_ID. - * Adds all members with ROLE_ID_TO_PING (no creator; email tickets have no Discord user). - * @param {import('discord.js').Guild} guild - * @param {number} ticketNumber - * @param {string} chanName - * @returns {Promise} - */ -async function createEmailTicketAsThread(guild, ticketNumber, chanName) { - const parentId = CONFIG.EMAIL_THREAD_CHANNEL_ID; - if (!parentId) throw new Error('EMAIL_THREAD_CHANNEL_ID is not set'); - const parentChannel = guild.channels.cache.get(parentId); - if (!parentChannel) throw new Error('Email thread parent channel not found'); - - const thread = await parentChannel.threads.create({ - name: chanName || `🎫・ticket-${ticketNumber}`, - autoArchiveDuration: 1440, - type: ChannelType.PrivateThread, - invitable: false, - reason: `Ticket #${ticketNumber}` - }); - - if (CONFIG.ROLE_ID_TO_PING) { - const role = guild.roles.cache.get(CONFIG.ROLE_ID_TO_PING); - if (role?.members?.size) { - for (const [memberId] of role.members) { - await thread.members.add(memberId).catch(() => {}); - } - } - } - return thread; -} - -// --- LIMITS & PERMISSIONS --- - -async function checkTicketLimits(senderEmail) { - if (!CONFIG.GLOBAL_TICKET_LIMIT) return { ok: true }; - - const currentCount = await Ticket.countDocuments({ senderEmail, status: 'open' }); - if (currentCount >= CONFIG.GLOBAL_TICKET_LIMIT) { - return { - ok: false, - reason: `You have reached the maximum limit of ${CONFIG.GLOBAL_TICKET_LIMIT} open tickets.` - }; - } - - return { ok: true }; -} - -function hasBlacklistedRole(member) { - if (!CONFIG.BLACKLISTED_ROLES || CONFIG.BLACKLISTED_ROLES.length === 0) { - return false; - } - return member.roles.cache.some(role => - CONFIG.BLACKLISTED_ROLES.includes(role.id) - ); -} - -// --- ACTIVITY --- - -async function updateTicketActivity(gmailThreadId) { - const now = new Date(); - await Ticket.updateOne( - { gmailThreadId }, - { $set: { lastActivity: now, reminderSent: false } } - ); -} - -// --- SCHEDULED CHECKS --- -// These accept `client` and optionally `sendTicketClosedEmail` to avoid circular deps. - -async function checkAutoClose(client, sendTicketClosedEmail) { - if (!CONFIG.AUTO_CLOSE_ENABLED) return; - - const cutoffTime = new Date(Date.now() - (CONFIG.AUTO_CLOSE_AFTER_HOURS * 60 * 60 * 1000)); - // Bounded per-tick so a huge backlog drains across successive hourly runs. - const staleTickets = await withRetry(() => Ticket.find({ - status: 'open', - lastActivity: { $lt: cutoffTime, $ne: null } - }).sort({ createdAt: 1 }).limit(500).lean()); - - let checked = 0, closed = 0; - for (const ticket of staleTickets) { - checked++; - try { - const guild = client.guilds.cache.first(); - if (!guild) continue; - - const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null); - if (channel) { - await enqueueSend(channel, CONFIG.DISCORD_AUTO_CLOSE_MESSAGE); - - // Persist pendingDelete BEFORE the delay so a shutdown mid-delay can be - // resumed on boot via resumePendingDeletes(). Cleared after enqueueDelete - // resolves; if the doc is gone the unset is a no-op. - await withRetry(() => Ticket.updateOne( - { gmailThreadId: ticket.gmailThreadId }, - { $set: { status: 'closed', pendingDelete: true } } - )); - - await sendTicketClosedEmail(ticket, 'Auto-Close System'); - - setTimeout(() => { - enqueueDelete(channel).then(() => { - withRetry(() => Ticket.updateOne( - { gmailThreadId: ticket.gmailThreadId }, - { $unset: { pendingDelete: '' } } - )).catch(() => {}); - }).catch(() => {}); - }, 5000); - closed++; - } - } catch (error) { - console.error(`Auto-close error for ticket ${ticket.gmailThreadId}:`, error); - } - } - logAutomation('Auto-close run', null, `checked: ${checked}, closed: ${closed}`).catch(() => {}); -} - -async function checkReminders(client) { - if (!CONFIG.REMINDER_ENABLED) return; - - const reminderTime = new Date(Date.now() - (CONFIG.REMINDER_AFTER_HOURS * 60 * 60 * 1000)); - const ticketsNeedingReminder = await withRetry(() => Ticket.find({ - status: 'open', - lastActivity: { $lt: reminderTime, $ne: null }, - reminderSent: false - }).lean()); - - let checked = 0, reminded = 0; - for (const ticket of ticketsNeedingReminder) { - checked++; - try { - const guild = client.guilds.cache.first(); - if (!guild) continue; - - const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null); - if (channel) { - const ping = ticket.claimedBy - ? `<@${ticket.claimedBy}>` - : (CONFIG.ROLE_ID_TO_PING ? `<@&${CONFIG.ROLE_ID_TO_PING}>` : 'everyone'); - const message = CONFIG.REMINDER_MESSAGE - .replace(/\{hours\}/g, String(CONFIG.REMINDER_AFTER_HOURS)) - .replace(/\{ping\}/g, ping); - await enqueueSend(channel, message); - - await withRetry(() => Ticket.updateOne( - { gmailThreadId: ticket.gmailThreadId }, - { $set: { reminderSent: true } } - )); - reminded++; - } - } catch (error) { - console.error(`Reminder error for ticket ${ticket.gmailThreadId}:`, error); - } - } - logAutomation('Reminder run', null, `checked: ${checked}, reminded: ${reminded}`).catch(() => {}); -} - -async function checkAutoUnclaim(client) { - if (!CONFIG.AUTO_UNCLAIM_ENABLED) return; - - const unclaimTime = new Date(Date.now() - (CONFIG.AUTO_UNCLAIM_AFTER_HOURS * 60 * 60 * 1000)); - const staleClaimedTickets = await withRetry(() => Ticket.find({ - status: 'open', - claimedBy: { $ne: null }, - lastActivity: { $lt: unclaimTime, $ne: null } - }).lean()); - - let checked = 0, unclaimed = 0; - for (const ticket of staleClaimedTickets) { - checked++; - try { - const guild = client.guilds.cache.first(); - if (!guild) continue; - - const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null); - if (channel) { - await withRetry(() => Ticket.updateOne( - { gmailThreadId: ticket.gmailThreadId }, - { $set: { claimedBy: null } } - )); - - await enqueueSend(channel, - `This ticket has been auto-unclaimed due to inactivity (${CONFIG.AUTO_UNCLAIM_AFTER_HOURS} hours).` - ); - - console.log(`Auto-unclaimed ticket ${ticket.gmailThreadId}`); - unclaimed++; - } - } catch (error) { - console.error(`Auto-unclaim error for ticket ${ticket.gmailThreadId}:`, error); - } - } - logAutomation('Auto-unclaim run', null, `checked: ${checked}, unclaimed: ${unclaimed}`).catch(() => {}); -} - -async function reconcileDeletedTicketChannels(client) { - const guild = client.guilds.cache.get(CONFIG.DISCORD_GUILD_ID) || client.guilds.cache.first(); - if (!guild) return { checked: 0, reconciled: 0 }; - - // Bounded per-tick; a larger backlog drains in subsequent hourly runs. - const openTickets = await Ticket.find({ - status: 'open', - discordThreadId: { $ne: null } - }).sort({ createdAt: 1 }).limit(500).lean(); - - let checked = 0, reconciled = 0; - for (const ticket of openTickets) { - checked++; - try { - let channel = guild.channels.cache.get(ticket.discordThreadId); - if (!channel) { - channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null); - } - if (!channel) { - await Ticket.updateOne( - { gmailThreadId: ticket.gmailThreadId }, - { $set: { status: 'closed', discordThreadId: null } } - ); - logAutomation('Reconcile: channel deleted', ticket.discordThreadId, `ticket #${ticket.ticketNumber}`).catch(() => {}); - reconciled++; - } - } catch (err) { - console.error(`reconcileDeletedTicketChannels error for ${ticket.gmailThreadId}:`, err); - } - } - if (reconciled > 0) { - logAutomation('Reconcile run', null, `checked: ${checked}, reconciled: ${reconciled}`).catch(() => {}); - } - return { checked, reconciled }; -} - -/** - * Resume deletes that were pending when the bot last shut down. Called once - * from the ready handler. Clears the flag regardless of fetch result so a - * stale flag (e.g. channel already gone) can't loop. - */ -async function resumePendingDeletes(client) { - const pending = await Ticket.find({ pendingDelete: true }).lean().catch(() => []); - if (!pending.length) return 0; - let resumed = 0; - for (const ticket of pending) { - try { - const guild = client.guilds.cache.first(); - if (guild && ticket.discordThreadId) { - const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null); - if (channel) { - enqueueDelete(channel).catch(() => {}); - resumed++; - } - } - Ticket.updateOne( - { gmailThreadId: ticket.gmailThreadId }, - { $unset: { pendingDelete: '' } } - ).catch(() => {}); - } catch (e) { - console.error('resumePendingDeletes error:', e); - } - } - logAutomation('Pending-delete resume', null, `pending: ${pending.length}, resumed: ${resumed}`).catch(() => {}); - return resumed; -} - -module.exports = { - getNextTicketNumber, - getOrCreateTicketCategory, - cleanupEmptyOverflowCategory, - createDiscordTicketAsThread, - createEmailTicketAsThread, - RENAME_WINDOW_MS, - RENAME_LIMIT, - getSenderLocal, - toDiscordSafeName, - resolveCreatorNickname, - makeTicketName, - canRename, - minutesFromMs, - checkTicketCreationRateLimit, - createTicketChannel, - checkTicketLimits, - hasBlacklistedRole, - updateTicketActivity, - checkAutoClose, - checkReminders, - checkAutoUnclaim, - reconcileDeletedTicketChannels, - resumePendingDeletes, - startTicketsSweeps, - sweepTicketCreationByUser, - _internals: { ticketCreationByUser, TICKET_CREATION_SWEEP_TTL_MS } -}; diff --git a/settings-site/public/css/main.css.bak-20260421 b/settings-site/public/css/main.css.bak-20260421 deleted file mode 100644 index a01551b..0000000 --- a/settings-site/public/css/main.css.bak-20260421 +++ /dev/null @@ -1,1026 +0,0 @@ -@import url('https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;500;600;700&family=Sora:wght@400;500;600;700;800&display=swap'); - -* { margin: 0; padding: 0; box-sizing: border-box; } - -:root { - /* Palette — "indifferent broccoli": deep near-black + chartreuse primary */ - --bg: #0D0F13; - --surface: #151920; - --surface-2: #1E242C; - --card: #151920; - --border: #262C35; - --border-strong: #3A4150; - - --primary: #C7E94D; - --primary-hover: #B5D83D; - --primary-dim: rgba(199, 233, 77, 0.12); - --primary-dim-2: rgba(199, 233, 77, 0.06); - - --secondary: #FFB84D; - --danger: #FF5A52; - --warning: #FFD66B; - --success: #7EE0A3; - - --text: #EFEEE8; - --text-muted: #a0a0a8; - --text-dim: #6B7280; - - --sidebar-width: 260px; - --topbar-height: 60px; - - --font-title: 'Sora', system-ui, -apple-system, sans-serif; - --font-body: 'Open Sans', system-ui, -apple-system, sans-serif; - --font-mono: ui-monospace, 'SF Mono', 'JetBrains Mono', Menlo, monospace; -} - -body { - font-family: var(--font-body); - background: var(--bg); - color: var(--text); - display: flex; - min-height: 100vh; - font-size: 14px; - line-height: 1.5; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -/* Ambient atmospheric glow — a single diffused lime pool, top-left */ -body::before { - content: ''; - position: fixed; - top: -200px; - left: -100px; - width: 640px; - height: 640px; - background: radial-gradient(circle, rgba(199, 233, 77, 0.08), transparent 60%); - filter: blur(60px); - pointer-events: none; - z-index: 0; -} - -/* Top bar */ -.topbar { - position: fixed; - top: 0; - left: var(--sidebar-width); - right: 0; - height: var(--topbar-height); - background: var(--surface); - border-bottom: 1px solid var(--border); - display: flex; - align-items: center; - justify-content: space-between; - padding: 0 24px; - z-index: 100; - gap: 16px; -} -.topbar h1 { - font-family: var(--font-title); - font-size: 13px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.18em; - color: var(--text); -} -.topbar h1::after { - content: ' (:|)'; - color: var(--primary); - margin-left: 2px; - letter-spacing: 0; - font-weight: 500; -} -.topbar .status { - display: flex; - align-items: center; - gap: 10px; - font-family: var(--font-title); - font-size: 11px; - font-weight: 600; - color: var(--text-muted); - text-transform: uppercase; - letter-spacing: 0.14em; -} -.topbar .status .dot { width: 8px; height: 8px; border-radius: 0; flex-shrink: 0; } -.topbar .status .dot.online { background: var(--primary); box-shadow: 0 0 14px var(--primary-dim); } -.topbar .status .dot.offline { background: var(--danger); } -.topbar .actions { display: flex; gap: 10px; align-items: center; } -.topbar .actions button { - background: transparent; - border: 1px solid var(--border-strong); - color: var(--text); - padding: 8px 16px; - font-family: var(--font-title); - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.14em; - cursor: pointer; - transition: border-color 180ms ease, color 180ms ease, background 180ms ease; -} -.topbar .actions button:hover { - border-color: var(--primary); - color: var(--primary); - background: var(--primary-dim-2); -} - -/* Sidebar */ -.sidebar { - position: fixed; - top: 0; - left: 0; - width: var(--sidebar-width); - height: 100vh; - background: var(--surface); - border-right: 1px solid var(--border); - padding: 20px 0 24px; - overflow-y: auto; - z-index: 101; -} -.sidebar .logo { - padding: 8px 20px 24px; - font-family: var(--font-title); - font-size: 13px; - font-weight: 700; - letter-spacing: 0.16em; - text-transform: uppercase; - color: var(--text); - line-height: 1.3; - position: relative; -} -.sidebar .logo::after { - content: '(:|)'; - display: block; - margin-top: 4px; - font-size: 11px; - letter-spacing: 0; - color: var(--primary); - font-weight: 500; -} -.sidebar a { - display: flex; - align-items: center; - padding: 11px 20px; - color: var(--text-muted); - text-decoration: none; - font-family: var(--font-title); - font-size: 11px; - font-weight: 600; - letter-spacing: 0.12em; - text-transform: uppercase; - border-left: 3px solid transparent; - transition: color 160ms ease, background 160ms ease, border-color 160ms ease; -} -.sidebar a:hover { - color: var(--text); - background: var(--primary-dim-2); -} -.sidebar a.active { - color: var(--primary); - border-left-color: var(--primary); - background: var(--primary-dim-2); -} - -/* Main content */ -.main { - margin-left: var(--sidebar-width); - margin-top: var(--topbar-height); - padding: 32px; - flex: 1; - padding-bottom: 140px; - position: relative; - z-index: 1; -} - -/* Sections */ -.section { margin-bottom: 20px; } -.section-header { - background: var(--surface); - border: 1px solid var(--border); - border-bottom: none; - padding: 18px 22px; - cursor: pointer; - display: flex; - align-items: center; - gap: 14px; - transition: background 180ms ease, border-color 180ms ease; -} -.section-header:hover { - background: var(--surface-2); - border-color: var(--border-strong); -} -.section-header h2 { - font-family: var(--font-title); - font-size: 14px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.12em; - color: var(--text); - flex: 1; - display: flex; - align-items: center; - gap: 12px; -} -.section-header h2::before { - content: ''; - display: inline-block; - width: 6px; - height: 6px; - background: var(--primary); - flex-shrink: 0; -} -.section-header p { - font-family: var(--font-body); - font-size: 12px; - font-weight: 400; - color: var(--text-muted); - text-transform: none; - letter-spacing: 0; - line-height: 1.4; -} -.section-header .chevron { - transition: transform 200ms ease; - font-size: 12px; - color: var(--primary); -} -.section.collapsed .section-header { border-bottom: 1px solid var(--border); } -.section.collapsed .section-body { display: none; } -.section.collapsed .chevron { transform: rotate(-90deg); } -.section-body { - background: var(--surface); - border: 1px solid var(--border); - padding: 28px 24px; -} - -/* Field grid */ -.field-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); - gap: 22px; -} -.field { display: flex; flex-direction: column; gap: 8px; } -.field.full-width { grid-column: 1 / -1; } -.field label { - font-family: var(--font-title); - font-size: 13px; - font-weight: 700; - color: var(--text-muted); - letter-spacing: 0; -} -.field input, -.field select, -.field textarea { - background: var(--bg); - border: 1px solid var(--border); - border-radius: 2px; - padding: 11px 14px; - color: var(--text); - font-family: var(--font-body); - font-size: 14px; - outline: none; - transition: border-color 180ms ease, box-shadow 180ms ease; -} -.field input::placeholder, -.field textarea::placeholder { color: var(--text-dim); } -.field input:focus, -.field select:focus, -.field textarea:focus { - border-color: var(--primary); - box-shadow: 0 0 0 3px var(--primary-dim-2); -} -.field textarea { - min-height: 88px; - resize: vertical; - line-height: 1.55; -} -.field input.changed, -.field select.changed, -.field textarea.changed { - border-color: var(--warning); - box-shadow: 0 0 0 3px rgba(255, 214, 107, 0.1); -} -.field .hint { - font-size: 12px; - color: var(--text-muted); - font-style: italic; - line-height: 1.4; -} - -/* Toggle switch */ -.toggle-wrap { display: flex; align-items: center; gap: 12px; } -.toggle-wrap > span { - font-family: var(--font-title); - font-size: 10px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.14em; - color: var(--text-muted); -} -.toggle { position: relative; width: 44px; height: 22px; } -.toggle input { opacity: 0; width: 0; height: 0; } -.toggle .slider { - position: absolute; - inset: 0; - background: var(--surface-2); - border: 1px solid var(--border-strong); - cursor: pointer; - transition: background 180ms ease, border-color 180ms ease; -} -.toggle .slider::before { - content: ''; - position: absolute; - left: 3px; - top: 3px; - width: 14px; - height: 14px; - background: var(--text-muted); - transition: transform 200ms ease, background 180ms ease; -} -.toggle input:checked + .slider { - border-color: var(--primary); - background: var(--primary-dim); -} -.toggle input:checked + .slider::before { - transform: translateX(20px); - background: var(--primary); -} - -/* Color picker */ -.color-field { display: flex; align-items: center; gap: 12px; } -.color-field input[type="color"] { - width: 44px; - height: 44px; - border: 1px solid var(--border); - cursor: pointer; - background: var(--bg); - padding: 3px; - border-radius: 0; -} -.color-field span { - font-family: var(--font-body); - color: var(--text-muted); - font-size: 13px; -} - -/* Smart select */ -.smart-select { position: relative; } -.smart-select-display { - background: var(--bg); - border: 1px solid var(--border); - border-radius: 2px; - padding: 11px 14px; - cursor: pointer; - display: flex; - align-items: center; - gap: 10px; - min-height: 44px; - transition: border-color 180ms ease; - font-size: 14px; -} -.smart-select-display:hover { border-color: var(--primary); } -.smart-select-dropdown { - position: absolute; - top: 100%; - left: 0; - right: 0; - background: var(--surface); - border: 1px solid var(--border-strong); - margin-top: 4px; - z-index: 200; - box-shadow: 0 16px 48px rgba(0, 0, 0, 0.55); - max-height: 320px; - overflow: hidden; - display: flex; - flex-direction: column; -} -.smart-select-dropdown.hidden { display: none; } -.ss-search { - background: var(--bg); - border: none; - border-bottom: 1px solid var(--border); - padding: 12px 14px; - color: var(--text); - font-family: var(--font-body); - font-size: 13px; - outline: none; -} -.ss-list { - overflow-y: auto; - max-height: 260px; - padding: 4px; -} -.ss-option { - padding: 10px 12px; - cursor: pointer; - display: flex; - align-items: center; - gap: 10px; - font-size: 13px; - transition: background 120ms ease, color 120ms ease; -} -.ss-option:hover { background: var(--primary-dim-2); color: var(--primary); } -.ss-option.selected { background: var(--primary-dim); color: var(--primary); } -.ss-option.ss-clear { color: var(--text-muted); font-style: italic; } -.ss-label { flex: 1; } -.ss-sub { font-size: 11px; color: var(--text-muted); } -.ss-id { font-size: 11px; color: var(--text-dim); font-family: var(--font-mono); } -.ss-placeholder { color: var(--text-dim); } -.ss-avatar { width: 20px; height: 20px; border-radius: 50%; } -.ss-dot { width: 10px; height: 10px; border-radius: 0; flex-shrink: 0; } -.ss-chips { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 8px; } -.ss-option.ss-chip { - display: inline-flex; - padding: 4px 8px; - margin: 2px; - border-radius: 12px; - font-size: 12px; - cursor: pointer; -} - -/* Save bar */ -.save-bar { - position: fixed; - bottom: 0; - left: var(--sidebar-width); - right: 0; - background: var(--surface); - border-top: 2px solid var(--primary); - padding: 16px 24px; - display: flex; - align-items: center; - justify-content: space-between; - transform: translateY(100%); - transition: transform 320ms cubic-bezier(0.4, 0, 0.2, 1); - z-index: 100; - box-shadow: 0 -16px 40px rgba(0, 0, 0, 0.35); - gap: 16px; -} -.save-bar.visible { transform: translateY(0); } -.save-bar > span { - font-family: var(--font-title); - font-size: 11px; - font-weight: 700; - color: var(--warning); - text-transform: uppercase; - letter-spacing: 0.16em; - display: inline-flex; - align-items: center; - gap: 10px; -} -.save-bar > span::before { - content: ''; - width: 7px; - height: 7px; - background: var(--warning); - animation: pulse 1.6s ease-in-out infinite; -} -@keyframes pulse { - 0%, 100% { opacity: 1; transform: scale(1); } - 50% { opacity: 0.3; transform: scale(0.8); } -} -.save-actions { display: flex; gap: 10px; flex-wrap: wrap; } -.save-actions button { - padding: 11px 22px; - font-family: var(--font-title); - font-size: 11px; - font-weight: 700; - letter-spacing: 0.16em; - text-transform: uppercase; - cursor: pointer; - border: 1px solid transparent; - border-radius: 0; - transition: all 180ms ease; -} -.save-actions button:first-child { - background: var(--primary); - color: var(--bg); - border-color: var(--primary); -} -.save-actions button:first-child:hover { - background: var(--primary-hover); - border-color: var(--primary-hover); -} -.save-actions button.secondary { - background: transparent; - color: var(--text); - border-color: var(--border-strong); -} -.save-actions button.secondary:hover { - border-color: var(--primary); - color: var(--primary); -} -.save-actions button.danger { - background: transparent; - color: var(--danger); - border-color: var(--danger); -} -.save-actions button.danger:hover { - background: var(--danger); - color: var(--bg); -} -.save-actions button:disabled { opacity: 0.4; cursor: not-allowed; } - -/* Toast */ -#toast-container { - position: fixed; - top: 76px; - right: 24px; - z-index: 300; - display: flex; - flex-direction: column; - gap: 8px; - pointer-events: none; -} -.toast { - padding: 12px 18px; - font-family: var(--font-title); - font-size: 11px; - font-weight: 700; - letter-spacing: 0.12em; - text-transform: uppercase; - border-left: 3px solid currentColor; - background: var(--surface); - animation: toast-in 260ms ease; - pointer-events: auto; - max-width: 420px; -} -.toast-success { color: var(--primary); background: rgba(199, 233, 77, 0.06); } -.toast-warning { color: var(--warning); background: rgba(255, 214, 107, 0.08); } -.toast-error { color: var(--danger); background: rgba(255, 90, 82, 0.08); } -@keyframes toast-in { - from { opacity: 0; transform: translateX(20px); } - to { opacity: 1; transform: translateX(0); } -} - -/* Modal */ -.modal { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.72); - backdrop-filter: blur(4px); - -webkit-backdrop-filter: blur(4px); - display: flex; - align-items: center; - justify-content: center; - z-index: 400; -} -.modal.hidden { display: none; } -.modal-card { - background: var(--surface); - border: 1px solid var(--border); - border-top: 3px solid var(--primary); - padding: 28px; - min-width: 360px; -} -.modal-card h3 { - margin-bottom: 20px; - font-family: var(--font-title); - font-size: 13px; - font-weight: 700; - letter-spacing: 0.16em; - text-transform: uppercase; -} -.modal-card input { - width: 100%; - padding: 11px 14px; - background: var(--bg); - border: 1px solid var(--border); - color: var(--text); - font-family: var(--font-body); - font-size: 14px; - margin-bottom: 20px; - outline: none; - transition: border-color 180ms ease; -} -.modal-card input:focus { border-color: var(--primary); } -.modal-actions { display: flex; gap: 8px; justify-content: flex-end; } -.modal-actions button { - padding: 10px 20px; - font-family: var(--font-title); - font-size: 11px; - font-weight: 700; - letter-spacing: 0.14em; - text-transform: uppercase; - cursor: pointer; - border: 1px solid transparent; -} -.modal-actions button:first-child { - background: var(--primary); - color: var(--bg); - border-color: var(--primary); -} -.modal-actions button:first-child:hover { background: var(--primary-hover); } -.modal-actions button.secondary { - background: transparent; - color: var(--text); - border-color: var(--border-strong); -} -.modal-actions button.secondary:hover { border-color: var(--primary); color: var(--primary); } - -/* Loading */ -.loading { - position: fixed; - inset: 0; - background: var(--bg); - display: flex; - flex-direction: column; - gap: 20px; - align-items: center; - justify-content: center; - z-index: 500; -} -.loading.hidden { display: none; } -.loading::after { - content: 'LOADING (:|)'; - font-family: var(--font-title); - font-size: 11px; - font-weight: 700; - letter-spacing: 0.32em; - color: var(--primary); -} -.spinner { - width: 40px; - height: 40px; - border: 2px solid var(--border); - border-top-color: var(--primary); - border-radius: 0; - animation: spin 0.9s linear infinite; -} -@keyframes spin { to { transform: rotate(360deg); } } - -/* Notifications section */ -#s-notifications .notif-tabs { - display: flex; - gap: 4px; - flex-wrap: wrap; - margin-bottom: 22px; - border-bottom: 1px solid var(--border); -} -#s-notifications .notif-tab-btn { - border: none; - background: transparent; - color: var(--text-muted); - font-family: var(--font-title); - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.14em; - padding: 10px 16px; - cursor: pointer; - border-bottom: 2px solid transparent; - margin-bottom: -1px; - transition: color 160ms ease, border-color 160ms ease; -} -#s-notifications .notif-tab-btn:hover { color: var(--text); } -#s-notifications .notif-tab-btn.active { color: var(--primary); border-bottom-color: var(--primary); } -#s-notifications .notif-panel.hidden { display: none; } -#s-notifications .notif-editor { - border: 1px solid var(--border); - padding: 20px; - margin-bottom: 16px; - background: var(--surface-2); -} -#s-notifications .notif-chips { - display: flex; - gap: 8px; - flex-wrap: wrap; - margin: 14px 0; - min-height: 32px; -} -#s-notifications .notif-chip { - display: inline-flex; - align-items: center; - gap: 10px; - border: 1px solid var(--primary); - background: var(--primary-dim); - color: var(--primary); - padding: 5px 12px; - font-family: var(--font-title); - font-size: 11px; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; -} -#s-notifications .notif-chip button { - border: none; - background: transparent; - color: currentColor; - cursor: pointer; - padding: 0; - line-height: 1; - font-size: 14px; - opacity: 0.6; -} -#s-notifications .notif-chip button:hover { opacity: 1; } -#s-notifications .notif-input-row { - display: flex; - gap: 8px; - flex-wrap: wrap; - align-items: center; -} -#s-notifications .notif-input-row input { width: 220px; } -#s-notifications .notif-presets { - display: flex; - gap: 6px; - flex-wrap: wrap; - margin-top: 14px; -} -#s-notifications .notif-presets button, -#s-notifications .notif-add-btn { - padding: 8px 14px; - border: 1px solid var(--border-strong); - background: transparent; - color: var(--text-muted); - font-family: var(--font-title); - font-size: 11px; - font-weight: 600; - letter-spacing: 0.12em; - text-transform: uppercase; - cursor: pointer; - transition: border-color 160ms ease, color 160ms ease, background 160ms ease; -} -#s-notifications .notif-presets button:hover, -#s-notifications .notif-add-btn:hover { - border-color: var(--primary); - color: var(--primary); - background: var(--primary-dim-2); -} -#s-notifications .notif-trigger { margin-top: 16px; } -#s-notifications .notif-trigger summary { - cursor: pointer; - color: var(--text-muted); - font-family: var(--font-title); - font-size: 11px; - font-weight: 700; - letter-spacing: 0.16em; - text-transform: uppercase; - margin-bottom: 14px; - user-select: none; - list-style: none; - display: inline-flex; - align-items: center; - gap: 8px; -} -#s-notifications .notif-trigger summary::-webkit-details-marker { display: none; } -#s-notifications .notif-trigger summary::before { - content: '+'; - color: var(--primary); - font-weight: 700; - font-size: 14px; -} -#s-notifications .notif-trigger[open] summary::before { content: '−'; } -#s-notifications .notif-trigger[open] summary { color: var(--primary); } - -/* Phase 9 — notification enable toggles */ -#s-notifications .notif-toggle-row { - display: flex; - align-items: center; - justify-content: space-between; - flex-wrap: wrap; - gap: 12px; - padding-bottom: 14px; - margin-bottom: 14px; - border-bottom: 1px solid var(--border); -} -#s-notifications .notif-toggle-group { - display: flex; - align-items: center; - gap: 10px; -} -#s-notifications .notif-toggle-label { - font-family: var(--font-title); - font-size: 13px; - font-weight: 700; - color: var(--text); - letter-spacing: 0; -} -#s-notifications .notif-per-alert-row { - display: flex; - align-items: center; - gap: 10px; - margin-bottom: 12px; -} -.notif-disabled { - opacity: 0.5; - pointer-events: none; - user-select: none; -} - -/* Logging hint link */ -.logging-hint { color: var(--text-muted); font-size: 13px; } -.logging-hint a { - color: var(--primary); - text-decoration: underline; - text-decoration-style: wavy; - text-decoration-thickness: 1.5px; - text-underline-offset: 4px; -} -.logging-hint a:hover { color: var(--primary-hover); } - -/* Legacy logout form wrapper (kept for compatibility) */ -.logout-form { display: inline; } - -/* Select element styling (native appearance override) */ -.field select { - appearance: none; - -webkit-appearance: none; - background-image: linear-gradient(45deg, transparent 50%, var(--primary) 50%), - linear-gradient(135deg, var(--primary) 50%, transparent 50%); - background-position: calc(100% - 18px) center, calc(100% - 13px) center; - background-size: 5px 5px, 5px 5px; - background-repeat: no-repeat; - padding-right: 36px; -} - -/* ---------- Mobile navigation primitives ---------- */ -.menu-toggle { - display: none; - align-items: center; - justify-content: center; - width: 44px; - height: 44px; - padding: 0; - margin-right: 4px; - background: transparent; - border: 1px solid var(--border-strong); - border-radius: 0; - color: var(--text); - cursor: pointer; - transition: border-color 180ms ease, color 180ms ease, background 180ms ease; -} -.menu-toggle:hover { border-color: var(--primary); color: var(--primary); background: var(--primary-dim-2); } -.menu-toggle:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; } -.menu-toggle-bars, -.menu-toggle-bars::before, -.menu-toggle-bars::after { - display: block; - width: 20px; - height: 2px; - background: currentColor; - position: relative; - transition: transform 200ms; -} -.menu-toggle-bars::before, -.menu-toggle-bars::after { content: ''; position: absolute; left: 0; } -.menu-toggle-bars::before { top: -6px; } -.menu-toggle-bars::after { top: 6px; } - -.sidebar-backdrop { - display: none; - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.72); - backdrop-filter: blur(3px); - -webkit-backdrop-filter: blur(3px); - opacity: 0; - pointer-events: none; - transition: opacity 200ms ease; - z-index: 150; -} - -/* ---------- Mobile breakpoint ---------- */ -@media (max-width: 900px) { - body.sidebar-open { overflow: hidden; } - - .sidebar { - transform: translateX(-100%); - transition: transform 260ms ease; - z-index: 151; - box-shadow: 4px 0 40px rgba(0, 0, 0, 0.7); - } - body.sidebar-open .sidebar { transform: translateX(0); } - - .sidebar-backdrop { display: block; } - body.sidebar-open .sidebar-backdrop { opacity: 1; pointer-events: auto; } - - .topbar { left: 0; padding: 0 14px; gap: 10px; height: 56px; } - .topbar h1 { font-size: 12px; flex: 1; min-width: 0; letter-spacing: 0.14em; } - .topbar .status { font-size: 10px; flex-shrink: 0; } - .topbar .actions button { min-height: 44px; padding: 10px 14px; font-size: 10px; } - .menu-toggle { display: inline-flex; } - - .main { - margin-left: 0; - margin-top: 56px; - padding: 20px 16px; - padding-bottom: 180px; - } - - .field-grid { grid-template-columns: 1fr; } - - .save-bar { - left: 0; - padding: 14px 16px calc(14px + env(safe-area-inset-bottom, 0px)); - flex-wrap: wrap; - gap: 10px; - } - .save-bar > span { width: 100%; } - .save-actions { width: 100%; display: flex; flex-wrap: wrap; gap: 8px; } - .save-actions button { flex: 1 1 140px; min-height: 44px; padding: 14px 16px; font-size: 11px; } - .save-actions button:first-child { flex-basis: 100%; } - - .sidebar a { padding: 14px 20px; min-height: 44px; font-size: 12px; } - .section-header { padding: 18px 20px; } - .smart-select-display { min-height: 44px; } - #s-notifications .notif-chip { padding: 8px 12px; } - #s-notifications .notif-chip button { min-width: 28px; min-height: 28px; font-size: 18px; } - #s-notifications .notif-tab-btn, - #s-notifications .notif-add-btn, - #s-notifications .notif-presets button { min-height: 40px; padding: 10px 14px; } - #s-notifications .notif-input-row input { flex: 1 1 auto; width: auto; min-width: 0; } - - .modal-card { width: calc(100vw - 32px); min-width: 0; max-width: 420px; } - - #toast-container { right: 12px; left: 12px; top: 64px; } - .toast { max-width: none; } -} - -/* ---------- Accessibility (Phase 6) ---------- */ - -/* Universal keyboard-focus indicator. Kept narrow: only shown when the - browser's focus heuristic says this is a keyboard user (never on click). - Pointer-driven focus still uses the component-specific hover/active/border - treatments defined above. */ -a:focus-visible, -button:focus-visible, -input:focus-visible, -select:focus-visible, -textarea:focus-visible, -[role="combobox"]:focus-visible, -[role="option"]:focus-visible, -[tabindex]:focus-visible { - outline: 2px solid var(--primary); - outline-offset: 2px; -} - -/* Smart-select option keyboard navigation. Same visual as :hover so - pointer and keyboard users see the same highlight on the active row. */ -.ss-option:focus { - background: var(--primary-dim-2); - color: var(--primary); -} -.ss-option[aria-selected="true"] { - background: var(--primary-dim); - color: var(--primary); -} -.ss-option:focus-visible { - outline: 2px solid var(--primary); - outline-offset: -2px; -} - -/* Combobox trigger shows an explicit focus ring in addition to the - border-color change, so keyboard users can see it against the dark bg. */ -.smart-select-display:focus-visible { - border-color: var(--primary); - outline: 2px solid var(--primary); - outline-offset: 2px; -} - -/* Toast close button */ -.toast { - display: flex; - align-items: center; - gap: 10px; -} -.toast-message { flex: 1; } -.toast-close { - background: transparent; - border: none; - color: currentColor; - cursor: pointer; - padding: 2px 6px; - font-size: 16px; - line-height: 1; - opacity: 0.7; - font-family: inherit; -} -.toast-close:hover { opacity: 1; } - -/* Multi-select chip removal button (was a , now a -

Settings

-
- - Checking... -
-
- -
- - - -
- - -
-

Core

Discord bot credentials and guild

-
-
-
-
-
-
- - -
-

Channels

Channel assignments for logging, transcripts, and alerts

-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- - -
-

Categories

Ticket category assignments and escalation targets

-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- - -
-

Gmail

Google OAuth credentials and email settings

-
-
-
-
-
-
-
- - -
-

Ticket Behavior

Automation, limits, and messages

-
-
Enabled
-
-
Enabled
-
-
Enabled
-
Enabled
-
-
Enabled
-
-
Enabled
-
-
-
-
-
-
Variables: {staff_mention}, {staff_name}
-
-
Variables: {support_name}
-
Variables: {ping}, {hours}
-
-
- - -
-

Staff Threads

Private staff discussion threads on ticket channels

-
-
Enabled
-
-
Enabled
-
-
-
- - -
-

Pin Messages

Auto-pin welcome and escalation messages

-
-
Enabled
-
Enabled
-
Enabled
-
-
- - -
-

Notifications

Threshold milestones and trigger conditions by alert category

-
- - -
- - - - -
- -
-
-
- - Master (all categories) -
-
- - All in category -
-
-

Surge alerts fire when active ticket conditions cross thresholds — high volume, unclaimed queues, no staff online. Each alert escalates through its threshold list, spacing out pings as the condition persists. The counter resets when the condition clears.

-
-
-
-
- - Alert disabled -
-
-
- - -
-
-
-
- Trigger conditions -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Enabled
-
-
-
-
-
- - - - - - -
-
- - -
-

Logging

Log channel configuration (channels set in Channels section)

-
-

Log channels are configured in the Channels section. This section shows which logs are active based on configured channels.

-
-
- - -
-

Automation

Polling intervals and timer durations

-
-
-
-
-
- - -
-

Appearance

Embed colors, button labels, and emojis

-
-
Open tickets
-
Closed tickets
-
Claimed tickets
-
Escalated tickets
-
Info embeds
-
-
-
-
-
-
-
-
-
-
-
-
- - -
-

Staff

Staff IDs, emojis, and admin settings

-
-
-
-
Format: 123456:emoji,789012:emoji
-
Role IDs with staff permissions
-
Role IDs that cannot open tickets
-
e.g. 1,2,4
-
-
- - -
-

Advanced

Ports, URLs, game list, branding

-
-
-
-
-
-
-
-
-
-
-
-
-
-
Variables: {channel_name}, {email}, {date_opened}, {date_closed}
-
-
-
- -
- - -
- 0 unsaved changes -
- - - -
-
- - - - - - - - - - - - diff --git a/settings-site/public/js/app.js.bak-20260421 b/settings-site/public/js/app.js.bak-20260421 deleted file mode 100644 index 909fbbd..0000000 --- a/settings-site/public/js/app.js.bak-20260421 +++ /dev/null @@ -1,162 +0,0 @@ -(function () { - 'use strict'; - - async function init() { - document.getElementById('loading').classList.remove('hidden'); - try { - await Util.fetchCsrfToken(); - const [config] = await Promise.all([ - fetch('/api/config', { credentials: 'same-origin' }).then(r => r.json()), - DiscordFields.fetchGuildData() - ]); - Fields.setSavedConfig(config); - document.getElementById('bot-status-dot').className = 'dot online'; - document.getElementById('bot-status-text').textContent = 'Connected'; - Fields.populateFields(config); - Notifications.initNotificationsEditor(config); - Fields.initSmartSelects(config); - } catch (e) { - document.getElementById('bot-status-dot').className = 'dot offline'; - document.getElementById('bot-status-text').textContent = 'Unreachable'; - } - document.getElementById('loading').classList.add('hidden'); - setupSectionToggles(); - Fields.setupSaveBar(); - } - - function setupSectionToggles() { - document.querySelectorAll('.section-header').forEach(header => { - header.addEventListener('click', () => { - header.closest('.section').classList.toggle('collapsed'); - }); - }); - } - - function openScheduleModal() { - const modal = document.getElementById('schedule-modal'); - const dt = document.getElementById('schedule-datetime'); - const min = Util.formatLocalDateTime(new Date(Date.now() + 60000)); - dt.min = min; - dt.value = min; - Util.openModal(modal, { initialFocus: '#schedule-datetime' }); - } - - async function confirmScheduledRestart() { - const dt = document.getElementById('schedule-datetime').value; - if (!dt) return; - await fetch('/api/restart', { - method: 'POST', - credentials: 'same-origin', - headers: Util.csrfHeaders({ 'Content-Type': 'application/json' }), - body: JSON.stringify({ mode: 'scheduled', scheduledFor: new Date(dt).toISOString() }) - }); - Util.closeModal(document.getElementById('schedule-modal')); - Util.showToast(`Restart scheduled for ${new Date(dt).toLocaleString()}`, 'warning'); - } - - async function doLogout() { - try { - await fetch('/logout', { - method: 'POST', - credentials: 'same-origin', - headers: Util.csrfHeaders() - }); - } catch (e) { /* ignore */ } - window.location.href = '/login'; - } - - function setupActionButtons() { - document.getElementById('save-btn')?.addEventListener('click', () => Fields.saveConfig('save')); - document.getElementById('save-restart-btn')?.addEventListener('click', () => Fields.saveConfig('restart')); - document.getElementById('schedule-restart-btn')?.addEventListener('click', openScheduleModal); - document.getElementById('schedule-confirm-btn')?.addEventListener('click', confirmScheduledRestart); - document.getElementById('schedule-cancel-btn')?.addEventListener('click', () => { - Util.closeModal(document.getElementById('schedule-modal')); - }); - document.getElementById('logout-btn')?.addEventListener('click', doLogout); - } - - function setupMobileNav() { - const toggle = document.getElementById('menu-toggle'); - const backdrop = document.getElementById('sidebar-backdrop'); - - toggle?.addEventListener('click', () => { - Util.setSidebarOpen(!document.body.classList.contains('sidebar-open')); - }); - backdrop?.addEventListener('click', () => Util.setSidebarOpen(false)); - document.addEventListener('keydown', (e) => { - if (e.key === 'Escape' && document.body.classList.contains('sidebar-open')) { - Util.setSidebarOpen(false); - } - }); - window.addEventListener('resize', () => { - if (!Util.isMobileViewport() && document.body.classList.contains('sidebar-open')) { - Util.setSidebarOpen(false); - } - }); - } - - let healthPollHandle = null; - - function setBotStatus(online) { - const dot = document.getElementById('bot-status-dot'); - const text = document.getElementById('bot-status-text'); - if (!dot || !text) return; - dot.className = online ? 'dot online' : 'dot offline'; - text.textContent = online ? 'Connected' : 'Unreachable'; - } - - async function pollHealth() { - try { - const res = await fetch('/healthz', { credentials: 'same-origin' }); - if (res.ok) { - const data = await res.json(); - setBotStatus(Boolean(data.bot)); - } else { - setBotStatus(false); - } - } catch (_) { - setBotStatus(false); - } - } - - function scheduleNextHealthPoll() { - if (document.hidden) return; - healthPollHandle = setTimeout(async () => { - await pollHealth(); - scheduleNextHealthPoll(); - }, 20000); - } - - function startHealthPolling() { - if (healthPollHandle) clearTimeout(healthPollHandle); - scheduleNextHealthPoll(); - } - - function stopHealthPolling() { - if (healthPollHandle) { - clearTimeout(healthPollHandle); - healthPollHandle = null; - } - } - - function setupHealthPolling() { - document.addEventListener('visibilitychange', () => { - if (document.hidden) stopHealthPolling(); - else startHealthPolling(); - }); - window.addEventListener('pagehide', stopHealthPolling); - startHealthPolling(); - } - - document.addEventListener('DOMContentLoaded', async () => { - Router.setupSidebarRouting(); - setupActionButtons(); - setupMobileNav(); - await init(); - Router.navigate(location.pathname, false); - setupHealthPolling(); - }); - - window.App = { init }; -})(); diff --git a/settings-site/public/js/router.js.bak-20260421 b/settings-site/public/js/router.js.bak-20260421 deleted file mode 100644 index 9435014..0000000 --- a/settings-site/public/js/router.js.bak-20260421 +++ /dev/null @@ -1,52 +0,0 @@ -(function () { - 'use strict'; - - const ROUTES = { - '/': 's-core', - '/channels': 's-channels', - '/categories': 's-categories', - '/gmail': 's-gmail', - '/behavior': 's-behavior', - '/threads': 's-threads', - '/pins': 's-pins', - '/notifications': 's-notifications', - '/logging': 's-logging', - '/automation': 's-automation', - '/appearance': 's-appearance', - '/staff': 's-staff', - '/advanced': 's-advanced' - }; - - function navigate(path, updateHistory = true) { - const sectionId = ROUTES[path] || ROUTES['/']; - const normalizedPath = ROUTES[path] ? path : '/'; - if (updateHistory) history.pushState({}, '', normalizedPath); - - document.querySelectorAll('.section').forEach(section => { - section.classList.toggle('hidden', section.id !== sectionId); - }); - - document.querySelectorAll('.sidebar a').forEach(link => { - link.classList.toggle('active', link.getAttribute('href') === normalizedPath); - }); - } - - function setupSidebarRouting() { - const sidebar = document.querySelector('.sidebar'); - if (!sidebar) return; - - sidebar.addEventListener('click', e => { - const a = e.target.closest('a'); - if (!a) return; - e.preventDefault(); - navigate(a.getAttribute('href')); - if (Util.isMobileViewport()) Util.setSidebarOpen(false); - }); - - window.addEventListener('popstate', () => { - navigate(location.pathname, false); - }); - } - - window.Router = { ROUTES, navigate, setupSidebarRouting }; -})();