import type express from 'express'; import crypto from 'node:crypto'; import { Client, EmbedBuilder, TextChannel, ChannelType } from 'discord.js'; import type { Plugin, PluginContext } from '../../core/plugin.js'; import { getState, setState } from '../../core/persistence.js'; const NB = '[Notifications]'; // ── Types ── interface NotifyChannelConfig { channelId: string; channelName: string; guildId: string; guildName: string; events: string[]; // e.g. ['stream_start', 'stream_end'] } interface NotificationConfig { channels: NotifyChannelConfig[]; } // ── Module-level state ── let _client: Client | null = null; let _ctx: PluginContext | null = null; let _publicUrl = ''; // ── Admin Auth (JWT-like with HMAC) ── type AdminPayload = { iat: number; exp: number }; function readCookie(req: express.Request, name: string): string | undefined { const header = req.headers.cookie; if (!header) return undefined; const match = header.split(';').map(s => s.trim()).find(s => s.startsWith(`${name}=`)); return match?.split('=').slice(1).join('='); } function b64url(str: string): string { return Buffer.from(str).toString('base64url'); } function verifyAdminToken(adminPwd: string, token: string | undefined): boolean { if (!adminPwd || !token) return false; const parts = token.split('.'); if (parts.length !== 2) return false; const [body, sig] = parts; const expected = crypto.createHmac('sha256', adminPwd).update(body).digest('base64url'); if (expected !== sig) return false; try { const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as AdminPayload; return typeof payload.exp === 'number' && Date.now() < payload.exp; } catch { return false; } } function signAdminToken(adminPwd: string): string { const payload: AdminPayload = { iat: Date.now(), exp: Date.now() + 7 * 24 * 3600_000 }; const body = b64url(JSON.stringify(payload)); const sig = crypto.createHmac('sha256', adminPwd).update(body).digest('base64url'); return `${body}.${sig}`; } // ── Exported notification functions (called by other plugins) ── export async function notifyStreamStart(info: { streamId: string; broadcasterName: string; title: string; hasPassword: boolean; }): Promise { if (!_client?.isReady()) return; const config = getState('notifications_config', { channels: [] }); const targets = config.channels.filter(c => c.events.includes('stream_start')); if (targets.length === 0) return; const streamUrl = _publicUrl ? `${_publicUrl}?viewStream=${info.streamId}` : null; const embed = new EmbedBuilder() .setColor(0x57F287) // green .setTitle('🔴 Stream gestartet') .addFields( { name: 'Titel', value: info.title, inline: true }, { name: 'Streamer', value: info.broadcasterName, inline: true }, ) .setTimestamp(); if (info.hasPassword) { embed.addFields({ name: '🔒', value: 'Passwortgeschützt', inline: true }); } if (streamUrl) { embed.addFields({ name: 'Link', value: `[Stream öffnen](${streamUrl})` }); } for (const target of targets) { try { const channel = await _client.channels.fetch(target.channelId); if (channel?.type === ChannelType.GuildText) { await (channel as TextChannel).send({ embeds: [embed] }); } } catch (err) { console.error(`${NB} Failed to send to ${target.channelId}:`, err); } } console.log(`${NB} Stream-start notification sent to ${targets.length} channel(s)`); } export async function notifyStreamEnd(info: { broadcasterName: string; title: string; viewerCount: number; duration: string; // human readable e.g. "1h 23m" }): Promise { if (!_client?.isReady()) return; const config = getState('notifications_config', { channels: [] }); const targets = config.channels.filter(c => c.events.includes('stream_end')); if (targets.length === 0) return; const embed = new EmbedBuilder() .setColor(0xED4245) // red .setTitle('⏹️ Stream beendet') .addFields( { name: 'Titel', value: info.title, inline: true }, { name: 'Streamer', value: info.broadcasterName, inline: true }, { name: 'Zuschauer', value: String(info.viewerCount), inline: true }, { name: 'Dauer', value: info.duration, inline: true }, ) .setTimestamp(); for (const target of targets) { try { const channel = await _client.channels.fetch(target.channelId); if (channel?.type === ChannelType.GuildText) { await (channel as TextChannel).send({ embeds: [embed] }); } } catch (err) { console.error(`${NB} Failed to send to ${target.channelId}:`, err); } } console.log(`${NB} Stream-end notification sent to ${targets.length} channel(s)`); } // ── Plugin ── const notificationsPlugin: Plugin = { name: 'notifications', version: '1.0.0', description: 'Discord Notification Bot', async init(ctx) { _ctx = ctx; _client = ctx.client; _publicUrl = process.env.PUBLIC_URL?.replace(/\/$/, '') ?? ''; console.log(`${NB} Initialized${_publicUrl ? ` (PUBLIC_URL=${_publicUrl})` : ' (no PUBLIC_URL set)'}`); }, async onReady(ctx) { console.log(`${NB} Bot ready as ${ctx.client.user?.tag}`); }, registerRoutes(app, ctx) { const requireAdmin = (req: express.Request, res: express.Response, next: () => void): void => { if (!ctx.adminPwd) { res.status(503).json({ error: 'Admin nicht konfiguriert' }); return; } if (!verifyAdminToken(ctx.adminPwd, readCookie(req, 'admin'))) { res.status(401).json({ error: 'Nicht eingeloggt' }); return; } next(); }; // Admin status app.get('/api/notifications/admin/status', (req, res) => { if (!ctx.adminPwd) { res.json({ admin: false }); return; } res.json({ admin: verifyAdminToken(ctx.adminPwd, readCookie(req, 'admin')) }); }); // Admin login app.post('/api/notifications/admin/login', (req, res) => { if (!ctx.adminPwd) { res.status(503).json({ error: 'Admin nicht konfiguriert' }); return; } const { password } = req.body ?? {}; if (password !== ctx.adminPwd) { res.status(401).json({ error: 'Falsches Passwort' }); return; } const token = signAdminToken(ctx.adminPwd); res.setHeader('Set-Cookie', `admin=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${7 * 86400}`); res.json({ ok: true }); }); // Admin logout app.post('/api/notifications/admin/logout', (_req, res) => { res.setHeader('Set-Cookie', 'admin=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0'); res.json({ ok: true }); }); // List available text channels (requires admin) app.get('/api/notifications/channels', requireAdmin, async (_req, res) => { if (!ctx.client.isReady()) { res.status(503).json({ error: 'Bot nicht verbunden' }); return; } const result: Array<{ channelId: string; channelName: string; guildId: string; guildName: string }> = []; for (const guild of ctx.client.guilds.cache.values()) { // Filter by allowed guilds if configured if (ctx.allowedGuildIds.length > 0 && !ctx.allowedGuildIds.includes(guild.id)) continue; try { const channels = await guild.channels.fetch(); for (const channel of channels.values()) { if (channel && channel.type === ChannelType.GuildText) { result.push({ channelId: channel.id, channelName: channel.name, guildId: guild.id, guildName: guild.name, }); } } } catch (err) { console.error(`${NB} Failed to fetch channels for guild ${guild.name}:`, err); } } res.json({ channels: result }); }); // Get current config app.get('/api/notifications/config', requireAdmin, (_req, res) => { const config = getState('notifications_config', { channels: [] }); res.json(config); }); // Save config app.post('/api/notifications/config', requireAdmin, (req, res) => { const { channels } = req.body ?? {}; if (!Array.isArray(channels)) { res.status(400).json({ error: 'channels array erforderlich' }); return; } // Validate each channel config const validChannels: NotifyChannelConfig[] = channels.map((c: any) => ({ channelId: String(c.channelId || ''), channelName: String(c.channelName || ''), guildId: String(c.guildId || ''), guildName: String(c.guildName || ''), events: Array.isArray(c.events) ? c.events.filter((e: string) => ['stream_start', 'stream_end'].includes(e)) : [], })).filter((c: NotifyChannelConfig) => c.channelId && c.events.length > 0); setState('notifications_config', { channels: validChannels }); console.log(`${NB} Config saved: ${validChannels.length} channel(s)`); res.json({ ok: true, channels: validChannels }); }); // Bot status (public) app.get('/api/notifications/status', (_req, res) => { res.json({ online: ctx.client.isReady(), botTag: ctx.client.user?.tag ?? null, configuredChannels: getState('notifications_config', { channels: [] }).channels.length, }); }); }, getSnapshot() { return { notifications: { online: _client?.isReady() ?? false, botTag: _client?.user?.tag ?? null, }, }; }, async destroy() { console.log(`${NB} Destroyed`); }, }; export default notificationsPlugin;