From 2fab3b97bf661a90b0e2e5c2c6f42b40dd43b9df Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Tue, 2 Jun 2026 19:59:14 +0000 Subject: [PATCH] Remove dead/stale code, dedup close+escalation paths Dead/stale removals (grep-confirmed no consumers): - config: drop 9 unread CONFIG keys (ROLE_TO_PING_ID, SIGNATURE, REMINDER_*, RENAME_LOG_CHANNEL_ID, SETTINGS_*); remove their ALLOWED_CONFIG_KEYS entries and the orphaned settings-site UI fields - configSchema: delete unreachable json/string_or_json validators - models: drop unused ticketTag field - gmail-poll: remove unused isPollSuspended export - utils: remove dead htmlToTextWithBlocks/decodeHtmlEntities/BLOCK_TAG_REGEX - internalApi: remove router._allowedKeys (test it served is gone) - discord client: drop unused GuildPresences privileged intent - broccolini-discord: remove dormant /api 503 gate (no /api routes) Fixes: - context-menu ticket create now uses makeTicketName('unclaimed', ...) instead of the contract-violating ticket- name - drop write-only pending.userId from both close paths Dedup / simplify: - new services/transcript.js shares the transcript text/date/header builders between the button and force-close paths (had drifted) - resolveEscalationCategoryId() replaces 3 copies of the category logic - ticketChannelOverwrites() shares the create-permission array between the two interactive ticket-create paths - finalizeBody() shares the email-cleanup tail in parseGmailMessage - getTicketActionRow drops its never-passed options arg; sendTicketNotificationEmail drops its always-null subjectLine arg - hoist invariant guild lookup out of the auto-close/unclaim loops - drop redundant lastActivity write (and now-dead updateTicketActivity) - /help lists all current commands and the right-click apps --- broccolini-discord.js | 12 +------- config.js | 9 ------ gmail-poll.js | 23 ++++++++------ handlers/buttons.js | 52 ++++---------------------------- handlers/commands/close.js | 28 ++++------------- handlers/commands/contextMenu.js | 23 +++++--------- handlers/commands/escalation.js | 25 ++++++++++----- handlers/commands/index.js | 19 +++++++++--- handlers/messages.js | 3 -- handlers/pendingCloses.js | 4 +-- models.js | 1 - routes/internalApi.js | 4 --- services/configSchema.js | 27 ++--------------- services/gmail.js | 5 ++- services/tickets.js | 24 +++++---------- services/transcript.js | 37 +++++++++++++++++++++++ settings-site/public/index.html | 4 --- utils.js | 26 ---------------- utils/ticketComponents.js | 32 ++++++++++++++++---- 19 files changed, 141 insertions(+), 217 deletions(-) create mode 100644 services/transcript.js diff --git a/broccolini-discord.js b/broccolini-discord.js index 4cbd02f..c103764 100644 --- a/broccolini-discord.js +++ b/broccolini-discord.js @@ -79,8 +79,7 @@ const client = new Client({ GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent, - GatewayIntentBits.GuildMembers, - GatewayIntentBits.GuildPresences // Required for staff presence detection; enable in Discord Developer Portal + GatewayIntentBits.GuildMembers ], partials: [Partials.Channel] }); @@ -238,15 +237,6 @@ client.once('ready', async () => { client.login(CONFIG.DISCORD_TOKEN); const app = express(); -app.use(express.json()); -// Reject API traffic with 503 until ready event has fired and routes are mounted. -// (appReady is declared at module top so the ready callback can flip it.) -app.use((req, res, next) => { - if (!appReady && req.path.startsWith('/api')) { - return res.status(503).json({ error: 'Bot is starting; API not ready yet.' }); - } - next(); -}); app.get('/', (req, res) => res.send(appReady ? 'Active' : 'Starting')); // app.listen is called inside client.once('ready') after MongoDB connects and routes mount. diff --git a/config.js b/config.js index d5120b1..b3e9d9f 100644 --- a/config.js +++ b/config.js @@ -17,7 +17,6 @@ const CONFIG = { TICKET_CATEGORY_NAME: process.env.TICKET_CATEGORY_NAME || 'Open Tickets', DISCORD_TICKET_CATEGORY_ID: process.env.DISCORD_TICKET_CATEGORY_ID || process.env.TICKET_CATEGORY_ID, ROLE_ID_TO_PING: process.env.ROLE_ID_TO_PING, - ROLE_TO_PING_ID: process.env.ROLE_ID_TO_PING || process.env.ROLE_TO_PING_ID, TRANSCRIPT_CHANNEL_ID: process.env.TRANSCRIPT_CHANNEL_ID, LOGGING_CHANNEL_ID: process.env.LOGGING_CHANNEL_ID, DEBUGGING_CHANNEL_ID: process.env.DEBUGGING_CHANNEL_ID || null, @@ -30,7 +29,6 @@ const CONFIG = { CLAIMER_EMOJI_FALLBACK: process.env.CLAIMER_EMOJI_FALLBACK || '🎫', PORT: toInt(process.env.DISCORD_ONLY_PORT, 5000), HEALTHCHECK_HOST: process.env.HEALTHCHECK_HOST || null, // null = listen on all interfaces; set to 127.0.0.1 for local-only - SIGNATURE: (process.env.EMAIL_SIGNATURE || '').trim().replace(/\\n/g, '\n'), GAME_LIST: process.env.GAME_LIST || '', // Tier 2/3 escalation: category IDs where ticket channels are placed (env uses *_CHANNEL_* for legacy naming). EMAIL_ESCALATED2_CHANNEL_ID: process.env.EMAIL_ESCALATED2_CHANNEL_ID || null, @@ -56,9 +54,6 @@ const CONFIG = { TICKET_WELCOME_MESSAGE: process.env.TICKET_WELCOME_MESSAGE || "We got your ticket. We'll be with you as soon as possible. Feel free to add any additional information to your ticket.", TICKET_CLAIMED_MESSAGE: process.env.TICKET_CLAIMED_MESSAGE || 'Ticket claimed by {staff_mention} πŸš€', TICKET_UNCLAIMED_MESSAGE: process.env.TICKET_UNCLAIMED_MESSAGE || 'Ticket unclaimed by {staff_mention} β˜€οΈ', - REMINDER_ENABLED: process.env.REMINDER_ENABLED === 'true', - REMINDER_AFTER_HOURS: toInt(process.env.REMINDER_AFTER_HOURS, 24), - REMINDER_MESSAGE: process.env.REMINDER_MESSAGE || 'Hey {ping}! This ticket has been inactive for {hours} hours. Please provide an update or close the ticket.', PRIORITY_ENABLED: process.env.PRIORITY_ENABLED === 'true', DEFAULT_PRIORITY: process.env.DEFAULT_PRIORITY || 'normal', PRIORITY_HIGH_EMOJI: process.env.PRIORITY_HIGH_EMOJI || 'πŸ”΄', @@ -80,7 +75,6 @@ const CONFIG = { ADMIN_ID: process.env.ADMIN_ID || null, FORCE_CLOSE_TIMER: toInt(process.env.FORCE_CLOSE_TIMER_SECONDS, 60), GMAIL_POLL_INTERVAL_MS: toInt(process.env.GMAIL_POLL_INTERVAL_SECONDS, 30) * 1000, - RENAME_LOG_CHANNEL_ID: process.env.RENAME_LOG_CHANNEL_ID || null, STAFF_THREAD_ENABLED: process.env.STAFF_THREAD_ENABLED === 'true', STAFF_THREAD_NAME: process.env.STAFF_THREAD_NAME || 'Staff Discussion', STAFF_THREAD_AUTO_ADD_ROLE: process.env.STAFF_THREAD_AUTO_ADD_ROLE === 'true', @@ -89,9 +83,6 @@ const CONFIG = { PIN_ESCALATION_MESSAGE_ENABLED: process.env.PIN_ESCALATION_MESSAGE_ENABLED === 'true', TRANSCRIPT_DM_TO_CREATOR: process.env.TRANSCRIPT_DM_TO_CREATOR === 'true', PIN_SUPPRESS_SYSTEM_MESSAGE: process.env.PIN_SUPPRESS_SYSTEM_MESSAGE === 'true', - SETTINGS_PORT: toInt(process.env.SETTINGS_PORT, 12752), - SETTINGS_ADMIN_PASSWORD: process.env.SETTINGS_ADMIN_PASSWORD || null, - SETTINGS_DOMAIN: process.env.SETTINGS_DOMAIN || 'tickets.indifferentketchup.com', INTERNAL_API_PORT: toInt(process.env.INTERNAL_API_PORT, 12753), INTERNAL_API_SECRET: process.env.INTERNAL_API_SECRET || null }; diff --git a/gmail-poll.js b/gmail-poll.js index fd8dbd7..7ec37e7 100644 --- a/gmail-poll.js +++ b/gmail-poll.js @@ -37,7 +37,6 @@ function setPollSuspended(val) { pollSuspended = !!val; if (!pollSuspended) authErrorNotified = false; } -function isPollSuspended() { return pollSuspended; } // ============================================================ // Helpers (extracted from the original 309-line poll()). @@ -73,6 +72,16 @@ function locateGuild(client) { * - followupBody: defensive β€” strip quotes but fall back to raw text if * stripping leaves nothing. Used for follow-up posts on an existing thread. */ +// Shared final cleanup for both the first-message and follow-up body paths: +// drop the "Get Outlook for ..." mobile-signature line, strip a dangling +// trailing "<" left by truncated HTML, and trim. +function finalizeBody(text) { + return text + .replace(/Get Outlook for [^\n]+/gi, '') + .replace(/<\s*$/gm, '') + .trim(); +} + function parseGmailMessage(email) { const headers = email.data.payload.headers; const from = headers.find(h => h.name === 'From')?.value || ''; @@ -92,10 +101,7 @@ function parseGmailMessage(email) { firstBody = stripMobileFooter(firstBody); firstBody = firstBody.replace(/^\s*\n+/g, ''); firstBody = firstBody.replace(/\n{3,}/g, '\n\n'); - firstBody = firstBody - .replace(/Get Outlook for [^\n]+/gi, '') - .replace(/<\s*$/gm, '') - .trim(); + firstBody = finalizeBody(firstBody); const rawText = rawBody.replace(/\r\n/g, '\n'); let followupBody = stripEmailQuotes(rawText); @@ -103,10 +109,7 @@ function parseGmailMessage(email) { followupBody = followupBody.replace(/^\s*\n*/, '\n'); followupBody = followupBody.replace(/\n{3,}/g, '\n\n'); followupBody = stripMobileFooter(followupBody); - followupBody = followupBody - .replace(/Get Outlook for [^\n]+/gi, '') - .replace(/<\s*$/gm, '') - .trim(); + followupBody = finalizeBody(followupBody); return { isSelf, @@ -405,4 +408,4 @@ async function poll(client) { } } -module.exports = { poll, setPollSuspended, isPollSuspended }; +module.exports = { poll, setPollSuspended }; diff --git a/handlers/buttons.js b/handlers/buttons.js index c408202..83d72f5 100644 --- a/handlers/buttons.js +++ b/handlers/buttons.js @@ -16,7 +16,6 @@ const { AttachmentBuilder, EmbedBuilder, MessageFlags, - PermissionFlagsBits, ModalBuilder, TextInputBuilder, TextInputStyle @@ -25,10 +24,11 @@ const { mongoose } = require('../db-connection'); const { CONFIG } = require('../config'); const { makeTicketName, resolveCreatorNickname, getOrCreateTicketCategory, cleanupEmptyOverflowCategory, checkTicketCreationRateLimit, toDiscordSafeName } = require('../services/tickets'); const { sendTicketClosedEmail } = require('../services/gmail'); -const { getTicketActionRow } = require('../utils/ticketComponents'); +const { getTicketActionRow, ticketChannelOverwrites } = require('../utils/ticketComponents'); +const { buildTranscriptText, formatDateForTranscript, renderTranscriptHeader } = require('../services/transcript'); const { sanitizeEmbedText, truncateEmbedDescription } = require('../utils'); const { enqueueRename, enqueueSend } = require('../services/channelQueue'); -const { runEscalation, runDeescalation } = require('./commands'); +const { runEscalation, runDeescalation, resolveEscalationCategoryId } = require('./commands'); const { pendingCloses } = require('./pendingCloses'); const { addMemberToStaffThread, createStaffThread } = require('../services/staffThread'); const { pinMessage } = require('../services/pinMessage'); @@ -309,7 +309,7 @@ async function handleConfirmCloseRequest(interaction, ticket) { await runFinalClose(interaction, freshTicket, effectiveSendEmail); }, timerSeconds * 1000)); - pendingCloses.set(channelId, { timeout: timerId, userId: interaction.user.id, username: userTag, sendEmail }); + pendingCloses.set(channelId, { timeout: timerId, username: userTag, sendEmail }); } async function handleCancelCloseRequest(interaction) { @@ -358,10 +358,7 @@ async function handleEscalateButton(interaction, ticket) { return interaction.reply({ content: `This ticket is already at tier ${tier + 1}.`, flags: MessageFlags.Ephemeral }); } - const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-'); - const categoryId = tier === 1 - ? (isDiscordTicket ? CONFIG.DISCORD_ESCALATED2_CHANNEL_ID : CONFIG.EMAIL_ESCALATED2_CHANNEL_ID) - : (isDiscordTicket ? CONFIG.DISCORD_ESCALATED3_CHANNEL_ID : CONFIG.EMAIL_ESCALATED3_CHANNEL_ID); + const categoryId = resolveEscalationCategoryId(ticket, tier); if (!categoryId && !interaction.channel.isThread()) { return interaction.reply({ @@ -474,33 +471,6 @@ async function runFinalClose(interaction, ticket, sendEmail = true) { } } -/** Render the last 100 messages of a channel as a plaintext transcript. */ -async function buildTranscriptText(channel, ticket) { - const messages = await channel.messages.fetch({ limit: 100 }); - return `TRANSCRIPT: ${ticket.subject}\nUser: ${ticket.senderEmail}\n---\n` + - messages - .reverse() - .map(m => `[${m.createdAt.toLocaleString()}] ${m.author.tag}: ${m.cleanContent}`) - .join('\n'); -} - -function formatDateForTranscript(d) { - return new Date(d).toLocaleString('en-US', { - month: '2-digit', day: '2-digit', year: 'numeric', - hour: '2-digit', minute: '2-digit', second: '2-digit', - hour12: true, timeZoneName: 'short' - }); -} - -function renderTranscriptHeader(channelName, senderEmail, openedStr, closedStr) { - return CONFIG.DISCORD_TRANSCRIPT_MESSAGE - .replace(/\{channel_name\}/g, channelName) - .replace(/\{email\}/g, senderEmail || '') - .replace(/\{date_opened\}/g, openedStr) - .replace(/\{date_closed\}/g, closedStr) - + `\n\nDate Opened: ${openedStr}\nDate Closed: ${closedStr}`; -} - async function dmTranscriptToCreator(client, ticket, channelName, transcriptText, openedStr, closedStr) { // Prefer ticket.creatorId (stored on creation). Fall back to legacy parsing for // pre-creatorId modal tickets only β€” split-pop returns the wrong value for @@ -601,17 +571,7 @@ async function handleTicketModal(interaction) { name: unclaimedName, type: ChannelType.GuildText, parent: parentCategoryIdForTicket, - 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] - } - ] + permissionOverwrites: ticketChannelOverwrites(guild, interaction.user.id) }); } catch (err) { console.error('guild.channels.create (ticket modal):', err); diff --git a/handlers/commands/close.js b/handlers/commands/close.js index 73582f0..55c0a98 100644 --- a/handlers/commands/close.js +++ b/handlers/commands/close.js @@ -14,6 +14,7 @@ const { enqueueSend } = require('../../services/channelQueue'); const { logTicketEvent } = require('../../services/debugLog'); const { pendingCloses } = require('../pendingCloses'); const { findTicketForChannel } = require('../sharedHelpers'); +const { buildTranscriptText, formatDateForTranscript, renderTranscriptHeader } = require('../../services/transcript'); const Ticket = mongoose.model('Ticket'); @@ -56,7 +57,7 @@ async function handleForceClose(interaction) { const channelRef = interaction.channel; const clientRef = interaction.client; const timerId = setTimeout(() => finalizeForceClose(channelRef, clientRef), timerSeconds * 1000); - pendingCloses.set(channelRef.id, { timeout: timerId, userId: interaction.user.id, username: interaction.user.tag }); + pendingCloses.set(channelRef.id, { timeout: timerId, username: interaction.user.tag }); } /** Performs the actual force-close work after the countdown elapses. */ @@ -92,14 +93,7 @@ async function finalizeForceClose(channelRef, clientRef) { async function postTranscript(channelRef, clientRef, freshTicket) { await enqueueSend(channelRef, CONFIG.DISCORD_CLOSE_MESSAGE); - const messages = await channelRef.messages.fetch({ limit: 100 }); - const log = - `TRANSCRIPT: ${freshTicket.subject}\nUser: ${freshTicket.senderEmail}\n---\n` + - messages - .reverse() - .map(m => `[${m.createdAt.toLocaleString()}] ${m.author.tag}: ${m.cleanContent}`) - .join('\n'); - + const log = await buildTranscriptText(channelRef, freshTicket); const file = new AttachmentBuilder(Buffer.from(log), { name: `transcript-${channelRef.name}.txt` }); @@ -109,19 +103,9 @@ async function postTranscript(channelRef, clientRef, freshTicket) { .catch(() => null); if (!transcriptChan) return; - const fmt = (d) => new Date(d).toLocaleString('en-US', { - month: '2-digit', day: '2-digit', year: 'numeric', - hour: '2-digit', minute: '2-digit', second: '2-digit', - hour12: true, timeZoneName: 'short' - }); - const openedStr = fmt(freshTicket.createdAt); - const closedStr = fmt(new Date()); - const transcriptContent = CONFIG.DISCORD_TRANSCRIPT_MESSAGE - .replace(/\{channel_name\}/g, channelRef.name) - .replace(/\{email\}/g, freshTicket.senderEmail || '') - .replace(/\{date_opened\}/g, openedStr) - .replace(/\{date_closed\}/g, closedStr) - + `\n\nDate Opened: ${openedStr}\nDate Closed: ${closedStr}`; + const openedStr = formatDateForTranscript(freshTicket.createdAt); + const closedStr = formatDateForTranscript(new Date()); + const transcriptContent = renderTranscriptHeader(channelRef.name, freshTicket.senderEmail, openedStr, closedStr); await enqueueSend(transcriptChan, { content: transcriptContent, files: [file] }); } diff --git a/handlers/commands/contextMenu.js b/handlers/commands/contextMenu.js index 24a36f3..46b5323 100644 --- a/handlers/commands/contextMenu.js +++ b/handlers/commands/contextMenu.js @@ -6,14 +6,13 @@ const { ChannelType, EmbedBuilder, - MessageFlags, - PermissionFlagsBits + MessageFlags } = require('discord.js'); const { mongoose } = require('../../db-connection'); const { CONFIG } = require('../../config'); const { getPriorityEmoji } = require('../../utils'); -const { checkTicketCreationRateLimit, getOrCreateTicketCategory } = require('../../services/tickets'); -const { getTicketActionRow } = require('../../utils/ticketComponents'); +const { checkTicketCreationRateLimit, getOrCreateTicketCategory, makeTicketName } = require('../../services/tickets'); +const { getTicketActionRow, ticketChannelOverwrites } = require('../../utils/ticketComponents'); const { enqueueSend } = require('../../services/channelQueue'); const { logError } = require('../../services/debugLog'); @@ -36,6 +35,8 @@ async function handleCreateTicketFromMessage(interaction) { const guild = interaction.guild; const lastTicket = await Ticket.findOne().sort({ ticketNumber: -1 }).select('ticketNumber').lean(); const ticketNumber = (lastTicket?.ticketNumber || 0) + 1; + const creatorNickname = message.member?.displayName || message.author.username; + const unclaimedName = makeTicketName('unclaimed', { ticketNumber }, creatorNickname); let parentCategoryIdForTicket; try { @@ -52,20 +53,10 @@ async function handleCreateTicketFromMessage(interaction) { let channel; try { channel = await guild.channels.create({ - name: `ticket-${ticketNumber}`, + name: unclaimedName, type: ChannelType.GuildText, parent: parentCategoryIdForTicket, - permissionOverwrites: [ - { id: guild.id, deny: [PermissionFlagsBits.ViewChannel] }, - { - id: message.author.id, - allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory] - }, - { - id: CONFIG.ROLE_ID_TO_PING, - allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory] - } - ] + permissionOverwrites: ticketChannelOverwrites(guild, message.author.id) }); } catch (err) { console.error('guild.channels.create (context menu ticket):', err); diff --git a/handlers/commands/escalation.js b/handlers/commands/escalation.js index f1ebede..70f9920 100644 --- a/handlers/commands/escalation.js +++ b/handlers/commands/escalation.js @@ -19,15 +19,26 @@ const { fetchLoggingChannel } = require('./helpers'); const Ticket = mongoose.model('Ticket'); +/** + * Resolve the destination category for an escalation target tier + * (nextTier 1 = tier 2, 2 = tier 3), picking the Discord vs email category set + * by ticket origin. Returns null/undefined when the relevant category is unset. + */ +function resolveEscalationCategoryId(ticket, nextTier) { + const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-'); + if (nextTier === 1) { + return isDiscordTicket ? CONFIG.DISCORD_ESCALATED2_CHANNEL_ID : CONFIG.EMAIL_ESCALATED2_CHANNEL_ID; + } + return isDiscordTicket ? CONFIG.DISCORD_ESCALATED3_CHANNEL_ID : CONFIG.EMAIL_ESCALATED3_CHANNEL_ID; +} + /** * Run escalation to a target tier (1 = tier 2, 2 = tier 3). Caller must * validate ticket and currentTier < nextTier, and have already deferred. */ async function runEscalation(interaction, ticket, nextTier) { 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 categoryId = resolveEscalationCategoryId(ticket, nextTier); // Clear claim on escalation await Ticket.updateOne( @@ -88,7 +99,7 @@ async function runEscalation(interaction, ticket, nextTier) { const escalatorName = interaction.member?.displayName || interaction.user.username; const tierLabel = nextTier === 1 ? 'tier 2' : 'tier 3'; const emailBody = `${escalatorName} escalated this ticket to ${tierLabel}.`; - await sendTicketNotificationEmail(ticket, null, emailBody, interaction.user.id); + await sendTicketNotificationEmail(ticket, emailBody, interaction.user.id); } catch (emailErr) { console.error('Escalation email failed (non-fatal):', emailErr.message); } @@ -179,9 +190,7 @@ async function handleEscalate(interaction) { } 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 categoryId = resolveEscalationCategoryId(ticket, nextTier); const configKey = nextTier === 1 ? 'ESCALATED2' : 'ESCALATED3'; if (!categoryId && !interaction.channel.isThread()) { return interaction.reply({ @@ -210,4 +219,4 @@ async function handleDeescalate(interaction) { ); } -module.exports = { runEscalation, runDeescalation, handleEscalate, handleDeescalate }; +module.exports = { runEscalation, runDeescalation, handleEscalate, handleDeescalate, resolveEscalationCategoryId }; diff --git a/handlers/commands/index.js b/handlers/commands/index.js index bcc849e..0733bdc 100644 --- a/handlers/commands/index.js +++ b/handlers/commands/index.js @@ -25,7 +25,7 @@ const { logError, logTicketEvent } = require('../../services/debugLog'); const { findTicketForChannel } = require('../sharedHelpers'); const { requireStaffRole, fetchLoggingChannel } = require('./helpers'); -const { runEscalation, runDeescalation, handleEscalate, handleDeescalate } = require('./escalation'); +const { runEscalation, runDeescalation, handleEscalate, handleDeescalate, resolveEscalationCategoryId } = require('./escalation'); const { handleCloseTimer, handleCancelClose, handleForceClose } = require('./close'); const { handleResponse, handleAutocomplete } = require('./response'); const { handlePanel, handleSignature } = require('./panel'); @@ -266,7 +266,7 @@ async function handleHelp(interaction) { }, { 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' + value: '`/transfer @staff [reason]` - Transfer ticket to another staff member\n`/move #category` - Move ticket to another category\n`/force-close` - Force close ticket without confirmation\n`/cancel-close` - Abort a pending force-close countdown\n`/topic ` - Set ticket topic/description' }, { name: 'Saved Responses', @@ -282,10 +282,18 @@ async function handleHelp(interaction) { }, { 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)' + value: '`/escalate ` - Escalate ticket (tier 2 or 3, or one step)\n`/deescalate` - De-escalate ticket (tier 3β†’2 or tier 2β†’normal)' + }, + { + name: 'Staff Configuration', + value: '`/notifydm` - Toggle DM notifications for your claimed tickets\n`/signature` - Set your email signature\n`/closetimer ` - Set the force-close countdown\n`/staffthread` - Toggle/configure per-ticket staff threads\n`/pinmessages` - Toggle auto-pinning of ticket messages\n`/gmailpoll ` - Set the Gmail poll interval' + }, + { + name: 'Right-click (Apps menu)', + value: '`Create Ticket From Message` - Turn a message into a ticket\n`View User Tickets` - Show a user\'s recent tickets' } ]) - .setFooter({ text: 'Click buttons on ticket messages to claim/close' }); + .setFooter({ text: 'Click buttons on ticket messages to claim/close. Config changes via slash commands apply until the next restart.' }); await interaction.reply({ embeds: [embed], flags: MessageFlags.Ephemeral }); } @@ -342,5 +350,6 @@ module.exports = { handleContextMenu, handleAutocomplete, runEscalation, - runDeescalation + runDeescalation, + resolveEscalationCategoryId }; diff --git a/handlers/messages.js b/handlers/messages.js index ae299ad..7077df5 100644 --- a/handlers/messages.js +++ b/handlers/messages.js @@ -5,7 +5,6 @@ const { mongoose } = require('../db-connection'); const { CONFIG } = require('../config'); const { extractRawEmail, isStaff } = require('../utils'); const { getGmailClient, sendGmailReply } = require('../services/gmail'); -const { updateTicketActivity } = require('../services/tickets'); const { getNotifyDm } = require('../services/staffSettings'); const { logError } = require('../services/debugLog'); @@ -93,8 +92,6 @@ async function handleDiscordReply(m) { msgId, m.author.id ); - - await updateTicketActivity(ticket.gmailThreadId); } catch (e) { console.error('REPLY ERROR:', e); } diff --git a/handlers/pendingCloses.js b/handlers/pendingCloses.js index ca93243..c911f78 100644 --- a/handlers/pendingCloses.js +++ b/handlers/pendingCloses.js @@ -1,7 +1,7 @@ /** * Shared pending-close timer map. - * Keyed by channel.id β†’ { timeout, userId, username }. - * Used by buttons.js (sets timers) and commands.js (cancel-close clears them). + * Keyed by channel.id β†’ { timeout, username, sendEmail }. + * Used by buttons.js (sets timers) and commands/ (cancel-close clears them). */ const pendingCloses = new Map(); diff --git a/models.js b/models.js index 1d9d3b6..2328a22 100644 --- a/models.js +++ b/models.js @@ -15,7 +15,6 @@ const ticketSchema = new mongoose.Schema({ escalationTier: { type: Number, default: 0 }, ticketNumber: Number, priority: { type: String, default: 'normal', enum: ['low', 'normal', 'medium', 'high'] }, - ticketTag: String, lastActivity: Date, welcomeMessageId: String, claimerId: String, diff --git a/routes/internalApi.js b/routes/internalApi.js index ebe8b62..ecffff7 100644 --- a/routes/internalApi.js +++ b/routes/internalApi.js @@ -193,8 +193,4 @@ router.post('/gmail/reload', express.json(), async (req, res) => { } }); -// Expose the allowlist for the Phase 8 schema smoke test. Attached to the -// router function object; doesn't show up as a route. -router._allowedKeys = Array.from(ALLOWED_CONFIG_KEYS); - module.exports = router; diff --git a/services/configSchema.js b/services/configSchema.js index b5111b2..3eaf823 100644 --- a/services/configSchema.js +++ b/services/configSchema.js @@ -24,23 +24,22 @@ const ALLOWED_CONFIG_KEYS = new Set([ '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', + 'ROLE_ID_TO_PING', 'ADDITIONAL_STAFF_ROLES', 'BLACKLISTED_ROLES', 'ADMIN_ID', // Channel IDs 'TRANSCRIPT_CHANNEL_ID', 'LOGGING_CHANNEL_ID', 'DEBUGGING_CHANNEL_ID', - 'RENAME_LOG_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', 'TICKET_WELCOME_MESSAGE', 'TICKET_CLAIMED_MESSAGE', 'TICKET_UNCLAIMED_MESSAGE', - 'REMINDER_MESSAGE', 'BUTTON_LABEL_CLOSE', 'BUTTON_LABEL_CLAIM', 'BUTTON_LABEL_UNCLAIM', + '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', 'ALLOW_CLAIM_OVERWRITE', - 'REMINDER_ENABLED', 'REMINDER_AFTER_HOURS', 'PRIORITY_ENABLED', 'DEFAULT_PRIORITY', + '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', // Limits and thresholds @@ -140,26 +139,6 @@ const VALIDATORS = { 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: { diff --git a/services/gmail.js b/services/gmail.js index 8551218..e9e6f1f 100644 --- a/services/gmail.js +++ b/services/gmail.js @@ -182,18 +182,17 @@ async function sendTicketClosedEmail(ticket, closerName, userId = null) { /** * Send a notification email in the ticket thread (e.g. escalation, high-priority). * @param {Object} ticket - Ticket with gmailThreadId, senderEmail, subject - * @param {string} subjectLine - Fallback subject if the thread can't be queried * @param {string} messageBody - Plain or HTML message body * @param {string} [userId] - Discord user ID for signature (optional) */ -async function sendTicketNotificationEmail(ticket, subjectLine, messageBody, userId = null) { +async function sendTicketNotificationEmail(ticket, messageBody, userId = null) { try { const recipient = resolveCustomerRecipient(ticket, 'sendTicketNotificationEmail'); if (!recipient) return; const gmail = getGmailClient(); const { subject, msgId } = await fetchThreadSubjectAndMsgId(gmail, ticket.gmailThreadId); - const encodedSubject = encodeReplySubject(subject || subjectLine || ticket.subject || 'Support'); + const encodedSubject = encodeReplySubject(subject || ticket.subject || 'Support'); await sendThreadedEmail(gmail, { threadId: ticket.gmailThreadId, diff --git a/services/tickets.js b/services/tickets.js index 778afda..b8639a2 100644 --- a/services/tickets.js +++ b/services/tickets.js @@ -1,6 +1,6 @@ /** * Ticket database helpers – counters, rename, limits, auto-close, - * reminders, auto-unclaim, channel creation. + * auto-unclaim, channel creation. */ const { ChannelType } = require('discord.js'); const { mongoose, withRetry } = require('../db-connection'); @@ -269,15 +269,6 @@ async function checkTicketLimits(senderEmail) { return { ok: true }; } -// --- ACTIVITY --- - -async function updateTicketActivity(gmailThreadId) { - await Ticket.updateOne( - { gmailThreadId }, - { $set: { lastActivity: new Date() } } - ); -} - // --- SCHEDULED CHECKS --- // These accept `client` and optionally `sendTicketClosedEmail` to avoid circular deps. @@ -291,11 +282,11 @@ async function checkAutoClose(client, sendTicketClosedEmail) { lastActivity: { $lt: cutoffTime, $ne: null } }).sort({ createdAt: 1 }).limit(500).lean()); + const guild = client.guilds.cache.first(); + if (!guild) return; + for (const ticket of staleTickets) { 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); @@ -337,11 +328,11 @@ async function checkAutoUnclaim(client) { lastActivity: { $lt: unclaimTime, $ne: null } }).lean()); + const guild = client.guilds.cache.first(); + if (!guild) return; + for (const ticket of staleClaimedTickets) { 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( @@ -426,7 +417,6 @@ module.exports = { makeTicketName, checkTicketCreationRateLimit, checkTicketLimits, - updateTicketActivity, checkAutoClose, checkAutoUnclaim, reconcileDeletedTicketChannels, diff --git a/services/transcript.js b/services/transcript.js new file mode 100644 index 0000000..a1e22c0 --- /dev/null +++ b/services/transcript.js @@ -0,0 +1,37 @@ +/** + * Shared transcript rendering for the two close paths + * (handlers/buttons.js runFinalClose and handlers/commands/close.js postTranscript). + * Pure formatting only β€” each caller owns its own posting / DB / email side effects. + */ +const { CONFIG } = require('../config'); + +/** Render the last 100 messages of a channel as a plaintext transcript. */ +async function buildTranscriptText(channel, ticket) { + const messages = await channel.messages.fetch({ limit: 100 }); + return `TRANSCRIPT: ${ticket.subject}\nUser: ${ticket.senderEmail}\n---\n` + + messages + .reverse() + .map(m => `[${m.createdAt.toLocaleString()}] ${m.author.tag}: ${m.cleanContent}`) + .join('\n'); +} + +/** Format a date for the transcript header (US locale, 12h, with time zone). */ +function formatDateForTranscript(d) { + return new Date(d).toLocaleString('en-US', { + month: '2-digit', day: '2-digit', year: 'numeric', + hour: '2-digit', minute: '2-digit', second: '2-digit', + hour12: true, timeZoneName: 'short' + }); +} + +/** Build the transcript-channel message body from DISCORD_TRANSCRIPT_MESSAGE. */ +function renderTranscriptHeader(channelName, senderEmail, openedStr, closedStr) { + return CONFIG.DISCORD_TRANSCRIPT_MESSAGE + .replace(/\{channel_name\}/g, channelName) + .replace(/\{email\}/g, senderEmail || '') + .replace(/\{date_opened\}/g, openedStr) + .replace(/\{date_closed\}/g, closedStr) + + `\n\nDate Opened: ${openedStr}\nDate Closed: ${closedStr}`; +} + +module.exports = { buildTranscriptText, formatDateForTranscript, renderTranscriptHeader }; diff --git a/settings-site/public/index.html b/settings-site/public/index.html index 31c985b..66f7ab8 100644 --- a/settings-site/public/index.html +++ b/settings-site/public/index.html @@ -84,7 +84,6 @@
-
@@ -123,8 +122,6 @@
Enabled
-
Enabled
-
Enabled
Enabled
@@ -136,7 +133,6 @@
Variables: {staff_mention}, {staff_name}
Variables: {support_name}
-
Variables: {ping}, {hours}
diff --git a/utils.js b/utils.js index d5af413..7c90b56 100644 --- a/utils.js +++ b/utils.js @@ -22,9 +22,6 @@ function isStaff(member) { // --- TEXT PROCESSING --- -const BLOCK_TAG_REGEX = - /<\/(p|div|li|h[1-6]|tr|table|section|article|blockquote)>/gi; - function escapeRegex(str) { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } @@ -40,28 +37,6 @@ function escapeHtml(str) { .replace(/'/g, '''); } -function decodeHtmlEntities(str) { - if (!str) return ''; - return str - .replace(/&/g, '&') - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/ /g, ' '); -} - -function htmlToTextWithBlocks(html) { - return decodeHtmlEntities( - html - .replace(/\r\n/g, '\n') - .replace(//gi, '\n') - .replace(BLOCK_TAG_REGEX, '\n\n') - .replace(/<(ul|ol)[^>]*>/gi, '\n') - .replace(/<[^>]*>?/gm, '') - ); -} - // --- EMAIL BODY EXTRACTION --- function decodeGmailData(p) { @@ -277,7 +252,6 @@ module.exports = { escapeHtml, safeEqual, isStaff, - htmlToTextWithBlocks, getCleanBody, stripEmailQuotes, stripMobileFooter, diff --git a/utils/ticketComponents.js b/utils/ticketComponents.js index 341d26c..9b72c82 100644 --- a/utils/ticketComponents.js +++ b/utils/ticketComponents.js @@ -2,16 +2,36 @@ * Ticket action row builder – Close, Claim, Escalate (if tier < 3), Deescalate (if tier >= 2). * Used by handlers/buttons.js and handlers/commands.js. */ -const { ActionRowBuilder, ButtonBuilder, ButtonStyle } = require('discord.js'); +const { ActionRowBuilder, ButtonBuilder, ButtonStyle, PermissionFlagsBits } = require('discord.js'); const { CONFIG } = require('../config'); +/** + * permissionOverwrites for a Discord-originated ticket channel: deny @everyone, + * allow the creating user and the staff ping role. Used by the button and + * context-menu creation paths (the email/gmail path differs β€” no Discord + * creator β€” and builds its own overwrites). + * @param {import('discord.js').Guild} guild + * @param {string} creatorId - Discord user ID of the ticket creator + */ +function ticketChannelOverwrites(guild, creatorId) { + const allow = [ + PermissionFlagsBits.ViewChannel, + PermissionFlagsBits.SendMessages, + PermissionFlagsBits.ReadMessageHistory + ]; + return [ + { id: guild.id, deny: [PermissionFlagsBits.ViewChannel] }, + { id: creatorId, allow }, + { id: CONFIG.ROLE_ID_TO_PING, allow } + ]; +} + /** * Build the standard ticket action row (Close, Claim, optionally Escalate, optionally Deescalate). * @param {Object} ticket - Ticket with escalationTier (0, 1, 2) and optionally escalated - * @param {Object} [options] - { unclaimLabel, unclaimEmoji } for claim button when ticket is claimed * @returns {ActionRowBuilder} */ -function getTicketActionRow(ticket, options = {}) { +function getTicketActionRow(ticket) { const tier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0); const row = new ActionRowBuilder(); @@ -23,8 +43,8 @@ function getTicketActionRow(ticket, options = {}) { .setStyle(ButtonStyle.Secondary), new ButtonBuilder() .setCustomId('claim_ticket') - .setLabel(options.unclaimLabel ?? CONFIG.BUTTON_LABEL_CLAIM) - .setEmoji(options.unclaimEmoji ?? CONFIG.BUTTON_EMOJI_CLAIM) + .setLabel(CONFIG.BUTTON_LABEL_CLAIM) + .setEmoji(CONFIG.BUTTON_EMOJI_CLAIM) .setStyle(ButtonStyle.Secondary) ); @@ -48,4 +68,4 @@ function getTicketActionRow(ticket, options = {}) { return row; } -module.exports = { getTicketActionRow }; +module.exports = { getTicketActionRow, ticketChannelOverwrites };