Files
indifferentketchup 3c13e55dad audit week 3 quality batch: QUAL-004/005/007/008/010 + SEC-002
QUAL-004 handlers/messages.js — DM-on-customer-reply now reads
guild.members.cache.get(claimerId) first and only falls back to
guild.members.fetch on cache miss. Avoids a REST round-trip per non-staff
reply on busy tickets. GuildMembers intent already keeps the cache warm.

QUAL-005 handlers/buttons.js (runFinalClose) + handlers/commands/close.js
(finalizeForceClose) — close paths now $unset welcomeMessageId alongside
the status: 'closed' write. Stops a stale message-ID from carrying into a
future reopen on the same Gmail thread, where escalation's "edit welcome
buttons" path would silently fail trying to fetch a message in a deleted
channel.

QUAL-007 services/configPersistence.js — writeEnvFile mismatch error now
includes the missing/extra key sets, not just count vs count. Saves the
operator from guessing which key vanished after a partial write.

QUAL-008 utils.js stripEmailQuotes — replaced order-dependent first-match
loop with an earliest-match-across-all-markers scan. The previous code
could truncate at a late "_____" signature underline even when an earlier
"On X wrote:" reply header was the real cutoff. New test in
tests/utils.test.js exercises the dual-marker case.

QUAL-010 broccolini-discord.js — moved `let httpServer / internalServer /
appReady` declarations from after the ready handler to before it. Same
runtime behavior (module-load completes before ready fires asynchronously),
but the read order now matches the assignment order.

SEC-002 routes/internalApi.js — POST /restart now goes through a tighter
2/min limiter on top of the shared 10/min internalLimiter. Defense in
depth in case INTERNAL_API_SECRET ever leaks; an attacker with the secret
can no longer crash-loop the container.

Skipped: QUAL-009 (re-checked the regex; ^\s*\n* → \n is already
idempotent — the audit finding was incorrect).

vitest run: 88/88 (one new test for QUAL-008).
2026-05-08 20:46:04 +00:00

129 lines
5.3 KiB
JavaScript

/**
* Force-close flow: /force-close, /cancel-close, /closetimer, plus the
* countdown-elapses finalize step and transcript renderer that the
* countdown's setTimeout calls back into.
*
* Note: the button-driven close path lives in handlers/buttons.js
* (handleCloseButton / handleConfirmCloseRequest / runFinalClose).
* This module covers the slash-command-driven path only.
*/
const { AttachmentBuilder, MessageFlags } = require('discord.js');
const { mongoose } = require('../../db-connection');
const { CONFIG } = require('../../config');
const { enqueueSend } = require('../../services/channelQueue');
const { logTicketEvent } = require('../../services/debugLog');
const { pendingCloses } = require('../pendingCloses');
const { findTicketForChannel } = require('../sharedHelpers');
const Ticket = mongoose.model('Ticket');
async function handleCloseTimer(interaction) {
const seconds = parseInt(interaction.options.getString('seconds'), 10);
CONFIG.FORCE_CLOSE_TIMER = seconds;
logTicketEvent('Close timer updated', [
{ name: 'Duration', value: `${seconds}s` },
{ name: 'Set by', value: interaction.user.tag }
], interaction).catch(() => {});
return interaction.reply({ content: `Force-close timer set to ${seconds} seconds.`, 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.', flags: MessageFlags.Ephemeral });
}
clearTimeout(pending.timeout);
logTicketEvent('Force-close cancelled', [
{ name: 'Ticket', value: interaction.channel.name || interaction.channel.id },
{ name: 'Cancelled by', value: interaction.user.tag },
{ name: 'Original setter', value: pending.username || 'Unknown' }
], interaction).catch(() => {});
pendingCloses.delete(interaction.channel.id);
return interaction.reply({ content: 'Close cancelled.', flags: MessageFlags.Ephemeral });
}
async function handleForceClose(interaction) {
const ticket = await findTicketForChannel(interaction);
if (!ticket) return;
if (pendingCloses.has(interaction.channel.id)) {
return interaction.reply({ content: 'A close is already pending for this ticket.', flags: MessageFlags.Ephemeral });
}
const timerSeconds = CONFIG.FORCE_CLOSE_TIMER;
await interaction.reply(`Closing ticket in ${timerSeconds} seconds. Use \`/cancel-close\` to abort.`);
const channelRef = interaction.channel;
const clientRef = interaction.client;
const timerId = setTimeout(() => finalizeForceClose(channelRef, clientRef), timerSeconds * 1000);
pendingCloses.set(channelRef.id, { timeout: timerId, userId: interaction.user.id, username: interaction.user.tag });
}
/** Performs the actual force-close work after the countdown elapses. */
async function finalizeForceClose(channelRef, clientRef) {
pendingCloses.delete(channelRef.id);
const freshTicket = await Ticket.findOne({ discordThreadId: channelRef.id }).lean();
if (!freshTicket || freshTicket.status === 'closed') return;
try {
// $unset welcomeMessageId so a future reopen on this thread doesn't carry
// a stale message ID pointing into the now-deleted channel.
await Ticket.updateOne(
{ gmailThreadId: freshTicket.gmailThreadId },
{ $set: { status: 'closed' }, $unset: { welcomeMessageId: '' } }
);
await enqueueSend(channelRef, 'Ticket force-closed. Archiving...');
await postTranscript(channelRef, clientRef, freshTicket).catch(tErr =>
console.error('Transcript error (force-close):', tErr)
);
setTimeout(() => {
channelRef.delete('Ticket force-closed').catch(e =>
console.error('Failed to delete channel:', e)
);
}, 5000);
} catch (err) {
console.error('Force close error:', err);
}
}
/** Render and post a closing transcript for a ticket. */
async function postTranscript(channelRef, clientRef, freshTicket) {
await enqueueSend(channelRef, CONFIG.DISCORD_CLOSE_MESSAGE);
const messages = await channelRef.messages.fetch({ limit: 100 });
const log =
`TRANSCRIPT: ${freshTicket.subject}\nUser: ${freshTicket.senderEmail}\n---\n` +
messages
.reverse()
.map(m => `[${m.createdAt.toLocaleString()}] ${m.author.tag}: ${m.cleanContent}`)
.join('\n');
const file = new AttachmentBuilder(Buffer.from(log), {
name: `transcript-${channelRef.name}.txt`
});
const transcriptChan = await clientRef.channels
.fetch(CONFIG.TRANSCRIPT_CHANNEL_ID)
.catch(() => null);
if (!transcriptChan) return;
const fmt = (d) => new Date(d).toLocaleString('en-US', {
month: '2-digit', day: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit', second: '2-digit',
hour12: true, timeZoneName: 'short'
});
const openedStr = fmt(freshTicket.createdAt);
const closedStr = fmt(new Date());
const transcriptContent = CONFIG.DISCORD_TRANSCRIPT_MESSAGE
.replace(/\{channel_name\}/g, channelRef.name)
.replace(/\{email\}/g, freshTicket.senderEmail || '')
.replace(/\{date_opened\}/g, openedStr)
.replace(/\{date_closed\}/g, closedStr)
+ `\n\nDate Opened: ${openedStr}\nDate Closed: ${closedStr}`;
await enqueueSend(transcriptChan, { content: transcriptContent, files: [file] });
}
module.exports = { handleCloseTimer, handleCancelClose, handleForceClose };