diff --git a/server/src/core/discord.ts b/server/src/core/discord.ts index 1feb2f9..708dd6c 100644 --- a/server/src/core/discord.ts +++ b/server/src/core/discord.ts @@ -1,13 +1,14 @@ import { Client, GatewayIntentBits, Partials } from 'discord.js'; -const client = new Client({ - intents: [ - GatewayIntentBits.Guilds, - GatewayIntentBits.GuildVoiceStates, - GatewayIntentBits.DirectMessages, - GatewayIntentBits.MessageContent, - ], - partials: [Partials.Channel], -}); - -export default client; +/** Create a new Discord client instance (one per bot token). */ +export function createClient(): Client { + return new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildVoiceStates, + GatewayIntentBits.DirectMessages, + GatewayIntentBits.MessageContent, + ], + partials: [Partials.Channel], + }); +} diff --git a/server/src/core/plugin.ts b/server/src/core/plugin.ts index a98936e..4495c47 100644 --- a/server/src/core/plugin.ts +++ b/server/src/core/plugin.ts @@ -30,9 +30,11 @@ export interface PluginContext { } const loadedPlugins: Plugin[] = []; +const pluginContexts = new Map(); -export function registerPlugin(plugin: Plugin): void { +export function registerPlugin(plugin: Plugin, ctx: PluginContext): void { loadedPlugins.push(plugin); + pluginContexts.set(plugin.name, ctx); console.log(`[Plugin] Registered: ${plugin.name} v${plugin.version}`); } @@ -40,6 +42,10 @@ export function getPlugins(): Plugin[] { return [...loadedPlugins]; } +export function getPluginCtx(pluginName: string): PluginContext | undefined { + return pluginContexts.get(pluginName); +} + // ── Voice claim system ── // Only one plugin can use voice per guild. When a new plugin claims voice, // the previous claimant's cleanup callback is invoked automatically. diff --git a/server/src/index.ts b/server/src/index.ts index b5d9a41..d48adff 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,20 +1,24 @@ import express from 'express'; import path from 'node:path'; -import client from './core/discord.js'; +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, PluginContext } from './core/plugin.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 DISCORD_TOKEN = process.env.DISCORD_TOKEN ?? ''; 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(); @@ -23,8 +27,18 @@ const app = express(); app.use(express.json()); app.use(express.static(path.join(import.meta.dirname ?? __dirname, '..', '..', 'web', 'dist'))); -// ── Plugin Context ── -const ctx: PluginContext = { client, dataDir: DATA_DIR, adminPwd: ADMIN_PWD, allowedGuildIds: ALLOWED_GUILD_IDS }; +// ── 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) => { @@ -38,8 +52,9 @@ app.get('/api/events', (_req, res) => { // Send snapshot from all plugins const snapshot: Record = { type: 'snapshot' }; for (const p of getPlugins()) { - if (p.getSnapshot) { - Object.assign(snapshot, p.getSnapshot(ctx)); + 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 {} @@ -60,6 +75,7 @@ app.get('/api/health', (_req, res) => { 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(), }); }); @@ -86,29 +102,31 @@ app.get('/api/plugins', (_req, res) => { // NOTE: SPA fallback is added in boot() AFTER plugin routes -// ── 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); } +// ── 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 Plugins ── +// ── Init ── async function boot(): Promise { - // ── Register plugins ── - registerPlugin(radioPlugin); - registerPlugin(soundboardPlugin); + // ── 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(ctx); - p.registerRoutes?.(app, ctx); + await p.init(pCtx); + p.registerRoutes?.(app, pCtx); console.log(`[Plugin:${p.name}] Initialized`); } catch (e) { console.error(`[Plugin:${p.name}] Init error:`, e); @@ -116,7 +134,6 @@ async function boot(): Promise { } // SPA Fallback (MUST be after plugin routes) - // Express 5 uses path-to-regexp v8+ which requires named splat syntax app.get('/{*splat}', (_req, res) => { res.sendFile(path.join(import.meta.dirname ?? __dirname, '..', '..', 'web', 'dist', 'index.html')); }); @@ -124,11 +141,18 @@ async function boot(): Promise { // 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'); + // 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'); } } @@ -136,11 +160,14 @@ async function boot(): Promise { async function shutdown(signal: string): Promise { 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); } + const pCtx = getPluginCtx(p.name); + if (p.destroy && pCtx) { + try { await p.destroy(pCtx); } catch (e) { console.error(`[Plugin:${p.name}] destroy error:`, e); } } } - client.destroy(); + for (const bot of clients) { + try { bot.client.destroy(); } catch {} + } process.exit(0); }