Files
broccolini-bot/broccolini-discord.js
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

301 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Entry point initializes the Discord bot, wires event handlers,
* connects to MongoDB, starts Gmail polling, and runs the Express healthcheck.
*/
const { Client, GatewayIntentBits, Partials, MessageFlags } = require('discord.js');
const express = require('express');
const { connectMongoDB, closeMongoDB } = require('./db-connection');
const { CONFIG } = require('./config');
const { mongoose } = require('./db-connection');
// Handlers
const { handleButton, handleTicketModal } = require('./handlers/buttons');
const { handleCommand, handleContextMenu, handleAutocomplete } = require('./handlers/commands');
const { handleDiscordReply } = require('./handlers/messages');
// Services & jobs
const { sendTicketClosedEmail } = require('./services/gmail');
const { checkAutoClose, checkAutoUnclaim, reconcileDeletedTicketChannels, resumePendingDeletes } = require('./services/tickets');
const { registerCommands } = require('./commands/register');
const { poll } = require('./gmail-poll');
const { setClient: setDebugClient, logError } = require('./services/debugLog');
let gmailPollInterval = null;
// Track all background setInterval handles so shutdown can clear them.
const activeIntervals = new Set();
function trackInterval(handle) {
if (handle) activeIntervals.add(handle);
return handle;
}
// Track one-shot setTimeout handles so shutdown can clear them (e.g., scheduled restarts).
const activeTimeouts = new Set();
function trackTimeout(handle) {
if (handle) activeTimeouts.add(handle);
return handle;
}
/**
* Update the Gmail poll interval at runtime.
* @param {number} ms - new interval in milliseconds
*/
function setGmailPollInterval(ms) {
if (gmailPollInterval) {
clearInterval(gmailPollInterval);
activeIntervals.delete(gmailPollInterval);
}
CONFIG.GMAIL_POLL_INTERVAL_MS = ms;
gmailPollInterval = setInterval(() => poll(client), ms);
activeIntervals.add(gmailPollInterval);
}
function clearGmailPollInterval() {
if (gmailPollInterval) {
clearInterval(gmailPollInterval);
activeIntervals.delete(gmailPollInterval);
gmailPollInterval = null;
}
}
// --- VALIDATE CONFIG ---
if (!CONFIG.DISCORD_TOKEN) {
console.error('DISCORD_TOKEN or DISCORD_BOT_TOKEN is not set in .env');
process.exit(1);
}
if (!CONFIG.TICKET_CATEGORY_ID) {
console.error('TICKET_CATEGORY_ID is not set in .env cannot create ticket channels.');
process.exit(1);
}
if (!CONFIG.CLIENT_ID) {
console.error('DISCORD_APPLICATION_ID is not set in .env cannot register slash commands.');
}
if (!process.env.GOOGLE_CLIENT_ID || !process.env.GOOGLE_CLIENT_SECRET) {
console.error('GOOGLE_CLIENT_ID or GOOGLE_CLIENT_SECRET is not set in .env Gmail OAuth may fail.');
}
// --- DISCORD CLIENT ---
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
GatewayIntentBits.GuildMembers,
GatewayIntentBits.GuildPresences // Required for staff presence detection; enable in Discord Developer Portal
],
partials: [Partials.Channel]
});
// --- EVENT: interactionCreate ---
async function safeReplyError(interaction) {
const payload = { content: 'Something went wrong.', flags: MessageFlags.Ephemeral };
if (interaction.deferred || interaction.replied) {
await interaction.followUp(payload).catch(() => {});
} else {
await interaction.reply(payload).catch(() => {});
}
}
async function runHandler(name, interaction, fn) {
try {
return await fn();
} catch (err) {
console.error(`${name} error:`, err);
logError(name, err instanceof Error ? err : new Error(String(err)), null, client).catch(() => {});
await safeReplyError(interaction);
}
}
client.on('interactionCreate', async interaction => {
if (interaction.isButton()) {
return runHandler('handleButton', interaction, () => handleButton(interaction));
}
if (interaction.isModalSubmit() && interaction.customId.startsWith('signature_modal_')) {
// Handle signature modal submit
try {
const valediction = interaction.fields.getTextInputValue('valediction');
const displayName = interaction.fields.getTextInputValue('display_name');
const tagline = interaction.fields.getTextInputValue('tagline');
const StaffSignature = mongoose.model('StaffSignature');
await StaffSignature.findOneAndUpdate(
{ userId: interaction.user.id, guildId: interaction.guildId },
{
userId: interaction.user.id,
guildId: interaction.guildId,
valediction,
displayName,
tagline,
updatedAt: new Date()
},
{ upsert: true, new: true }
);
await interaction.reply({
content: 'Signature settings saved successfully!',
flags: MessageFlags.Ephemeral
});
} catch (err) {
console.error('Signature modal submit error:', err);
await interaction.reply({
content: 'Failed to save signature settings.',
flags: MessageFlags.Ephemeral
});
}
return;
}
if (interaction.isModalSubmit() && ['ticket_modal', 'ticket_modal_thread', 'ticket_modal_channel'].includes(interaction.customId)) {
return runHandler('handleTicketModal', interaction, () => handleTicketModal(interaction));
}
if (interaction.isChatInputCommand()) {
return runHandler('handleCommand', interaction, () => handleCommand(interaction));
}
if (interaction.isMessageContextMenuCommand() || interaction.isUserContextMenuCommand()) {
return runHandler('handleContextMenu', interaction, () => handleContextMenu(interaction));
}
if (interaction.isAutocomplete()) {
return runHandler('handleAutocomplete', interaction, () => handleAutocomplete(interaction));
}
});
client.on('messageCreate', async msg => {
await handleDiscordReply(msg);
});
// HTTP server handles + readiness flag. Assigned inside the ready callback
// (httpServer, appReady) and the INTERNAL_API_SECRET branch below
// (internalServer); declared here so they're visible to the ready callback,
// the express middleware below, and the shutdown handler at the bottom.
let httpServer = null;
let internalServer = null;
let appReady = false;
client.once('ready', async () => {
if (!process.env.MONGODB_URI) {
console.error('MONGODB_URI is not set in .env. Broccolini Bot requires MongoDB.');
process.exit(1);
}
await connectMongoDB(process.env.MONGODB_URI);
setDebugClient(client);
const healthcheckHost = CONFIG.HEALTHCHECK_HOST || undefined;
httpServer = app.listen(CONFIG.PORT, healthcheckHost, () => {
console.log(`Healthcheck server listening on ${healthcheckHost || '*'}:${CONFIG.PORT}`);
});
appReady = true;
console.log(`Broccolini Bot active on port ${CONFIG.PORT}`);
const guild = CONFIG.DISCORD_GUILD_ID
? client.guilds.cache.get(CONFIG.DISCORD_GUILD_ID)
: client.guilds.cache.first();
if (!guild) {
console.warn('No guild found on ready.');
} else {
const parent = guild.channels.cache.get(CONFIG.TICKET_CATEGORY_ID);
console.log('Ticket parent lookup:', {
id: CONFIG.TICKET_CATEGORY_ID,
exists: !!parent,
type: parent?.type
});
}
registerCommands().catch(console.error);
gmailPollInterval = trackInterval(setInterval(() => poll(client), CONFIG.GMAIL_POLL_INTERVAL_MS));
poll(client);
if (CONFIG.AUTO_CLOSE_ENABLED) {
trackInterval(setInterval(() => checkAutoClose(client, sendTicketClosedEmail), 60 * 60 * 1000));
checkAutoClose(client, sendTicketClosedEmail);
console.log('✓ Auto-close enabled: checking every hour');
}
if (CONFIG.AUTO_UNCLAIM_ENABLED) {
trackInterval(setInterval(() => checkAutoUnclaim(client), 60 * 60 * 1000));
checkAutoUnclaim(client);
console.log('✓ Auto-unclaim enabled: checking every hour');
}
reconcileDeletedTicketChannels(client).catch(e => console.error('reconcileDeletedTicketChannels:', e));
trackInterval(setInterval(() => reconcileDeletedTicketChannels(client).catch(e => console.error('reconcileDeletedTicketChannels:', e)), 60 * 60 * 1000));
resumePendingDeletes(client).catch(e => console.error('resumePendingDeletes:', e));
console.log('✓ Reconcile deleted ticket channels: every 1 hour');
// Start in-memory Map sweeps (per-module) — keeps long-running processes bounded.
require('./services/tickets').startTicketsSweeps(trackInterval);
console.log('✓ Memory sweeps registered: every 6 hours (unref\'d)');
console.log('✓ Discord bot ready. Tag:', client.user.tag);
});
client.login(CONFIG.DISCORD_TOKEN);
const app = express();
app.use(express.json());
// Reject API traffic with 503 until ready event has fired and routes are mounted.
// (appReady is declared at module top so the ready callback can flip it.)
app.use((req, res, next) => {
if (!appReady && req.path.startsWith('/api')) {
return res.status(503).json({ error: 'Bot is starting; API not ready yet.' });
}
next();
});
app.get('/', (req, res) => res.send(appReady ? 'Active' : 'Starting'));
// app.listen is called inside client.once('ready') after MongoDB connects and routes mount.
// --- Internal API for settings site ---
const internalApi = require('./routes/internalApi');
const internalApp = express();
internalApp.use('/internal', internalApi);
if (CONFIG.INTERNAL_API_SECRET) {
// Must bind all-interfaces inside the bot container: the settings-site is a
// separate container on broccoli-net and reaches this API over the docker
// bridge, not loopback. Not publicly exposed — docker-compose.yml has no
// `ports:` publish for INTERNAL_API_PORT, so ingress is limited to peers on
// broccoli-net, still gated by INTERNAL_API_SECRET and rate-limited.
// Do NOT flip this back to 127.0.0.1 — see commits d134f5f / 33b1f27.
internalServer = internalApp.listen(CONFIG.INTERNAL_API_PORT, '0.0.0.0', () => {
console.log(`[internalApi] listening on 0.0.0.0:${CONFIG.INTERNAL_API_PORT}`);
});
} else {
console.warn('[internalApi] INTERNAL_API_SECRET not set — internal API disabled.');
}
// --- Shutdown & error handlers ---
let shuttingDown = false;
async function handleShutdown(signal) {
if (shuttingDown) return;
shuttingDown = true;
console.log(`Bot shutting down (${signal})`);
for (const handle of activeIntervals) {
try { clearInterval(handle); } catch (_) {}
}
activeIntervals.clear();
for (const handle of activeTimeouts) {
try { clearTimeout(handle); } catch (_) {}
}
activeTimeouts.clear();
gmailPollInterval = null;
try { if (httpServer) await new Promise(r => httpServer.close(() => r())); } catch (_) {}
try { if (internalServer) await new Promise(r => internalServer.close(() => r())); } catch (_) {}
try { client.destroy(); } catch (_) {}
try { await closeMongoDB(); } catch (_) {}
process.exit(0);
}
process.on('SIGTERM', () => handleShutdown('SIGTERM'));
process.on('SIGINT', () => handleShutdown('SIGINT'));
process.on('unhandledRejection', (reason) => {
logError('unhandledRejection', reason instanceof Error ? reason : new Error(String(reason))).catch(() => {});
});
module.exports = {
client,
setGmailPollInterval,
clearGmailPollInterval,
trackTimeout
};