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).
301 lines
11 KiB
JavaScript
301 lines
11 KiB
JavaScript
/**
|
||
* 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
|
||
};
|