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:
@@ -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
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user