Overlay: fuenf Browser-Sources fuer OBS
Einblendungen, Chat, Statusleiste, Effekte, Pausenbild. Kein Build, kein Framework - OBS laedt die Dateien direkt, und jede Zwischenstufe waere ein Ding, das mitten im Stream kaputtgehen kann. Server-Sent Events statt WebSocket, entgegen der Skizze in chat-control.md: Node bringt einen WebSocket-*Client* mit, aber keinen Server, `EventSource` verbindet sich von allein neu, und die Richtung stimmt - das Overlay hoert nur zu. Gegen die tote Browser-Source nach einem Neustart stehen drei Dinge: der eingebaute Reconnect, ein Herzschlag als echtes Ereignis (ein SSE-Kommentar haelt die Verbindung offen, ist im Browser aber unsichtbar), und ein Zustands-Schnappschuss beim Verbinden. Abos, Raids, Bits und Loeschungen kommen ueber IRC-USERNOTICE und CLEARCHAT herein; das wurde bisher weggeworfen. Follows fehlen weiter, die gibt es nur ueber EventSub. GIFs im Chat: Twitch schickt dafuer einen Tag `gifs`, den wir nicht kannten. gifs=0-22|Pnd7qLFuGCOntZcNKB|https://media0.giphy.com/media/.../giphy.gif PRIVMSG #abdulive_ :[Idk GIF by Respective] Der Nachrichtentext ist nur ein Platzhalter, der Bereich markiert ihn. Ausgewertet wird er jetzt; ohne das stand woertlich "[Idk GIF by Respective]" im Bild. Gefunden wurde der Tag durch einen Rohmitschnitt - in 3094 Nachrichten aus acht grossen Kanaelen war kein einziges GIF, weil die Adresse eben nicht im Text steht. Zwei Fehler, die beim Durchklicken im Browser aufgefallen sind: - Die Aufraeumfunktion im Chat zaehlte Zeilen, die erst nach der Ausblend-Animation aus dem DOM verschwinden, und lief in eine Endlosschleife. In OBS waere die Quelle beim vierzehnten Chatbeitrag eingefroren. - Der Bereichsparser der GIFs war zu nachsichtig: `-1-4` zerfaellt beim Trennen in ['', '1', '4'], und `Number('')` ist 0 - der Bereich saehe gueltig aus und wuerde den falschen Textteil ersetzen. Auf der Uebersichtsseite steht fuer jede Einblendung, jede Chat-Variante und jeden Effekt ein Knopf. Ohne den liesse sich eine Raid-Einblendung nur pruefen, indem man auf einen echten Raid wartet. Was einen Zustand setzt, ist waehrend eines Streams gesperrt; "Stand zuruecksetzen" ausdruecklich nicht, sonst kaeme man aus einem erfundenen Zustand nicht mehr heraus. build-package.ts nimmt jetzt auch html/css/js mit - sonst liefert das Paket 404 statt Overlay. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
3b606e36fb
commit
bc2c8f6cbe
24 files changed
+3221
-8
No files matched your search
@@ -0,0 +1,68 @@
|
||||
// Das Sitzungs-Cookie ist der einzige Schutz vor der Einrichtungsseite, auf
|
||||
// der Twitch-Zugangsdaten eingetragen werden. Hier wird geprueft, dass es
|
||||
// haelt, was es soll - und dass es einen Neustart übersteht.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import crypto from 'node:crypto';
|
||||
import { sitzungGueltig, sitzungToken } from './auth.ts';
|
||||
|
||||
const KEY = crypto.randomBytes(32);
|
||||
const HASH = 'ein-passwort-hash';
|
||||
const JETZT = Date.parse('2026-09-17T18:00:00Z');
|
||||
const IN_30_TAGEN = JETZT + 30 * 24 * 3_600_000;
|
||||
|
||||
test('ein frisch ausgestelltes Cookie gilt', () => {
|
||||
const t = sitzungToken(KEY, HASH, IN_30_TAGEN);
|
||||
assert.equal(sitzungGueltig(t, KEY, HASH, JETZT), true);
|
||||
});
|
||||
|
||||
test('ein Neustart meldet niemanden ab', () => {
|
||||
// Der eigentliche Grund für diese Bauform: der Dienst startet bei jedem
|
||||
// Ausrollen neu. Lag die Sitzungsliste im Arbeitsspeicher, war man danach
|
||||
// jedes Mal draußen. Derselbe Schlüssel - dasselbe Ergebnis, ohne dass
|
||||
// irgendwo etwas gemerkt wurde.
|
||||
const t = sitzungToken(KEY, HASH, IN_30_TAGEN);
|
||||
const nachNeustart = Buffer.from(KEY); // derselbe Wert, neues Objekt
|
||||
assert.equal(sitzungGueltig(t, nachNeustart, HASH, JETZT), true);
|
||||
});
|
||||
|
||||
test('abgelaufen ist abgelaufen', () => {
|
||||
const t = sitzungToken(KEY, HASH, JETZT - 1);
|
||||
assert.equal(sitzungGueltig(t, KEY, HASH, JETZT), false);
|
||||
});
|
||||
|
||||
test('das Ablaufdatum laesst sich nicht verlaengern', () => {
|
||||
// Ohne Signatur ueber das Datum koennte jeder das Cookie umschreiben und
|
||||
// waere damit dauerhaft angemeldet.
|
||||
const t = sitzungToken(KEY, HASH, JETZT + 1000);
|
||||
const [, sig] = t.split('.');
|
||||
const gefaelscht = `${Buffer.from(String(IN_30_TAGEN)).toString('base64url')}.${sig}`;
|
||||
assert.equal(sitzungGueltig(gefaelscht, KEY, HASH, JETZT), false);
|
||||
});
|
||||
|
||||
test('ein fremder Schluessel oeffnet nichts', () => {
|
||||
const t = sitzungToken(KEY, HASH, IN_30_TAGEN);
|
||||
assert.equal(sitzungGueltig(t, crypto.randomBytes(32), HASH, JETZT), false);
|
||||
});
|
||||
|
||||
test('ein neues Passwort macht alle Cookies ungueltig', () => {
|
||||
// Das ist der Ersatz für eine Abmeldeliste: wer sein Passwort wechselt,
|
||||
// wirft damit alle offenen Sitzungen raus.
|
||||
const t = sitzungToken(KEY, HASH, IN_30_TAGEN);
|
||||
assert.equal(sitzungGueltig(t, KEY, 'neuer-hash', JETZT), false);
|
||||
});
|
||||
|
||||
test('Unsinn im Cookie wird abgewiesen, nicht geglaubt', () => {
|
||||
for (const müll of [undefined, '', 'abc', 'abc.def', '.', 'x.y.z',
|
||||
`${Buffer.from('keine-zahl').toString('base64url')}.xx`]) {
|
||||
assert.equal(sitzungGueltig(müll, KEY, HASH, JETZT), false, `"${müll}" haette abgewiesen gehoert`);
|
||||
}
|
||||
});
|
||||
|
||||
test('zwei Anmeldungen mit demselben Ablauf ergeben dasselbe Cookie', () => {
|
||||
// Bewusst so: ohne Zufallsanteil gibt es nichts, was der Server sich merken
|
||||
// müsste. Der Preis ist, dass sich eine einzelne Sitzung nicht gezielt
|
||||
// beenden lässt - dafür gibt es den Passwortwechsel.
|
||||
assert.equal(sitzungToken(KEY, HASH, IN_30_TAGEN), sitzungToken(KEY, HASH, IN_30_TAGEN));
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
// Zugangskontrolle. Nur noetig, seit der Dienst zentral laeuft: solange er an
|
||||
// 127.0.0.1 hing, war "wer am Rechner sitzt, darf" die ganze Regel.
|
||||
//
|
||||
// Zwei Wege, weil es zwei Arten von Besuchern gibt:
|
||||
//
|
||||
// Mensch -> Passwort, einmal eingeben, danach ein Sitzungs-Cookie.
|
||||
// Gilt fuer die Uebersicht, die Einrichtung und den Status.
|
||||
// OBS -> Schluessel in der Adresse (?key=...). Eine Browser-Source
|
||||
// kann kein Formular ausfuellen und keinen Login durchhalten;
|
||||
// ein Geheimnis in der URL ist hier der einzige Weg, der ohne
|
||||
// Handgriff mitten im Stream auskommt.
|
||||
//
|
||||
// Der Schluessel steht damit in jedem OBS-Profil und in jedem Screenshot der
|
||||
// Einstellungen. Deshalb kann er nur lesen, nie schreiben, und er laesst sich
|
||||
// auf der Uebersichtsseite neu wuerfeln.
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import type http from 'node:http';
|
||||
import { logger } from '#core/logger.ts';
|
||||
import { readSecrets, updateSecrets } from '#twitch/secrets.ts';
|
||||
|
||||
const log = logger('overlay:zugang');
|
||||
|
||||
/** Wie lange eine Anmeldung gilt. Danach einmal neu eintippen. */
|
||||
const SESSION_MS = 30 * 24 * 3_600_000;
|
||||
const COOKIE = 'abdulive_sitzung';
|
||||
|
||||
export type Zugang = {
|
||||
/** Darf diese Anfrage eine geschuetzte Seite sehen? */
|
||||
istAngemeldet: (req: http.IncomingMessage) => boolean;
|
||||
/** Stimmt der Schluessel in der Adresse? Angemeldete duerfen auch ohne. */
|
||||
darfOverlay: (req: http.IncomingMessage, url: URL) => boolean;
|
||||
/** Prueft das Passwort und gibt bei Erfolg den Cookie-Kopf zurueck. */
|
||||
anmelden: (passwort: string) => string | null;
|
||||
abmelden: () => string;
|
||||
overlayKey: () => string;
|
||||
neuerOverlayKey: () => string;
|
||||
/** Ist ueberhaupt ein Passwort gesetzt? Ohne das ist alles offen. */
|
||||
hatPasswort: () => boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Vergleich in konstanter Zeit. Bei einem einzelnen Passwortfeld ist der
|
||||
* Zeitunterschied kaum messbar, aber das ist kein Grund, ihn zu verschenken.
|
||||
*/
|
||||
function gleich(a: string, b: string): boolean {
|
||||
const ab = Buffer.from(a);
|
||||
const bb = Buffer.from(b);
|
||||
if (ab.length !== bb.length) return false;
|
||||
return crypto.timingSafeEqual(ab, bb);
|
||||
}
|
||||
|
||||
function hash(passwort: string, salt: string): string {
|
||||
return crypto.scryptSync(passwort, salt, 32).toString('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Das Cookie traegt sein eigenes Ablaufdatum und eine Signatur darueber.
|
||||
* Der Server merkt sich keine Sitzungen.
|
||||
*
|
||||
* Der Grund ist eine gelernte Lektion: eine Liste im Arbeitsspeicher ist
|
||||
* hinfaellig, sobald der Dienst neu startet - und er startet bei jedem
|
||||
* Ausrollen neu. Wer sich nach jeder Aenderung neu anmelden muss, waehlt
|
||||
* irgendwann ein kuerzeres Passwort.
|
||||
*
|
||||
* Mit signiert wird auch der Passwort-Hash. Ein neues Passwort macht damit
|
||||
* alle ausgegebenen Cookies ungueltig, ohne dass es eine Abmeldeliste braucht.
|
||||
*/
|
||||
export function sitzungToken(key: Buffer, passwordHash: string, bisMs: number): string {
|
||||
const bis = String(bisMs);
|
||||
return `${Buffer.from(bis).toString('base64url')}.${signatur(key, passwordHash, bis)}`;
|
||||
}
|
||||
|
||||
export function sitzungGueltig(
|
||||
token: string | undefined, key: Buffer, passwordHash: string, jetztMs = Date.now()
|
||||
): boolean {
|
||||
if (!token) return false;
|
||||
const [bisB64, sig] = token.split('.');
|
||||
if (!bisB64 || !sig) return false;
|
||||
const bis = Buffer.from(bisB64, 'base64url').toString('utf8');
|
||||
if (!/^\d+$/.test(bis) || Number(bis) < jetztMs) return false;
|
||||
return gleich(sig, signatur(key, passwordHash, bis));
|
||||
}
|
||||
|
||||
function signatur(key: Buffer, passwordHash: string, bis: string): string {
|
||||
return crypto.createHmac('sha256', key)
|
||||
.update(`${bis}|${passwordHash}`)
|
||||
.digest('base64url');
|
||||
}
|
||||
|
||||
function cookies(req: http.IncomingMessage): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const teil of (req.headers.cookie ?? '').split(';')) {
|
||||
const i = teil.indexOf('=');
|
||||
if (i > 0) out[teil.slice(0, i).trim()] = decodeURIComponent(teil.slice(i + 1).trim());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function createZugang(): Zugang {
|
||||
function store() {
|
||||
const s = readSecrets();
|
||||
return s.web ?? {};
|
||||
}
|
||||
|
||||
// Beim ersten Start einen Overlay-Schluessel wuerfeln. Ohne einen waere
|
||||
// jede Browser-Source-Adresse zu erraten.
|
||||
function key(): string {
|
||||
const vorhanden = store().overlayKey;
|
||||
if (vorhanden) return vorhanden;
|
||||
const neu = crypto.randomBytes(18).toString('base64url');
|
||||
updateSecrets((s) => { s.web = { ...s.web, overlayKey: neu }; });
|
||||
log.info('Neuer Overlay-Schluessel erzeugt - die Adressen stehen auf der Uebersichtsseite');
|
||||
return neu;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schluessel zum Signieren der Sitzungen. Liegt neben dem Passwort, wird
|
||||
* einmal gewuerfelt und ueberlebt damit jeden Neustart. Geht er verloren,
|
||||
* sind nur die Anmeldungen weg.
|
||||
*/
|
||||
function sitzungsKey(): Buffer {
|
||||
const vorhanden = store().sessionKey;
|
||||
if (vorhanden) return Buffer.from(vorhanden, 'base64');
|
||||
const neu = crypto.randomBytes(32);
|
||||
updateSecrets((s) => { s.web = { ...s.web, sessionKey: neu.toString('base64') }; });
|
||||
return neu;
|
||||
}
|
||||
|
||||
function istAngemeldet(req: http.IncomingMessage): boolean {
|
||||
const hash = store().passwordHash;
|
||||
if (!hash) return true; // noch nichts eingerichtet
|
||||
return sitzungGueltig(cookies(req)[COOKIE], sitzungsKey(), hash);
|
||||
}
|
||||
|
||||
return {
|
||||
hatPasswort: () => Boolean(store().passwordHash),
|
||||
istAngemeldet,
|
||||
|
||||
darfOverlay(req, url) {
|
||||
const gegeben = url.searchParams.get('key');
|
||||
if (gegeben && gleich(gegeben, key())) return true;
|
||||
// Wer angemeldet ist, darf die Seiten auch im Browser ansehen - sonst
|
||||
// muesste man zum Nachschauen jedes Mal den Schluessel anhaengen.
|
||||
return istAngemeldet(req);
|
||||
},
|
||||
|
||||
anmelden(passwort) {
|
||||
const s = store();
|
||||
if (!s.passwordHash || !s.passwordSalt) return null;
|
||||
if (!gleich(hash(passwort, s.passwordSalt), s.passwordHash)) {
|
||||
log.warn('Fehlgeschlagene Anmeldung');
|
||||
return null;
|
||||
}
|
||||
const token = sitzungToken(sitzungsKey(), s.passwordHash, Date.now() + SESSION_MS);
|
||||
// Kein `Secure`: NPM beendet TLS, und der Dienst selbst spricht HTTP.
|
||||
// Das Cookie geht trotzdem nur ueber die HTTPS-Adresse raus, weil es
|
||||
// keine andere gibt, die von aussen erreichbar ist.
|
||||
return `${COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_MS / 1000}`;
|
||||
},
|
||||
|
||||
abmelden: () => `${COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`,
|
||||
overlayKey: key,
|
||||
neuerOverlayKey() {
|
||||
const neu = crypto.randomBytes(18).toString('base64url');
|
||||
updateSecrets((s) => { s.web = { ...s.web, overlayKey: neu }; });
|
||||
log.info('Overlay-Schluessel neu gewuerfelt - die alten Adressen sind damit tot');
|
||||
return neu;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt das Passwort. Wird von `abdulive web:passwort` aufgerufen, nicht ueber
|
||||
* das Netz - ein Dienst, bei dem man das Passwort ueber die offene
|
||||
* Weboberflaeche setzen kann, hat kein Passwort.
|
||||
*/
|
||||
export function setzePasswort(passwort: string): void {
|
||||
if (passwort.length < 8) throw new Error('Mindestens acht Zeichen.');
|
||||
const salt = crypto.randomBytes(16).toString('base64');
|
||||
updateSecrets((s) => {
|
||||
s.web = { ...s.web, passwordSalt: salt, passwordHash: hash(passwort, salt) };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// Der Bus entscheidet, was eine frisch verbundene Browser-Source sieht.
|
||||
// Danach richtet sich, ob das Overlay nach einem Neustart vollstaendig ist
|
||||
// oder leer im Bild steht - und ob eine geloeschte Nachricht geloescht bleibt.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createBus, type ChatEvent, type OverlayEvent } from './bus.ts';
|
||||
|
||||
function nachricht(over: Partial<ChatEvent> = {}): ChatEvent {
|
||||
return {
|
||||
kind: 'chat', id: 'm1', userId: 'u1', login: 'kevin', name: 'Kevin',
|
||||
text: 'moin', color: null, emotes: [], gifs: [], first: false, mod: false,
|
||||
sub: false, broadcaster: false, bits: 0,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function schnappschuss(bus: ReturnType<typeof createBus>) {
|
||||
const s = bus.snapshot();
|
||||
assert.equal(s.kind, 'state');
|
||||
return s as Extract<OverlayEvent, { kind: 'state' }>;
|
||||
}
|
||||
|
||||
test('ein neuer Zuhoerer bekommt den bisherigen Chat', () => {
|
||||
const bus = createBus();
|
||||
bus.publish(nachricht({ id: 'a' }));
|
||||
bus.publish(nachricht({ id: 'b' }));
|
||||
|
||||
const s = schnappschuss(bus);
|
||||
assert.deepEqual(s.chat.map((m) => m.id), ['a', 'b']);
|
||||
});
|
||||
|
||||
test('der Rueckblick waechst nicht unbegrenzt', () => {
|
||||
const bus = createBus();
|
||||
for (let i = 0; i < 100; i++) bus.publish(nachricht({ id: `m${i}` }));
|
||||
|
||||
const s = schnappschuss(bus);
|
||||
assert.equal(s.chat.length, 30);
|
||||
assert.equal(s.chat[s.chat.length - 1]?.id, 'm99', 'die neuesten bleiben');
|
||||
});
|
||||
|
||||
test('der zuletzt gemeldete Stream-Zustand steht im Schnappschuss', () => {
|
||||
const bus = createBus();
|
||||
bus.publish({ kind: 'stream', live: true, title: 'Titel', category: 'Elden Ring',
|
||||
viewers: 12, startedAt: '2026-09-17T18:00:00Z' });
|
||||
bus.publish({ kind: 'stream', live: true, title: 'Titel', category: 'Elden Ring',
|
||||
viewers: 19, startedAt: '2026-09-17T18:00:00Z' });
|
||||
|
||||
assert.equal(schnappschuss(bus).stream.viewers, 19);
|
||||
});
|
||||
|
||||
test('eine geloeschte Nachricht verschwindet auch aus dem Rueckblick', () => {
|
||||
// Sonst holt der naechste Neustart der Browser-Source sie zurueck ins Bild -
|
||||
// und zwar genau die eine, die ein Mod entfernt hat.
|
||||
const bus = createBus();
|
||||
bus.publish(nachricht({ id: 'a' }));
|
||||
bus.publish(nachricht({ id: 'b' }));
|
||||
bus.publish({ kind: 'clear', messageId: 'a' });
|
||||
|
||||
assert.deepEqual(schnappschuss(bus).chat.map((m) => m.id), ['b']);
|
||||
});
|
||||
|
||||
test('ein gesperrter Chatter verschwindet mit allen Nachrichten', () => {
|
||||
const bus = createBus();
|
||||
bus.publish(nachricht({ id: 'a', userId: 'u1' }));
|
||||
bus.publish(nachricht({ id: 'b', userId: 'u2' }));
|
||||
bus.publish(nachricht({ id: 'c', userId: 'u1' }));
|
||||
bus.publish({ kind: 'clear', userId: 'u1' });
|
||||
|
||||
assert.deepEqual(schnappschuss(bus).chat.map((m) => m.id), ['b']);
|
||||
});
|
||||
|
||||
test('ein geleerter Chat leert auch den Rueckblick', () => {
|
||||
const bus = createBus();
|
||||
bus.publish(nachricht({ id: 'a' }));
|
||||
bus.publish({ kind: 'clear' });
|
||||
assert.deepEqual(schnappschuss(bus).chat, []);
|
||||
});
|
||||
|
||||
test('alte Einblendungen werden nicht endlos aufbewahrt', () => {
|
||||
const bus = createBus();
|
||||
for (let i = 0; i < 20; i++) {
|
||||
bus.publish({ kind: 'alert', type: 'sub', id: `a${i}`, name: 'x' });
|
||||
}
|
||||
assert.equal(schnappschuss(bus).alerts.length, 5);
|
||||
});
|
||||
|
||||
test('ein Zuhoerer, der wirft, reisst die anderen nicht mit', () => {
|
||||
// Eine Browser-Source, deren Verbindung gerade zusammengebrochen ist, darf
|
||||
// den Chat nicht fuer alle anderen anhalten.
|
||||
const bus = createBus();
|
||||
const gesehen: string[] = [];
|
||||
bus.subscribe(() => { throw new Error('Verbindung weg'); });
|
||||
bus.subscribe((ev) => { gesehen.push(ev.kind); });
|
||||
|
||||
bus.publish(nachricht());
|
||||
assert.deepEqual(gesehen, ['chat']);
|
||||
});
|
||||
|
||||
test('abgemeldete Zuhoerer bekommen nichts mehr', () => {
|
||||
const bus = createBus();
|
||||
let n = 0;
|
||||
const ab = bus.subscribe(() => { n++; });
|
||||
bus.publish(nachricht());
|
||||
ab();
|
||||
bus.publish(nachricht());
|
||||
|
||||
assert.equal(n, 1);
|
||||
assert.equal(bus.clients(), 0);
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
// Der Ereignisbus zwischen Daemon und Browser-Sources.
|
||||
//
|
||||
// Warum Server-Sent Events und nicht WebSocket, wie in chat-control.md
|
||||
// skizziert: Node bringt einen WebSocket-*Client* mit, aber keinen Server -
|
||||
// den muesste man selbst schreiben, inklusive Handshake und Rahmenformat.
|
||||
// SSE ist einfacher HTTP, `EventSource` verbindet sich von allein neu, und
|
||||
// die Richtung stimmt: das Overlay hoert nur zu, es sendet nie.
|
||||
//
|
||||
// Der haeufigste Fehler bei solchen Overlays ist die Browser-Source, die nach
|
||||
// einem Serverneustart tot im Bild haengt. Dagegen stehen hier drei Dinge:
|
||||
// der eingebaute Reconnect von EventSource, ein Herzschlag alle paar
|
||||
// Sekunden, und der Zustands-Schnappschuss, den jeder neue Client als erstes
|
||||
// bekommt - damit ist das Overlay nach dem Neuverbinden sofort wieder
|
||||
// vollstaendig und nicht erst nach der naechsten Chatnachricht.
|
||||
|
||||
export type ChatEvent = {
|
||||
kind: 'chat';
|
||||
/** msg-id aus IRC - dieselbe ID, mit der CLEARMSG spaeter loeschen kann. */
|
||||
id: string;
|
||||
userId: string;
|
||||
login: string;
|
||||
name: string;
|
||||
text: string;
|
||||
color: string | null;
|
||||
emotes: Array<{ id: string; name: string; count: number }>;
|
||||
/** GIFs aus Twitchs Auswahlfenster, mit dem Zeichenbereich im Text. */
|
||||
gifs: Array<{ id: string; url: string; from: number; to: number }>;
|
||||
first: boolean;
|
||||
mod: boolean;
|
||||
sub: boolean;
|
||||
broadcaster: boolean;
|
||||
bits: number;
|
||||
};
|
||||
|
||||
export type AlertKind =
|
||||
| 'sub' | 'resub' | 'subgift' | 'massgift' | 'raid' | 'cheer' | 'first' | 'clip' | 'follow'
|
||||
/**
|
||||
* Kanalpunkt-Einloesung. Bewusst eine eigene Art und nicht die des Abos:
|
||||
* eine eingeloeste Belohnung als "Neues Abo" einzublenden ist eine
|
||||
* Falschmeldung im Bild, und zwar eine, die in der Aufzeichnung bleibt.
|
||||
*/
|
||||
| 'reward';
|
||||
|
||||
export type AlertEvent = {
|
||||
kind: 'alert';
|
||||
type: AlertKind;
|
||||
id: string;
|
||||
name: string;
|
||||
/** Bits, Zuschauer beim Raid, Anzahl verschenkter Abos. */
|
||||
amount?: number;
|
||||
months?: number;
|
||||
tier?: string;
|
||||
recipient?: string;
|
||||
message?: string;
|
||||
/** Titel der eingeloesten Belohnung. Nur bei `reward`. */
|
||||
reward?: string;
|
||||
};
|
||||
|
||||
export type StreamEvent = {
|
||||
kind: 'stream';
|
||||
live: boolean;
|
||||
title: string;
|
||||
category: string;
|
||||
viewers: number;
|
||||
startedAt: string | null;
|
||||
};
|
||||
|
||||
export type OverlayEvent =
|
||||
| ChatEvent
|
||||
| AlertEvent
|
||||
| StreamEvent
|
||||
| { kind: 'hype'; score: number; rate: number }
|
||||
/**
|
||||
* Konfetti als eigenes Ereignis, nicht als Nebenwirkung einer Einblendung.
|
||||
* So muss die Effektseite nicht wissen, welche Belohnung welche Aktion
|
||||
* ausloest - das entscheidet der Hub.
|
||||
*/
|
||||
| { kind: 'konfetti'; staerke: number }
|
||||
/**
|
||||
* Vorzulesender Text. Geht nur raus, wenn er freigegeben wurde oder die
|
||||
* Belohnung ohne Freigabe eingerichtet ist - die Entscheidung faellt im Hub
|
||||
* (apps/hub/src/rewards.ts), nicht hier.
|
||||
*/
|
||||
| { kind: 'tts'; id: string; name: string; text: string }
|
||||
/** Alles sofort still. Fuer den Panikschalter. */
|
||||
| { kind: 'still' }
|
||||
| { kind: 'scene'; scene: string }
|
||||
| { kind: 'clear'; userId?: string; messageId?: string }
|
||||
| { kind: 'beat'; t: number }
|
||||
| { kind: 'state'; stream: StreamEvent; chat: ChatEvent[]; alerts: AlertEvent[] };
|
||||
|
||||
/**
|
||||
* So viele Nachrichten bekommt ein frisch verbundenes Overlay mit. Mehr
|
||||
* braucht keine Seite: der Chat zeigt ein gutes Dutzend, und ein Alarm von
|
||||
* vor zehn Minuten soll nach einem Neustart gerade nicht noch einmal
|
||||
* durchlaufen.
|
||||
*/
|
||||
const KEEP_CHAT = 30;
|
||||
const KEEP_ALERTS = 5;
|
||||
|
||||
export type Bus = {
|
||||
publish: (ev: OverlayEvent) => void;
|
||||
/** Meldet einen Zuhoerer an. Der Rueckgabewert meldet ihn wieder ab. */
|
||||
subscribe: (fn: (ev: OverlayEvent) => void) => () => void;
|
||||
/** Der Zustand, den ein neuer Client als erstes bekommt. */
|
||||
snapshot: () => OverlayEvent;
|
||||
clients: () => number;
|
||||
};
|
||||
|
||||
export function createBus(): Bus {
|
||||
const listeners = new Set<(ev: OverlayEvent) => void>();
|
||||
const chat: ChatEvent[] = [];
|
||||
const alerts: AlertEvent[] = [];
|
||||
let stream: StreamEvent = {
|
||||
kind: 'stream', live: false, title: '', category: '', viewers: 0, startedAt: null,
|
||||
};
|
||||
|
||||
return {
|
||||
publish(ev) {
|
||||
// Mitschreiben, bevor verteilt wird: ein Client, der genau jetzt
|
||||
// verbindet, soll dasselbe sehen wie einer, der schon verbunden war.
|
||||
if (ev.kind === 'chat') {
|
||||
chat.push(ev);
|
||||
if (chat.length > KEEP_CHAT) chat.shift();
|
||||
} else if (ev.kind === 'alert') {
|
||||
alerts.push(ev);
|
||||
if (alerts.length > KEEP_ALERTS) alerts.shift();
|
||||
} else if (ev.kind === 'stream') {
|
||||
stream = ev;
|
||||
} else if (ev.kind === 'clear') {
|
||||
// Geloeschtes verschwindet auch aus dem Rueckblick, sonst holt der
|
||||
// naechste Neustart der Browser-Source es zurueck ins Bild.
|
||||
for (let i = chat.length - 1; i >= 0; i--) {
|
||||
const m = chat[i]!;
|
||||
if ((ev.messageId && m.id === ev.messageId) ||
|
||||
(ev.userId && m.userId === ev.userId)) chat.splice(i, 1);
|
||||
}
|
||||
if (!ev.messageId && !ev.userId) chat.length = 0;
|
||||
}
|
||||
|
||||
for (const fn of listeners) {
|
||||
// Ein kaputter Zuhoerer darf die anderen nicht mitreissen.
|
||||
try { fn(ev); } catch { /* der Transport meldet es selbst */ }
|
||||
}
|
||||
},
|
||||
|
||||
subscribe(fn) {
|
||||
listeners.add(fn);
|
||||
return () => { listeners.delete(fn); };
|
||||
},
|
||||
|
||||
snapshot: () => ({ kind: 'state', stream, chat: [...chat], alerts: [...alerts] }),
|
||||
clients: () => listeners.size,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Dauerbetrieb mit erfundenen Ereignissen: erfundener Chat und alle paar
|
||||
// Sekunden eine Einblendung.
|
||||
//
|
||||
// Fuer `abdulive overlay --demo` und fuer den Knopf "Vorfuehrung" auf der
|
||||
// Uebersichtsseite. Die einzelnen Ereignisse kommen aus test-events.ts - die
|
||||
// Knoepfe und der Dauerbetrieb sollen dasselbe zeigen.
|
||||
|
||||
import { logger } from '#core/logger.ts';
|
||||
import type { Bus } from './bus.ts';
|
||||
import { testEvent, zufall, type TestArt } from './test-events.ts';
|
||||
|
||||
const log = logger('overlay:demo');
|
||||
|
||||
const ALARME: TestArt[] = ['sub', 'resub', 'subgift', 'massgift', 'raid', 'cheer', 'first'];
|
||||
const NACHRICHTEN: TestArt[] = ['chat', 'chat', 'chat', 'chat-emote', 'chat-emote', 'chat-erstmals'];
|
||||
|
||||
/**
|
||||
* Speist den Bus, bis `signal` abbricht.
|
||||
*
|
||||
* Der Stream-Zustand wird mitgesetzt, damit Statusleiste und Pausenbild etwas
|
||||
* zu zeigen haben. Genau deshalb darf das nicht laufen, waehrend ein echter
|
||||
* Stream laeuft - der Aufrufer prueft das.
|
||||
*/
|
||||
export function startDemo(bus: Bus, signal: AbortSignal): void {
|
||||
log.info('Vorfuehrmodus: erfundener Chat und alle paar Sekunden eine Einblendung');
|
||||
|
||||
let viewers = 17;
|
||||
const seit = new Date(Date.now() - 74 * 60_000).toISOString();
|
||||
|
||||
const stand = (): void => {
|
||||
viewers = Math.max(3, viewers + Math.round((Math.random() - 0.45) * 4));
|
||||
bus.publish({
|
||||
kind: 'stream', live: true,
|
||||
title: 'Elden Ring, aber nur mit Fäusten - Tag 3',
|
||||
category: 'Elden Ring',
|
||||
viewers, startedAt: seit,
|
||||
});
|
||||
};
|
||||
stand();
|
||||
|
||||
const sende = (art: TestArt): void => {
|
||||
const ev = testEvent(art);
|
||||
if (ev) bus.publish(ev);
|
||||
};
|
||||
|
||||
const chat = setInterval(() => sende(zufall(NACHRICHTEN)), 1400);
|
||||
const alarm = setInterval(() => sende(zufall(ALARME)), 9000);
|
||||
const puls = setInterval(stand, 20_000);
|
||||
// Ab und zu eine Chat-Spitze, damit der Emote-Regen auch mal laeuft.
|
||||
const spitze = setInterval(() => sende('hype'), 31_000);
|
||||
|
||||
signal.addEventListener('abort', () => {
|
||||
clearInterval(chat); clearInterval(alarm); clearInterval(puls); clearInterval(spitze);
|
||||
log.info('Vorfuehrmodus beendet');
|
||||
}, { once: true });
|
||||
}
|
||||
@@ -0,0 +1,627 @@
|
||||
// Der einzige Server, der von aussen erreichbar ist.
|
||||
//
|
||||
// Er macht drei Dinge unter einer Adresse, weil ein zweiter oeffentlicher
|
||||
// Port ein zweiter Eintrag im Reverse-Proxy und ein zweites Zertifikat waere:
|
||||
//
|
||||
// / Uebersicht mit den Adressen fuer OBS (Passwort)
|
||||
// /setup Einrichtung: Twitch-Apps, Tokens, Discord (Passwort)
|
||||
// /callback OAuth-Rueckruf von Twitch (offen, state schuetzt)
|
||||
// /alerts ... die Browser-Sources (Schluessel)
|
||||
// /events der SSE-Strom dahinter (Schluessel)
|
||||
//
|
||||
// Zum Binden: hinter dem Reverse-Proxy bindet der Dienst auf 0.0.0.0, damit
|
||||
// der Proxy aus einem anderen Container drankommt. Das ist der Bruch mit
|
||||
// ADR-0005, und der Schutz wandert dafuer von "nur localhost" auf Passwort
|
||||
// und Schluessel. Ohne PUBLIC_URL bleibt es bei 127.0.0.1.
|
||||
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { logger } from '#core/logger.ts';
|
||||
import { setupRoutes, type ServerDeps } from '../../ingest/src/server.ts';
|
||||
import { createZugang, type Zugang } from './auth.ts';
|
||||
import { createBus, type Bus, type OverlayEvent } from './bus.ts';
|
||||
import { startDemo } from './demo.ts';
|
||||
import { TEST_KNOEPFE, testEvent, type TestArt } from './test-events.ts';
|
||||
|
||||
const log = logger('overlay');
|
||||
|
||||
const WEB_ROOT = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'overlay-web', 'public');
|
||||
|
||||
/**
|
||||
* Herzschlag. OBS' Browser-Source haelt eine tote Verbindung sonst fuer
|
||||
* lebendig, bis die naechste Chatnachricht kommt - und wenn der Chat still
|
||||
* ist, ist das eine Stunde.
|
||||
*/
|
||||
const HEARTBEAT_MS = 15_000;
|
||||
|
||||
const TYPES: Record<string, string> = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.woff2': 'font/woff2',
|
||||
};
|
||||
|
||||
/** Die Seiten, die als Browser-Source in OBS eingetragen werden. */
|
||||
export const PAGES = [
|
||||
{ path: '/alerts', file: 'alerts.html', w: 1920, h: 1080,
|
||||
title: 'Einblendungen', hint: 'Abos, Raids, Bits, Clips. Gehört ganz nach oben.' },
|
||||
{ path: '/chat', file: 'chat.html', w: 460, h: 900,
|
||||
title: 'Chat', hint: 'Nachrichten laufen herein und verblassen wieder.' },
|
||||
{ path: '/status', file: 'status.html', w: 1920, h: 160,
|
||||
title: 'Statusleiste', hint: 'Titel, Kategorie, Laufzeit, Zuschauer.' },
|
||||
{ path: '/effects', file: 'effects.html', w: 1920, h: 1080,
|
||||
title: 'Effekte', hint: 'Emote-Regen bei Chat-Spitzen, Konfetti bei Abos und Raids.' },
|
||||
{ path: '/starting', file: 'starting.html', w: 1920, h: 1080,
|
||||
title: 'Pausenbild', hint: 'Eigene Szene: "Gleich geht es los", Pause, Ende.' },
|
||||
{ path: '/tts', file: 'tts.html', w: 600, h: 200,
|
||||
title: 'Vorlesen', hint: 'Liest freigegebene Nachrichten vor. Braucht Ton in OBS.' },
|
||||
] as const;
|
||||
|
||||
export type OverlayServer = {
|
||||
bus: Bus;
|
||||
zugang: Zugang;
|
||||
close: () => void;
|
||||
port: number;
|
||||
};
|
||||
|
||||
export type OverlayOptions = {
|
||||
port: number;
|
||||
channel: string;
|
||||
/** Oeffentliche Adresse ohne Schraegstrich, z. B. https://abdu.dimension47.de */
|
||||
publicUrl: string;
|
||||
bus?: Bus;
|
||||
/** Wird eingehaengt, damit Einrichtung und Status unter derselben Adresse liegen. */
|
||||
setup?: ServerDeps;
|
||||
/**
|
||||
* Die Freigabe wartender Vorlese-Nachrichten. Der Hub reicht sie herein;
|
||||
* ohne sie zeigt die Seite den Abschnitt gar nicht erst an.
|
||||
*/
|
||||
tts?: {
|
||||
wartend: () => Array<{ id: string; name: string; text: string; seit: string }>;
|
||||
freigeben: (id: string) => boolean;
|
||||
ablehnen: (id: string) => boolean;
|
||||
alleAblehnen: () => number;
|
||||
still: () => void;
|
||||
};
|
||||
};
|
||||
|
||||
export function startOverlayServer(opts: OverlayOptions): OverlayServer {
|
||||
const bus = opts.bus ?? createBus();
|
||||
const zugang = createZugang();
|
||||
// Laeuft die Vorfuehrung, steht hier ihr Abbruch. Ein zweiter Start soll
|
||||
// den ersten ersetzen, nicht danebenlaufen.
|
||||
const vorfuehrung = { abbruch: null as AbortController | null };
|
||||
// Hinter einem Proxy muss der Dienst von aussen erreichbar sein. Ohne
|
||||
// oeffentliche Adresse bleibt alles wie vorher an der Loopback haengen.
|
||||
const host = process.env.BIND_HOST || (opts.publicUrl ? '0.0.0.0' : '127.0.0.1');
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
void handle(req, res, bus, zugang, opts, vorfuehrung).catch((err: Error) => {
|
||||
log.error(`Anfrage fehlgeschlagen: ${err.message}`);
|
||||
if (!res.headersSent) res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
|
||||
res.end('Interner Fehler');
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(opts.port, host, () => {
|
||||
log.info(`Overlay-Server laeuft auf ${host}:${opts.port}` +
|
||||
(opts.publicUrl ? ` (oeffentlich: ${opts.publicUrl})` : ''));
|
||||
if (!zugang.hatPasswort() && opts.publicUrl) {
|
||||
log.warn('Es ist kein Passwort gesetzt - die Einrichtungsseite steht offen. ' +
|
||||
'Setzen mit: abdulive web:passwort');
|
||||
}
|
||||
});
|
||||
server.on('error', (err) => {
|
||||
log.error(`Overlay-Server nicht gestartet: ${err.message}` +
|
||||
((err as NodeJS.ErrnoException).code === 'EADDRINUSE'
|
||||
? ` - Port ${opts.port} ist belegt. Anderer Port: overlay.port in config.yaml.` : ''));
|
||||
});
|
||||
|
||||
return { bus, zugang, close: () => server.close(), port: opts.port };
|
||||
}
|
||||
|
||||
type Vorfuehrung = { abbruch: AbortController | null };
|
||||
|
||||
async function handle(
|
||||
req: http.IncomingMessage, res: http.ServerResponse,
|
||||
bus: Bus, zugang: Zugang, opts: OverlayOptions, vorfuehrung: Vorfuehrung
|
||||
): Promise<void> {
|
||||
const url = new URL(req.url ?? '/', opts.publicUrl || `http://127.0.0.1:${opts.port}`);
|
||||
const p = url.pathname;
|
||||
|
||||
// ---- Anmeldung. Bewusst vor allem anderen.
|
||||
if (p === '/login' && req.method === 'POST') {
|
||||
const cookie = zugang.anmelden((await readForm(req)).get('passwort') ?? '');
|
||||
if (!cookie) return redirect(res, '/?fehler=1');
|
||||
res.writeHead(303, { location: '/', 'set-cookie': cookie });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
if (p === '/logout') {
|
||||
res.writeHead(303, { location: '/', 'set-cookie': zugang.abmelden() });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- Der Rueckruf von Twitch. Kein Passwort: Twitch schickt den Browser
|
||||
// hierher zurueck, und der `state`-Wert ist der Schutz.
|
||||
if (p === '/callback' && opts.setup) {
|
||||
if (await setupRoutes(req, res, opts.setup, url)) return;
|
||||
}
|
||||
|
||||
// ---- Alles Statische ausser den Seiten selbst. Enthaelt keine Daten,
|
||||
// also auch keinen Schluessel - sonst muesste ihn jede Seite weiterreichen.
|
||||
if (/\.(css|js|svg|png|jpg|woff2)$/.test(p)) return sendFile(res, p.replace(/^\/+/, ''));
|
||||
|
||||
// ---- Die Browser-Sources und ihr Datenstrom.
|
||||
const page = PAGES.find((x) => x.path === p || `/${x.file}` === p);
|
||||
if (page || p === '/events') {
|
||||
if (!zugang.darfOverlay(req, url)) {
|
||||
return send(res, 403, 'text/plain; charset=utf-8',
|
||||
'Kein Zugang. Die Adresse braucht den Schluessel: ?key=...\n' +
|
||||
'Sie steht fertig auf der Uebersichtsseite.');
|
||||
}
|
||||
if (p === '/events') return sse(req, res, bus);
|
||||
return sendFile(res, page!.file);
|
||||
}
|
||||
|
||||
// ---- Ab hier nur mit Anmeldung.
|
||||
if (!zugang.istAngemeldet(req)) {
|
||||
return send(res, 401, 'text/html; charset=utf-8',
|
||||
loginPage(opts.channel, url.searchParams.has('fehler')));
|
||||
}
|
||||
|
||||
// ---- Testknoepfe. Die Ereignisse gehen nur an die Browser-Sources: nichts
|
||||
// davon landet in der Datenbank, auf Discord oder bei Twitch.
|
||||
if (p === '/test' && req.method === 'POST') {
|
||||
const form = await readForm(req);
|
||||
const art = (form.get('art') ?? '') as TestArt | 'demo-an' | 'demo-aus';
|
||||
const laeuftStream = zustand(bus).live;
|
||||
|
||||
const antwort = (text: string, code = 200): void =>
|
||||
send(res, code, 'application/json; charset=utf-8',
|
||||
JSON.stringify({ text, demo: vorfuehrung.abbruch !== null }));
|
||||
|
||||
if (art === 'demo-aus') {
|
||||
vorfuehrung.abbruch?.abort();
|
||||
vorfuehrung.abbruch = null;
|
||||
// Der letzte erfundene Stand bleibt sonst im Bild stehen.
|
||||
bus.publish({ kind: 'stream', live: false, title: '', category: '', viewers: 0, startedAt: null });
|
||||
return antwort('Vorführung gestoppt.');
|
||||
}
|
||||
|
||||
if (art === 'demo-an') {
|
||||
if (laeuftStream) {
|
||||
return antwort('Nicht während eines Streams: die Vorführung überschreibt ' +
|
||||
'Titel, Kategorie und Zuschauerzahl in der Statusleiste.', 409);
|
||||
}
|
||||
vorfuehrung.abbruch?.abort();
|
||||
vorfuehrung.abbruch = new AbortController();
|
||||
startDemo(bus, vorfuehrung.abbruch.signal);
|
||||
return antwort('Vorführung läuft.');
|
||||
}
|
||||
|
||||
const knopf = TEST_KNOEPFE.find((k) => k.art === art);
|
||||
if (!knopf) return antwort('Unbekannter Knopf.', 400);
|
||||
if (knopf.nurOffline && laeuftStream) {
|
||||
return antwort('Gesperrt, solange der Stream läuft - das würde in der ' +
|
||||
'Statusleiste stehen bleiben.', 409);
|
||||
}
|
||||
|
||||
// Die neueste Nachricht aus dem Rueckblick, damit "letzte löschen" etwas
|
||||
// trifft, das auch wirklich im Bild steht.
|
||||
const chat = zustand(bus).chat;
|
||||
const ev = testEvent(knopf.art, chat[chat.length - 1]?.id);
|
||||
if (!ev) return antwort('Nichts zu löschen - der Chat ist leer.', 409);
|
||||
bus.publish(ev);
|
||||
log.info(`Testereignis ausgeloest: ${knopf.art}`);
|
||||
return antwort(`${knopf.label} ausgelöst.`);
|
||||
}
|
||||
|
||||
// ---- Vorlese-Warteschlange
|
||||
if (p === '/tts' && req.method === 'POST' && opts.tts) {
|
||||
const form = await readForm(req);
|
||||
const was = form.get('was') ?? '';
|
||||
const id = form.get('id') ?? '';
|
||||
const antwort = (text: string, code = 200): void =>
|
||||
send(res, code, 'application/json; charset=utf-8',
|
||||
JSON.stringify({ text, wartend: opts.tts!.wartend() }));
|
||||
|
||||
if (was === 'still') { opts.tts.still(); return antwort('Alles still.'); }
|
||||
if (was === 'alle-ablehnen') {
|
||||
const n = opts.tts.alleAblehnen();
|
||||
return antwort(`${n} Nachricht(en) abgelehnt.`);
|
||||
}
|
||||
if (was === 'freigeben') {
|
||||
return antwort(opts.tts.freigeben(id) ? 'Wird vorgelesen.' : 'Nicht mehr da.');
|
||||
}
|
||||
if (was === 'ablehnen') {
|
||||
return antwort(opts.tts.ablehnen(id) ? 'Abgelehnt.' : 'Nicht mehr da.');
|
||||
}
|
||||
return antwort('Unbekannt.', 400);
|
||||
}
|
||||
|
||||
if (p === '/tts/wartend' && opts.tts) {
|
||||
return send(res, 200, 'application/json; charset=utf-8',
|
||||
JSON.stringify({ wartend: opts.tts.wartend() }));
|
||||
}
|
||||
|
||||
if (p === '/schluessel/neu' && req.method === 'POST') {
|
||||
zugang.neuerOverlayKey();
|
||||
return redirect(res, '/');
|
||||
}
|
||||
|
||||
if (p === '/' || p === '/index.html') {
|
||||
return send(res, 200, 'text/html; charset=utf-8',
|
||||
indexPage(opts, zugang.overlayKey(), bus, vorfuehrung.abbruch !== null));
|
||||
}
|
||||
|
||||
if (opts.setup && await setupRoutes(req, res, opts.setup, url)) return;
|
||||
|
||||
send(res, 404, 'text/plain; charset=utf-8', 'Nicht gefunden. Die Uebersicht steht auf /');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- SSE
|
||||
|
||||
function sse(req: http.IncomingMessage, res: http.ServerResponse, bus: Bus): void {
|
||||
res.writeHead(200, {
|
||||
'content-type': 'text/event-stream; charset=utf-8',
|
||||
'cache-control': 'no-cache, no-transform',
|
||||
connection: 'keep-alive',
|
||||
// Ohne das puffert so mancher Proxy den Strom, und die Einblendung kommt
|
||||
// gesammelt nach zwei Minuten. Nginx beachtet genau diesen Kopf.
|
||||
'x-accel-buffering': 'no',
|
||||
});
|
||||
|
||||
const write = (ev: OverlayEvent): void => {
|
||||
// `retry` sagt dem Browser, wie schnell er neu verbinden soll. Vorgabe
|
||||
// waeren drei Sekunden; nach einem Neustart mitten im Stream ist eine
|
||||
// Sekunde besser, und mehr kostet es hier nicht.
|
||||
res.write(`retry: 1000\ndata: ${JSON.stringify(ev)}\n\n`);
|
||||
};
|
||||
|
||||
// Erst anmelden, dann den Schnappschuss: andersherum wuerde ein Ereignis,
|
||||
// das genau dazwischen faellt, verloren gehen.
|
||||
const unsubscribe = bus.subscribe(write);
|
||||
write(bus.snapshot());
|
||||
|
||||
// Als echtes Ereignis, nicht als SSE-Kommentar: ein Kommentar haelt die
|
||||
// Verbindung zwar offen, ist im Browser aber unsichtbar. Die Seite soll
|
||||
// merken koennen, dass laengst nichts mehr kam, und selbst neu verbinden.
|
||||
const beat = setInterval(
|
||||
() => res.write(`data: ${JSON.stringify({ kind: 'beat', t: Date.now() })}\n\n`),
|
||||
HEARTBEAT_MS);
|
||||
beat.unref();
|
||||
|
||||
const end = (): void => { clearInterval(beat); unsubscribe(); };
|
||||
req.on('close', end);
|
||||
res.on('error', end);
|
||||
}
|
||||
|
||||
/** Der aktuelle Stand aus dem Bus, ohne den Typ an jeder Stelle aufzudroeseln. */
|
||||
function zustand(bus: Bus) {
|
||||
const s = bus.snapshot();
|
||||
if (s.kind !== 'state') throw new Error('Schnappschuss hat die falsche Form');
|
||||
return { live: s.stream.live, chat: s.chat };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Werkzeug
|
||||
|
||||
function readForm(req: http.IncomingMessage): Promise<URLSearchParams> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
req.on('data', (c: Buffer) => {
|
||||
size += c.length;
|
||||
if (size > 16 * 1024) { reject(new Error('Anfrage zu gross')); req.destroy(); return; }
|
||||
chunks.push(c);
|
||||
});
|
||||
req.on('end', () => resolve(new URLSearchParams(Buffer.concat(chunks).toString('utf8'))));
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function redirect(res: http.ServerResponse, to: string): void {
|
||||
res.writeHead(303, { location: to });
|
||||
res.end();
|
||||
}
|
||||
|
||||
function send(res: http.ServerResponse, code: number, type: string, body: string | Buffer): void {
|
||||
res.writeHead(code, {
|
||||
'content-type': type,
|
||||
'cache-control': 'no-store',
|
||||
'referrer-policy': 'no-referrer',
|
||||
// Der Schluessel steht in der Adresse. Ohne diesen Kopf traegt ihn jeder
|
||||
// Bildaufruf zu Twitchs Emote-CDN im Referer mit hinaus.
|
||||
});
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
function sendFile(res: http.ServerResponse, rel: string): void {
|
||||
// Aufloesen und danach pruefen, ob das Ergebnis noch unter WEB_ROOT liegt.
|
||||
// `..` im Pfad ist der Klassiker, und ein Server, der beliebige Dateien
|
||||
// ausliefert, liefert irgendwann secrets.json aus.
|
||||
const file = path.resolve(WEB_ROOT, rel);
|
||||
if (!file.startsWith(WEB_ROOT + path.sep)) {
|
||||
return send(res, 403, 'text/plain; charset=utf-8', 'Nicht erlaubt');
|
||||
}
|
||||
let data: Buffer;
|
||||
try {
|
||||
data = fs.readFileSync(file);
|
||||
} catch {
|
||||
return send(res, 404, 'text/plain; charset=utf-8', 'Nicht gefunden');
|
||||
}
|
||||
send(res, 200, TYPES[path.extname(file).toLowerCase()] ?? 'application/octet-stream', data);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Seiten
|
||||
|
||||
const SEITEN_CSS = `
|
||||
:root { color-scheme: dark; }
|
||||
body { margin: 0; padding: 2.5rem clamp(1rem, 5vw, 4rem); background: #0d0b14; color: #e9e6f3;
|
||||
font: 16px/1.6 "Segoe UI Variable Text", "Segoe UI", system-ui, sans-serif; }
|
||||
h1 { font-size: 1.6rem; margin: 0 0 .25rem; } h1 span { color: #a970ff; }
|
||||
p.lead { color: #9b94b3; margin: 0 0 2rem; max-width: 62ch; }
|
||||
table { border-collapse: collapse; width: 100%; max-width: 74rem; }
|
||||
td { padding: .9rem 1rem; border-top: 1px solid #241f35; vertical-align: top; }
|
||||
tr:first-child td { border-top: 0; }
|
||||
.hint { color: #8b84a3; font-size: .875rem; }
|
||||
.size { color: #8b84a3; white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
code { background: #171327; padding: .25rem .5rem; border-radius: .4rem; word-break: break-all;
|
||||
font-family: "Cascadia Mono", Consolas, monospace; font-size: .8rem; }
|
||||
button, a.knopf { font: inherit; font-size: .85rem; margin-left: .5rem; background: none;
|
||||
border: 1px solid #3a3352; color: #c9c2e0; border-radius: .4rem;
|
||||
padding: .2rem .6rem; cursor: pointer; text-decoration: none; }
|
||||
button:hover, a.knopf:hover { border-color: #a970ff; color: #fff; }
|
||||
input { font: inherit; background: #171327; border: 1px solid #3a3352; color: #e9e6f3;
|
||||
border-radius: .4rem; padding: .5rem .7rem; }
|
||||
.status { margin-top: 2rem; color: #8b84a3; font-size: .9rem; }
|
||||
.dot { display: inline-block; width: .6rem; height: .6rem; border-radius: 50%;
|
||||
background: #ef4444; margin-right: .4rem; }
|
||||
.dot.on { background: #22c55e; }
|
||||
ol { max-width: 62ch; color: #9b94b3; }
|
||||
.warn { color: #ffc53d; }
|
||||
h2 { font-size: 1.1rem; margin: 2.5rem 0 .4rem; }
|
||||
.gruppe { display: flex; flex-wrap: wrap; align-items: center; gap: .4rem;
|
||||
margin-bottom: .6rem; }
|
||||
.gruppenname { display: inline-block; min-width: 9rem; color: #8b84a3;
|
||||
font-size: .85rem; }
|
||||
button.test { margin-left: 0; }
|
||||
button.test:disabled { opacity: .35; cursor: not-allowed; border-color: #241f35; }
|
||||
.warnbox { border: 1px solid #ffc53d; color: #ffc53d; border-radius: .5rem;
|
||||
padding: .6rem .9rem; margin: .8rem 0; max-width: 62ch; }
|
||||
.meldung { min-height: 1.5rem; font-size: .9rem; color: #8b84a3; margin-top: .8rem; }
|
||||
.meldung.ok { color: #4ade80; }
|
||||
.meldung.fehler { color: #ffc53d; }
|
||||
.wartend { display: flex; justify-content: space-between; align-items: flex-start;
|
||||
gap: 1rem; padding: .7rem .9rem; border-top: 1px solid #241f35; }
|
||||
.wartend:first-child { border-top: 0; }
|
||||
.wartend .hint { max-width: 60ch; word-break: break-word; }
|
||||
button.tts { margin-left: .4rem; }
|
||||
`;
|
||||
|
||||
export function loginPage(channel: string, fehler: boolean): string {
|
||||
return `<!doctype html><html lang="de"><meta charset="utf-8">
|
||||
<title>Anmelden</title><style>${SEITEN_CSS}
|
||||
body { display: grid; place-items: center; height: 100vh; padding: 0; }
|
||||
form { text-align: left; }
|
||||
</style>
|
||||
<form method="post" action="/login">
|
||||
<h1>abdulive <span>${escape(channel)}</span></h1>
|
||||
<p class="lead">${fehler ? '<span class="warn">Passwort stimmt nicht.</span>' : 'Passwort eingeben.'}</p>
|
||||
<input type="password" name="passwort" autofocus autocomplete="current-password" size="30">
|
||||
<button type="submit">Anmelden</button>
|
||||
</form>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Seite, die man aufmacht, bevor man OBS einrichtet. Sie ist bewusst hier
|
||||
* und nicht in public/: sie kennt die oeffentliche Adresse, den Schluessel und
|
||||
* die Seitenliste, und das soll nicht an zwei Stellen stehen.
|
||||
*/
|
||||
export function indexPage(
|
||||
opts: OverlayOptions, key: string, bus: Bus, demoLaeuft = false
|
||||
): string {
|
||||
// Der Abschnitt erscheint nur, wenn der Hub eine Warteschlange einhaengt -
|
||||
// im Alleinbetrieb des Overlay-Servers gibt es nichts freizugeben.
|
||||
const ttsFeld = opts.tts ? `
|
||||
<h2>Vorlesen: wartet auf Freigabe</h2>
|
||||
<p class="lead">Nichts wird vorgelesen, bevor du hier klickst. Ablehnen gibt die
|
||||
Kanalpunkte zurück, sofern das verbundene Konto das darf.</p>
|
||||
<div id="wartend"><span class="hint">wird geladen …</span></div>
|
||||
<div class="gruppe" style="margin-top:.8rem">
|
||||
<button class="tts" data-was="alle-ablehnen">Alle ablehnen</button>
|
||||
<button class="tts" data-was="still">Sofort still</button>
|
||||
<span class="hint">„Sofort still" bricht auch ab, was gerade gesprochen wird.</span>
|
||||
</div>
|
||||
<p id="tts-meldung" class="meldung"> </p>` : '';
|
||||
const base = opts.publicUrl || `http://127.0.0.1:${opts.port}`;
|
||||
const rows = PAGES.map((p) => {
|
||||
const id = `u${p.path.slice(1)}`;
|
||||
return `
|
||||
<tr>
|
||||
<td><strong>${p.title}</strong><div class="hint">${p.hint}</div></td>
|
||||
<td class="size">${p.w} × ${p.h}</td>
|
||||
<td>
|
||||
<code id="${id}">${base}${p.path}?key=${key}</code>
|
||||
<button data-copy="${id}">kopieren</button>
|
||||
<a class="knopf" href="${p.path}?probe=1" target="_blank" rel="noreferrer">ansehen</a>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
const gruppen = ['Einblendungen', 'Kanalpunkte', 'Chat', 'Effekte', 'Stand'] as const;
|
||||
const testfeld = gruppen.map((g) => `
|
||||
<div class="gruppe">
|
||||
<span class="gruppenname">${g}</span>
|
||||
${TEST_KNOEPFE.filter((k) => k.gruppe === g).map((k) =>
|
||||
`<button class="test" data-art="${k.art}"${k.nurOffline ? ' data-offline="1"' : ''}>${k.label}</button>`
|
||||
).join('')}
|
||||
</div>`).join('');
|
||||
|
||||
return `<!doctype html><html lang="de"><meta charset="utf-8">
|
||||
<title>Overlay - ${escape(opts.channel)}</title><style>${SEITEN_CSS}</style>
|
||||
<h1>Overlay <span>${escape(opts.channel)}</span></h1>
|
||||
<p class="lead">In OBS je eine Browser-Source anlegen, Adresse eintragen, Breite und
|
||||
Höhe setzen. "Quelle beim Unsichtbarwerden deaktivieren" ausschalten, sonst
|
||||
verpasst das Overlay alles, was in einer anderen Szene passiert.</p>
|
||||
<table>${rows}</table>
|
||||
<p class="status"><span class="dot" id="dot"></span><span id="txt">verbinde ...</span>
|
||||
· ${bus.clients()} verbundene Quelle(n)
|
||||
${opts.setup ? ' · <a class="knopf" href="/setup">Einrichtung</a>' +
|
||||
'<a class="knopf" href="/health">Status</a>' : ''}
|
||||
<a class="knopf" href="/logout">Abmelden</a>
|
||||
</p>
|
||||
<h2>Ausprobieren</h2>
|
||||
<p class="lead">Löst ein erfundenes Ereignis aus. Es geht nur an die
|
||||
Browser-Sources - nichts davon landet in der Datenbank, auf Discord oder bei
|
||||
Twitch. Am besten die Seiten in einem zweiten Fenster offen haben
|
||||
(„ansehen“ oben).</p>
|
||||
<div id="live-warnung" class="warnbox" hidden>
|
||||
Der Stream läuft gerade. Was du hier auslöst, sehen die Zuschauer.
|
||||
</div>
|
||||
${testfeld}
|
||||
<div class="gruppe">
|
||||
<span class="gruppenname">Dauerbetrieb</span>
|
||||
<button class="test" id="demo" data-art="${demoLaeuft ? 'demo-aus' : 'demo-an'}">
|
||||
${demoLaeuft ? 'Vorführung stoppen' : 'Vorführung starten'}
|
||||
</button>
|
||||
<span class="hint">Erfundener Chat und alle paar Sekunden eine Einblendung,
|
||||
bis du wieder stoppst.</span>
|
||||
</div>
|
||||
<p id="meldung" class="meldung"> </p>
|
||||
|
||||
${ttsFeld}
|
||||
|
||||
<h2>Schlüssel</h2>
|
||||
<form method="post" action="/schluessel/neu">
|
||||
<button type="submit">Schlüssel neu würfeln</button>
|
||||
<span class="hint">Danach müssen alle Adressen in OBS neu eingetragen werden.</span>
|
||||
</form>
|
||||
<ol>
|
||||
<li>Alle Seiten haben einen durchsichtigen Hintergrund. In OBS nichts weiter einstellen.</li>
|
||||
<li>Jede Seite verbindet sich von allein neu - ein Neustart des Dienstes kostet eine Sekunde.</li>
|
||||
<li>Der Schlüssel in der Adresse darf nur lesen. Er landet in jedem OBS-Profil, also nicht weitergeben.</li>
|
||||
</ol>
|
||||
<script>
|
||||
for (const b of document.querySelectorAll('button[data-copy]')) {
|
||||
b.addEventListener('click', async () => {
|
||||
await navigator.clipboard.writeText(document.getElementById(b.dataset.copy).textContent);
|
||||
b.textContent = 'kopiert';
|
||||
setTimeout(() => { b.textContent = 'kopieren'; }, 1200);
|
||||
});
|
||||
}
|
||||
const es = new EventSource('/events');
|
||||
const dot = document.getElementById('dot'), txt = document.getElementById('txt');
|
||||
es.onopen = () => { dot.classList.add('on'); txt.textContent = 'verbunden'; };
|
||||
es.onerror = () => { dot.classList.remove('on'); txt.textContent = 'getrennt, versuche es erneut'; };
|
||||
const warnung = document.getElementById('live-warnung');
|
||||
const meldung = document.getElementById('meldung');
|
||||
|
||||
function setzeLive(live) {
|
||||
warnung.hidden = !live;
|
||||
// Knöpfe, die einen Zustand setzen, bleiben während eines Streams aus:
|
||||
// sie würden in der Statusleiste stehen bleiben.
|
||||
for (const b of document.querySelectorAll('button.test[data-offline]')) b.disabled = live;
|
||||
const demo = document.getElementById('demo');
|
||||
if (demo.dataset.art === 'demo-an') demo.disabled = live;
|
||||
}
|
||||
|
||||
es.onmessage = (e) => {
|
||||
const ev = JSON.parse(e.data);
|
||||
if (ev.kind === 'state') {
|
||||
txt.textContent = ev.stream.live
|
||||
? 'Stream läuft: ' + (ev.stream.title || 'ohne Titel')
|
||||
: 'verbunden - kein Stream';
|
||||
setzeLive(ev.stream.live);
|
||||
} else if (ev.kind === 'stream') {
|
||||
setzeLive(ev.live);
|
||||
}
|
||||
};
|
||||
|
||||
let sagAb;
|
||||
async function loese(art) {
|
||||
const res = await fetch('/test', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ art }),
|
||||
});
|
||||
const body = await res.json().catch(() => ({ text: 'Fehler ' + res.status }));
|
||||
meldung.textContent = body.text;
|
||||
meldung.className = 'meldung' + (res.ok ? ' ok' : ' fehler');
|
||||
clearTimeout(sagAb);
|
||||
sagAb = setTimeout(() => { meldung.innerHTML = ' '; meldung.className = 'meldung'; }, 4000);
|
||||
|
||||
const demo = document.getElementById('demo');
|
||||
demo.dataset.art = body.demo ? 'demo-aus' : 'demo-an';
|
||||
demo.textContent = body.demo ? 'Vorführung stoppen' : 'Vorführung starten';
|
||||
}
|
||||
|
||||
for (const b of document.querySelectorAll('button.test')) {
|
||||
b.addEventListener('click', () => loese(b.dataset.art));
|
||||
}
|
||||
|
||||
const wartendEl = document.getElementById('wartend');
|
||||
if (wartendEl) {
|
||||
const ttsMeldung = document.getElementById('tts-meldung');
|
||||
|
||||
function zeichne(liste) {
|
||||
if (!liste.length) {
|
||||
wartendEl.innerHTML = '<span class="hint">Nichts wartet.</span>';
|
||||
return;
|
||||
}
|
||||
wartendEl.innerHTML = liste.map((w) => \`
|
||||
<div class="wartend">
|
||||
<div>
|
||||
<strong>\${w.name}</strong>
|
||||
<div class="hint">\${w.text}</div>
|
||||
</div>
|
||||
<div style="white-space:nowrap">
|
||||
<button class="tts" data-was="freigeben" data-id="\${w.id}">Vorlesen</button>
|
||||
<button class="tts" data-was="ablehnen" data-id="\${w.id}">Ablehnen</button>
|
||||
</div>
|
||||
</div>\`).join('');
|
||||
}
|
||||
|
||||
async function ttsRuf(was, id) {
|
||||
const res = await fetch('/tts', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ was, id: id || '' }),
|
||||
});
|
||||
const body = await res.json().catch(() => ({ text: 'Fehler ' + res.status, wartend: [] }));
|
||||
ttsMeldung.textContent = body.text || '';
|
||||
zeichne(body.wartend || []);
|
||||
setTimeout(() => { ttsMeldung.innerHTML = ' '; }, 4000);
|
||||
}
|
||||
|
||||
wartendEl.addEventListener('click', (e) => {
|
||||
const b = e.target.closest('button.tts');
|
||||
if (b) ttsRuf(b.dataset.was, b.dataset.id);
|
||||
});
|
||||
for (const b of document.querySelectorAll('.gruppe button.tts')) {
|
||||
b.addEventListener('click', () => ttsRuf(b.dataset.was));
|
||||
}
|
||||
|
||||
// Kurzer Takt: eine Einlösung soll nicht erst nach einem Neuladen
|
||||
// auftauchen. Kostet nichts, die Liste ist winzig.
|
||||
const laden = () => fetch('/tts/wartend')
|
||||
.then((r) => r.json()).then((b) => zeichne(b.wartend || [])).catch(() => {});
|
||||
laden();
|
||||
setInterval(laden, 3000);
|
||||
}
|
||||
</script>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function escape(s: string): string {
|
||||
return s.replace(/[<>&"]/g, (c) => ({ '<': '<', '>': '>', '&': '&', '"': '"' }[c]!));
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// Die Testknoepfe sind das einzige Werkzeug, mit dem sich das Overlay
|
||||
// ausserhalb eines Streams pruefen laesst. Geht hier etwas kaputt, merkt man
|
||||
// es ausgerechnet dann nicht, wenn man es am noetigsten braucht.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { TEST_KNOEPFE, testEvent, type TestArt } from './test-events.ts';
|
||||
|
||||
test('zu jedem Knopf gibt es ein Ereignis', () => {
|
||||
// Ein Knopf ohne Ereignis tut beim Druecken nichts und sieht dabei aus wie
|
||||
// ein kaputtes Overlay.
|
||||
for (const k of TEST_KNOEPFE) {
|
||||
if (k.art === 'chat-loeschen') continue; // braucht eine Nachricht, siehe unten
|
||||
assert.ok(testEvent(k.art), `${k.art} liefert kein Ereignis`);
|
||||
}
|
||||
});
|
||||
|
||||
test('jeder Knopf kommt nur einmal vor', () => {
|
||||
const arten = TEST_KNOEPFE.map((k) => k.art);
|
||||
assert.equal(new Set(arten).size, arten.length);
|
||||
});
|
||||
|
||||
test('die Einblendungen tragen die Felder, die die Seite anzeigt', () => {
|
||||
const raid = testEvent('raid') as { kind: string; type: string; amount: number };
|
||||
assert.equal(raid.kind, 'alert');
|
||||
assert.equal(raid.type, 'raid');
|
||||
assert.ok(raid.amount > 0, 'ohne Zuschauerzahl steht "mit undefined Zuschauern" im Bild');
|
||||
|
||||
const gift = testEvent('subgift') as { recipient?: string };
|
||||
assert.ok(gift.recipient, 'ohne Beschenkten fehlt die zweite Zeile');
|
||||
|
||||
const resub = testEvent('resub') as { months?: number };
|
||||
assert.ok(resub.months && resub.months > 0);
|
||||
});
|
||||
|
||||
test('"letzte löschen" braucht eine Nachricht und trifft genau die', () => {
|
||||
assert.equal(testEvent('chat-loeschen'), null, 'ohne Chat gibt es nichts zu loeschen');
|
||||
assert.deepEqual(testEvent('chat-loeschen', 'abc'), { kind: 'clear', messageId: 'abc' });
|
||||
});
|
||||
|
||||
test('"Chat leeren" loescht alles, nicht nur eine Nachricht', () => {
|
||||
assert.deepEqual(testEvent('chat-leeren'), { kind: 'clear' });
|
||||
});
|
||||
|
||||
test('Chatnachrichten haben eine eigene ID', () => {
|
||||
// Zwei Nachrichten mit derselben ID wuerden sich beim Loeschen gegenseitig
|
||||
// mitnehmen.
|
||||
const a = testEvent('chat') as { id: string };
|
||||
const b = testEvent('chat') as { id: string };
|
||||
assert.notEqual(a.id, b.id);
|
||||
});
|
||||
|
||||
test('das Emote im Test ist im Text auch enthalten', () => {
|
||||
// Sonst wird nichts ersetzt und der Knopf prueft die Emote-Anzeige nicht.
|
||||
const ev = testEvent('chat-emote') as { text: string; emotes: Array<{ name: string }> };
|
||||
assert.equal(ev.emotes.length, 1);
|
||||
assert.ok(ev.text.includes(ev.emotes[0]!.name));
|
||||
});
|
||||
|
||||
test('gesperrt ist nur, was einen falschen Stand setzt - nicht der Weg zurueck', () => {
|
||||
// Eine Einblendung blitzt auf und ist weg. Ein erfundener Stream-Zustand
|
||||
// bleibt in der Statusleiste stehen, bis die naechste Abfrage ihn
|
||||
// geraderueckt - und das dauert bis zu einer Minute.
|
||||
//
|
||||
// "Zuruecksetzen" darf dabei nie gesperrt sein: sonst kommt man aus einem
|
||||
// erfundenen Zustand ohne Neustart nicht mehr heraus, und auf einem Hub ohne
|
||||
// Broadcaster-Token raeumt ueberhaupt niemand hinterher auf.
|
||||
const gesperrt = TEST_KNOEPFE.filter((k) => k.nurOffline).map((k) => k.art);
|
||||
assert.deepEqual(gesperrt, ['live-an']);
|
||||
|
||||
const zurueck = TEST_KNOEPFE.find((k) => k.art === 'live-aus');
|
||||
assert.equal(zurueck?.nurOffline, undefined, 'der Weg zurueck bleibt immer offen');
|
||||
});
|
||||
|
||||
test('der Stand-Knopf setzt einen anzeigbaren Stream', () => {
|
||||
const an = testEvent('live-an') as
|
||||
{ kind: string; live: boolean; title: string; startedAt: string | null };
|
||||
assert.equal(an.kind, 'stream');
|
||||
assert.equal(an.live, true);
|
||||
assert.ok(an.title.length > 0);
|
||||
assert.ok(an.startedAt && Date.parse(an.startedAt) < Date.now(), 'Laufzeit muss positiv sein');
|
||||
|
||||
const aus = testEvent('live-aus') as { live: boolean; startedAt: string | null };
|
||||
assert.equal(aus.live, false);
|
||||
assert.equal(aus.startedAt, null);
|
||||
});
|
||||
|
||||
test('unbekannte Knoepfe liefern nichts, statt etwas zu erfinden', () => {
|
||||
assert.equal(testEvent('gibtsnicht' as TestArt), null);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- Kanalpunkte
|
||||
|
||||
test('eine Belohnung wird nie als Abo eingeblendet', () => {
|
||||
// Genau das stand hier einmal: die Konfetti-Belohnung hat die Abo-
|
||||
// Einblendung wiederverwendet und "Neues Abo" ins Bild geschrieben. Eine
|
||||
// Falschmeldung im Stream, die in der Aufzeichnung bleibt.
|
||||
const e = testEvent('reward') as { kind: string; type: string; reward?: string };
|
||||
assert.equal(e.kind, 'alert');
|
||||
assert.equal(e.type, 'reward');
|
||||
assert.notEqual(e.type, 'sub');
|
||||
assert.ok(e.reward, 'ohne Titel weiss niemand, welche Belohnung gemeint ist');
|
||||
});
|
||||
|
||||
test('Konfetti ist ein eigenes Ereignis, keine Nebenwirkung einer Einblendung', () => {
|
||||
// So muss die Effektseite nicht wissen, welche Belohnung was ausloest.
|
||||
const e = testEvent('reward-konfetti') as { kind: string; staerke: number };
|
||||
assert.equal(e.kind, 'konfetti');
|
||||
assert.ok(e.staerke > 0);
|
||||
});
|
||||
|
||||
test('der Vorlese-Knopf schickt Text, den die Seite auch sprechen kann', () => {
|
||||
const e = testEvent('tts') as { kind: string; text: string; name: string };
|
||||
assert.equal(e.kind, 'tts');
|
||||
assert.ok(e.text.length > 0);
|
||||
assert.ok(e.name.length > 0);
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
// Erfundene Ereignisse zum Ausprobieren, für die Knöpfe auf der
|
||||
// Übersichtsseite und für den Vorführmodus.
|
||||
//
|
||||
// Warum das gebraucht wird: ein Raid kommt vielleicht einmal die Woche vor.
|
||||
// Darauf zu warten, um zu sehen, ob die Einblendung passt, ist keine
|
||||
// Arbeitsweise - und mitten im Stream herauszufinden, dass der Text abgeschnitten
|
||||
// wird, ist die schlechteste aller Möglichkeiten.
|
||||
//
|
||||
// Die Knopfliste und die Ereignisse stehen hier zusammen, damit die Seite die
|
||||
// Knöpfe daraus bauen kann. Ein Knopf, den es gibt, hat damit immer auch ein
|
||||
// Ereignis dahinter.
|
||||
|
||||
import { ulid } from '#shared/events.ts';
|
||||
import type { ChatEvent, OverlayEvent } from './bus.ts';
|
||||
|
||||
export const NAMEN = [
|
||||
'kevin_ohne_c', 'Melli', 'HansWurst42', 'dieNina', 'Tobi', 'Schnitzelfan',
|
||||
'pixelpudel', 'Rewe_Kassiererin', 'Olaf', 'zwiebeljule',
|
||||
];
|
||||
|
||||
export const SPRUECHE = [
|
||||
'moin', 'was geht', 'der Boss hat 3 Phasen oder?', 'hahaha',
|
||||
'rechts! RECHTS!', 'ich guck seit ner Stunde zu, richtig gut',
|
||||
'kann jemand den Titel lesen', 'das war knapp', 'gg',
|
||||
'mein Internet ist heute wieder Müll', 'hast du den Trank noch?',
|
||||
'erster', 'wie lange streamst du heute noch',
|
||||
];
|
||||
|
||||
/**
|
||||
* Globale Twitch-Emotes mit ihren echten IDs - nachgeschlagen, nicht geraten,
|
||||
* damit die Bilder wirklich laden. KEKW fehlt hier absichtlich: das ist ein
|
||||
* BTTV-Emote und hat bei Twitch keine ID.
|
||||
*/
|
||||
export const EMOTES = [
|
||||
{ id: '425618', name: 'LUL', count: 1 },
|
||||
{ id: '25', name: 'Kappa', count: 1 },
|
||||
{ id: '354', name: '4Head', count: 1 },
|
||||
];
|
||||
|
||||
export const FARBEN = ['#ff4fa3', '#4fd1c5', '#a970ff', '#ffc53d', '#4ade80', null];
|
||||
|
||||
export function zufall<T>(list: readonly T[]): T {
|
||||
return list[Math.floor(Math.random() * list.length)]!;
|
||||
}
|
||||
|
||||
export type TestArt =
|
||||
| 'sub' | 'resub' | 'subgift' | 'massgift' | 'raid' | 'cheer' | 'first' | 'clip' | 'follow'
|
||||
| 'reward' | 'reward-konfetti' | 'tts'
|
||||
| 'chat' | 'chat-emote' | 'chat-gif' | 'chat-erstmals' | 'chat-bits' | 'chat-lang'
|
||||
| 'chat-loeschen' | 'chat-leeren'
|
||||
| 'hype' | 'live-an' | 'live-aus';
|
||||
|
||||
export type TestKnopf = {
|
||||
art: TestArt;
|
||||
label: string;
|
||||
gruppe: 'Einblendungen' | 'Kanalpunkte' | 'Chat' | 'Effekte' | 'Stand';
|
||||
/**
|
||||
* Verändert den Zustand, den das Overlay dauerhaft anzeigt, statt nur einmal
|
||||
* aufzublitzen. Läuft gerade ein Stream, steht danach etwas Falsches im Bild,
|
||||
* bis die nächste Abfrage es geraderückt - solche Knöpfe sind gesperrt.
|
||||
*/
|
||||
nurOffline?: boolean;
|
||||
};
|
||||
|
||||
export const TEST_KNOEPFE: TestKnopf[] = [
|
||||
{ art: 'sub', label: 'Abo', gruppe: 'Einblendungen' },
|
||||
{ art: 'resub', label: 'Abo verlängert', gruppe: 'Einblendungen' },
|
||||
{ art: 'subgift', label: 'Abo verschenkt', gruppe: 'Einblendungen' },
|
||||
{ art: 'massgift', label: '5 Abos verschenkt', gruppe: 'Einblendungen' },
|
||||
{ art: 'raid', label: 'Raid', gruppe: 'Einblendungen' },
|
||||
{ art: 'cheer', label: 'Bits', gruppe: 'Einblendungen' },
|
||||
{ art: 'first', label: 'Erstchatter', gruppe: 'Einblendungen' },
|
||||
{ art: 'clip', label: 'Clip gespeichert', gruppe: 'Einblendungen' },
|
||||
{ art: 'follow', label: 'Follow', gruppe: 'Einblendungen' },
|
||||
|
||||
{ art: 'reward', label: 'Belohnung eingelöst', gruppe: 'Kanalpunkte' },
|
||||
{ art: 'reward-konfetti', label: 'Belohnung mit Konfetti', gruppe: 'Kanalpunkte' },
|
||||
{ art: 'tts', label: 'Vorlesen', gruppe: 'Kanalpunkte' },
|
||||
|
||||
{ art: 'chat', label: 'Nachricht', gruppe: 'Chat' },
|
||||
{ art: 'chat-emote', label: 'mit Emote', gruppe: 'Chat' },
|
||||
{ art: 'chat-gif', label: 'mit GIF', gruppe: 'Chat' },
|
||||
{ art: 'chat-erstmals', label: 'von einem Erstchatter', gruppe: 'Chat' },
|
||||
{ art: 'chat-bits', label: 'mit Bits', gruppe: 'Chat' },
|
||||
{ art: 'chat-lang', label: 'sehr lang', gruppe: 'Chat' },
|
||||
{ art: 'chat-loeschen', label: 'letzte löschen', gruppe: 'Chat' },
|
||||
{ art: 'chat-leeren', label: 'Chat leeren', gruppe: 'Chat' },
|
||||
|
||||
{ art: 'hype', label: 'Emote-Regen', gruppe: 'Effekte' },
|
||||
|
||||
{ art: 'live-an', label: 'Stream läuft', gruppe: 'Stand', nurOffline: true },
|
||||
// Absichtlich NICHT gesperrt: das ist der Weg aus einem erfundenen Zustand
|
||||
// wieder heraus. Wäre der Knopf gesperrt, sobald "Stream läuft" gedrückt
|
||||
// wurde, käme man ohne Neustart nicht mehr zurück - und auf einem Hub ohne
|
||||
// Broadcaster-Token räumt auch keine Abfrage hinterher auf.
|
||||
{ art: 'live-aus', label: 'Stand zurücksetzen', gruppe: 'Stand' },
|
||||
];
|
||||
|
||||
function chat(over: Partial<ChatEvent> = {}): ChatEvent {
|
||||
const name = zufall(NAMEN);
|
||||
return {
|
||||
kind: 'chat',
|
||||
id: ulid(),
|
||||
userId: `test-${NAMEN.indexOf(name)}`,
|
||||
login: name.toLowerCase(),
|
||||
name,
|
||||
text: zufall(SPRUECHE),
|
||||
color: zufall(FARBEN),
|
||||
emotes: [],
|
||||
gifs: [],
|
||||
first: false,
|
||||
mod: Math.random() < 0.15,
|
||||
sub: Math.random() < 0.3,
|
||||
broadcaster: false,
|
||||
bits: 0,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut das Ereignis zu einem Knopf.
|
||||
*
|
||||
* `letzteChatId` ist die neueste Nachricht im Rückblick des Busses - nur so
|
||||
* kann "letzte löschen" etwas treffen, das auch wirklich im Bild steht.
|
||||
*/
|
||||
export function testEvent(art: TestArt, letzteChatId?: string): OverlayEvent | null {
|
||||
const name = zufall(NAMEN);
|
||||
const alarm = (rest: Record<string, unknown>): OverlayEvent =>
|
||||
({ kind: 'alert', id: ulid(), name, ...rest } as OverlayEvent);
|
||||
|
||||
switch (art) {
|
||||
case 'sub': return alarm({ type: 'sub', tier: '1000' });
|
||||
case 'resub': return alarm({
|
||||
type: 'resub', months: 1 + Math.floor(Math.random() * 30),
|
||||
message: 'endlich wieder dabei, zwei Monate Pause waren genug',
|
||||
});
|
||||
case 'subgift': return alarm({ type: 'subgift', recipient: zufall(NAMEN) });
|
||||
case 'massgift': return alarm({ type: 'massgift', amount: 5 });
|
||||
case 'raid': return alarm({ type: 'raid', amount: 5 + Math.floor(Math.random() * 60) });
|
||||
case 'cheer': return alarm({
|
||||
type: 'cheer', amount: 100 * (1 + Math.floor(Math.random() * 10)),
|
||||
message: 'das musste sein',
|
||||
});
|
||||
case 'first': return alarm({ type: 'first' });
|
||||
case 'clip': return alarm({ type: 'clip' });
|
||||
case 'follow': return alarm({ type: 'follow' });
|
||||
case 'reward': return alarm({
|
||||
type: 'reward', reward: 'Nachricht anheften', amount: 500,
|
||||
message: 'guck mal links oben',
|
||||
});
|
||||
case 'reward-konfetti': return { kind: 'konfetti', staerke: 1 };
|
||||
case 'tts': return {
|
||||
kind: 'tts', id: ulid(), name,
|
||||
text: 'Das hier wird vorgelesen, sobald du es freigibst.',
|
||||
};
|
||||
|
||||
case 'chat': return chat();
|
||||
case 'chat-emote': {
|
||||
const e = zufall(EMOTES);
|
||||
return chat({ text: `${zufall(SPRUECHE)} ${e.name}`, emotes: [e] });
|
||||
}
|
||||
case 'chat-gif': {
|
||||
// Genau so, wie Twitch es schickt: der Text ist nur ein Platzhalter,
|
||||
// das Bild steckt im Tag. Echte Adresse, damit der Knopf auch wirklich
|
||||
// etwas anzeigt.
|
||||
const text = '[Idk GIF by Respective]';
|
||||
return chat({
|
||||
text,
|
||||
gifs: [{
|
||||
id: 'Pnd7qLFuGCOntZcNKB',
|
||||
url: 'https://media0.giphy.com/media/Pnd7qLFuGCOntZcNKB/giphy.gif',
|
||||
from: 0, to: [...text].length - 1,
|
||||
}],
|
||||
});
|
||||
}
|
||||
case 'chat-erstmals': return chat({ first: true, text: 'moin, bin neu hier' });
|
||||
case 'chat-bits': return chat({ bits: 500, text: 'nimm die 500 Bits' });
|
||||
case 'chat-lang': return chat({
|
||||
// Der Fall, den man sonst erst im Stream sieht: eine Nachricht, die
|
||||
// den Kasten sprengt.
|
||||
text: 'also ich hab das gestern auch probiert und da ist mir aufgefallen ' +
|
||||
'dass der Boss in Phase zwei immer nach links ausweicht, aber nur wenn ' +
|
||||
'man vorher nicht geblockt hat, und das steht in keinem Guide drin',
|
||||
});
|
||||
case 'chat-loeschen':
|
||||
return letzteChatId ? { kind: 'clear', messageId: letzteChatId } : null;
|
||||
case 'chat-leeren': return { kind: 'clear' };
|
||||
|
||||
case 'hype': return { kind: 'hype', score: 4 + Math.random() * 4, rate: 45 };
|
||||
|
||||
case 'live-an': return {
|
||||
kind: 'stream', live: true,
|
||||
title: 'Probelauf des Overlays', category: 'Just Chatting',
|
||||
viewers: 12 + Math.floor(Math.random() * 30),
|
||||
startedAt: new Date(Date.now() - 42 * 60_000).toISOString(),
|
||||
};
|
||||
case 'live-aus': return {
|
||||
kind: 'stream', live: false, title: '', category: '', viewers: 0, startedAt: null,
|
||||
};
|
||||
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user