/** * Entry point – initializes the Discord bot, wires event handlers, * connects to MongoDB, starts Gmail polling, and runs the Express healthcheck. */ const { Client, GatewayIntentBits, Partials } = require('discord.js'); const express = require('express'); const { connectMongoDB } = require('./db-connection'); const { CONFIG } = require('./config'); // Handlers const { handleButton, handleTicketModal } = require('./handlers/buttons'); const { handleCommand, handleContextMenu, handleAutocomplete } = require('./handlers/commands'); const { handleSendAccountInfoToChannel, BUTTON_PREFIX } = require('./handlers/accountinfo'); const { handleSetupButton, handleSetupModal, handleSetupSelect, PREFIX_BUTTON: SETUP_BUTTON_PREFIX, PREFIX_MODAL: SETUP_MODAL_PREFIX, PREFIX_SELECT: SETUP_SELECT_PREFIX } = require('./handlers/setup'); const { handleDiscordReply } = require('./handlers/messages'); // Services & jobs const { sendTicketClosedEmail } = require('./services/gmail'); const { checkAutoClose, checkAutoUnclaim } = require('./services/tickets'); const { notifyAllStaffUnclaimed } = require('./services/staffNotifications'); const { registerCommands } = require('./commands/register'); const bosscordRoutes = require('./routes/bosscord'); const { setBot } = require('./api/bosscordClient'); const { poll } = require('./gmail-poll'); const { setClient: setDebugClient, logError, logSystem } = require('./services/debugLog'); // Re-export utilities for any external consumers const { sendGmailReply } = require('./services/gmail'); const { getNextTicketNumber } = require('./services/tickets'); const { getCleanBody, detectGame, stripEmailQuotes, stripMobileFooter, htmlToTextWithBlocks } = require('./utils'); let gmailPollInterval = null; /** * Update the Gmail poll interval at runtime. * @param {number} ms - new interval in milliseconds */ function setGmailPollInterval(ms) { if (gmailPollInterval) clearInterval(gmailPollInterval); CONFIG.GMAIL_POLL_INTERVAL_MS = ms; gmailPollInterval = setInterval(() => poll(client), ms); } // --- 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 --- client.on('interactionCreate', async interaction => { if (interaction.isButton() && interaction.customId.startsWith(BUTTON_PREFIX)) { const handled = await handleSendAccountInfoToChannel(interaction); if (handled) return; } if (interaction.isButton() && interaction.customId.startsWith(SETUP_BUTTON_PREFIX)) { try { const handled = await handleSetupButton(interaction); if (handled) return; } catch (err) { console.error('Setup button error:', err); await interaction.reply({ content: `Setup error: ${err.message}. Try \`/setup\` again.`, ephemeral: true }).catch(() => {}); return; } } if (interaction.isButton()) { return handleButton(interaction); } if (interaction.isModalSubmit() && interaction.customId.startsWith(SETUP_MODAL_PREFIX)) { const handled = await handleSetupModal(interaction); if (handled) return; } if (interaction.isModalSubmit() && ['ticket_modal', 'ticket_modal_thread', 'ticket_modal_channel'].includes(interaction.customId)) { return handleTicketModal(interaction); } if (interaction.customId?.startsWith(SETUP_SELECT_PREFIX) && (interaction.isRoleSelectMenu() || interaction.isChannelSelectMenu())) { const handled = await handleSetupSelect(interaction); if (handled) return; } if (interaction.isChatInputCommand()) { return handleCommand(interaction); } if (interaction.isMessageContextMenuCommand() || interaction.isUserContextMenuCommand()) { return handleContextMenu(interaction); } if (interaction.isAutocomplete()) { return handleAutocomplete(interaction); } }); client.on('messageCreate', async msg => { // Track staff last-seen for zero-staff detection fallback if (!msg.author.bot && CONFIG.STAFF_IDS.includes(msg.author.id)) { const { updateStaffLastSeen } = require('./services/patternStore'); updateStaffLastSeen(msg.author.id); } // Chat channel monitoring const { handleChatMessage } = require('./services/chatAlertChecker'); await handleChatMessage(msg, client).catch(() => {}); // Existing ticket reply handler await handleDiscordReply(msg); }); 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); setBot(client); if (process.env.BOSSCORD_API_KEY) { app.use('/api', bosscordRoutes); app.use('/api', (err, req, res, next) => { console.error('bOSScord API error:', err && err.stack ? err.stack : err); res.status(500).json({ error: 'Internal server error' }); }); } 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 = setInterval(() => poll(client), CONFIG.GMAIL_POLL_INTERVAL_MS); poll(client); if (CONFIG.AUTO_CLOSE_ENABLED) { setInterval(() => checkAutoClose(client, sendTicketClosedEmail), 60 * 60 * 1000); checkAutoClose(client, sendTicketClosedEmail); console.log('✓ Auto-close enabled: checking every hour'); } setInterval(() => notifyAllStaffUnclaimed(client).catch(e => console.error('notifyAllStaffUnclaimed:', e)), 30 * 60 * 1000); notifyAllStaffUnclaimed(client).catch(e => console.error('notifyAllStaffUnclaimed:', e)); console.log('✓ Staff unclaimed reminders: checking every 30 minutes'); if (CONFIG.AUTO_UNCLAIM_ENABLED) { setInterval(() => checkAutoUnclaim(client), 60 * 60 * 1000); checkAutoUnclaim(client); console.log('✓ Auto-unclaim enabled: checking every hour'); } const { runPatternChecks } = require('./services/patternChecker'); const { scheduleResets } = require('./services/patternStore'); scheduleResets(); setInterval(() => runPatternChecks(client).catch(e => console.error('runPatternChecks:', e)), CONFIG.PATTERN_CHECK_INTERVAL_MINUTES * 60 * 1000); console.log(`✓ Pattern checks: every ${CONFIG.PATTERN_CHECK_INTERVAL_MINUTES} minutes`); const { runSurgeChecks } = require('./services/surgeChecker'); setInterval(() => runSurgeChecks(client).catch(e => console.error('runSurgeChecks:', e)), 5 * 60 * 1000); setTimeout(() => runSurgeChecks(client).catch(e => console.error('runSurgeChecks:', e)), 30000); console.log('✓ Surge checks: every 5 minutes'); const { initChatMonitoring, runChatAlertChecks } = require('./services/chatAlertChecker'); initChatMonitoring(client); setInterval(() => runChatAlertChecks(client).catch(e => console.error('runChatAlertChecks:', e)), 5 * 60 * 1000); console.log('✓ Chat alert monitoring: every 5 minutes'); if (!CONFIG.STAFF_IDS.length) { console.warn('[surgeChecker] STAFF_IDS is not set — zero-staff detection disabled.'); } console.log('✓ Discord bot ready. Tag:', client.user.tag); logSystem('Bot online', [ { name: 'Guild', value: guild ? `${guild.name} (${guild.id})` : 'N/A' }, { name: 'Poll interval', value: `${CONFIG.GMAIL_POLL_INTERVAL_MS / 1000}s` }, { name: 'Auto-close', value: CONFIG.AUTO_CLOSE_ENABLED ? `enabled (${CONFIG.AUTO_CLOSE_AFTER_HOURS}h)` : 'disabled' }, { name: 'Auto-unclaim', value: CONFIG.AUTO_UNCLAIM_ENABLED ? `enabled (${CONFIG.AUTO_UNCLAIM_AFTER_HOURS}h)` : 'disabled' }, { name: 'Claim timeout', value: CONFIG.CLAIM_TIMEOUT_ENABLED ? `enabled (${CONFIG.CLAIM_TIMEOUT_HOURS}h)` : 'disabled' }, { name: 'Gmail log', value: CONFIG.GMAIL_LOG_CHANNEL_ID ? 'configured' : 'not configured' }, { name: 'Automation log', value: CONFIG.AUTOMATION_LOG_CHANNEL_ID ? 'configured' : 'not configured' }, { name: 'Staff threads', value: CONFIG.STAFF_THREAD_ENABLED ? `enabled (name: "${CONFIG.STAFF_THREAD_NAME}")` : 'disabled' }, { name: 'Pin initial message', value: CONFIG.PIN_INITIAL_MESSAGE_ENABLED ? 'enabled' : 'disabled' }, { name: 'Pin escalation message', value: CONFIG.PIN_ESCALATION_MESSAGE_ENABLED ? 'enabled' : 'disabled' } ]).catch(() => {}); }); client.login(CONFIG.DISCORD_TOKEN); const app = express(); app.use(express.json()); app.get('/', (req, res) => res.send('Active')); // Mount bOSScord API only after MongoDB is connected (inside ready), to avoid 500 on first request const healthcheckHost = CONFIG.HEALTHCHECK_HOST || undefined; app.listen(CONFIG.PORT, healthcheckHost, () => { console.log(`Healthcheck server listening on ${healthcheckHost || '*'}:${CONFIG.PORT}`); }); // --- Internal API for settings site --- const internalApi = require('./routes/internalApi'); const internalApp = express(); internalApp.use('/internal', internalApi); if (CONFIG.INTERNAL_API_SECRET) { internalApp.listen(CONFIG.INTERNAL_API_PORT, '127.0.0.1', () => { console.log(`[internalApi] listening on 127.0.0.1:${CONFIG.INTERNAL_API_PORT}`); }); } else { console.warn('[internalApi] INTERNAL_API_SECRET not set — internal API disabled.'); } // --- Shutdown & error handlers --- async function handleShutdown(signal) { await Promise.race([logSystem('Bot shutting down', [{ name: 'Signal', value: signal }]), new Promise(r => setTimeout(r, 2000))]); 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, sendGmailReply, sendTicketClosedEmail, getNextTicketNumber, getCleanBody, detectGame, stripEmailQuotes, stripMobileFooter, htmlToTextWithBlocks };