import express from 'express'; import path from 'node:path'; import { Client } from 'discord.js'; import { createClient } from './core/discord.js'; import { addSSEClient, removeSSEClient, sseBroadcast, getSSEClientCount } from './core/sse.js'; import { loadState, getFullState } from './core/persistence.js'; import { getPlugins, registerPlugin, getPluginCtx, type PluginContext } from './core/plugin.js'; import radioPlugin from './plugins/radio/index.js'; import soundboardPlugin from './plugins/soundboard/index.js'; // ── Config ── const PORT = Number(process.env.PORT ?? 8080); const DATA_DIR = process.env.DATA_DIR ?? '/data'; const ADMIN_PWD = process.env.ADMIN_PWD ?? ''; const ALLOWED_GUILD_IDS = (process.env.ALLOWED_GUILD_IDS ?? '') .split(',').map(s => s.trim()).filter(Boolean); // Per-bot tokens (DISCORD_TOKEN is legacy fallback for jukebox/soundboard) const TOKEN_JUKEBOX = process.env.DISCORD_TOKEN_JUKEBOX ?? process.env.DISCORD_TOKEN ?? ''; const TOKEN_RADIO = process.env.DISCORD_TOKEN_RADIO ?? ''; // ── Persistence ── loadState(); // ── Express ── const app = express(); app.use(express.json()); app.use(express.static(path.join(import.meta.dirname ?? __dirname, '..', '..', 'web', 'dist'))); // ── Create per-bot clients ── const clients: { name: string; client: Client; token: string }[] = []; const clientJukebox = createClient(); if (TOKEN_JUKEBOX) clients.push({ name: 'Jukebox', client: clientJukebox, token: TOKEN_JUKEBOX }); const clientRadio = createClient(); if (TOKEN_RADIO) clients.push({ name: 'Radio', client: clientRadio, token: TOKEN_RADIO }); // ── Plugin Contexts ── const ctxJukebox: PluginContext = { client: clientJukebox, dataDir: DATA_DIR, adminPwd: ADMIN_PWD, allowedGuildIds: ALLOWED_GUILD_IDS }; const ctxRadio: PluginContext = { client: clientRadio, dataDir: DATA_DIR, adminPwd: ADMIN_PWD, allowedGuildIds: ALLOWED_GUILD_IDS }; // ── SSE Events ── app.get('/api/events', (_req, res) => { res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', }); res.flushHeaders(); // Send snapshot from all plugins const snapshot: Record = { type: 'snapshot' }; for (const p of getPlugins()) { const pCtx = getPluginCtx(p.name); if (p.getSnapshot && pCtx) { Object.assign(snapshot, p.getSnapshot(pCtx)); } } try { res.write(`data: ${JSON.stringify(snapshot)}\n\n`); } catch {} const ping = setInterval(() => { try { res.write(':\n\n'); } catch {} }, 15_000); addSSEClient(res); _req.on('close', () => { removeSSEClient(res); clearInterval(ping); try { res.end(); } catch {} }); }); // ── Health ── app.get('/api/health', (_req, res) => { res.json({ status: 'ok', uptime: process.uptime(), plugins: getPlugins().map(p => ({ name: p.name, version: p.version })), bots: clients.map(b => ({ name: b.name, user: b.client.user?.tag ?? 'offline', guilds: b.client.guilds.cache.size })), sseClients: getSSEClientCount(), }); }); // ── Admin Login ── app.post('/api/admin/login', (req, res) => { if (!ADMIN_PWD) { res.status(503).json({ error: 'ADMIN_PWD not configured' }); return; } const { password } = req.body ?? {}; if (password === ADMIN_PWD) { res.json({ ok: true }); } else { res.status(401).json({ error: 'Invalid password' }); } }); // ── API: List plugins ── app.get('/api/plugins', (_req, res) => { res.json(getPlugins().map(p => ({ name: p.name, version: p.version, description: p.description, }))); }); // NOTE: SPA fallback is added in boot() AFTER plugin routes // ── Discord Ready (per bot) ── function onClientReady(botName: string, client: Client): void { client.once('ready', async () => { console.log(`[Discord:${botName}] Logged in as ${client.user?.tag} (${client.guilds.cache.size} guilds)`); for (const p of getPlugins()) { const pCtx = getPluginCtx(p.name); if (pCtx?.client === client && p.onReady) { try { await p.onReady(pCtx); } catch (e) { console.error(`[Plugin:${p.name}] onReady error:`, e); } } } }); } // ── Init ── async function boot(): Promise { // ── Register plugins with their bot contexts ── registerPlugin(soundboardPlugin, ctxJukebox); registerPlugin(radioPlugin, ctxRadio); // Init all plugins for (const p of getPlugins()) { const pCtx = getPluginCtx(p.name)!; try { await p.init(pCtx); p.registerRoutes?.(app, pCtx); console.log(`[Plugin:${p.name}] Initialized`); } catch (e) { console.error(`[Plugin:${p.name}] Init error:`, e); } } // SPA Fallback (MUST be after plugin routes) app.get('/{*splat}', (_req, res) => { res.sendFile(path.join(import.meta.dirname ?? __dirname, '..', '..', 'web', 'dist', 'index.html')); }); // Start Express app.listen(PORT, () => console.log(`[HTTP] Listening on :${PORT}`)); // Login Discord bots for (const bot of clients) { onClientReady(bot.name, bot.client); try { await bot.client.login(bot.token); } catch (e) { console.error(`[Discord:${bot.name}] Login failed:`, e); } } if (clients.length === 0) { console.warn('[Discord] No bot tokens configured - running without Discord'); } } // ── Graceful Shutdown ── async function shutdown(signal: string): Promise { console.log(`\n[${signal}] Shutting down...`); for (const p of getPlugins()) { const pCtx = getPluginCtx(p.name); if (p.destroy && pCtx) { try { await p.destroy(pCtx); } catch (e) { console.error(`[Plugin:${p.name}] destroy error:`, e); } } } for (const bot of clients) { try { bot.client.destroy(); } catch {} } process.exit(0); } process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT')); process.on('uncaughtException', (err) => { console.error('Uncaught:', err); }); process.on('unhandledRejection', (err) => { console.error('Unhandled:', err); }); process.on('warning', (w) => { if (w.name === 'TimeoutNegativeWarning') return; console.warn(w.name + ': ' + w.message); }); boot().catch(console.error);