diff --git a/.env.example b/.env.example index 245cf03..6623c5b 100644 --- a/.env.example +++ b/.env.example @@ -68,3 +68,5 @@ LOG_DEBUG=false SERIES_AUTO_ADVANCE_SECONDS=30 # Schlüssel für die Stream-Übersicht aller Matches (/stream?key=...); leer = offen STREAM_KEY= +# Discord-Webhook für Meldungen (Veto, Match-Start, Map- und Serienende, Fehler). Leer = aus +DISCORD_WEBHOOK_URL= diff --git a/README.md b/README.md index 6634a4d..bdcf093 100644 --- a/README.md +++ b/README.md @@ -341,6 +341,22 @@ Für die Übersicht eines einzelnen Matches ohne Zuschauercode gibt es `/stream/ - **Laufband:** zeigt das letzte für Zuschauer relevante Ereignis, keine technischen Servermeldungen. - **Datenschutz:** Verbindungsdaten und Passwörter erscheinen dort nie. +### Discord-Meldungen für Streamer + +Mit `DISCORD_WEBHOOK_URL` schickt die Anwendung kurze Meldungen in einen Discord-Kanal: + +- Veto gestartet +- Match gestartet (Aufwärmphase beendet) +- Map beendet mit Ergebnis +- Serie entschieden +- Match abgeschlossen oder abgebrochen +- Serverfehler + +Jede Meldung enthält einen Link auf die Match-Übersicht (`/stream/match/`). Ist `STREAM_KEY` gesetzt, entfällt der Link, damit der Schlüssel nicht im Kanal landet. Ein Fehler beim Senden bremst den Matchablauf nicht. Er wird nur im Log vermerkt. Die Webhook-URL enthält ein Geheimnis und erscheint nie in Logs oder Oberflächen. + +Webhook anlegen: Discord-Kanal → Einstellungen → Integrationen → Webhooks → Neuer Webhook, dann die URL kopieren. + +Welche Ereignisse gemeldet werden, wählt der Admin im Admin-Bereich unter „Discord-Meldungen“. Die Auswahl wird in der Datenbank gespeichert. Die Webhook-URL bleibt in der Konfiguration, weil sie ein Geheimnis ist. Die Schaltfläche „Testmeldung senden“ prüft die Verbindung. --- ## Umgebungsvariablen @@ -366,6 +382,7 @@ Alle Variablen mit Standardwerten stehen in [`.env.example`](.env.example). Die | `LOG_RECEIVER_URL` | `PUBLIC_URL` | Adresse, unter der der CS2-Server das Backend für Live-Logs erreicht | | `LOG_DEBUG` | `false` | empfangene Logzeilen im Terminal ausgeben | | `STREAM_KEY` | leer | Schlüssel für die Stream-Übersicht aller Matches (`/stream?key=…`); leer = offen | +| `DISCORD_WEBHOOK_URL` | leer | Discord-Webhook für Meldungen zu Veto, Match-Start, Map- und Serienende sowie Serverfehlern; leer = aus | | `SERIES_AUTO_ADVANCE_SECONDS` | `30` | Wartezeit nach Map-Ende bis zur nächsten Map bzw. zum Abschluss; `-1` = manuell | Benötigte Zugangsdaten für den Echtbetrieb: diff --git a/client/src/pages/AdminPage.jsx b/client/src/pages/AdminPage.jsx index ffecfba..c346d84 100644 --- a/client/src/pages/AdminPage.jsx +++ b/client/src/pages/AdminPage.jsx @@ -92,6 +92,7 @@ export default function AdminPage() { + {token && } {notice &&
{notice.text}
} {error &&
{error}
} {matches === null && !error &&

Lade Matches …

} @@ -106,6 +107,73 @@ export default function AdminPage() { ); } +/** Auswahl, welche Ereignisse Discord erhalten. Die Werte werden erst beim Speichern übernommen. */ +function NotificationSettings({ token, setNotice }) { + const [settings, setSettings] = useState(null); + const [events, setEvents] = useState({}); + const [busy, setBusy] = useState(false); + + useEffect(() => { + api('/admin/notifications', { token }) + .then((res) => { setSettings(res); setEvents(res.events); }) + .catch((err) => setNotice({ tone: 'error', text: err.message })); + }, [token, setNotice]); + + const save = async () => { + setBusy(true); + try { + const res = await api('/admin/notifications', { method: 'PUT', body: { events }, token }); + setSettings(res); + setEvents(res.events); + setNotice({ tone: 'ok', text: 'Meldungen gespeichert' }); + } catch (err) { + setNotice({ tone: 'error', text: err.message }); + } finally { + setBusy(false); + } + }; + + const test = async () => { + setBusy(true); + try { + await api('/admin/notifications/test', { method: 'POST', token }); + setNotice({ tone: 'ok', text: 'Testmeldung gesendet' }); + } catch (err) { + setNotice({ tone: 'error', text: err.message }); + } finally { + setBusy(false); + } + }; + + if (!settings) return null; + const dirty = settings.catalog.some(({ key }) => !!events[key] !== settings.events[key]); + + return ( +
+

Discord-Meldungen

+ {!settings.enabled && ( +

Kein Webhook eingerichtet. Setze DISCORD_WEBHOOK_URL und starte die Anwendung neu.

+ )} +
+ {settings.catalog.map(({ key, label }) => ( + + ))} +
+
+ + +
+
+ ); +} + function AdminLogin({ onLogin }) { const [password, setPassword] = useState(''); const [error, setError] = useState(null); diff --git a/client/src/styles.css b/client/src/styles.css index 8d192c9..f0e657b 100644 --- a/client/src/styles.css +++ b/client/src/styles.css @@ -512,3 +512,8 @@ input:focus, select:focus { border-color: var(--link); outline: none; } .rollback h3 { margin: 0 0 8px; display: flex; align-items: center; gap: 10px; } .rollback select { min-width: 200px; } .admin-badge { font: 600 .75rem var(--body); color: #fff; background: var(--brand-red); padding: 2px 8px; border-radius: 3px; } + +.admin-settings { border: 1px solid var(--line); background: var(--surface); border-radius: var(--radius); padding: 16px 20px; margin-bottom: 20px; } +.admin-settings h2 { margin: 0 0 12px; font-size: 1.1rem; } +.admin-settings-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 8px 16px; margin-bottom: 12px; } +.admin-check { display: flex; align-items: center; gap: 8px; cursor: pointer; } diff --git a/server/src/adminRoutes.js b/server/src/adminRoutes.js index f1afd33..281a8a2 100644 --- a/server/src/adminRoutes.js +++ b/server/src/adminRoutes.js @@ -76,6 +76,17 @@ export function createAdminRouter({ service, manager, config }) { router.get('/session', requireAdmin, (req, res) => res.json({ ok: true })); + // Discord-Meldungen: welche Ereignisse gemeldet werden + router.get('/notifications', requireAdmin, wrap(() => service.notificationSettings())); + router.put('/notifications', requireAdmin, wrap((req) => service.setNotificationEvents(req.body?.events))); + router.post('/notifications/test', requireAdmin, wrap(async () => { + if (!service.notifier.enabled) throw new HttpError(409, 'Kein Discord-Webhook eingerichtet (DISCORD_WEBHOOK_URL)', 'not_configured'); + if (!(await service.notifier.send('Testmeldung aus CS2 Mapveto'))) { + throw new HttpError(502, 'Discord hat die Meldung nicht angenommen (siehe Log)', 'send_failed'); + } + return { ok: true }; + })); + // Alle Matches mit allen Details (Codes, Verbindungsdaten, Spieler, Veto, Serie, Ereignisse) router.get('/matches', requireAdmin, wrap(() => { const ids = service.db.prepare('SELECT id FROM matches ORDER BY created_at DESC LIMIT 100').all(); diff --git a/server/src/app.js b/server/src/app.js index 26fe070..76223c4 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -5,6 +5,7 @@ import express from 'express'; import { REPO_ROOT } from './config.js'; import { openDatabase } from './db.js'; import { createApiRouter } from './http.js'; +import { createDiscordNotifier } from './discord.js'; import { loadMapPools } from './mapPools.js'; import { MatchService } from './matchService.js'; import { attachRealtime } from './realtime.js'; @@ -15,7 +16,8 @@ import { ServerManager } from './servers/serverManager.js'; export function createApp(config, { adapter: adapterOverride, logger = console } = {}) { const db = openDatabase(config.databaseFile); const pools = loadMapPools(config.mapPoolFile); - const service = new MatchService({ db, pools, config }); + const notifier = createDiscordNotifier(config.discordWebhookUrl, { logger }); + const service = new MatchService({ db, pools, config, notifier }); let manager; const adapter = adapterOverride ?? createServerAdapter(config, { getUsedPorts: () => manager.usedPorts() }); diff --git a/server/src/config.js b/server/src/config.js index 2a2cf1d..933bb41 100644 --- a/server/src/config.js +++ b/server/src/config.js @@ -41,6 +41,7 @@ export function loadConfig(overrides = {}) { createMatchPassword: str('CREATE_MATCH_PASSWORD'), // Optionaler Schlüssel für die Stream-Übersicht aller Matches (/stream?key=…); leer = offen streamKey: str('STREAM_KEY'), + discordWebhookUrl: str('DISCORD_WEBHOOK_URL'), // Passwort für den Admin-Modus (/admin). Leer = Admin-Modus ist aus. adminPassword: str('ADMIN_PASSWORD'), requireFullTeams: bool('REQUIRE_FULL_TEAMS', false), diff --git a/server/src/db.js b/server/src/db.js index e20d3c9..228c46d 100644 --- a/server/src/db.js +++ b/server/src/db.js @@ -97,6 +97,10 @@ CREATE TABLE IF NOT EXISTS events ( created_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS events_match ON events(match_id, id); +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); `; export function openDatabase(file) { diff --git a/server/src/discord.js b/server/src/discord.js new file mode 100644 index 0000000..a9f9e36 --- /dev/null +++ b/server/src/discord.js @@ -0,0 +1,46 @@ +// Meldungen an einen Discord-Webhook, z. B. für Streamer. Fehler werden nur protokolliert +// und bremsen den Matchablauf nie aus. + +const WEBHOOK_PATTERN = /^https:\/\/(discord\.com|discordapp\.com)\/api\/webhooks\/\d+\/[\w-]+$/; +const TIMEOUT_MS = 5000; +const MAX_LENGTH = 1900; + +/** Ereignisse, die gemeldet werden können. Alle sind standardmäßig aktiv. */ +export const NOTIFY_EVENTS = [ + { key: 'veto_start', label: 'Veto gestartet' }, + { key: 'match_start', label: 'Match gestartet (Aufwärmphase beendet)' }, + { key: 'map_end', label: 'Map beendet (mit Ergebnis)' }, + { key: 'series_end', label: 'Serie entschieden' }, + { key: 'match_closed', label: 'Match abgeschlossen oder abgebrochen' }, + { key: 'server_error', label: 'Serverfehler' }, +]; + +/** + * @param {string} url Webhook-URL aus DISCORD_WEBHOOK_URL, leer = keine Meldungen + * @param {object} options fetchImpl und logger, vor allem für Tests + */ +export function createDiscordNotifier(url, { fetchImpl = fetch, logger = console } = {}) { + if (!url) return { enabled: false, send: async () => false }; + // Die URL enthält ein Geheimnis. Sie wird deshalb nie in Fehlermeldungen ausgegeben. + if (!WEBHOOK_PATTERN.test(url)) throw new Error('DISCORD_WEBHOOK_URL ist keine gültige Discord-Webhook-URL'); + + return { + enabled: true, + /** Liefert true, wenn Discord die Nachricht angenommen hat. */ + async send(content) { + try { + const res = await fetchImpl(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content: content.slice(0, MAX_LENGTH) }), + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + if (res.ok) return true; + logger.warn(`Discord-Webhook antwortet mit HTTP ${res.status}`); + } catch (err) { + logger.warn(`Discord-Meldung fehlgeschlagen: ${err.name}`); + } + return false; + }, + }; +} diff --git a/server/src/matchService.js b/server/src/matchService.js index 4a589d6..4efbaa9 100644 --- a/server/src/matchService.js +++ b/server/src/matchService.js @@ -4,6 +4,7 @@ import { formatCode, hashToken, normalizeCode, randomCode, randomId, randomToken import { applyLogLines, initialLiveState, seriesScore, splitLogBody } from './liveScore.js'; import { trimLiveState } from './rounds.js'; import { sanitizeConsoleText } from './servers/cs2Config.js'; +import { NOTIFY_EVENTS } from './discord.js'; export class HttpError extends Error { constructor(status, message, code = 'error') { @@ -41,11 +42,12 @@ function cleanSteamId(value) { } export class MatchService extends EventEmitter { - constructor({ db, pools, config }) { + constructor({ db, pools, config, notifier = { enabled: false, send: async () => {} } }) { super(); this.db = db; this.pools = pools; this.config = config; + this.notifier = notifier; this.presence = () => new Set(); } @@ -339,6 +341,7 @@ export class MatchService extends EventEmitter { .run(firstTeam, this.now(), this.now(), match.id); const how = match.veto_start === 'coin' ? 'per Münzwurf ausgelost' : 'vom Organisator festgelegt'; this.log(match.id, 'veto', `Veto gestartet – ${this.teamName(match, firstTeam)} beginnt (${how})`); + this.notify('veto_start', match.id, `Veto gestartet: ${this.matchTitle(match.id)}, ${this.teamName(match, firstTeam)} beginnt`); })(); this.changed(viewer.matchId); } @@ -516,6 +519,8 @@ export class MatchService extends EventEmitter { const winner = state.winner ? this.teamName(match, state.winner) : 'Unentschieden'; this.log(matchId, 'score', `Map ${row.number} (${this.mapName(match, row.map)}) beendet: ${match.team_a_name} ${state.score.A}:${state.score.B} ${match.team_b_name} – Sieger: ${winner}`); + this.notify('map_end', matchId, `${this.matchTitle(matchId)} +Map ${row.number} (${this.mapName(match, row.map)}) beendet: ${state.score.A}:${state.score.B}, Sieger ${winner}`); } })(); if (ended) { @@ -524,6 +529,8 @@ export class MatchService extends EventEmitter { const needed = Math.floor(BEST_OF[ended.match.format] / 2) + 1; const decided = score.A >= needed ? 'A' : score.B >= needed ? 'B' : null; if (decided) this.log(matchId, 'score', `Serie entschieden: ${this.teamName(ended.match, decided)} gewinnt ${score[decided]}:${score[decided === 'A' ? 'B' : 'A']}`); + if (decided) this.notify('series_end', matchId, `${this.matchTitle(matchId)} +Serie entschieden: ${this.teamName(ended.match, decided)} gewinnt ${score[decided]}:${score[decided === 'A' ? 'B' : 'A']}`); const hasNext = maps.some((m) => m.number === ended.row.number + 1); this.changed(matchId); this.emit('mapEnded', { matchId, number: ended.row.number, decided, hasNext }); @@ -555,6 +562,8 @@ export class MatchService extends EventEmitter { this.db.prepare("UPDATE series_maps SET status = 'done' WHERE match_id = ? AND status IN ('live','next')").run(match.id); } this.log(match.id, 'status', message ?? (status === 'finished' ? 'Match abgeschlossen' : 'Match abgebrochen')); + this.notify('match_closed', match.id, `${this.matchTitle(match.id)} +${message ?? (status === 'finished' ? 'Match abgeschlossen' : 'Match abgebrochen')}`); this.setStatus(match.id, status); this.emit('closed', match.id); } @@ -569,6 +578,45 @@ export class MatchService extends EventEmitter { this.db.prepare('UPDATE matches SET last_activity_at = ? WHERE id = ?').run(this.now(), matchId); } + /** Titel für Meldungen, z. B. „Team A vs. Team B (BO3)“. */ + matchTitle(matchId) { + const m = this.getMatch(matchId); + return `${m.team_a_name} vs. ${m.team_b_name} (${m.format.toUpperCase()})`; + } + + /** + * Meldung an den Discord-Webhook, falls konfiguriert. Ohne Stream-Schlüssel gehört die Übersicht + * des Matches dazu, mit Schlüssel entfällt der Link, damit der Schlüssel nicht in den Kanal gelangt. + */ + /** Discord-Meldungen: der Webhook muss eingerichtet und das Ereignis ausgewählt sein. */ + notificationSettings() { + const row = this.db.prepare("SELECT value FROM settings WHERE key = 'discord_events'").get(); + let stored = {}; + try { + stored = row ? JSON.parse(row.value) : {}; + } catch { + stored = {}; + } + const events = Object.fromEntries(NOTIFY_EVENTS.map(({ key }) => [key, stored[key] !== false])); + return { enabled: this.notifier.enabled, events, catalog: NOTIFY_EVENTS }; + } + + setNotificationEvents(input) { + if (!input || typeof input !== 'object') throw new HttpError(400, 'Ungültige Auswahl der Meldungen', 'invalid'); + const events = Object.fromEntries(NOTIFY_EVENTS.map(({ key }) => [key, input[key] === true])); + this.db + .prepare("INSERT INTO settings (key, value) VALUES ('discord_events', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value") + .run(JSON.stringify(events)); + return this.notificationSettings(); + } + + notify(type, matchId, text) { + if (!this.notifier.enabled || !this.notificationSettings().events[type]) return; + const link = this.config.streamKey ? '' : ` +${this.config.publicUrl}/stream/match/${matchId}`; + this.notifier.send(`${text}${link}`); + } + log(matchId, type, message) { this.db .prepare('INSERT INTO events (match_id, type, message, created_at) VALUES (?, ?, ?, ?)') diff --git a/server/src/servers/serverManager.js b/server/src/servers/serverManager.js index 6954747..2d036c7 100644 --- a/server/src/servers/serverManager.js +++ b/server/src/servers/serverManager.js @@ -265,6 +265,7 @@ export class ServerManager { } else if (status.state === 'failed') { Object.assign(fields, { status: current.status === 'starting' ? 'start_failed' : 'failed', message: status.message }); this.service.log(matchId, 'server', `Server-Fehler: ${status.message}`); + this.service.notify('server_error', matchId, `Server-Fehler bei ${this.service.matchTitle(matchId)}: ${status.message}`); } else if (status.state === 'stopped') { Object.assign(fields, { status: 'stopped', stopped_at: Date.now(), message: status.message ?? 'Server wurde extern beendet' }); this.service.log(matchId, 'server', 'Server wurde außerhalb der Anwendung beendet'); @@ -387,6 +388,7 @@ export class ServerManager { throw new HttpError(502, `Befehl konnte nicht gesendet werden: ${err.message}`, 'server_error'); } this.service.log(viewer.matchId, 'server', this.adapter.simulated ? 'Aufwärmphase beendet (simuliert)' : 'Aufwärmphase beendet – Match startet'); + this.service.notify('match_start', viewer.matchId, `Match gestartet: ${this.service.matchTitle(viewer.matchId)}`); if (this.adapter.simulated) this.startDemoSimulation(viewer.matchId); this.service.changed(viewer.matchId); } diff --git a/server/test/discord.test.js b/server/test/discord.test.js new file mode 100644 index 0000000..c497103 --- /dev/null +++ b/server/test/discord.test.js @@ -0,0 +1,46 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createDiscordNotifier } from '../src/discord.js'; + +const URL_OK = 'https://discord.com/api/webhooks/123456/geheim_TOKEN-1'; + +test('Ohne URL ist der Notifier aus und tut nichts', async () => { + let called = false; + const n = createDiscordNotifier('', { fetchImpl: async () => { called = true; } }); + assert.equal(n.enabled, false); + await n.send('Test'); + assert.equal(called, false); +}); + +test('Ungültige URL wird beim Start abgelehnt, ohne die URL zu zeigen', () => { + assert.throws(() => createDiscordNotifier('https://example.com/geheim'), (err) => { + assert.match(err.message, /keine gültige Discord-Webhook-URL/); + assert.ok(!err.message.includes('geheim')); + return true; + }); +}); + +test('Nachricht wird als JSON an den Webhook geschickt, überlange Texte gekürzt', async () => { + const sent = []; + const n = createDiscordNotifier(URL_OK, { + fetchImpl: async (url, init) => { sent.push({ url, init }); return { ok: true, status: 204 }; }, + }); + await n.send('Hallo'); + await n.send('x'.repeat(5000)); + assert.equal(sent[0].url, URL_OK); + assert.equal(sent[0].init.method, 'POST'); + assert.equal(JSON.parse(sent[0].init.body).content, 'Hallo'); + assert.equal(JSON.parse(sent[1].init.body).content.length, 1900); +}); + +test('Fehler des Webhooks brechen den Ablauf nicht ab und werden protokolliert ohne URL', async () => { + const warnings = []; + const logger = { warn: (m) => warnings.push(m) }; + const failing = createDiscordNotifier(URL_OK, { logger, fetchImpl: async () => { throw new TypeError('fetch failed'); } }); + await failing.send('Test'); + const bad = createDiscordNotifier(URL_OK, { logger, fetchImpl: async () => ({ ok: false, status: 404 }) }); + await bad.send('Test'); + assert.equal(warnings.length, 2); + assert.match(warnings[1], /HTTP 404/); + assert.ok(warnings.every((w) => !w.includes('TOKEN'))); +});