Files
broccolini-bot/broccolini-discord.js

311 lines
12 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 } = 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, reconcileDeletedTicketChannels } = 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() && 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 },
{
userId: interaction.user.id,
valediction,
displayName,
tagline,
updatedAt: new Date()
},
{ upsert: true, new: true }
);
await interaction.reply({
content: 'Signature settings saved successfully!',
ephemeral: true
});
} catch (err) {
console.error('Signature modal submit error:', err);
await interaction.reply({
content: 'Failed to save signature settings.',
ephemeral: true
});
}
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');
reconcileDeletedTicketChannels(client).catch(e => console.error('reconcileDeletedTicketChannels:', e));
setInterval(() => reconcileDeletedTicketChannels(client).catch(e => console.error('reconcileDeletedTicketChannels:', e)), 60 * 60 * 1000);
console.log('✓ Reconcile deleted ticket channels: every 1 hour');
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, '0.0.0.0', () => {
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
};