From cdf85f6364850582ea77a9c2b9d6cd2d6343450a Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Fri, 8 May 2026 20:19:14 +0000 Subject: [PATCH] audit week 1: creator ID tracking, channel-queue migration, deprecation cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QUAL-006 store ticket.creatorId on creation; legacy split-pop returned the message ID for discord-msg-* tickets, breaking transcript DM, close log, and channel rename for context-menu-created tickets. Adds the field to the Ticket schema and writes a one-shot backfill script (scripts/backfill-creatorId.js, dry-run by default). QUEUE-001 add enqueueOverwrite + enqueueTopic to services/channelQueue.js (chain on renameChains alongside enqueueMove). Migrate handleAdd / handleRemove / handleMove / handleTopic so permissionOverwrites, setParent, and setTopic no longer race pending renames or sends. handleMove now uses the existing enqueueMove. Initial overwrites in handleTicketModal stay inline; channel doesn't exist yet so no race. DISCORD-001 replace ephemeral: true with flags: MessageFlags.Ephemeral across broccolini-discord.js, handlers/sharedHelpers.js, handlers/buttons.js, handlers/commands.js. runDeferred opts now take { flags } directly. SEC-003 /gmailpoll min interval is 30s. Drop the 5s/10s slash-command choices and clamp Math.max(30000, ms) in handleGmailPoll for defense in depth. QUAL-001 upgrade silent .catch(() => {}) on the lastActivity updateOne in handlers/messages.js to log via logError, so transient Mongo errors surface in the debug channel instead of disappearing. QUAL-002 drop await from logError/logWarn calls in services/staffThread.js and services/pinMessage.js — fire-and-forget per CLAUDE.md hard rule. QUAL-003 wrap stray setTimeouts (handleConfirmCloseRequest force-close timer, runFinalClose channel-delete + overflow-cleanup, checkAutoClose delete-after-email) in trackTimeout via lazy require so they clear on shutdown. --- broccolini-discord.js | 8 +-- commands/register.js | 2 - handlers/buttons.js | 66 +++++++++++++-------- handlers/commands.js | 107 +++++++++++++++++----------------- handlers/messages.js | 3 +- handlers/sharedHelpers.js | 11 ++-- models.js | 1 + scripts/backfill-creatorId.js | 88 ++++++++++++++++++++++++++++ services/channelQueue.js | 77 +++++++++++++++++++++++- services/pinMessage.js | 4 +- services/staffThread.js | 4 +- services/tickets.js | 13 ++++- 12 files changed, 287 insertions(+), 97 deletions(-) create mode 100644 scripts/backfill-creatorId.js diff --git a/broccolini-discord.js b/broccolini-discord.js index a6773d8..e2b2c13 100644 --- a/broccolini-discord.js +++ b/broccolini-discord.js @@ -2,7 +2,7 @@ * Entry point – initializes the Discord bot, wires event handlers, * connects to MongoDB, starts Gmail polling, and runs the Express healthcheck. */ -const { Client, GatewayIntentBits, Partials } = require('discord.js'); +const { Client, GatewayIntentBits, Partials, MessageFlags } = require('discord.js'); const express = require('express'); const { connectMongoDB, closeMongoDB } = require('./db-connection'); const { CONFIG } = require('./config'); @@ -86,7 +86,7 @@ const client = new Client({ // --- EVENT: interactionCreate --- async function safeReplyError(interaction) { - const payload = { content: 'Something went wrong.', ephemeral: true }; + const payload = { content: 'Something went wrong.', flags: MessageFlags.Ephemeral }; if (interaction.deferred || interaction.replied) { await interaction.followUp(payload).catch(() => {}); } else { @@ -132,13 +132,13 @@ client.on('interactionCreate', async interaction => { await interaction.reply({ content: 'Signature settings saved successfully!', - ephemeral: true + flags: MessageFlags.Ephemeral }); } catch (err) { console.error('Signature modal submit error:', err); await interaction.reply({ content: 'Failed to save signature settings.', - ephemeral: true + flags: MessageFlags.Ephemeral }); } return; diff --git a/commands/register.js b/commands/register.js index 8fb247e..8b7f726 100644 --- a/commands/register.js +++ b/commands/register.js @@ -357,8 +357,6 @@ async function registerCommands() { .setDescription('Poll interval') .setRequired(true) .addChoices( - { name: '5s', value: '5' }, - { name: '10s', value: '10' }, { name: '30s', value: '30' }, { name: '45s', value: '45' }, { name: '1m', value: '60' }, diff --git a/handlers/buttons.js b/handlers/buttons.js index 2498e85..c950066 100644 --- a/handlers/buttons.js +++ b/handlers/buttons.js @@ -15,6 +15,7 @@ const { ButtonStyle, AttachmentBuilder, EmbedBuilder, + MessageFlags, PermissionFlagsBits, ModalBuilder, TextInputBuilder, @@ -121,7 +122,7 @@ async function handleTagDeleteConfirm(interaction) { async function handleClaimButton(interaction, ticket) { const freshTicket = await Ticket.findOne({ gmailThreadId: ticket.gmailThreadId }).lean(); if (!freshTicket) { - return interaction.reply({ content: 'Ticket data missing.', ephemeral: true }); + return interaction.reply({ content: 'Ticket data missing.', flags: MessageFlags.Ephemeral }); } const isClaimed = !!freshTicket.claimedBy; @@ -131,19 +132,19 @@ async function handleClaimButton(interaction, ticket) { const [row0] = interaction.message.components; if (!row0) { - return interaction.reply({ content: 'No components to update.', ephemeral: true }); + return interaction.reply({ content: 'No components to update.', flags: MessageFlags.Ephemeral }); } const row = ActionRowBuilder.from(row0); const [btnClose, btnClaim] = row.components; if (!btnClose || !btnClaim) { - return interaction.reply({ content: 'Buttons missing.', ephemeral: true }); + return interaction.reply({ content: 'Buttons missing.', flags: MessageFlags.Ephemeral }); } if (isClaimed && !isClaimedByMe && !CONFIG.ALLOW_CLAIM_OVERWRITE) { return interaction.reply({ content: `This ticket is already claimed by **${freshTicket.claimedBy}**.`, - ephemeral: true + flags: MessageFlags.Ephemeral }); } @@ -277,7 +278,7 @@ async function handleConfirmCloseRequest(interaction, ticket) { 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 }); + return interaction.reply({ content: 'A close is already pending for this ticket.', flags: MessageFlags.Ephemeral }); } const cancelRow = new ActionRowBuilder().addComponents( @@ -289,7 +290,9 @@ async function handleConfirmCloseRequest(interaction, ticket) { const channelName = interaction.channel.name; const userTag = interaction.user.tag; - const timerId = setTimeout(async () => { + // Lazy require — broccolini-discord re-exports trackTimeout and we'd otherwise cycle. + const { trackTimeout } = require('../broccolini-discord'); + const timerId = trackTimeout(setTimeout(async () => { const pending = pendingCloses.get(channelId); pendingCloses.delete(channelId); const freshTicket = await Ticket.findOne({ discordThreadId: channelId }).lean(); @@ -303,7 +306,7 @@ async function handleConfirmCloseRequest(interaction, ticket) { const effectiveSendEmail = pending?.sendEmail ?? true; await runFinalClose(interaction, freshTicket, effectiveSendEmail); - }, timerSeconds * 1000); + }, timerSeconds * 1000)); pendingCloses.set(channelId, { timeout: timerId, userId: interaction.user.id, username: userTag, sendEmail }); } @@ -324,7 +327,7 @@ async function handleCancelCloseRequest(interaction) { async function handleEscalatePrompt(interaction, 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 }); + return interaction.reply({ content: 'This ticket is already at tier 3 support.', flags: MessageFlags.Ephemeral }); } const buttons = []; @@ -338,7 +341,7 @@ async function handleEscalatePrompt(interaction, ticket) { return interaction.reply({ content: 'Escalate to which tier?', components: [new ActionRowBuilder().addComponents(buttons)], - ephemeral: true + flags: MessageFlags.Ephemeral }); } @@ -351,7 +354,7 @@ async function handleEscalateButton(interaction, ticket) { const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0); if (currentTier >= tier) { - return interaction.reply({ content: `This ticket is already at tier ${tier + 1}.`, ephemeral: true }); + return interaction.reply({ content: `This ticket is already at tier ${tier + 1}.`, flags: MessageFlags.Ephemeral }); } const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-'); @@ -362,7 +365,7 @@ async function handleEscalateButton(interaction, ticket) { if (!categoryId && !interaction.channel.isThread()) { return interaction.reply({ content: `Tier ${tier + 1} (ESCALATED${tier + 1}) is not configured for this ticket type.`, - ephemeral: true + flags: MessageFlags.Ephemeral }); } @@ -372,12 +375,12 @@ async function handleEscalateButton(interaction, ticket) { async function handleDeescalateButton(interaction, ticket) { const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0); if (currentTier === 0) { - return interaction.reply({ content: 'This ticket is not escalated.', ephemeral: true }); + return interaction.reply({ content: 'This ticket is not escalated.', flags: MessageFlags.Ephemeral }); } await runDeferred(interaction, 'deescalate', () => runDeescalation(interaction, ticket), - { ephemeral: true } + { flags: MessageFlags.Ephemeral } ); } @@ -455,12 +458,14 @@ async function runFinalClose(interaction, ticket, sendEmail = true) { const parentCatId = ticket.parentCategoryId; const guildRef = interaction.guild; - setTimeout(() => interaction.channel.delete().catch(() => {}), 5000); - setTimeout(() => { + // Lazy require — same cycle reason as in handleConfirmCloseRequest above. + const { trackTimeout } = require('../broccolini-discord'); + trackTimeout(setTimeout(() => interaction.channel.delete().catch(() => {}), 5000)); + trackTimeout(setTimeout(() => { if (parentCatId && guildRef) { cleanupEmptyOverflowCategory(guildRef, parentCatId, CONFIG.TICKET_CATEGORY_NAME).catch(() => {}); } - }, 6000); + }, 6000)); } catch (e) { console.error('Close ticket error:', e); } @@ -494,7 +499,12 @@ function renderTranscriptHeader(channelName, senderEmail, openedStr, closedStr) } async function dmTranscriptToCreator(client, ticket, channelName, transcriptText, openedStr, closedStr) { - const creatorId = ticket.gmailThreadId.split('-').pop(); + // Prefer ticket.creatorId (stored on creation). Fall back to legacy parsing for + // pre-creatorId modal tickets only — split-pop returns the wrong value for + // discord-msg-* tickets (it yields the message ID, not the user ID). + const creatorId = ticket.creatorId + || (ticket.gmailThreadId.startsWith('discord-msg-') ? null : ticket.gmailThreadId.split('-').pop()); + if (!creatorId) return; try { const creator = await client.users.fetch(creatorId); const dmFile = new AttachmentBuilder(Buffer.from(transcriptText), { @@ -524,13 +534,15 @@ async function postCloseLogEntry(interaction, ticket, channelName) { let logMsg; if (ticket.gmailThreadId?.startsWith('discord-')) { - const creatorId = ticket.gmailThreadId.split('-').pop(); - try { - const creator = await interaction.client.users.fetch(creatorId); - logMsg = `Closed ${creator.toString()}'s **${channelName}** by ${closerMention} (${closerDisplayName})`; - } catch { - logMsg = `Closed **${channelName}** by ${closerMention} (${closerDisplayName})`; + const creatorId = ticket.creatorId + || (ticket.gmailThreadId.startsWith('discord-msg-') ? null : ticket.gmailThreadId.split('-').pop()); + let creator = null; + if (creatorId) { + creator = await interaction.client.users.fetch(creatorId).catch(() => null); } + logMsg = creator + ? `Closed ${creator.toString()}'s **${channelName}** by ${closerMention} (${closerDisplayName})` + : `Closed **${channelName}** by ${closerMention} (${closerDisplayName})`; } else { logMsg = `Closed **${channelName}** (${ticket.senderEmail}) by ${closerMention} (${closerDisplayName})`; } @@ -542,7 +554,7 @@ async function postCloseLogEntry(interaction, ticket, channelName) { // ============================================================ async function handleTicketModal(interaction) { - await interaction.deferReply({ ephemeral: true }); + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); const email = interaction.fields.getTextInputValue('ticket_email').trim().toLowerCase(); const game = interaction.fields.getTextInputValue('ticket_game').trim(); @@ -578,7 +590,10 @@ async function handleTicketModal(interaction) { let channel; 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. + // Initial permissionOverwrites on guild.channels.create are safe-by-construction: + // the channel doesn't exist yet, so there's no in-flight rename/send/move to race + // against. Any *subsequent* mutation on this channel (add/remove user, move, + // topic, rename) must go through services/channelQueue.js. channel = await guild.channels.create({ name: unclaimedName, type: ChannelType.GuildText, @@ -613,6 +628,7 @@ async function handleTicketModal(interaction) { ticketNumber, priority, lastActivity: now, + creatorId: interaction.user.id, parentCategoryId: parentCategoryIdForTicket }); diff --git a/handlers/commands.js b/handlers/commands.js index a49d3ff..7fe0e45 100644 --- a/handlers/commands.js +++ b/handlers/commands.js @@ -12,6 +12,7 @@ const { ButtonStyle, AttachmentBuilder, EmbedBuilder, + MessageFlags, ModalBuilder, TextInputBuilder, TextInputStyle, @@ -23,7 +24,7 @@ const { getPriorityEmoji, replaceVariables, isStaff } = require('../utils'); const { makeTicketName, resolveCreatorNickname, getOrCreateTicketCategory, checkTicketCreationRateLimit } = require('../services/tickets'); const { sendTicketNotificationEmail } = require('../services/gmail'); const { getTicketActionRow } = require('../utils/ticketComponents'); -const { enqueueRename, enqueueMove, enqueueSend } = require('../services/channelQueue'); +const { enqueueRename, enqueueMove, enqueueOverwrite, enqueueTopic, enqueueSend } = require('../services/channelQueue'); const { setNotifyDm } = require('../services/staffSettings'); const { pinMessage } = require('../services/pinMessage'); const { logError, logTicketEvent } = require('../services/debugLog'); @@ -49,7 +50,7 @@ async function requireStaffRole(interaction) { 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 + flags: MessageFlags.Ephemeral }); return true; } @@ -222,10 +223,10 @@ async function handleEscalate(interaction) { 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 }); + return interaction.reply({ content: 'This ticket is already at tier 3 support.', flags: MessageFlags.Ephemeral }); } if (nextTier <= currentTier) { - return interaction.reply({ content: 'Ticket is already at or past that tier.', ephemeral: true }); + return interaction.reply({ content: 'Ticket is already at or past that tier.', flags: MessageFlags.Ephemeral }); } const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-'); @@ -236,7 +237,7 @@ async function handleEscalate(interaction) { if (!categoryId && !interaction.channel.isThread()) { return interaction.reply({ content: `${configKey} is not configured for ${isDiscordTicket ? 'Discord' : 'email'} tickets.`, - ephemeral: true + flags: MessageFlags.Ephemeral }); } @@ -251,12 +252,12 @@ async function handleDeescalate(interaction) { const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0); if (currentTier === 0) { - return interaction.reply({ content: 'This ticket is not escalated.', ephemeral: true }); + return interaction.reply({ content: 'This ticket is not escalated.', flags: MessageFlags.Ephemeral }); } await runDeferred(interaction, 'de-escalate', () => runDeescalation(interaction, ticket), - { ephemeral: true } + { flags: MessageFlags.Ephemeral } ); } @@ -266,11 +267,11 @@ async function handleNotifyDm(interaction) { await setNotifyDm(interaction.user.id, interaction.guildId, setting); await interaction.reply({ content: `DM notifications ${setting ? 'enabled ✅' : 'disabled 🔕'}.`, - ephemeral: true + flags: MessageFlags.Ephemeral }); } catch (err) { console.error('notifydm error:', err); - await interaction.reply({ content: 'Failed to update notification setting.', ephemeral: true }).catch(() => {}); + await interaction.reply({ content: 'Failed to update notification setting.', flags: MessageFlags.Ephemeral }).catch(() => {}); } } @@ -280,8 +281,7 @@ async function handleAdd(interaction) { if (!ticket) return; 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, { + await enqueueOverwrite(interaction.channel, user.id, { ViewChannel: true, SendMessages: true, ReadMessageHistory: true @@ -289,7 +289,7 @@ async function handleAdd(interaction) { 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 }); + await interaction.reply({ content: 'Failed to add user.', flags: MessageFlags.Ephemeral }); } } @@ -299,12 +299,11 @@ async function handleRemove(interaction) { if (!ticket) return; 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 enqueueOverwrite(interaction.channel, user.id, null, 'delete'); await interaction.reply({ content: `Removed ${user} from this ticket.`, allowedMentions: { parse: ['users'] } }); } catch (err) { console.error('Remove user error:', err); - await interaction.reply({ content: 'Failed to remove user.', ephemeral: true }); + await interaction.reply({ content: 'Failed to remove user.', flags: MessageFlags.Ephemeral }); } } @@ -318,7 +317,7 @@ async function handleTransfer(interaction) { 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 }); + return interaction.reply({ content: 'The target member must have the staff role.', flags: MessageFlags.Ephemeral }); } try { @@ -344,7 +343,7 @@ async function handleTransfer(interaction) { } } catch (err) { console.error('Transfer error:', err); - await interaction.reply({ content: 'Failed to transfer ticket.', ephemeral: true }); + await interaction.reply({ content: 'Failed to transfer ticket.', flags: MessageFlags.Ephemeral }); } } @@ -354,8 +353,7 @@ async function handleMove(interaction) { if (!ticket) return; 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 enqueueMove(interaction.channel, category.id); await interaction.reply(`Moved ticket to **${category.name}**.`); const logChan = await fetchLoggingChannel(interaction.client); @@ -366,7 +364,7 @@ async function handleMove(interaction) { } } catch (err) { console.error('Move error:', err); - await interaction.reply({ content: 'Failed to move ticket.', ephemeral: true }); + await interaction.reply({ content: 'Failed to move ticket.', flags: MessageFlags.Ephemeral }); } } @@ -374,17 +372,17 @@ async function handleStaffThread(interaction) { const sub = interaction.options.getSubcommand(); if (sub === 'toggle') { CONFIG.STAFF_THREAD_ENABLED = !CONFIG.STAFF_THREAD_ENABLED; - return interaction.reply({ content: `Staff threads are now **${CONFIG.STAFF_THREAD_ENABLED ? 'enabled' : 'disabled'}**.`, ephemeral: true }); + return interaction.reply({ content: `Staff threads are now **${CONFIG.STAFF_THREAD_ENABLED ? 'enabled' : 'disabled'}**.`, flags: MessageFlags.Ephemeral }); } if (sub === 'name') { const name = interaction.options.getString('thread_name').slice(0, 100); CONFIG.STAFF_THREAD_NAME = name; - return interaction.reply({ content: `Staff thread name set to **${name}**.`, ephemeral: true }); + return interaction.reply({ content: `Staff thread name set to **${name}**.`, flags: MessageFlags.Ephemeral }); } if (sub === 'autorole') { const enabled = interaction.options.getBoolean('enabled'); CONFIG.STAFF_THREAD_AUTO_ADD_ROLE = enabled; - return interaction.reply({ content: `Auto-add role to staff thread is now **${enabled ? 'enabled' : 'disabled'}**.`, ephemeral: true }); + return interaction.reply({ content: `Auto-add role to staff thread is now **${enabled ? 'enabled' : 'disabled'}**.`, flags: MessageFlags.Ephemeral }); } } @@ -393,28 +391,33 @@ async function handlePinMessages(interaction) { 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 }); + return interaction.reply({ content: `Auto-pin initial message is now **${enabled ? 'enabled' : 'disabled'}**.`, flags: MessageFlags.Ephemeral }); } if (sub === 'escalation') { CONFIG.PIN_ESCALATION_MESSAGE_ENABLED = enabled; - return interaction.reply({ content: `Auto-pin escalation message is now **${enabled ? 'enabled' : 'disabled'}**.`, ephemeral: true }); + return interaction.reply({ content: `Auto-pin escalation message is now **${enabled ? 'enabled' : 'disabled'}**.`, flags: MessageFlags.Ephemeral }); } if (sub === 'suppress') { CONFIG.PIN_SUPPRESS_SYSTEM_MESSAGE = enabled; - return interaction.reply({ content: `Suppress pin system message is now **${enabled ? 'enabled' : 'disabled'}**.`, ephemeral: true }); + return interaction.reply({ content: `Suppress pin system message is now **${enabled ? 'enabled' : 'disabled'}**.`, flags: MessageFlags.Ephemeral }); } } async function handleGmailPoll(interaction) { - const seconds = parseInt(interaction.options.getString('interval'), 10); + const requested = parseInt(interaction.options.getString('interval'), 10); + // Defense-in-depth: the slash command's addChoices already floors at 30s, but + // clamp the resolved ms here too so any future caller (or skewed input) can't + // drop below 30s and trip Gmail's per-user quota under sustained load. + const ms = Math.max(30000, requested * 1000); + const seconds = ms / 1000; // Lazy require — broccolini-discord re-exports this and we'd otherwise cycle. const { setGmailPollInterval } = require('../broccolini-discord'); - setGmailPollInterval(seconds * 1000); + setGmailPollInterval(ms); logTicketEvent('Gmail poll interval updated', [ { name: 'Interval', value: `${seconds}s` }, { name: 'Set by', value: interaction.user.tag } ], interaction).catch(() => {}); - return interaction.reply({ content: `Gmail poll interval set to ${seconds} seconds.`, ephemeral: true }); + return interaction.reply({ content: `Gmail poll interval set to ${seconds} seconds.`, flags: MessageFlags.Ephemeral }); } async function handleCloseTimer(interaction) { @@ -424,13 +427,13 @@ async function handleCloseTimer(interaction) { { 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 }); + return interaction.reply({ content: `Force-close timer set to ${seconds} seconds.`, flags: MessageFlags.Ephemeral }); } async function handleCancelClose(interaction) { const pending = pendingCloses.get(interaction.channel.id); if (!pending) { - return interaction.reply({ content: 'No pending close for this channel.', ephemeral: true }); + return interaction.reply({ content: 'No pending close for this channel.', flags: MessageFlags.Ephemeral }); } clearTimeout(pending.timeout); logTicketEvent('Force-close cancelled', [ @@ -439,7 +442,7 @@ async function handleCancelClose(interaction) { { name: 'Original setter', value: pending.username || 'Unknown' } ], interaction).catch(() => {}); pendingCloses.delete(interaction.channel.id); - return interaction.reply({ content: 'Close cancelled.', ephemeral: true }); + return interaction.reply({ content: 'Close cancelled.', flags: MessageFlags.Ephemeral }); } async function handleForceClose(interaction) { @@ -447,7 +450,7 @@ async function handleForceClose(interaction) { if (!ticket) return; if (pendingCloses.has(interaction.channel.id)) { - return interaction.reply({ content: 'A close is already pending for this ticket.', ephemeral: true }); + return interaction.reply({ content: 'A close is already pending for this ticket.', flags: MessageFlags.Ephemeral }); } const timerSeconds = CONFIG.FORCE_CLOSE_TIMER; @@ -529,12 +532,11 @@ async function handleTopic(interaction) { if (!ticket) return; try { - // TODO(queue-migrate): setTopic bypasses channelQueue — could race a pending rename/send on the same channel. - await interaction.channel.setTopic(text); + await enqueueTopic(interaction.channel, text); await interaction.reply('Topic updated successfully.'); } catch (err) { console.error('Topic error:', err); - await interaction.reply({ content: 'Failed to update topic.', ephemeral: true }); + await interaction.reply({ content: 'Failed to update topic.', flags: MessageFlags.Ephemeral }); } } @@ -559,7 +561,7 @@ async function handleResponse(interaction) { if (interaction.deferred) { await interaction.editReply(errorMsg); } else { - await interaction.reply({ content: errorMsg, ephemeral: true }); + await interaction.reply({ content: errorMsg, flags: MessageFlags.Ephemeral }); } } } @@ -568,7 +570,7 @@ async function handleResponseSend(interaction) { const name = interaction.options.getString('name'); const tag = await Tag.findOne({ name }).lean(); if (!tag) { - return interaction.reply({ content: `❌ Tag "${name}" not found.`, ephemeral: true }); + return interaction.reply({ content: `❌ Tag "${name}" not found.`, flags: MessageFlags.Ephemeral }); } const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean(); @@ -595,13 +597,13 @@ async function handleResponseCreate(interaction) { try { await Tag.create({ name, content, createdBy: interaction.user.id }); - await interaction.reply({ content: `✅ Tag "${name}" created successfully.`, ephemeral: true }); + await interaction.reply({ content: `✅ Tag "${name}" created successfully.`, flags: MessageFlags.Ephemeral }); } catch (err) { if (err.code === 11000 || err.message?.includes('duplicate')) { - await interaction.reply({ content: `❌ Tag "${name}" already exists.`, ephemeral: true }); + await interaction.reply({ content: `❌ Tag "${name}" already exists.`, flags: MessageFlags.Ephemeral }); } else { logError('tag-create', err, interaction).catch(() => {}); - await interaction.reply({ content: '❌ Failed to create tag.', ephemeral: true }); + await interaction.reply({ content: '❌ Failed to create tag.', flags: MessageFlags.Ephemeral }); } } } @@ -613,13 +615,13 @@ async function handleResponseEdit(interaction) { try { const result = await Tag.updateOne({ name }, { $set: { content } }); if (result.matchedCount === 0) { - await interaction.reply({ content: `❌ Tag "${name}" not found.`, ephemeral: true }); + await interaction.reply({ content: `❌ Tag "${name}" not found.`, flags: MessageFlags.Ephemeral }); } else { - await interaction.reply({ content: `✅ Tag "${name}" updated successfully.`, ephemeral: true }); + await interaction.reply({ content: `✅ Tag "${name}" updated successfully.`, flags: MessageFlags.Ephemeral }); } } catch (err) { logError('tag-edit', err, interaction).catch(() => {}); - await interaction.reply({ content: '❌ Failed to edit tag.', ephemeral: true }); + await interaction.reply({ content: '❌ Failed to edit tag.', flags: MessageFlags.Ephemeral }); } } @@ -641,12 +643,12 @@ async function handleResponseDelete(interaction) { return interaction.reply({ content: `⚠️ Are you sure you want to delete the tag "${name}"? This action cannot be undone.`, components: [confirmRow], - ephemeral: true + flags: MessageFlags.Ephemeral }); } async function handleResponseList(interaction) { - await interaction.deferReply({ ephemeral: true }); + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); const tags = await Tag.find().sort({ useCount: -1 }).select('name useCount').lean(); if (!tags || tags.length === 0) { @@ -703,7 +705,7 @@ async function handleSignature(interaction) { } 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(() => {}); + await interaction.reply({ content: 'Failed to open signature settings.', flags: MessageFlags.Ephemeral }).catch(() => {}); } } } @@ -740,7 +742,7 @@ async function handleHelp(interaction) { ]) .setFooter({ text: 'Click buttons on ticket messages to claim/close' }); - await interaction.reply({ embeds: [embed], ephemeral: true }); + await interaction.reply({ embeds: [embed], flags: MessageFlags.Ephemeral }); } async function handlePanel(interaction) { @@ -761,10 +763,10 @@ async function handlePanel(interaction) { try { await enqueueSend(channel, { embeds: [embed], components: [row] }); - await interaction.reply({ content: `Panel created in ${channel}!`, ephemeral: true }); + await interaction.reply({ content: `Panel created in ${channel}!`, flags: MessageFlags.Ephemeral }); } catch (err) { console.error('Panel creation error:', err); - await interaction.reply({ content: 'Failed to create panel.', ephemeral: true }); + await interaction.reply({ content: 'Failed to create panel.', flags: MessageFlags.Ephemeral }); } } @@ -815,7 +817,7 @@ function buildPanelButtonRow(panelType) { // ============================================================ async function handleCreateTicketFromMessage(interaction) { - await interaction.deferReply({ ephemeral: true }); + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); const rateLimit = checkTicketCreationRateLimit(interaction.user.id); if (!rateLimit.allowed) { @@ -879,6 +881,7 @@ async function handleCreateTicketFromMessage(interaction) { ticketNumber, priority: 'normal', lastActivity: now, + creatorId: message.author.id, parentCategoryId: parentCategoryIdForTicket }); @@ -920,7 +923,7 @@ async function handleCreateTicketFromMessage(interaction) { } async function handleViewUserTickets(interaction) { - await interaction.deferReply({ ephemeral: true }); + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); try { const targetUser = interaction.targetUser; diff --git a/handlers/messages.js b/handlers/messages.js index 92cb3a1..50f8589 100644 --- a/handlers/messages.js +++ b/handlers/messages.js @@ -7,6 +7,7 @@ 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'); const Ticket = mongoose.model('Ticket'); @@ -24,7 +25,7 @@ async function handleDiscordReply(m) { Ticket.updateOne( { discordThreadId: m.channel.id }, { $set: { lastActivity: new Date() } } - ).catch(() => {}); + ).catch(err => logError('updateActivity', err).catch(() => {})); // DM the claimer if they have notifydm on and a non-staff user replied. if (ticket.claimerId && !isStaffMember && m.author.id !== ticket.claimerId) { diff --git a/handlers/sharedHelpers.js b/handlers/sharedHelpers.js index 4cb1fcf..72774c2 100644 --- a/handlers/sharedHelpers.js +++ b/handlers/sharedHelpers.js @@ -4,6 +4,7 @@ * Both handlers/commands.js and handlers/buttons.js use these to avoid * repeating the lookup-and-defer-and-try-catch pattern across 30+ branches. */ +const { MessageFlags } = require('discord.js'); const { mongoose } = require('../db-connection'); const { logError } = require('../services/debugLog'); @@ -20,7 +21,7 @@ const Ticket = mongoose.model('Ticket'); async function findTicketForChannel(interaction, missingMessage = 'This channel is not linked to a ticket.') { const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean(); if (!ticket) { - await interaction.reply({ content: missingMessage, ephemeral: true }); + await interaction.reply({ content: missingMessage, flags: MessageFlags.Ephemeral }); return null; } return ticket; @@ -34,18 +35,18 @@ async function findTicketForChannel(interaction, missingMessage = 'This channel * @param {import('discord.js').Interaction} interaction * @param {string} verb * @param {() => Promise} fn - * @param {{ ephemeral?: boolean }} [opts] + * @param {{ flags?: number }} [opts] - pass `MessageFlags.Ephemeral` for ephemeral defer */ -async function runDeferred(interaction, verb, fn, { ephemeral = false } = {}) { +async function runDeferred(interaction, verb, fn, { flags } = {}) { try { - await interaction.deferReply({ ephemeral }); + await interaction.deferReply(flags ? { flags } : {}); await fn(); } catch (err) { console.error(`${verb} error:`, err); logError(verb, err, interaction).catch(() => {}); const msg = `Failed to ${verb} this ticket.`; await interaction.editReply({ content: msg }).catch(() => - interaction.followUp({ content: msg, ephemeral: true }).catch(() => {}) + interaction.followUp({ content: msg, flags: MessageFlags.Ephemeral }).catch(() => {}) ); } } diff --git a/models.js b/models.js index f09aa0a..1d9d3b6 100644 --- a/models.js +++ b/models.js @@ -19,6 +19,7 @@ const ticketSchema = new mongoose.Schema({ lastActivity: Date, welcomeMessageId: String, claimerId: String, + creatorId: String, parentCategoryId: String, pendingDelete: { type: Boolean, default: false } }); diff --git a/scripts/backfill-creatorId.js b/scripts/backfill-creatorId.js new file mode 100644 index 0000000..47f61e3 --- /dev/null +++ b/scripts/backfill-creatorId.js @@ -0,0 +1,88 @@ +#!/usr/bin/env node +/** + * One-shot backfill for Ticket.creatorId on Discord-originated tickets. + * + * Modal-created tickets (`discord-${ts}-${userId}`): tail segment is the user ID — extract it. + * Context-menu tickets (`discord-msg-${ts}-${msgId}`): tail segment is the *message* ID, not the + * user ID. Set creatorId = null and let runtime code fall through to the default-name path. + * Recovering these would require a Discord API fetch per message, which is unreliable for + * already-deleted ticket channels. + * + * Idempotent: skips tickets that already have creatorId set. + * + * Usage: + * node scripts/backfill-creatorId.js # dry-run, prints summary only + * node scripts/backfill-creatorId.js --apply # writes + */ + +require('dotenv').config(); +const { connectMongoDB, closeMongoDB, mongoose } = require('../db-connection'); + +const APPLY = process.argv.includes('--apply'); +const MODAL_RE = /^discord-\d+-(\d{17,20})$/; + +async function main() { + if (!process.env.MONGODB_URI) { + console.error('MONGODB_URI not set'); + process.exit(1); + } + await connectMongoDB(process.env.MONGODB_URI); + + const Ticket = mongoose.model('Ticket'); + + const candidates = await Ticket.find({ + gmailThreadId: /^discord-/, + creatorId: { $in: [null, undefined, ''] } + }).select('gmailThreadId creatorId').lean(); + + let modalHits = 0; + let msgSkipped = 0; + let unknown = 0; + const ops = []; + + for (const t of candidates) { + const id = t.gmailThreadId; + const modalMatch = id.match(MODAL_RE); + if (modalMatch) { + modalHits++; + ops.push({ + updateOne: { + filter: { _id: t._id }, + update: { $set: { creatorId: modalMatch[1] } } + } + }); + continue; + } + if (id.startsWith('discord-msg-')) { + msgSkipped++; + continue; + } + unknown++; + } + + console.log(`Scanned ${candidates.length} Discord-originated tickets without creatorId.`); + console.log(` Modal-pattern recoverable: ${modalHits}`); + console.log(` Context-menu (unrecoverable, leaving null): ${msgSkipped}`); + console.log(` Unknown shape: ${unknown}`); + + if (!APPLY) { + console.log('\nDry-run only. Re-run with --apply to write changes.'); + await closeMongoDB(); + return; + } + + if (ops.length === 0) { + console.log('Nothing to write.'); + await closeMongoDB(); + return; + } + + const res = await Ticket.bulkWrite(ops, { ordered: false }); + console.log(`Wrote ${res.modifiedCount} updates.`); + await closeMongoDB(); +} + +main().catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/services/channelQueue.js b/services/channelQueue.js index 3a8fad5..c6eefe1 100644 --- a/services/channelQueue.js +++ b/services/channelQueue.js @@ -113,6 +113,81 @@ function enqueueMove(channel, categoryId) { return next; } +// Shares renameChains so a permissionOverwrite mutation serializes with pending +// renames/moves on the same channel. Mode 'create' calls +// `channel.permissionOverwrites.create(id, perms)`; 'delete' calls +// `channel.permissionOverwrites.delete(id)`. No coalescing. +function enqueueOverwrite(channel, id, perms, mode = 'create') { + let entry = renameChains.get(channel.id); + if (!entry) { + entry = { chain: Promise.resolve(), pendingName: null }; + renameChains.set(channel.id, entry); + } + + const next = entry.chain.catch(() => {}).then(() => + mode === 'delete' + ? channel.permissionOverwrites.delete(id) + : channel.permissionOverwrites.create(id, perms) + ); + entry.chain = next; + + next.catch((err) => { + logWarn('overwriteQueue', `Overwrite ${mode} failed for ${channel.name}: ${err && err.message || err}`).catch(() => {}); + const status = err && err.status; + const msg = (err && err.message) || String(err); + if (status === 401 || status === 403) { + logError( + 'overwriteQueue:token/permission', + new Error(`${status} channel=${channel.id} target=${id} mode=${mode}: ${msg}`) + ).catch(() => {}); + } else if (status === 429) { + logError( + 'overwriteQueue:ratelimited', + new Error(`429 channel=${channel.id} target=${id} mode=${mode}: ${msg}`) + ).catch(() => {}); + } + }).finally(() => { + if (renameChains.get(channel.id) === entry && entry.chain === next && entry.pendingName == null) { + renameChains.delete(channel.id); + } + }); + return next; +} + +// Shares renameChains so setTopic serializes with pending renames/moves. +function enqueueTopic(channel, text) { + let entry = renameChains.get(channel.id); + if (!entry) { + entry = { chain: Promise.resolve(), pendingName: null }; + renameChains.set(channel.id, entry); + } + + const next = entry.chain.catch(() => {}).then(() => channel.setTopic(text)); + entry.chain = next; + + next.catch((err) => { + logWarn('topicQueue', `Topic set failed for ${channel.name}: ${err && err.message || err}`).catch(() => {}); + const status = err && err.status; + const msg = (err && err.message) || String(err); + if (status === 401 || status === 403) { + logError( + 'topicQueue:token/permission', + new Error(`${status} channel=${channel.id}: ${msg}`) + ).catch(() => {}); + } else if (status === 429) { + logError( + 'topicQueue:ratelimited', + new Error(`429 channel=${channel.id}: ${msg}`) + ).catch(() => {}); + } + }).finally(() => { + if (renameChains.get(channel.id) === entry && entry.chain === next && entry.pendingName == null) { + renameChains.delete(channel.id); + } + }); + return next; +} + // Per-channel promise chain for send ordering and to prevent interleaving. const sendChains = new Map(); @@ -157,4 +232,4 @@ function enqueueDelete(channel) { return next; } -module.exports = { enqueueRename, enqueueMove, enqueueSend, enqueueDelete }; +module.exports = { enqueueRename, enqueueMove, enqueueOverwrite, enqueueTopic, enqueueSend, enqueueDelete }; diff --git a/services/pinMessage.js b/services/pinMessage.js index e51bcda..4111008 100644 --- a/services/pinMessage.js +++ b/services/pinMessage.js @@ -31,9 +31,9 @@ async function pinMessage(message, client) { } } catch (err) { if (err.code === 30003) { - await logWarn('pinMessage', `Max pins reached in channel #${message.channel.name} — could not pin message.`, client).catch(() => {}); + logWarn('pinMessage', `Max pins reached in channel #${message.channel.name} — could not pin message.`, client).catch(() => {}); } else { - await logWarn('pinMessage', `Failed to pin message in #${message.channel.name}: ${err.message}`, client).catch(() => {}); + logWarn('pinMessage', `Failed to pin message in #${message.channel.name}: ${err.message}`, client).catch(() => {}); } } } diff --git a/services/staffThread.js b/services/staffThread.js index 8c3ada1..52ed213 100644 --- a/services/staffThread.js +++ b/services/staffThread.js @@ -48,7 +48,7 @@ async function createStaffThread(channel, client) { if (err.code === 50024 || err.code === 160004) { logWarn('staffThread', `Cannot create private thread in ${channel.name}: server may lack Community features or required boost level (code ${err.code}).`).catch(() => {}); } - await logError('staffThread:create', err, null, client).catch(() => {}); + logError('staffThread:create', err, null, client).catch(() => {}); return null; } } @@ -71,7 +71,7 @@ async function addRoleMembersToThread(thread, guild, client) { await new Promise(r => setTimeout(r, 300)); } } catch (err) { - await logError('staffThread:addMembers', err, null, client).catch(() => {}); + logError('staffThread:addMembers', err, null, client).catch(() => {}); } } diff --git a/services/tickets.js b/services/tickets.js index c170e23..778afda 100644 --- a/services/tickets.js +++ b/services/tickets.js @@ -51,7 +51,12 @@ function toDiscordSafeName(str) { */ async function resolveCreatorNickname(guild, ticket) { if (ticket.gmailThreadId.startsWith('discord-')) { - const creatorUserId = ticket.gmailThreadId.split('-').pop(); + // Prefer ticket.creatorId (stored on creation). Legacy fallback parses the + // tail segment, which is correct for discord-${ts}-${userId} but returns + // the message ID for discord-msg-${ts}-${msgId} — skip the parse for those. + const creatorUserId = ticket.creatorId + || (ticket.gmailThreadId.startsWith('discord-msg-') ? null : ticket.gmailThreadId.split('-').pop()); + if (!creatorUserId) return getSenderLocal(ticket.senderEmail); try { const member = await guild.members.fetch(creatorUserId); return member.displayName; @@ -305,14 +310,16 @@ async function checkAutoClose(client, sendTicketClosedEmail) { await sendTicketClosedEmail(ticket, 'Auto-Close System', null); - setTimeout(() => { + // Lazy require — broccolini-discord re-exports trackTimeout; cycle-safe. + const { trackTimeout } = require('../broccolini-discord'); + trackTimeout(setTimeout(() => { enqueueDelete(channel).then(() => { withRetry(() => Ticket.updateOne( { gmailThreadId: ticket.gmailThreadId }, { $unset: { pendingDelete: '' } } )).catch(() => {}); }).catch(() => {}); - }, 5000); + }, 5000)); } } catch (error) { console.error(`Auto-close error for ticket ${ticket.gmailThreadId}:`, error);