audit week 1: creator ID tracking, channel-queue migration, deprecation cleanup

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.
This commit is contained in:
2026-05-08 20:19:14 +00:00
parent e3b3b8d48c
commit cdf85f6364
12 changed files with 287 additions and 97 deletions

View File

@@ -2,7 +2,7 @@
* Entry point initializes the Discord bot, wires event handlers, * Entry point initializes the Discord bot, wires event handlers,
* connects to MongoDB, starts Gmail polling, and runs the Express healthcheck. * 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 express = require('express');
const { connectMongoDB, closeMongoDB } = require('./db-connection'); const { connectMongoDB, closeMongoDB } = require('./db-connection');
const { CONFIG } = require('./config'); const { CONFIG } = require('./config');
@@ -86,7 +86,7 @@ const client = new Client({
// --- EVENT: interactionCreate --- // --- EVENT: interactionCreate ---
async function safeReplyError(interaction) { 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) { if (interaction.deferred || interaction.replied) {
await interaction.followUp(payload).catch(() => {}); await interaction.followUp(payload).catch(() => {});
} else { } else {
@@ -132,13 +132,13 @@ client.on('interactionCreate', async interaction => {
await interaction.reply({ await interaction.reply({
content: 'Signature settings saved successfully!', content: 'Signature settings saved successfully!',
ephemeral: true flags: MessageFlags.Ephemeral
}); });
} catch (err) { } catch (err) {
console.error('Signature modal submit error:', err); console.error('Signature modal submit error:', err);
await interaction.reply({ await interaction.reply({
content: 'Failed to save signature settings.', content: 'Failed to save signature settings.',
ephemeral: true flags: MessageFlags.Ephemeral
}); });
} }
return; return;

View File

@@ -357,8 +357,6 @@ async function registerCommands() {
.setDescription('Poll interval') .setDescription('Poll interval')
.setRequired(true) .setRequired(true)
.addChoices( .addChoices(
{ name: '5s', value: '5' },
{ name: '10s', value: '10' },
{ name: '30s', value: '30' }, { name: '30s', value: '30' },
{ name: '45s', value: '45' }, { name: '45s', value: '45' },
{ name: '1m', value: '60' }, { name: '1m', value: '60' },

View File

@@ -15,6 +15,7 @@ const {
ButtonStyle, ButtonStyle,
AttachmentBuilder, AttachmentBuilder,
EmbedBuilder, EmbedBuilder,
MessageFlags,
PermissionFlagsBits, PermissionFlagsBits,
ModalBuilder, ModalBuilder,
TextInputBuilder, TextInputBuilder,
@@ -121,7 +122,7 @@ async function handleTagDeleteConfirm(interaction) {
async function handleClaimButton(interaction, ticket) { async function handleClaimButton(interaction, ticket) {
const freshTicket = await Ticket.findOne({ gmailThreadId: ticket.gmailThreadId }).lean(); const freshTicket = await Ticket.findOne({ gmailThreadId: ticket.gmailThreadId }).lean();
if (!freshTicket) { 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; const isClaimed = !!freshTicket.claimedBy;
@@ -131,19 +132,19 @@ async function handleClaimButton(interaction, ticket) {
const [row0] = interaction.message.components; const [row0] = interaction.message.components;
if (!row0) { 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 row = ActionRowBuilder.from(row0);
const [btnClose, btnClaim] = row.components; const [btnClose, btnClaim] = row.components;
if (!btnClose || !btnClaim) { 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) { if (isClaimed && !isClaimedByMe && !CONFIG.ALLOW_CLAIM_OVERWRITE) {
return interaction.reply({ return interaction.reply({
content: `This ticket is already claimed by **${freshTicket.claimedBy}**.`, 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; const timerSeconds = CONFIG.FORCE_CLOSE_TIMER;
if (pendingCloses.has(interaction.channel.id)) { 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( const cancelRow = new ActionRowBuilder().addComponents(
@@ -289,7 +290,9 @@ async function handleConfirmCloseRequest(interaction, ticket) {
const channelName = interaction.channel.name; const channelName = interaction.channel.name;
const userTag = interaction.user.tag; 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); const pending = pendingCloses.get(channelId);
pendingCloses.delete(channelId); pendingCloses.delete(channelId);
const freshTicket = await Ticket.findOne({ discordThreadId: channelId }).lean(); const freshTicket = await Ticket.findOne({ discordThreadId: channelId }).lean();
@@ -303,7 +306,7 @@ async function handleConfirmCloseRequest(interaction, ticket) {
const effectiveSendEmail = pending?.sendEmail ?? true; const effectiveSendEmail = pending?.sendEmail ?? true;
await runFinalClose(interaction, freshTicket, effectiveSendEmail); await runFinalClose(interaction, freshTicket, effectiveSendEmail);
}, timerSeconds * 1000); }, timerSeconds * 1000));
pendingCloses.set(channelId, { timeout: timerId, userId: interaction.user.id, username: userTag, sendEmail }); 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) { async function handleEscalatePrompt(interaction, ticket) {
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0); const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
if (currentTier >= 2) { 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 = []; const buttons = [];
@@ -338,7 +341,7 @@ async function handleEscalatePrompt(interaction, ticket) {
return interaction.reply({ return interaction.reply({
content: 'Escalate to which tier?', content: 'Escalate to which tier?',
components: [new ActionRowBuilder().addComponents(buttons)], 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); const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
if (currentTier >= tier) { 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-'); const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
@@ -362,7 +365,7 @@ async function handleEscalateButton(interaction, ticket) {
if (!categoryId && !interaction.channel.isThread()) { if (!categoryId && !interaction.channel.isThread()) {
return interaction.reply({ return interaction.reply({
content: `Tier ${tier + 1} (ESCALATED${tier + 1}) is not configured for this ticket type.`, 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) { async function handleDeescalateButton(interaction, ticket) {
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0); const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
if (currentTier === 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', await runDeferred(interaction, 'deescalate',
() => runDeescalation(interaction, ticket), () => runDeescalation(interaction, ticket),
{ ephemeral: true } { flags: MessageFlags.Ephemeral }
); );
} }
@@ -455,12 +458,14 @@ async function runFinalClose(interaction, ticket, sendEmail = true) {
const parentCatId = ticket.parentCategoryId; const parentCatId = ticket.parentCategoryId;
const guildRef = interaction.guild; const guildRef = interaction.guild;
setTimeout(() => interaction.channel.delete().catch(() => {}), 5000); // Lazy require — same cycle reason as in handleConfirmCloseRequest above.
setTimeout(() => { const { trackTimeout } = require('../broccolini-discord');
trackTimeout(setTimeout(() => interaction.channel.delete().catch(() => {}), 5000));
trackTimeout(setTimeout(() => {
if (parentCatId && guildRef) { if (parentCatId && guildRef) {
cleanupEmptyOverflowCategory(guildRef, parentCatId, CONFIG.TICKET_CATEGORY_NAME).catch(() => {}); cleanupEmptyOverflowCategory(guildRef, parentCatId, CONFIG.TICKET_CATEGORY_NAME).catch(() => {});
} }
}, 6000); }, 6000));
} catch (e) { } catch (e) {
console.error('Close ticket error:', 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) { 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 { try {
const creator = await client.users.fetch(creatorId); const creator = await client.users.fetch(creatorId);
const dmFile = new AttachmentBuilder(Buffer.from(transcriptText), { const dmFile = new AttachmentBuilder(Buffer.from(transcriptText), {
@@ -524,13 +534,15 @@ async function postCloseLogEntry(interaction, ticket, channelName) {
let logMsg; let logMsg;
if (ticket.gmailThreadId?.startsWith('discord-')) { if (ticket.gmailThreadId?.startsWith('discord-')) {
const creatorId = ticket.gmailThreadId.split('-').pop(); const creatorId = ticket.creatorId
try { || (ticket.gmailThreadId.startsWith('discord-msg-') ? null : ticket.gmailThreadId.split('-').pop());
const creator = await interaction.client.users.fetch(creatorId); let creator = null;
logMsg = `Closed ${creator.toString()}'s **${channelName}** by ${closerMention} (${closerDisplayName})`; if (creatorId) {
} catch { creator = await interaction.client.users.fetch(creatorId).catch(() => null);
logMsg = `Closed **${channelName}** by ${closerMention} (${closerDisplayName})`;
} }
logMsg = creator
? `Closed ${creator.toString()}'s **${channelName}** by ${closerMention} (${closerDisplayName})`
: `Closed **${channelName}** by ${closerMention} (${closerDisplayName})`;
} else { } else {
logMsg = `Closed **${channelName}** (${ticket.senderEmail}) by ${closerMention} (${closerDisplayName})`; logMsg = `Closed **${channelName}** (${ticket.senderEmail}) by ${closerMention} (${closerDisplayName})`;
} }
@@ -542,7 +554,7 @@ async function postCloseLogEntry(interaction, ticket, channelName) {
// ============================================================ // ============================================================
async function handleTicketModal(interaction) { 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 email = interaction.fields.getTextInputValue('ticket_email').trim().toLowerCase();
const game = interaction.fields.getTextInputValue('ticket_game').trim(); const game = interaction.fields.getTextInputValue('ticket_game').trim();
@@ -578,7 +590,10 @@ async function handleTicketModal(interaction) {
let channel; let channel;
try { 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({ channel = await guild.channels.create({
name: unclaimedName, name: unclaimedName,
type: ChannelType.GuildText, type: ChannelType.GuildText,
@@ -613,6 +628,7 @@ async function handleTicketModal(interaction) {
ticketNumber, ticketNumber,
priority, priority,
lastActivity: now, lastActivity: now,
creatorId: interaction.user.id,
parentCategoryId: parentCategoryIdForTicket parentCategoryId: parentCategoryIdForTicket
}); });

View File

@@ -12,6 +12,7 @@ const {
ButtonStyle, ButtonStyle,
AttachmentBuilder, AttachmentBuilder,
EmbedBuilder, EmbedBuilder,
MessageFlags,
ModalBuilder, ModalBuilder,
TextInputBuilder, TextInputBuilder,
TextInputStyle, TextInputStyle,
@@ -23,7 +24,7 @@ const { getPriorityEmoji, replaceVariables, isStaff } = require('../utils');
const { makeTicketName, resolveCreatorNickname, getOrCreateTicketCategory, checkTicketCreationRateLimit } = require('../services/tickets'); const { makeTicketName, resolveCreatorNickname, getOrCreateTicketCategory, checkTicketCreationRateLimit } = require('../services/tickets');
const { sendTicketNotificationEmail } = require('../services/gmail'); const { sendTicketNotificationEmail } = require('../services/gmail');
const { getTicketActionRow } = require('../utils/ticketComponents'); 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 { setNotifyDm } = require('../services/staffSettings');
const { pinMessage } = require('../services/pinMessage'); const { pinMessage } = require('../services/pinMessage');
const { logError, logTicketEvent } = require('../services/debugLog'); 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'; const roleMention = CONFIG.ROLE_ID_TO_PING ? `<@&${CONFIG.ROLE_ID_TO_PING}>` : 'support';
await interaction.reply({ await interaction.reply({
content: `This command is only available to the support team (${roleMention}).`, content: `This command is only available to the support team (${roleMention}).`,
ephemeral: true flags: MessageFlags.Ephemeral
}); });
return true; return true;
} }
@@ -222,10 +223,10 @@ async function handleEscalate(interaction) {
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0); const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
if (currentTier >= 2) { 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) { 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-'); const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
@@ -236,7 +237,7 @@ async function handleEscalate(interaction) {
if (!categoryId && !interaction.channel.isThread()) { if (!categoryId && !interaction.channel.isThread()) {
return interaction.reply({ return interaction.reply({
content: `${configKey} is not configured for ${isDiscordTicket ? 'Discord' : 'email'} tickets.`, 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); const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
if (currentTier === 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', await runDeferred(interaction, 'de-escalate',
() => runDeescalation(interaction, ticket), () => 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 setNotifyDm(interaction.user.id, interaction.guildId, setting);
await interaction.reply({ await interaction.reply({
content: `DM notifications ${setting ? 'enabled ✅' : 'disabled 🔕'}.`, content: `DM notifications ${setting ? 'enabled ✅' : 'disabled 🔕'}.`,
ephemeral: true flags: MessageFlags.Ephemeral
}); });
} catch (err) { } catch (err) {
console.error('notifydm error:', 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; if (!ticket) return;
try { try {
// TODO(queue-migrate): permissionOverwrites mutation bypasses channelQueue — could race a pending rename/send on the same channel. await enqueueOverwrite(interaction.channel, user.id, {
await interaction.channel.permissionOverwrites.create(user.id, {
ViewChannel: true, ViewChannel: true,
SendMessages: true, SendMessages: true,
ReadMessageHistory: true ReadMessageHistory: true
@@ -289,7 +289,7 @@ async function handleAdd(interaction) {
await interaction.reply({ content: `Added ${user} to this ticket.`, allowedMentions: { parse: ['users'] } }); await interaction.reply({ content: `Added ${user} to this ticket.`, allowedMentions: { parse: ['users'] } });
} catch (err) { } catch (err) {
console.error('Add user error:', 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; if (!ticket) return;
try { try {
// TODO(queue-migrate): permissionOverwrites mutation bypasses channelQueue — could race a pending rename/send on the same channel. await enqueueOverwrite(interaction.channel, user.id, null, 'delete');
await interaction.channel.permissionOverwrites.delete(user.id);
await interaction.reply({ content: `Removed ${user} from this ticket.`, allowedMentions: { parse: ['users'] } }); await interaction.reply({ content: `Removed ${user} from this ticket.`, allowedMentions: { parse: ['users'] } });
} catch (err) { } catch (err) {
console.error('Remove user error:', 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); const guildMember = await interaction.guild.members.fetch(member.id).catch(() => null);
if (!guildMember || !guildMember.roles.cache.has(staffRoleId)) { 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 { try {
@@ -344,7 +343,7 @@ async function handleTransfer(interaction) {
} }
} catch (err) { } catch (err) {
console.error('Transfer error:', 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; if (!ticket) return;
try { try {
// TODO(queue-migrate): setParent bypasses channelQueue (enqueueMove) — use enqueueMove so moves serialize with pending renames/sends. await enqueueMove(interaction.channel, category.id);
await interaction.channel.setParent(category.id, { lockPermissions: true });
await interaction.reply(`Moved ticket to **${category.name}**.`); await interaction.reply(`Moved ticket to **${category.name}**.`);
const logChan = await fetchLoggingChannel(interaction.client); const logChan = await fetchLoggingChannel(interaction.client);
@@ -366,7 +364,7 @@ async function handleMove(interaction) {
} }
} catch (err) { } catch (err) {
console.error('Move error:', 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(); const sub = interaction.options.getSubcommand();
if (sub === 'toggle') { if (sub === 'toggle') {
CONFIG.STAFF_THREAD_ENABLED = !CONFIG.STAFF_THREAD_ENABLED; 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') { if (sub === 'name') {
const name = interaction.options.getString('thread_name').slice(0, 100); const name = interaction.options.getString('thread_name').slice(0, 100);
CONFIG.STAFF_THREAD_NAME = name; 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') { if (sub === 'autorole') {
const enabled = interaction.options.getBoolean('enabled'); const enabled = interaction.options.getBoolean('enabled');
CONFIG.STAFF_THREAD_AUTO_ADD_ROLE = 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'); const enabled = interaction.options.getBoolean('enabled');
if (sub === 'initial') { if (sub === 'initial') {
CONFIG.PIN_INITIAL_MESSAGE_ENABLED = enabled; 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') { if (sub === 'escalation') {
CONFIG.PIN_ESCALATION_MESSAGE_ENABLED = enabled; 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') { if (sub === 'suppress') {
CONFIG.PIN_SUPPRESS_SYSTEM_MESSAGE = enabled; 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) { 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. // Lazy require — broccolini-discord re-exports this and we'd otherwise cycle.
const { setGmailPollInterval } = require('../broccolini-discord'); const { setGmailPollInterval } = require('../broccolini-discord');
setGmailPollInterval(seconds * 1000); setGmailPollInterval(ms);
logTicketEvent('Gmail poll interval updated', [ logTicketEvent('Gmail poll interval updated', [
{ name: 'Interval', value: `${seconds}s` }, { name: 'Interval', value: `${seconds}s` },
{ name: 'Set by', value: interaction.user.tag } { name: 'Set by', value: interaction.user.tag }
], interaction).catch(() => {}); ], 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) { async function handleCloseTimer(interaction) {
@@ -424,13 +427,13 @@ async function handleCloseTimer(interaction) {
{ name: 'Duration', value: `${seconds}s` }, { name: 'Duration', value: `${seconds}s` },
{ name: 'Set by', value: interaction.user.tag } { name: 'Set by', value: interaction.user.tag }
], interaction).catch(() => {}); ], 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) { async function handleCancelClose(interaction) {
const pending = pendingCloses.get(interaction.channel.id); const pending = pendingCloses.get(interaction.channel.id);
if (!pending) { 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); clearTimeout(pending.timeout);
logTicketEvent('Force-close cancelled', [ logTicketEvent('Force-close cancelled', [
@@ -439,7 +442,7 @@ async function handleCancelClose(interaction) {
{ name: 'Original setter', value: pending.username || 'Unknown' } { name: 'Original setter', value: pending.username || 'Unknown' }
], interaction).catch(() => {}); ], interaction).catch(() => {});
pendingCloses.delete(interaction.channel.id); 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) { async function handleForceClose(interaction) {
@@ -447,7 +450,7 @@ async function handleForceClose(interaction) {
if (!ticket) return; if (!ticket) return;
if (pendingCloses.has(interaction.channel.id)) { 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; const timerSeconds = CONFIG.FORCE_CLOSE_TIMER;
@@ -529,12 +532,11 @@ async function handleTopic(interaction) {
if (!ticket) return; if (!ticket) return;
try { try {
// TODO(queue-migrate): setTopic bypasses channelQueue — could race a pending rename/send on the same channel. await enqueueTopic(interaction.channel, text);
await interaction.channel.setTopic(text);
await interaction.reply('Topic updated successfully.'); await interaction.reply('Topic updated successfully.');
} catch (err) { } catch (err) {
console.error('Topic error:', 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) { if (interaction.deferred) {
await interaction.editReply(errorMsg); await interaction.editReply(errorMsg);
} else { } 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 name = interaction.options.getString('name');
const tag = await Tag.findOne({ name }).lean(); const tag = await Tag.findOne({ name }).lean();
if (!tag) { 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(); const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
@@ -595,13 +597,13 @@ async function handleResponseCreate(interaction) {
try { try {
await Tag.create({ name, content, createdBy: interaction.user.id }); 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) { } catch (err) {
if (err.code === 11000 || err.message?.includes('duplicate')) { 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 { } else {
logError('tag-create', err, interaction).catch(() => {}); 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 { try {
const result = await Tag.updateOne({ name }, { $set: { content } }); const result = await Tag.updateOne({ name }, { $set: { content } });
if (result.matchedCount === 0) { 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 { } else {
await interaction.reply({ content: `✅ Tag "${name}" updated successfully.`, ephemeral: true }); await interaction.reply({ content: `✅ Tag "${name}" updated successfully.`, flags: MessageFlags.Ephemeral });
} }
} catch (err) { } catch (err) {
logError('tag-edit', err, interaction).catch(() => {}); 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({ return interaction.reply({
content: `⚠️ Are you sure you want to delete the tag "${name}"? This action cannot be undone.`, content: `⚠️ Are you sure you want to delete the tag "${name}"? This action cannot be undone.`,
components: [confirmRow], components: [confirmRow],
ephemeral: true flags: MessageFlags.Ephemeral
}); });
} }
async function handleResponseList(interaction) { 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(); const tags = await Tag.find().sort({ useCount: -1 }).select('name useCount').lean();
if (!tags || tags.length === 0) { if (!tags || tags.length === 0) {
@@ -703,7 +705,7 @@ async function handleSignature(interaction) {
} catch (err) { } catch (err) {
console.error('Signature command error:', err); console.error('Signature command error:', err);
if (!interaction.replied && !interaction.deferred) { 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' }); .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) { async function handlePanel(interaction) {
@@ -761,10 +763,10 @@ async function handlePanel(interaction) {
try { try {
await enqueueSend(channel, { embeds: [embed], components: [row] }); 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) { } catch (err) {
console.error('Panel creation error:', 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) { async function handleCreateTicketFromMessage(interaction) {
await interaction.deferReply({ ephemeral: true }); await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const rateLimit = checkTicketCreationRateLimit(interaction.user.id); const rateLimit = checkTicketCreationRateLimit(interaction.user.id);
if (!rateLimit.allowed) { if (!rateLimit.allowed) {
@@ -879,6 +881,7 @@ async function handleCreateTicketFromMessage(interaction) {
ticketNumber, ticketNumber,
priority: 'normal', priority: 'normal',
lastActivity: now, lastActivity: now,
creatorId: message.author.id,
parentCategoryId: parentCategoryIdForTicket parentCategoryId: parentCategoryIdForTicket
}); });
@@ -920,7 +923,7 @@ async function handleCreateTicketFromMessage(interaction) {
} }
async function handleViewUserTickets(interaction) { async function handleViewUserTickets(interaction) {
await interaction.deferReply({ ephemeral: true }); await interaction.deferReply({ flags: MessageFlags.Ephemeral });
try { try {
const targetUser = interaction.targetUser; const targetUser = interaction.targetUser;

View File

@@ -7,6 +7,7 @@ const { extractRawEmail, isStaff } = require('../utils');
const { getGmailClient, sendGmailReply } = require('../services/gmail'); const { getGmailClient, sendGmailReply } = require('../services/gmail');
const { updateTicketActivity } = require('../services/tickets'); const { updateTicketActivity } = require('../services/tickets');
const { getNotifyDm } = require('../services/staffSettings'); const { getNotifyDm } = require('../services/staffSettings');
const { logError } = require('../services/debugLog');
const Ticket = mongoose.model('Ticket'); const Ticket = mongoose.model('Ticket');
@@ -24,7 +25,7 @@ async function handleDiscordReply(m) {
Ticket.updateOne( Ticket.updateOne(
{ discordThreadId: m.channel.id }, { discordThreadId: m.channel.id },
{ $set: { lastActivity: new Date() } } { $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. // DM the claimer if they have notifydm on and a non-staff user replied.
if (ticket.claimerId && !isStaffMember && m.author.id !== ticket.claimerId) { if (ticket.claimerId && !isStaffMember && m.author.id !== ticket.claimerId) {

View File

@@ -4,6 +4,7 @@
* Both handlers/commands.js and handlers/buttons.js use these to avoid * Both handlers/commands.js and handlers/buttons.js use these to avoid
* repeating the lookup-and-defer-and-try-catch pattern across 30+ branches. * repeating the lookup-and-defer-and-try-catch pattern across 30+ branches.
*/ */
const { MessageFlags } = require('discord.js');
const { mongoose } = require('../db-connection'); const { mongoose } = require('../db-connection');
const { logError } = require('../services/debugLog'); 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.') { async function findTicketForChannel(interaction, missingMessage = 'This channel is not linked to a ticket.') {
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean(); const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
if (!ticket) { if (!ticket) {
await interaction.reply({ content: missingMessage, ephemeral: true }); await interaction.reply({ content: missingMessage, flags: MessageFlags.Ephemeral });
return null; return null;
} }
return ticket; return ticket;
@@ -34,18 +35,18 @@ async function findTicketForChannel(interaction, missingMessage = 'This channel
* @param {import('discord.js').Interaction} interaction * @param {import('discord.js').Interaction} interaction
* @param {string} verb * @param {string} verb
* @param {() => Promise<void>} fn * @param {() => Promise<void>} 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 { try {
await interaction.deferReply({ ephemeral }); await interaction.deferReply(flags ? { flags } : {});
await fn(); await fn();
} catch (err) { } catch (err) {
console.error(`${verb} error:`, err); console.error(`${verb} error:`, err);
logError(verb, err, interaction).catch(() => {}); logError(verb, err, interaction).catch(() => {});
const msg = `Failed to ${verb} this ticket.`; const msg = `Failed to ${verb} this ticket.`;
await interaction.editReply({ content: msg }).catch(() => await interaction.editReply({ content: msg }).catch(() =>
interaction.followUp({ content: msg, ephemeral: true }).catch(() => {}) interaction.followUp({ content: msg, flags: MessageFlags.Ephemeral }).catch(() => {})
); );
} }
} }

View File

@@ -19,6 +19,7 @@ const ticketSchema = new mongoose.Schema({
lastActivity: Date, lastActivity: Date,
welcomeMessageId: String, welcomeMessageId: String,
claimerId: String, claimerId: String,
creatorId: String,
parentCategoryId: String, parentCategoryId: String,
pendingDelete: { type: Boolean, default: false } pendingDelete: { type: Boolean, default: false }
}); });

View File

@@ -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);
});

View File

@@ -113,6 +113,81 @@ function enqueueMove(channel, categoryId) {
return next; 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. // Per-channel promise chain for send ordering and to prevent interleaving.
const sendChains = new Map(); const sendChains = new Map();
@@ -157,4 +232,4 @@ function enqueueDelete(channel) {
return next; return next;
} }
module.exports = { enqueueRename, enqueueMove, enqueueSend, enqueueDelete }; module.exports = { enqueueRename, enqueueMove, enqueueOverwrite, enqueueTopic, enqueueSend, enqueueDelete };

View File

@@ -31,9 +31,9 @@ async function pinMessage(message, client) {
} }
} catch (err) { } catch (err) {
if (err.code === 30003) { 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 { } 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(() => {});
} }
} }
} }

View File

@@ -48,7 +48,7 @@ async function createStaffThread(channel, client) {
if (err.code === 50024 || err.code === 160004) { 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(() => {}); 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; return null;
} }
} }
@@ -71,7 +71,7 @@ async function addRoleMembersToThread(thread, guild, client) {
await new Promise(r => setTimeout(r, 300)); await new Promise(r => setTimeout(r, 300));
} }
} catch (err) { } catch (err) {
await logError('staffThread:addMembers', err, null, client).catch(() => {}); logError('staffThread:addMembers', err, null, client).catch(() => {});
} }
} }

View File

@@ -51,7 +51,12 @@ function toDiscordSafeName(str) {
*/ */
async function resolveCreatorNickname(guild, ticket) { async function resolveCreatorNickname(guild, ticket) {
if (ticket.gmailThreadId.startsWith('discord-')) { 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 { try {
const member = await guild.members.fetch(creatorUserId); const member = await guild.members.fetch(creatorUserId);
return member.displayName; return member.displayName;
@@ -305,14 +310,16 @@ async function checkAutoClose(client, sendTicketClosedEmail) {
await sendTicketClosedEmail(ticket, 'Auto-Close System', null); 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(() => { enqueueDelete(channel).then(() => {
withRetry(() => Ticket.updateOne( withRetry(() => Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId }, { gmailThreadId: ticket.gmailThreadId },
{ $unset: { pendingDelete: '' } } { $unset: { pendingDelete: '' } }
)).catch(() => {}); )).catch(() => {});
}).catch(() => {}); }).catch(() => {});
}, 5000); }, 5000));
} }
} catch (error) { } catch (error) {
console.error(`Auto-close error for ticket ${ticket.gmailThreadId}:`, error); console.error(`Auto-close error for ticket ${ticket.gmailThreadId}:`, error);