Feat: Multi-bot support - separate Discord bot per plugin
Each plugin gets its own Discord client and token: - DISCORD_TOKEN_JUKEBOX (fallback: DISCORD_TOKEN) → Soundboard - DISCORD_TOKEN_RADIO → Radio discord.ts: factory createClient() instead of singleton plugin.ts: per-plugin context storage via registerPlugin(p, ctx) index.ts: creates/logins/shutdowns multiple bots independently Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
fd0a95be8e
commit
3cd9f6f169
3 changed files with 78 additions and 44 deletions
|
|
@ -1,6 +1,8 @@
|
||||||
import { Client, GatewayIntentBits, Partials } from 'discord.js';
|
import { Client, GatewayIntentBits, Partials } from 'discord.js';
|
||||||
|
|
||||||
const client = new Client({
|
/** Create a new Discord client instance (one per bot token). */
|
||||||
|
export function createClient(): Client {
|
||||||
|
return new Client({
|
||||||
intents: [
|
intents: [
|
||||||
GatewayIntentBits.Guilds,
|
GatewayIntentBits.Guilds,
|
||||||
GatewayIntentBits.GuildVoiceStates,
|
GatewayIntentBits.GuildVoiceStates,
|
||||||
|
|
@ -8,6 +10,5 @@ const client = new Client({
|
||||||
GatewayIntentBits.MessageContent,
|
GatewayIntentBits.MessageContent,
|
||||||
],
|
],
|
||||||
partials: [Partials.Channel],
|
partials: [Partials.Channel],
|
||||||
});
|
});
|
||||||
|
}
|
||||||
export default client;
|
|
||||||
|
|
|
||||||
|
|
@ -30,9 +30,11 @@ export interface PluginContext {
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadedPlugins: Plugin[] = [];
|
const loadedPlugins: Plugin[] = [];
|
||||||
|
const pluginContexts = new Map<string, PluginContext>();
|
||||||
|
|
||||||
export function registerPlugin(plugin: Plugin): void {
|
export function registerPlugin(plugin: Plugin, ctx: PluginContext): void {
|
||||||
loadedPlugins.push(plugin);
|
loadedPlugins.push(plugin);
|
||||||
|
pluginContexts.set(plugin.name, ctx);
|
||||||
console.log(`[Plugin] Registered: ${plugin.name} v${plugin.version}`);
|
console.log(`[Plugin] Registered: ${plugin.name} v${plugin.version}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -40,6 +42,10 @@ export function getPlugins(): Plugin[] {
|
||||||
return [...loadedPlugins];
|
return [...loadedPlugins];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getPluginCtx(pluginName: string): PluginContext | undefined {
|
||||||
|
return pluginContexts.get(pluginName);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Voice claim system ──
|
// ── Voice claim system ──
|
||||||
// Only one plugin can use voice per guild. When a new plugin claims voice,
|
// Only one plugin can use voice per guild. When a new plugin claims voice,
|
||||||
// the previous claimant's cleanup callback is invoked automatically.
|
// the previous claimant's cleanup callback is invoked automatically.
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,24 @@
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import path from 'node:path';
|
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 { addSSEClient, removeSSEClient, sseBroadcast, getSSEClientCount } from './core/sse.js';
|
||||||
import { loadState, getFullState } from './core/persistence.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 radioPlugin from './plugins/radio/index.js';
|
||||||
import soundboardPlugin from './plugins/soundboard/index.js';
|
import soundboardPlugin from './plugins/soundboard/index.js';
|
||||||
|
|
||||||
// ── Config ──
|
// ── Config ──
|
||||||
const PORT = Number(process.env.PORT ?? 8080);
|
const PORT = Number(process.env.PORT ?? 8080);
|
||||||
const DATA_DIR = process.env.DATA_DIR ?? '/data';
|
const DATA_DIR = process.env.DATA_DIR ?? '/data';
|
||||||
const DISCORD_TOKEN = process.env.DISCORD_TOKEN ?? '';
|
|
||||||
const ADMIN_PWD = process.env.ADMIN_PWD ?? '';
|
const ADMIN_PWD = process.env.ADMIN_PWD ?? '';
|
||||||
const ALLOWED_GUILD_IDS = (process.env.ALLOWED_GUILD_IDS ?? '')
|
const ALLOWED_GUILD_IDS = (process.env.ALLOWED_GUILD_IDS ?? '')
|
||||||
.split(',').map(s => s.trim()).filter(Boolean);
|
.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 ──
|
// ── Persistence ──
|
||||||
loadState();
|
loadState();
|
||||||
|
|
||||||
|
|
@ -23,8 +27,18 @@ const app = express();
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(express.static(path.join(import.meta.dirname ?? __dirname, '..', '..', 'web', 'dist')));
|
app.use(express.static(path.join(import.meta.dirname ?? __dirname, '..', '..', 'web', 'dist')));
|
||||||
|
|
||||||
// ── Plugin Context ──
|
// ── Create per-bot clients ──
|
||||||
const ctx: PluginContext = { client, dataDir: DATA_DIR, adminPwd: ADMIN_PWD, allowedGuildIds: ALLOWED_GUILD_IDS };
|
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 ──
|
// ── SSE Events ──
|
||||||
app.get('/api/events', (_req, res) => {
|
app.get('/api/events', (_req, res) => {
|
||||||
|
|
@ -38,8 +52,9 @@ app.get('/api/events', (_req, res) => {
|
||||||
// Send snapshot from all plugins
|
// Send snapshot from all plugins
|
||||||
const snapshot: Record<string, any> = { type: 'snapshot' };
|
const snapshot: Record<string, any> = { type: 'snapshot' };
|
||||||
for (const p of getPlugins()) {
|
for (const p of getPlugins()) {
|
||||||
if (p.getSnapshot) {
|
const pCtx = getPluginCtx(p.name);
|
||||||
Object.assign(snapshot, p.getSnapshot(ctx));
|
if (p.getSnapshot && pCtx) {
|
||||||
|
Object.assign(snapshot, p.getSnapshot(pCtx));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
try { res.write(`data: ${JSON.stringify(snapshot)}\n\n`); } catch {}
|
try { res.write(`data: ${JSON.stringify(snapshot)}\n\n`); } catch {}
|
||||||
|
|
@ -60,6 +75,7 @@ app.get('/api/health', (_req, res) => {
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
uptime: process.uptime(),
|
uptime: process.uptime(),
|
||||||
plugins: getPlugins().map(p => ({ name: p.name, version: p.version })),
|
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(),
|
sseClients: getSSEClientCount(),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -86,29 +102,31 @@ app.get('/api/plugins', (_req, res) => {
|
||||||
|
|
||||||
// NOTE: SPA fallback is added in boot() AFTER plugin routes
|
// NOTE: SPA fallback is added in boot() AFTER plugin routes
|
||||||
|
|
||||||
// ── Discord Ready ──
|
// ── Discord Ready (per bot) ──
|
||||||
client.once('ready', async () => {
|
function onClientReady(botName: string, client: Client): void {
|
||||||
console.log(`[Discord] Logged in as ${client.user?.tag}`);
|
client.once('ready', async () => {
|
||||||
console.log(`[Discord] Serving ${client.guilds.cache.size} guild(s)`);
|
console.log(`[Discord:${botName}] Logged in as ${client.user?.tag} (${client.guilds.cache.size} guilds)`);
|
||||||
|
|
||||||
for (const p of getPlugins()) {
|
for (const p of getPlugins()) {
|
||||||
if (p.onReady) {
|
const pCtx = getPluginCtx(p.name);
|
||||||
try { await p.onReady(ctx); } catch (e) { console.error(`[Plugin:${p.name}] onReady error:`, e); }
|
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<void> {
|
async function boot(): Promise<void> {
|
||||||
// ── Register plugins ──
|
// ── Register plugins with their bot contexts ──
|
||||||
registerPlugin(radioPlugin);
|
registerPlugin(soundboardPlugin, ctxJukebox);
|
||||||
registerPlugin(soundboardPlugin);
|
registerPlugin(radioPlugin, ctxRadio);
|
||||||
|
|
||||||
// Init all plugins
|
// Init all plugins
|
||||||
for (const p of getPlugins()) {
|
for (const p of getPlugins()) {
|
||||||
|
const pCtx = getPluginCtx(p.name)!;
|
||||||
try {
|
try {
|
||||||
await p.init(ctx);
|
await p.init(pCtx);
|
||||||
p.registerRoutes?.(app, ctx);
|
p.registerRoutes?.(app, pCtx);
|
||||||
console.log(`[Plugin:${p.name}] Initialized`);
|
console.log(`[Plugin:${p.name}] Initialized`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`[Plugin:${p.name}] Init error:`, e);
|
console.error(`[Plugin:${p.name}] Init error:`, e);
|
||||||
|
|
@ -116,7 +134,6 @@ async function boot(): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
// SPA Fallback (MUST be after plugin routes)
|
// SPA Fallback (MUST be after plugin routes)
|
||||||
// Express 5 uses path-to-regexp v8+ which requires named splat syntax
|
|
||||||
app.get('/{*splat}', (_req, res) => {
|
app.get('/{*splat}', (_req, res) => {
|
||||||
res.sendFile(path.join(import.meta.dirname ?? __dirname, '..', '..', 'web', 'dist', 'index.html'));
|
res.sendFile(path.join(import.meta.dirname ?? __dirname, '..', '..', 'web', 'dist', 'index.html'));
|
||||||
});
|
});
|
||||||
|
|
@ -124,11 +141,18 @@ async function boot(): Promise<void> {
|
||||||
// Start Express
|
// Start Express
|
||||||
app.listen(PORT, () => console.log(`[HTTP] Listening on :${PORT}`));
|
app.listen(PORT, () => console.log(`[HTTP] Listening on :${PORT}`));
|
||||||
|
|
||||||
// Login Discord
|
// Login Discord bots
|
||||||
if (DISCORD_TOKEN) {
|
for (const bot of clients) {
|
||||||
await client.login(DISCORD_TOKEN);
|
onClientReady(bot.name, bot.client);
|
||||||
} else {
|
try {
|
||||||
console.warn('[Discord] No DISCORD_TOKEN set - running without Discord');
|
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<void> {
|
||||||
async function shutdown(signal: string): Promise<void> {
|
async function shutdown(signal: string): Promise<void> {
|
||||||
console.log(`\n[${signal}] Shutting down...`);
|
console.log(`\n[${signal}] Shutting down...`);
|
||||||
for (const p of getPlugins()) {
|
for (const p of getPlugins()) {
|
||||||
if (p.destroy) {
|
const pCtx = getPluginCtx(p.name);
|
||||||
try { await p.destroy(ctx); } catch (e) { console.error(`[Plugin:${p.name}] destroy error:`, e); }
|
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);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue