312 lines
12 KiB
JavaScript
312 lines
12 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 } = require('discord.js');
|
||
const express = require('express');
|
||
const { connectMongoDB } = 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 { 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
|
||
};
|