2026-03-05 22:52:13 +01:00
|
|
|
import express from 'express';
|
|
|
|
|
import path from 'node:path';
|
|
|
|
|
import client from './core/discord.js';
|
|
|
|
|
import { addSSEClient, removeSSEClient, sseBroadcast, getSSEClientCount } from './core/sse.js';
|
|
|
|
|
import { loadState, getFullState } from './core/persistence.js';
|
|
|
|
|
import { getPlugins, registerPlugin, PluginContext } from './core/plugin.js';
|
2026-03-05 23:23:52 +01:00
|
|
|
import radioPlugin from './plugins/radio/index.js';
|
2026-03-05 22:52:13 +01:00
|
|
|
|
|
|
|
|
// ── Config ──
|
|
|
|
|
const PORT = Number(process.env.PORT ?? 8080);
|
|
|
|
|
const DATA_DIR = process.env.DATA_DIR ?? '/data';
|
|
|
|
|
const DISCORD_TOKEN = process.env.DISCORD_TOKEN ?? '';
|
2026-03-05 23:48:23 +01:00
|
|
|
const ADMIN_PWD = process.env.ADMIN_PWD ?? '';
|
|
|
|
|
const ALLOWED_GUILD_IDS = (process.env.ALLOWED_GUILD_IDS ?? '')
|
|
|
|
|
.split(',').map(s => s.trim()).filter(Boolean);
|
2026-03-05 22:52:13 +01:00
|
|
|
|
|
|
|
|
// ── Persistence ──
|
|
|
|
|
loadState();
|
|
|
|
|
|
|
|
|
|
// ── Express ──
|
|
|
|
|
const app = express();
|
|
|
|
|
app.use(express.json());
|
|
|
|
|
app.use(express.static(path.join(import.meta.dirname ?? __dirname, '..', '..', 'web', 'dist')));
|
|
|
|
|
|
|
|
|
|
// ── Plugin Context ──
|
2026-03-05 23:48:23 +01:00
|
|
|
const ctx: PluginContext = { client, dataDir: DATA_DIR, adminPwd: ADMIN_PWD, allowedGuildIds: ALLOWED_GUILD_IDS };
|
2026-03-05 22:52:13 +01:00
|
|
|
|
|
|
|
|
// ── 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<string, any> = { type: 'snapshot' };
|
|
|
|
|
for (const p of getPlugins()) {
|
|
|
|
|
if (p.getSnapshot) {
|
|
|
|
|
Object.assign(snapshot, p.getSnapshot(ctx));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
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 })),
|
|
|
|
|
sseClients: getSSEClientCount(),
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-05 23:48:23 +01:00
|
|
|
// ── 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' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-05 22:52:13 +01:00
|
|
|
// ── API: List plugins ──
|
|
|
|
|
app.get('/api/plugins', (_req, res) => {
|
|
|
|
|
res.json(getPlugins().map(p => ({
|
|
|
|
|
name: p.name,
|
|
|
|
|
version: p.version,
|
|
|
|
|
description: p.description,
|
|
|
|
|
})));
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-05 23:23:52 +01:00
|
|
|
// NOTE: SPA fallback is added in boot() AFTER plugin routes
|
2026-03-05 22:52:13 +01:00
|
|
|
|
|
|
|
|
// ── Discord Ready ──
|
|
|
|
|
client.once('ready', async () => {
|
|
|
|
|
console.log(`[Discord] Logged in as ${client.user?.tag}`);
|
|
|
|
|
console.log(`[Discord] Serving ${client.guilds.cache.size} guild(s)`);
|
|
|
|
|
|
|
|
|
|
for (const p of getPlugins()) {
|
|
|
|
|
if (p.onReady) {
|
|
|
|
|
try { await p.onReady(ctx); } catch (e) { console.error(`[Plugin:${p.name}] onReady error:`, e); }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ── Init Plugins ──
|
|
|
|
|
async function boot(): Promise<void> {
|
2026-03-05 23:23:52 +01:00
|
|
|
// ── Register plugins ──
|
|
|
|
|
registerPlugin(radioPlugin);
|
2026-03-05 22:52:13 +01:00
|
|
|
|
|
|
|
|
// Init all plugins
|
|
|
|
|
for (const p of getPlugins()) {
|
|
|
|
|
try {
|
|
|
|
|
await p.init(ctx);
|
|
|
|
|
p.registerRoutes?.(app, ctx);
|
|
|
|
|
console.log(`[Plugin:${p.name}] Initialized`);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error(`[Plugin:${p.name}] Init error:`, e);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 23:23:52 +01:00
|
|
|
// SPA Fallback (MUST be after plugin routes)
|
2026-03-05 23:39:35 +01:00
|
|
|
// Express 5 uses path-to-regexp v8+ which requires named splat syntax
|
|
|
|
|
app.get('/{*splat}', (_req, res) => {
|
2026-03-05 23:23:52 +01:00
|
|
|
res.sendFile(path.join(import.meta.dirname ?? __dirname, '..', '..', 'web', 'dist', 'index.html'));
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-05 22:52:13 +01:00
|
|
|
// Start Express
|
|
|
|
|
app.listen(PORT, () => console.log(`[HTTP] Listening on :${PORT}`));
|
|
|
|
|
|
|
|
|
|
// Login Discord
|
|
|
|
|
if (DISCORD_TOKEN) {
|
|
|
|
|
await client.login(DISCORD_TOKEN);
|
|
|
|
|
} else {
|
|
|
|
|
console.warn('[Discord] No DISCORD_TOKEN set - running without Discord');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Graceful Shutdown ──
|
|
|
|
|
async function shutdown(signal: string): Promise<void> {
|
|
|
|
|
console.log(`\n[${signal}] Shutting down...`);
|
|
|
|
|
for (const p of getPlugins()) {
|
|
|
|
|
if (p.destroy) {
|
|
|
|
|
try { await p.destroy(ctx); } catch (e) { console.error(`[Plugin:${p.name}] destroy error:`, e); }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
client.destroy();
|
|
|
|
|
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);
|