/** * 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 { requireStaffRole } = require('./handlers/commands/helpers'); 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_')) { // Staff-only: /signature shows this modal, which is gated; double-gate the // submit path in case an attacker crafts the submission directly. if (await requireStaffRole(interaction)) return; // 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 };