Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bceceb31d | ||
|
|
eee4ceb315 | ||
|
|
c33185db3e | ||
|
|
1da08b256e | ||
|
|
1d627c212a | ||
|
|
e7f595176a | ||
|
|
bfe315d70c | ||
|
|
d2a811f177 | ||
|
|
c69d5548e5 | ||
|
|
af4f036515 | ||
|
|
91213d354f | ||
|
|
4fa4db3640 | ||
|
|
a98a34d769 | ||
|
|
3bfe087b7d | ||
|
|
43ecb8a1ad | ||
|
|
46d1954a6d | ||
|
|
30ec5359b1 | ||
|
|
5527c2ce58 | ||
|
|
1b7c13b592 | ||
|
|
e3a1ed4a63 | ||
|
|
84dad0993c | ||
|
|
3a109883b3 | ||
|
|
85041b2bff | ||
|
|
ea2207899f | ||
|
|
263c7e7b7c | ||
|
|
306b3aa7e5 | ||
|
|
34e5fc7d0e | ||
|
|
8be8b3d879 | ||
|
|
3a74e16bb8 | ||
|
|
f1fc1642ab | ||
|
|
db4a6bedf7 | ||
|
|
7be0d85ab5 | ||
|
|
e304556346 | ||
|
|
bf438511d6 | ||
|
|
f7520a6ca7 | ||
|
|
f08297dd0d |
No files matched your search
@@ -68,3 +68,5 @@ LOG_DEBUG=false
|
|||||||
SERIES_AUTO_ADVANCE_SECONDS=30
|
SERIES_AUTO_ADVANCE_SECONDS=30
|
||||||
# Schlüssel für die Stream-Übersicht aller Matches (/stream?key=...); leer = offen
|
# Schlüssel für die Stream-Übersicht aller Matches (/stream?key=...); leer = offen
|
||||||
STREAM_KEY=
|
STREAM_KEY=
|
||||||
|
# Discord-Webhook für Meldungen (Veto, Match-Start, Map- und Serienende, Fehler). Leer = aus
|
||||||
|
DISCORD_WEBHOOK_URL=
|
||||||
@@ -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.
|
- **Laufband:** zeigt das letzte für Zuschauer relevante Ereignis, keine technischen Servermeldungen.
|
||||||
- **Datenschutz:** Verbindungsdaten und Passwörter erscheinen dort nie.
|
- **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/<id>`). 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
|
## 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_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 |
|
| `LOG_DEBUG` | `false` | empfangene Logzeilen im Terminal ausgeben |
|
||||||
| `STREAM_KEY` | leer | Schlüssel für die Stream-Übersicht aller Matches (`/stream?key=…`); leer = offen |
|
| `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 |
|
| `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:
|
Benötigte Zugangsdaten für den Echtbetrieb:
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ export default function AdminPage() {
|
|||||||
<button className="btn btn-sm" onClick={logout}>Abmelden</button>
|
<button className="btn btn-sm" onClick={logout}>Abmelden</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{token && <NotificationSettings token={token} setNotice={setNotice} />}
|
||||||
{notice && <div className={`toast toast-${notice.tone}`} role="status">{notice.text}</div>}
|
{notice && <div className={`toast toast-${notice.tone}`} role="status">{notice.text}</div>}
|
||||||
{error && <div className="alert alert-error">{error}</div>}
|
{error && <div className="alert alert-error">{error}</div>}
|
||||||
{matches === null && !error && <p className="muted">Lade Matches …</p>}
|
{matches === null && !error && <p className="muted">Lade Matches …</p>}
|
||||||
@@ -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 (
|
||||||
|
<section className="admin-settings">
|
||||||
|
<h2>Discord-Meldungen</h2>
|
||||||
|
{!settings.enabled && (
|
||||||
|
<p className="alert alert-error">Kein Webhook eingerichtet. Setze DISCORD_WEBHOOK_URL und starte die Anwendung neu.</p>
|
||||||
|
)}
|
||||||
|
<div className="admin-settings-grid">
|
||||||
|
{settings.catalog.map(({ key, label }) => (
|
||||||
|
<label key={key} className="admin-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!events[key]}
|
||||||
|
onChange={(e) => setEvents({ ...events, [key]: e.target.checked })}
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="admin-actions">
|
||||||
|
<button className="btn btn-sm btn-primary" onClick={save} disabled={busy || !dirty}>Speichern</button>
|
||||||
|
<button className="btn btn-sm" onClick={test} disabled={busy || !settings.enabled}>Testmeldung senden</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function AdminLogin({ onLogin }) {
|
function AdminLogin({ onLogin }) {
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
|||||||
@@ -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 h3 { margin: 0 0 8px; display: flex; align-items: center; gap: 10px; }
|
||||||
.rollback select { min-width: 200px; }
|
.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-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; }
|
||||||
@@ -76,6 +76,17 @@ export function createAdminRouter({ service, manager, config }) {
|
|||||||
|
|
||||||
router.get('/session', requireAdmin, (req, res) => res.json({ ok: true }));
|
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)
|
// Alle Matches mit allen Details (Codes, Verbindungsdaten, Spieler, Veto, Serie, Ereignisse)
|
||||||
router.get('/matches', requireAdmin, wrap(() => {
|
router.get('/matches', requireAdmin, wrap(() => {
|
||||||
const ids = service.db.prepare('SELECT id FROM matches ORDER BY created_at DESC LIMIT 100').all();
|
const ids = service.db.prepare('SELECT id FROM matches ORDER BY created_at DESC LIMIT 100').all();
|
||||||
|
|||||||
+3
-1
@@ -5,6 +5,7 @@ import express from 'express';
|
|||||||
import { REPO_ROOT } from './config.js';
|
import { REPO_ROOT } from './config.js';
|
||||||
import { openDatabase } from './db.js';
|
import { openDatabase } from './db.js';
|
||||||
import { createApiRouter } from './http.js';
|
import { createApiRouter } from './http.js';
|
||||||
|
import { createDiscordNotifier } from './discord.js';
|
||||||
import { loadMapPools } from './mapPools.js';
|
import { loadMapPools } from './mapPools.js';
|
||||||
import { MatchService } from './matchService.js';
|
import { MatchService } from './matchService.js';
|
||||||
import { attachRealtime } from './realtime.js';
|
import { attachRealtime } from './realtime.js';
|
||||||
@@ -15,7 +16,8 @@ import { ServerManager } from './servers/serverManager.js';
|
|||||||
export function createApp(config, { adapter: adapterOverride, logger = console } = {}) {
|
export function createApp(config, { adapter: adapterOverride, logger = console } = {}) {
|
||||||
const db = openDatabase(config.databaseFile);
|
const db = openDatabase(config.databaseFile);
|
||||||
const pools = loadMapPools(config.mapPoolFile);
|
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;
|
let manager;
|
||||||
const adapter = adapterOverride ?? createServerAdapter(config, { getUsedPorts: () => manager.usedPorts() });
|
const adapter = adapterOverride ?? createServerAdapter(config, { getUsedPorts: () => manager.usedPorts() });
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export function loadConfig(overrides = {}) {
|
|||||||
createMatchPassword: str('CREATE_MATCH_PASSWORD'),
|
createMatchPassword: str('CREATE_MATCH_PASSWORD'),
|
||||||
// Optionaler Schlüssel für die Stream-Übersicht aller Matches (/stream?key=…); leer = offen
|
// Optionaler Schlüssel für die Stream-Übersicht aller Matches (/stream?key=…); leer = offen
|
||||||
streamKey: str('STREAM_KEY'),
|
streamKey: str('STREAM_KEY'),
|
||||||
|
discordWebhookUrl: str('DISCORD_WEBHOOK_URL'),
|
||||||
// Passwort für den Admin-Modus (/admin). Leer = Admin-Modus ist aus.
|
// Passwort für den Admin-Modus (/admin). Leer = Admin-Modus ist aus.
|
||||||
adminPassword: str('ADMIN_PASSWORD'),
|
adminPassword: str('ADMIN_PASSWORD'),
|
||||||
requireFullTeams: bool('REQUIRE_FULL_TEAMS', false),
|
requireFullTeams: bool('REQUIRE_FULL_TEAMS', false),
|
||||||
|
|||||||
@@ -97,6 +97,10 @@ CREATE TABLE IF NOT EXISTS events (
|
|||||||
created_at INTEGER NOT NULL
|
created_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS events_match ON events(match_id, id);
|
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) {
|
export function openDatabase(file) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { formatCode, hashToken, normalizeCode, randomCode, randomId, randomToken
|
|||||||
import { applyLogLines, initialLiveState, seriesScore, splitLogBody } from './liveScore.js';
|
import { applyLogLines, initialLiveState, seriesScore, splitLogBody } from './liveScore.js';
|
||||||
import { trimLiveState } from './rounds.js';
|
import { trimLiveState } from './rounds.js';
|
||||||
import { sanitizeConsoleText } from './servers/cs2Config.js';
|
import { sanitizeConsoleText } from './servers/cs2Config.js';
|
||||||
|
import { NOTIFY_EVENTS } from './discord.js';
|
||||||
|
|
||||||
export class HttpError extends Error {
|
export class HttpError extends Error {
|
||||||
constructor(status, message, code = 'error') {
|
constructor(status, message, code = 'error') {
|
||||||
@@ -41,11 +42,12 @@ function cleanSteamId(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class MatchService extends EventEmitter {
|
export class MatchService extends EventEmitter {
|
||||||
constructor({ db, pools, config }) {
|
constructor({ db, pools, config, notifier = { enabled: false, send: async () => {} } }) {
|
||||||
super();
|
super();
|
||||||
this.db = db;
|
this.db = db;
|
||||||
this.pools = pools;
|
this.pools = pools;
|
||||||
this.config = config;
|
this.config = config;
|
||||||
|
this.notifier = notifier;
|
||||||
this.presence = () => new Set();
|
this.presence = () => new Set();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,6 +341,7 @@ export class MatchService extends EventEmitter {
|
|||||||
.run(firstTeam, this.now(), this.now(), match.id);
|
.run(firstTeam, this.now(), this.now(), match.id);
|
||||||
const how = match.veto_start === 'coin' ? 'per Münzwurf ausgelost' : 'vom Organisator festgelegt';
|
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.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);
|
this.changed(viewer.matchId);
|
||||||
}
|
}
|
||||||
@@ -516,6 +519,8 @@ export class MatchService extends EventEmitter {
|
|||||||
const winner = state.winner ? this.teamName(match, state.winner) : 'Unentschieden';
|
const winner = state.winner ? this.teamName(match, state.winner) : 'Unentschieden';
|
||||||
this.log(matchId, 'score',
|
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}`);
|
`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) {
|
if (ended) {
|
||||||
@@ -524,6 +529,8 @@ export class MatchService extends EventEmitter {
|
|||||||
const needed = Math.floor(BEST_OF[ended.match.format] / 2) + 1;
|
const needed = Math.floor(BEST_OF[ended.match.format] / 2) + 1;
|
||||||
const decided = score.A >= needed ? 'A' : score.B >= needed ? 'B' : null;
|
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.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);
|
const hasNext = maps.some((m) => m.number === ended.row.number + 1);
|
||||||
this.changed(matchId);
|
this.changed(matchId);
|
||||||
this.emit('mapEnded', { matchId, number: ended.row.number, decided, hasNext });
|
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.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.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.setStatus(match.id, status);
|
||||||
this.emit('closed', match.id);
|
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);
|
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) {
|
log(matchId, type, message) {
|
||||||
this.db
|
this.db
|
||||||
.prepare('INSERT INTO events (match_id, type, message, created_at) VALUES (?, ?, ?, ?)')
|
.prepare('INSERT INTO events (match_id, type, message, created_at) VALUES (?, ?, ?, ?)')
|
||||||
|
|||||||
@@ -265,6 +265,7 @@ export class ServerManager {
|
|||||||
} else if (status.state === 'failed') {
|
} else if (status.state === 'failed') {
|
||||||
Object.assign(fields, { status: current.status === 'starting' ? 'start_failed' : 'failed', message: status.message });
|
Object.assign(fields, { status: current.status === 'starting' ? 'start_failed' : 'failed', message: status.message });
|
||||||
this.service.log(matchId, 'server', `Server-Fehler: ${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') {
|
} else if (status.state === 'stopped') {
|
||||||
Object.assign(fields, { status: 'stopped', stopped_at: Date.now(), message: status.message ?? 'Server wurde extern beendet' });
|
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');
|
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');
|
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.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);
|
if (this.adapter.simulated) this.startDemoSimulation(viewer.matchId);
|
||||||
this.service.changed(viewer.matchId);
|
this.service.changed(viewer.matchId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')));
|
||||||
|
});
|
||||||
Reference in new issue
Block a user