diff --git a/apps/ingest/src/spike.test.ts b/apps/ingest/src/spike.test.ts index c920ee9..696fa21 100644 --- a/apps/ingest/src/spike.test.ts +++ b/apps/ingest/src/spike.test.ts @@ -19,7 +19,7 @@ function msg(text: string, userId: string, advanceMs = 0): ChatMessage { clock += advanceMs; return { id: `m${clock}-${userId}`, ts: new Date(clock).toISOString(), userId, - login: `u${userId}`, displayName: `U${userId}`, text, emotes: [], + login: `u${userId}`, displayName: `U${userId}`, text, emotes: [], gifs: [], isFirst: false, isReturning: false, isCommand: text.startsWith('!'), isMod: false, isSub: false, isBroadcaster: false, bits: 0, color: null, }; diff --git a/apps/overlay-server/README.md b/apps/overlay-server/README.md index 6c240bd..5613ad2 100644 --- a/apps/overlay-server/README.md +++ b/apps/overlay-server/README.md @@ -1,5 +1,50 @@ # @abdulive/overlay-server -WebSocket-Backend und Command-Registry fuer alle Overlays. +Der einzige Server, der von außen erreichbar ist. Liefert die Seiten aus +[`overlay-web`](../overlay-web/README.md) aus, hält den Ereignisstrom dorthin +offen und bringt Einrichtung und OAuth-Rückruf unter dieselbe Adresse. -Spezifikation: [docs/projects/chat-control.md](../../docs/projects/chat-control.md) +``` +/ Übersicht mit den Adressen für OBS Passwort +/setup Einrichtung: Twitch-Apps, Tokens Passwort +/callback OAuth-Rückruf von Twitch offen, `state` schützt +/alerts … die Browser-Sources Schlüssel in der URL +/events der Ereignisstrom dahinter Schlüssel in der URL +``` + +## Server-Sent Events statt WebSocket + +[`chat-control.md`](../../docs/projects/chat-control.md) skizziert einen +WS-Kanal. Es sind SSE geworden, aus drei Gründen: + +1. Node bringt einen WebSocket-*Client* mit, aber keinen Server. Den müsste + man selbst schreiben, inklusive Handshake und Rahmenformat - in einem + Projekt ohne Abhängigkeiten heißt das: selbst pflegen. +2. `EventSource` verbindet sich von allein neu. Die tote Browser-Source nach + einem Neustart ist der häufigste Fehler bei solchen Overlays, und hier + löst ihn der Browser. +3. Die Richtung stimmt. Das Overlay hört nur zu; es sendet nie. + +Drei Dinge halten die Verbindung ehrlich: + +- **Herzschlag alle 15 s**, als echtes Ereignis und nicht als SSE-Kommentar - + ein Kommentar hält die Verbindung offen, ist im Browser aber unsichtbar. +- **Wachhund im Client**: 45 s ohne Nachricht heißt neu verbinden. OBS hält + eine tote Verbindung erstaunlich lange für lebendig. +- **Schnappschuss beim Verbinden**: der letzte Stream-Zustand und die letzten + Chatnachrichten, damit die Quelle nach dem Neuverbinden nicht leer im Bild + steht. + +## Zugang + +Siehe [ADR-0008](../../docs/decisions/ADR-0008-geteilter-betrieb.md). + +- **Passwort** für alles, was ein Mensch bedient. Setzen mit + `abdulive web:passwort`, nie über das Netz - ein Dienst, bei dem man das + Passwort über die offene Weboberfläche setzen kann, hat kein Passwort. +- **Schlüssel in der Adresse** für die Browser-Sources. Eine Browser-Source + kann kein Formular ausfüllen. Der Schlüssel darf nur lesen und lässt sich + auf der Übersichtsseite neu würfeln. + +Ohne `publicUrl` bindet der Server weiter auf `127.0.0.1` und verhält sich wie +vorher. diff --git a/apps/overlay-server/src/auth.test.ts b/apps/overlay-server/src/auth.test.ts new file mode 100644 index 0000000..af1358a --- /dev/null +++ b/apps/overlay-server/src/auth.test.ts @@ -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)); +}); diff --git a/apps/overlay-server/src/auth.ts b/apps/overlay-server/src/auth.ts new file mode 100644 index 0000000..95c2c7b --- /dev/null +++ b/apps/overlay-server/src/auth.ts @@ -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 { + const out: Record = {}; + 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) }; + }); +} diff --git a/apps/overlay-server/src/bus.test.ts b/apps/overlay-server/src/bus.test.ts new file mode 100644 index 0000000..c1a9d82 --- /dev/null +++ b/apps/overlay-server/src/bus.test.ts @@ -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 { + 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) { + const s = bus.snapshot(); + assert.equal(s.kind, 'state'); + return s as Extract; +} + +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); +}); diff --git a/apps/overlay-server/src/bus.ts b/apps/overlay-server/src/bus.ts new file mode 100644 index 0000000..3f72ed7 --- /dev/null +++ b/apps/overlay-server/src/bus.ts @@ -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, + }; +} diff --git a/apps/overlay-server/src/demo.ts b/apps/overlay-server/src/demo.ts new file mode 100644 index 0000000..a00ed4e --- /dev/null +++ b/apps/overlay-server/src/demo.ts @@ -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 }); +} diff --git a/apps/overlay-server/src/server.ts b/apps/overlay-server/src/server.ts new file mode 100644 index 0000000..9aa8c48 --- /dev/null +++ b/apps/overlay-server/src/server.ts @@ -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 = { + '.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 { + 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 { + 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 ` +Anmelden +
+

abdulive ${escape(channel)}

+

${fehler ? 'Passwort stimmt nicht.' : 'Passwort eingeben.'}

+ + +
+`; +} + +/** + * 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 ? ` +

Vorlesen: wartet auf Freigabe

+

Nichts wird vorgelesen, bevor du hier klickst. Ablehnen gibt die +Kanalpunkte zurück, sofern das verbundene Konto das darf.

+
wird geladen …
+
+ + + „Sofort still" bricht auch ab, was gerade gesprochen wird. +
+

 

` : ''; + const base = opts.publicUrl || `http://127.0.0.1:${opts.port}`; + const rows = PAGES.map((p) => { + const id = `u${p.path.slice(1)}`; + return ` + + ${p.title}
${p.hint}
+ ${p.w} × ${p.h} + + ${base}${p.path}?key=${key} + + ansehen + + `; + }).join(''); + + const gruppen = ['Einblendungen', 'Kanalpunkte', 'Chat', 'Effekte', 'Stand'] as const; + const testfeld = gruppen.map((g) => ` +
+ ${g} + ${TEST_KNOEPFE.filter((k) => k.gruppe === g).map((k) => + `` + ).join('')} +
`).join(''); + + return ` +Overlay - ${escape(opts.channel)} +

Overlay ${escape(opts.channel)}

+

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.

+${rows}
+

verbinde ... +  ·  ${bus.clients()} verbundene Quelle(n) + ${opts.setup ? ' ·  Einrichtung' + + 'Status' : ''} + Abmelden +

+

Ausprobieren

+

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).

+ +${testfeld} +
+ Dauerbetrieb + + Erfundener Chat und alle paar Sekunden eine Einblendung, + bis du wieder stoppst. +
+

 

+ +${ttsFeld} + +

Schlüssel

+
+ + Danach müssen alle Adressen in OBS neu eingetragen werden. +
+
    +
  1. Alle Seiten haben einen durchsichtigen Hintergrund. In OBS nichts weiter einstellen.
  2. +
  3. Jede Seite verbindet sich von allein neu - ein Neustart des Dienstes kostet eine Sekunde.
  4. +
  5. Der Schlüssel in der Adresse darf nur lesen. Er landet in jedem OBS-Profil, also nicht weitergeben.
  6. +
+ +`; +} + +function escape(s: string): string { + return s.replace(/[<>&"]/g, (c) => ({ '<': '<', '>': '>', '&': '&', '"': '"' }[c]!)); +} diff --git a/apps/overlay-server/src/test-events.test.ts b/apps/overlay-server/src/test-events.test.ts new file mode 100644 index 0000000..75e6d46 --- /dev/null +++ b/apps/overlay-server/src/test-events.test.ts @@ -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); +}); diff --git a/apps/overlay-server/src/test-events.ts b/apps/overlay-server/src/test-events.ts new file mode 100644 index 0000000..a1efbb2 --- /dev/null +++ b/apps/overlay-server/src/test-events.ts @@ -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(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 { + 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): 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; + } +} diff --git a/apps/overlay-web/README.md b/apps/overlay-web/README.md index 1c2b215..bd3130d 100644 --- a/apps/overlay-web/README.md +++ b/apps/overlay-web/README.md @@ -1,5 +1,103 @@ # @abdulive/overlay-web -Browser-Sources fuer OBS: Alerts, Effekte, Community-Hinweis, Umfrage, Raid-Panel. +Die Browser-Sources für OBS. Fünf Seiten, alle mit durchsichtigem Hintergrund, +alle an demselben Ereignisstrom. -Spezifikation: [docs/projects/chat-control.md](../../docs/projects/chat-control.md) +Kein Build, kein Framework, keine Abhängigkeit: OBS lädt die Dateien direkt, +und jede Zwischenstufe wäre ein Ding, das mitten im Stream kaputtgehen kann. +Gemeinsam sind nur [`theme.css`](public/theme.css) (Farben, Schrift, Kontur) +und [`client.js`](public/client.js) (Verbindung, Escaping, Emotes). + +## Die Seiten + +| Adresse | Größe | Was sie zeigt | +|---|---|---| +| `/alerts` | 1920×1080 | Abos, Raids, Bits, Erstchatter. Eine nach der anderen, nie übereinander. | +| `/chat` | 460×900 | Chatnachrichten mit Emotes, Abzeichen, Farben. | +| `/status` | 1920×160 | Titel, Kategorie, Zuschauer, Laufzeit. | +| `/effects` | 1920×1080 | Emote-Regen bei Chat-Spitzen, Konfetti bei Abos und Raids. | +| `/starting` | 1920×1080 | Pausenbild als eigene Szene. Deckend, nicht durchsichtig. | + +Die fertigen Adressen mit Schlüssel stehen auf der Übersichtsseite (`/`). + +## In OBS + +Je Seite eine Browser-Source anlegen, Adresse eintragen, Breite und Höhe +setzen. Zwei Häkchen sind wichtig: + +- **"Quelle beim Unsichtbarwerden deaktivieren" aus.** Sonst verpasst das + Overlay alles, was passiert, während eine andere Szene im Bild ist, und + baut sich bei jedem Szenenwechsel neu auf. +- **"Browser beim Szenenwechsel aktualisieren" aus.** Eine laufende + Einblendung würde sonst mitten im Wechsel abgeschnitten. + +## Einstellungen über die Adresse + +Es gibt keine Konfigurationsdatei. Was einstellbar ist, hängt hinten an der +URL - so lässt es sich in OBS ändern, ohne den Dienst anzufassen. + +| Seite | Parameter | Vorgabe | +|---|---|---| +| alle | `probe=1` | dunkler Hintergrund zum Ansehen im Browser | +| `/alerts` | `sekunden` | 7 - wie lange eine Einblendung steht | +| `/alerts` | `erstchatter=0` | Erstchatter nicht einblenden | +| `/chat` | `max` | 14 - so viele Zeilen gleichzeitig | +| `/chat` | `sekunden` | 120 - danach verblasst eine Zeile (0 = nie) | +| `/chat` | `befehle=1` | auch `!`-Befehle zeigen | +| `/status` | `kanal` | abdulive_ | +| `/effects` | `max` | 260 - Obergrenze für Teilchen | +| `/starting` | `text`, `unter` | "Gleich geht es los", leer | + +## Zum Ausprobieren + +Auf der Übersichtsseite (`/`) steht unter **Ausprobieren** für jede Einblendung, +jede Chat-Variante und jeden Effekt ein Knopf. Er löst ein erfundenes Ereignis +aus, das nur an die Browser-Sources geht - nichts davon landet in der Datenbank, +auf Discord oder bei Twitch. + +Am besten die zu prüfende Seite über „ansehen" in einem zweiten Fenster offen +haben. Dabei hilft `?probe=1`: das legt einen dunklen Grund unter die sonst +durchsichtige Seite. + +Zwei Dinge sind gesperrt, solange ein Stream läuft: „Stream läuft" und die +Vorführung. Beide setzen einen Zustand, der in der Statusleiste stehen bliebe, +bis die nächste Twitch-Abfrage ihn geraderückt. „Stand zurücksetzen" ist +absichtlich nie gesperrt - das ist der Weg aus einem erfundenen Zustand wieder +heraus. + +Dieselben Ereignisse im Dauerbetrieb, auch ohne laufenden Dienst: + +``` +abdulive overlay --demo +``` + +Erfundener Chat, alle neun Sekunden eine Einblendung, ab und zu eine +Chat-Spitze. Ohne das ließe sich eine Raid-Einblendung nur prüfen, indem man +auf einen echten Raid wartet. + +## Was fehlt + +- **Follows.** Kommen nicht über IRC, dafür braucht es EventSub. Bis dahin + bleibt das Overlay bei Follows stumm; Abos, Raids und Bits kommen an. +- **BTTV- und 7TV-Emotes.** Die hängen an einer zweiten API. Twitchs eigene + Emotes werden angezeigt. +- **Töne.** Es liegen keine Audiodateien im Repo, und ein Overlay, das + ungefragt Krach macht, ist schlimmer als eines, das still ist. + +## Vorlesen (`/tts`) + +Eigene Browser-Source, 600×200, **mit Ton**. In OBS darf „Audio über OBS +steuern" nicht ausgeschaltet sein, sonst hört der Stream nichts. + +Gesprochen wird über `speechSynthesis`, die im Browser eingebaute +Sprachausgabe. Kein Cloud-Dienst, keine Kosten, kein Zuschauertext, der das +Haus verlässt. **Ob OBS' Browser-Quelle das kann, ist offen** — CEF wird +teils ohne Sprachausgabe gebaut. Die Seite prüft das beim Start und blendet +einen roten Hinweis ein, wenn sie nicht sprechen kann; im Stream soll das +auffallen und nicht stillschweigend fehlen. + +Einstellungen: `?sprache=de-DE`, `?tempo=1`, `?laut=1`. + +Vorgelesen wird nur, was auf der Übersichtsseite freigegeben wurde. Nach einem +Neuverbinden wird **nichts nachgeholt** — eine Nachricht von vor zehn Minuten +vorzulesen ist schlimmer, als sie zu verpassen. diff --git a/apps/overlay-web/public/alerts.html b/apps/overlay-web/public/alerts.html new file mode 100644 index 0000000..2dc7419 --- /dev/null +++ b/apps/overlay-web/public/alerts.html @@ -0,0 +1,222 @@ + + + + +abdulive - Einblendungen + + + + +
+ + + + + diff --git a/apps/overlay-web/public/chat.html b/apps/overlay-web/public/chat.html new file mode 100644 index 0000000..e7c8927 --- /dev/null +++ b/apps/overlay-web/public/chat.html @@ -0,0 +1,196 @@ + + + + +abdulive - Chat + + + + +
+ + + + + diff --git a/apps/overlay-web/public/client.js b/apps/overlay-web/public/client.js new file mode 100644 index 0000000..9ea24a5 --- /dev/null +++ b/apps/overlay-web/public/client.js @@ -0,0 +1,168 @@ +/* Gemeinsamer Anschluss aller Browser-Sources an den Ereignisstrom. + * + * Bewusst kein Modul und kein Build: OBS laedt diese Datei direkt, und jede + * Zwischenstufe waere ein Ding, das im Stream kaputtgehen kann. + * + * Die drei Dinge, an denen solche Overlays sonst scheitern: + * + * 1. Die Quelle bleibt nach einem Neustart des Dienstes tot. -> EventSource + * verbindet von allein neu, und ein Wachhund greift ein, wenn selbst das + * haengt (OBS haelt tote Verbindungen erstaunlich lange fuer lebendig). + * 2. Die Quelle ist nach dem Neuverbinden leer. -> Der Server schickt als + * erstes einen Schnappschuss, den die Seiten als `state` bekommen. + * 3. Chat-Text landet ungeprueft im DOM. -> `Overlay.esc` und `emoteHtml` + * sind die einzigen Wege, auf denen fremder Text hier hineinkommt. + */ +(function () { + 'use strict'; + + var params = new URLSearchParams(location.search); + var handlers = {}; + var quelle = null; + var letzteNachricht = Date.now(); + + /** Ohne Nachricht in dieser Zeit gilt die Verbindung als tot. Der Server + * schickt alle 15 s einen Herzschlag, also sind 45 s drei verpasste. */ + var TOT_NACH_MS = 45000; + + function verbinde() { + if (quelle) { try { quelle.close(); } catch (e) { /* schon zu */ } } + var url = '/events' + (params.get('key') ? '?key=' + encodeURIComponent(params.get('key')) : ''); + quelle = new EventSource(url); + letzteNachricht = Date.now(); + + quelle.onopen = function () { + document.body.classList.remove('offline'); + letzteNachricht = Date.now(); + }; + quelle.onerror = function () { + document.body.classList.add('offline'); + }; + quelle.onmessage = function (e) { + letzteNachricht = Date.now(); + document.body.classList.remove('offline'); + var ev; + try { ev = JSON.parse(e.data); } catch (err) { return; } + if (ev.kind === 'beat') return; + var liste = handlers[ev.kind] || []; + for (var i = 0; i < liste.length; i++) { + try { liste[i](ev); } catch (err) { console.error('[overlay]', ev.kind, err); } + } + }; + } + + setInterval(function () { + if (Date.now() - letzteNachricht > TOT_NACH_MS) { + console.warn('[overlay] nichts mehr gehoert, verbinde neu'); + verbinde(); + } + }, 10000); + + window.Overlay = { + /** Auf eine Ereignisart hoeren. Mehrere Hoerer je Art sind erlaubt. */ + on: function (kind, fn) { + (handlers[kind] = handlers[kind] || []).push(fn); + return window.Overlay; + }, + + /** Einstellung aus der Adresse, z. B. ?sekunden=60. */ + param: function (name, fallback) { + var v = params.get(name); + if (v === null || v === '') return fallback; + if (typeof fallback === 'number') { + var n = Number(v); + return isFinite(n) ? n : fallback; + } + if (typeof fallback === 'boolean') return v !== '0' && v !== 'false' && v !== 'nein'; + return v; + }, + + /** Der einzige erlaubte Weg, fremden Text ins DOM zu bringen. */ + esc: function (s) { + return String(s).replace(/[&<>"']/g, function (c) { + return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; + }); + }, + + /** + * Text mit eingesetzten Emotes. Erst maskieren, dann ersetzen - in der + * anderen Reihenfolge waere jeder Emote-Name ein Einfallstor. + * + * Twitch liefert nur Name und ID, nicht die Position. Ersetzt wird + * deshalb der Name als ganzes Wort. BTTV und 7TV sind nicht dabei; die + * haengen an einer zweiten API und waeren ein eigenes Stueck Arbeit. + */ + emoteHtml: function (text, emotes) { + var html = window.Overlay.esc(text); + if (!emotes || !emotes.length) return html; + for (var i = 0; i < emotes.length; i++) { + var e = emotes[i]; + if (!e.id || !e.name) continue; + var sicher = window.Overlay.esc(e.name).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + var src = 'https://static-cdn.jtvnw.net/emoticons/v2/' + + encodeURIComponent(e.id) + '/default/dark/2.0'; + html = html.replace(new RegExp('(^|\\s)' + sicher + '(?=\\s|$)', 'g'), + '$1' + window.Overlay.esc(e.name) + ''); + } + return html; + }, + + /** + * Nachrichtentext mit eingesetzten GIFs und Emotes. + * + * GIFs kommen anders als Emotes mit einem Zeichenbereich: im Text steht + * nur ein Platzhalter wie "[Idk GIF by Respective]", und der gehoert + * ersetzt. Wer nur den Text anzeigt, zeigt genau diese eckige Klammer. + */ + nachrichtHtml: function (text, gifs, emotes) { + if (!gifs || !gifs.length) return window.Overlay.emoteHtml(text, emotes); + + // Nach Zeichen zerlegen, nicht nach Bytes: die Bereiche zaehlen + // Zeichen, und ein Emoji im Text wuerde die Rechnung sonst verschieben. + var chars = Array.from(text); + var sortiert = gifs.slice().sort(function (a, b) { return a.from - b.from; }); + var html = ''; + var pos = 0; + + for (var i = 0; i < sortiert.length; i++) { + var g = sortiert[i]; + if (typeof g.url !== 'string' || g.url.slice(0, 8) !== 'https://') continue; + if (g.from > pos) { + html += window.Overlay.emoteHtml(chars.slice(pos, g.from).join(''), emotes); + } + html += 'GIF'; + pos = Math.max(pos, g.to + 1); + } + if (pos < chars.length) { + html += window.Overlay.emoteHtml(chars.slice(pos).join(''), emotes); + } + return html; + }, + + /** "2 Std 14 Min" aus einem ISO-Zeitpunkt. */ + laufzeit: function (seit) { + var ms = Date.now() - Date.parse(seit); + if (!isFinite(ms) || ms < 0) return ''; + var m = Math.floor(ms / 60000); + var h = Math.floor(m / 60); + return h > 0 ? h + ':' + String(m % 60).padStart(2, '0') + ' Std' + : m + ' Min'; + }, + + /** Zahl mit Tausenderpunkt, wie man sie in Deutschland liest. */ + zahl: function (n) { + return Number(n || 0).toLocaleString('de-DE'); + }, + }; + + // Der Punkt unten rechts, der nur bei Verlust erscheint. + document.addEventListener('DOMContentLoaded', function () { + if (params.get('probe')) document.body.classList.add('probe'); + var d = document.createElement('div'); + d.className = 'getrennt'; + d.textContent = 'keine Verbindung'; + document.body.appendChild(d); + verbinde(); + }); +})(); diff --git a/apps/overlay-web/public/effects.html b/apps/overlay-web/public/effects.html new file mode 100644 index 0000000..c509855 --- /dev/null +++ b/apps/overlay-web/public/effects.html @@ -0,0 +1,194 @@ + + + + +abdulive - Effekte + + + + +
+ + + + + + diff --git a/apps/overlay-web/public/starting.html b/apps/overlay-web/public/starting.html new file mode 100644 index 0000000..8c6305f --- /dev/null +++ b/apps/overlay-web/public/starting.html @@ -0,0 +1,156 @@ + + + + +abdulive - Pausenbild + + + + +
+
+
+

Gleich geht es los

+
+
+
+
+ + twitch.tv/abdulive_ + +
+ + + + + diff --git a/apps/overlay-web/public/status.html b/apps/overlay-web/public/status.html new file mode 100644 index 0000000..32e473c --- /dev/null +++ b/apps/overlay-web/public/status.html @@ -0,0 +1,141 @@ + + + + +abdulive - Statusleiste + + + + +
+
+ + abdulive_ +
+
+
nicht live
+
+
+
+
- + Zuschauer
+
- + live seit
+
+
+ + + + + diff --git a/apps/overlay-web/public/theme.css b/apps/overlay-web/public/theme.css new file mode 100644 index 0000000..77433d1 --- /dev/null +++ b/apps/overlay-web/public/theme.css @@ -0,0 +1,122 @@ +/* Gemeinsames Aussehen aller Browser-Sources. + * + * Zwei Dinge gelten auf jeder Seite und sind der Grund, warum es diese Datei + * gibt: + * + * 1. Der Hintergrund ist durchsichtig. Eine Browser-Source mit weissem + * Hintergrund ist der haeufigste Anfaengerfehler, und man sieht ihn erst + * in der Aufzeichnung. + * 2. Text liegt ueber bewegtem Bild. Reine Farbe reicht dafuer nicht - + * jeder Text bekommt eine dunkle Kontur, sonst verschwindet er im hellen + * Gameplay. Deshalb `paint-order` und der Schatten unten. + */ + +:root { + /* Twitch-Lila wie im Discord-Bauplan, damit Stream und Server zusammengehoeren. */ + --lila: #a970ff; + --lila-tief: #6441a5; + --tuerkis: #4fd1c5; + --pink: #ff4fa3; + --gold: #ffc53d; + --gruen: #4ade80; + --rot: #ef4444; + + --tinte: #120e1d; + --tinte-klar: rgba(18, 14, 29, .72); + --schrift: #f6f3ff; + --schrift-leise: #c5bde0; + + --rund: 14px; + --schatten: 0 12px 40px rgba(0, 0, 0, .55); + --glanz: inset 0 1px 0 rgba(255, 255, 255, .12); + + /* Segoe UI Variable gibt es auf Windows 11 - Abdus Rechner. Der Rest der + * Liste ist Rueckfall fuer Entwicklung auf anderen Systemen. */ + --font: "Segoe UI Variable Display", "Segoe UI", Inter, system-ui, sans-serif; + --font-zahl: "Segoe UI Variable Text", "Segoe UI", system-ui, sans-serif; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + width: 100%; + height: 100%; + background: transparent; + overflow: hidden; + font-family: var(--font); + color: var(--schrift); + /* OBS rendert mit eigener Skalierung; ohne das wirken Kanten weich. */ + -webkit-font-smoothing: antialiased; + user-select: none; + cursor: none; +} + +.kontur { + paint-order: stroke fill; + -webkit-text-stroke: 4px rgba(0, 0, 0, .55); + text-shadow: 0 2px 10px rgba(0, 0, 0, .7); +} + +/* Glasplatte: der Traeger fuer alles, was ueber dem Spiel steht. */ +.glas { + background: var(--tinte-klar); + backdrop-filter: blur(14px) saturate(140%); + border: 1px solid rgba(255, 255, 255, .1); + border-radius: var(--rund); + box-shadow: var(--schatten), var(--glanz); +} + +/* Der Lichtstreifen, der einmal ueber eine Flaeche wandert. Wird von den + * Einblendungen benutzt und ist der Grund, warum eine Einblendung "teuer" + * aussieht, ohne dass ein Video dahintersteckt. */ +@keyframes streifen { + from { transform: translateX(-120%) skewX(-18deg); } + to { transform: translateX(320%) skewX(-18deg); } +} + +.streifen::after { + content: ""; + position: absolute; + inset: 0 auto 0 0; + width: 28%; + background: linear-gradient(100deg, transparent, rgba(255, 255, 255, .3), transparent); + animation: streifen 1.5s cubic-bezier(.3, 0, .2, 1) .35s both; + pointer-events: none; +} + +@keyframes pulsieren { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: .55; transform: scale(.82); } +} + +/* Zum Ansehen im normalen Browser: ?probe=1 legt einen dunklen Grund unter + * die Seite. Ohne den steht heller Text auf Weiss, und man sieht nichts - + * in OBS liegt das Overlay ueber dem Spiel, im Browser ueber gar nichts. */ +body.probe { + background: + radial-gradient(60% 60% at 30% 25%, #2a1f45, transparent 70%), + radial-gradient(50% 50% at 75% 70%, #123c3a, transparent 70%), + #0b0912; +} + +/* Wer im Browser nachsieht, will nicht raten, ob die Verbindung steht. + * In OBS ist der Punkt unsichtbar, weil er nur bei Verlust erscheint. */ +.getrennt { + position: fixed; + right: 10px; + bottom: 8px; + font: 500 12px/1 var(--font-zahl); + color: var(--rot); + background: rgba(0, 0, 0, .7); + padding: 6px 10px; + border-radius: 999px; + opacity: 0; + transition: opacity .4s; +} +body.offline .getrennt { opacity: 1; } + +/* Wer mit prefers-reduced-motion arbeitet, bekommt in OBS trotzdem die + * Animationen - eine Browser-Source hat keinen Menschen mit Vestibular- + * problemen davor. Die Einstellung wird hier bewusst nicht beachtet. */ diff --git a/apps/overlay-web/public/tts.html b/apps/overlay-web/public/tts.html new file mode 100644 index 0000000..b6b58f7 --- /dev/null +++ b/apps/overlay-web/public/tts.html @@ -0,0 +1,157 @@ + + + + +abdulive - Vorlesen + + + + +
+ Diese Browser-Quelle kann nicht sprechen (keine Sprachausgabe eingebaut). + Vorgelesen wird nichts. +
+
+ + + + + diff --git a/packages/db/src/repos.test.ts b/packages/db/src/repos.test.ts index 45cdf79..de68083 100644 --- a/packages/db/src/repos.test.ts +++ b/packages/db/src/repos.test.ts @@ -21,7 +21,7 @@ function msg(over: Partial = {}): ChatMessage { id: `m-${Math.random().toString(36).slice(2)}`, ts: '2026-09-09T20:00:00.000Z', userId: '42', login: 'maxi_2k', displayName: 'maxi_2k', - text: 'moin', emotes: [], isFirst: false, isReturning: false, + text: 'moin', emotes: [], gifs: [], isFirst: false, isReturning: false, isCommand: false, isMod: false, isSub: false, isBroadcaster: false, bits: 0, color: null, ...over, }; diff --git a/packages/shared/src/events.ts b/packages/shared/src/events.ts index ab4a398..02d8619 100644 --- a/packages/shared/src/events.ts +++ b/packages/shared/src/events.ts @@ -30,6 +30,11 @@ export type ChatMessage = { displayName: string; text: string; emotes: Array<{ id: string; name: string; count: number }>; + /** + * GIFs aus Twitchs Auswahlfenster. `from`/`to` zeigen auf die Zeichen im + * Text, an denen der Platzhalter steht - siehe `parseGifs` in #twitch/irc.ts. + */ + gifs: Array<{ id: string; url: string; from: number; to: number }>; isFirst: boolean; // first-msg Tag isReturning: boolean; // returning-chatter Tag isCommand: boolean; diff --git a/packages/twitch/src/irc.test.ts b/packages/twitch/src/irc.test.ts index caa1026..23d9103 100644 --- a/packages/twitch/src/irc.test.ts +++ b/packages/twitch/src/irc.test.ts @@ -1,6 +1,6 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { parseLine, toChatMessage } from './irc.ts'; +import { parseLine, toChatMessage, parseGifs } from './irc.ts'; const LINE = '@badge-info=subscriber/8;badges=broadcaster/1,subscriber/6;color=#1E90FF;' + @@ -57,3 +57,50 @@ test('PING wird als solches erkannt', () => { assert.equal(m?.command, 'PING'); assert.equal(m?.params[0], 'tmi.twitch.tv'); }); + +// ---------------------------------------------------------------- GIFs + +test('der gifs-Tag wird zerlegt', () => { + // Echte Zeile aus #abdulive_, mitgeschnitten am 2026-09-17. + const g = parseGifs( + '0-22|Pnd7qLFuGCOntZcNKB|https://media0.giphy.com/media/Pnd7qLFuGCOntZcNKB/giphy.gif?cid=095d&ct=g'); + assert.equal(g.length, 1); + assert.equal(g[0]!.id, 'Pnd7qLFuGCOntZcNKB'); + assert.equal(g[0]!.from, 0); + assert.equal(g[0]!.to, 22); + assert.match(g[0]!.url, /^https:\/\/media0\.giphy\.com\//); + assert.ok(g[0]!.url.includes('&ct=g'), 'die Adresse darf nicht am & abgeschnitten werden'); +}); + +test('der Bereich deckt genau den Platzhalter ab', () => { + // Wenn das nicht stimmt, bleibt ein Rest der eckigen Klammer im Bild stehen. + const text = '[Idk GIF by Respective]'; + const g = parseGifs('0-22|abc|https://x/y.gif')[0]!; + assert.equal([...text].slice(g.from, g.to + 1).join(''), text); +}); + +test('eine Nachricht ohne GIF hat eine leere Liste', () => { + const m = parseLine('@id=1;user-id=7;emotes= :a!a@a PRIVMSG #k :moin'); + assert.deepEqual(toChatMessage(m!)!.gifs, []); +}); + +test('eine Nachricht mit GIF traegt es bis in die ChatMessage', () => { + const m = parseLine( + '@id=1;user-id=7;emotes=;gifs=0-4|xy|https://media.giphy.com/a.gif ' + + ':a!a@a PRIVMSG #k :[GIF]'); + assert.equal(toChatMessage(m!)!.gifs[0]!.url, 'https://media.giphy.com/a.gif'); +}); + +test('kein https, kein Bild', () => { + // Der Tag kommt von Twitch, nicht vom Chatter - trotzdem landet die Adresse + // als Bild im Stream, und dort gilt kein Vertrauensvorschuss. + assert.deepEqual(parseGifs('0-4|xy|http://media.giphy.com/a.gif'), []); + assert.deepEqual(parseGifs('0-4|xy|javascript:alert(1)'), []); +}); + +test('unvollstaendige oder unsinnige Tags werden verworfen', () => { + for (const kaputt of ['', '0-4', '0-4|nurid', 'abc|x|https://y/z.gif', '-1-4|x|https://y/z.gif', + '9-4|x|https://y/z.gif']) { + assert.deepEqual(parseGifs(kaputt), [], `"${kaputt}" haette verworfen gehoert`); + } +}); diff --git a/packages/twitch/src/irc.ts b/packages/twitch/src/irc.ts index 9dd4516..4c39964 100644 --- a/packages/twitch/src/irc.ts +++ b/packages/twitch/src/irc.ts @@ -52,6 +52,48 @@ export function parseLine(line: string): IrcMessage | null { return { tags, prefix, command, params: parts }; } +/** + * Der `gifs`-Tag. Twitch hat ihn 2026 mit der GIF-Auswahl im Chat eingefuehrt. + * + * Aufbau, am echten Chat abgelesen: + * + * gifs=0-22|Pnd7qLFuGCOntZcNKB|https://media0.giphy.com/media/.../giphy.gif?cid=... + * ^^^^ ^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + * Bereich Giphy-ID Adresse des Bildes + * + * Der Bereich zeigt wie bei `emotes` auf Zeichen im Nachrichtentext - dort + * steht ein Platzhalter wie `[Idk GIF by Respective]`, der ersetzt gehoert. + * Wer den Tag nicht auswertet, zeigt genau diesen Platzhalter an. + * + * Ob mehrere GIFs je Nachricht moeglich sind, ist offen: der Trenner koennte + * dafuer nicht `/` sein wie bei Emotes, weil eine URL selbst Schraegstriche + * enthaelt. Beobachtet wurde bisher genau eines, und danach ist das hier + * gebaut - mehr wuerde ignoriert statt falsch dargestellt. + */ +export function parseGifs(tag: string | undefined): ChatMessage['gifs'] { + if (!tag) return []; + const teile = tag.split('|'); + if (teile.length < 3) return []; + + // Streng auf Ziffern pruefen und nicht auf `Number` vertrauen: `-1-4` + // zerfaellt beim Trennen in ['', '1', '4'], und `Number('')` ist 0. Der + // Bereich saehe dann gueltig aus und wuerde den falschen Textteil ersetzen. + const bereich = /^(\d+)-(\d+)$/.exec(teile[0] ?? ''); + if (!bereich) return []; + const from = Number(bereich[1]); + const to = Number(bereich[2]); + const id = teile[1] ?? ''; + // Alles ab dem zweiten Strich ist die Adresse - falls dort je einer auftaucht. + const url = teile.slice(2).join('|'); + + if (to < from) return []; + // Nur https. Der Tag kommt von Twitch und nicht vom Chatter, aber er landet + // als Bildadresse im Stream - das ist kein Ort fuer Vertrauensvorschuss. + if (!url.startsWith('https://')) return []; + + return [{ id, url, from, to }]; +} + function parseEmotes(tag: string | undefined, text: string): ChatMessage['emotes'] { if (!tag) return []; const out: ChatMessage['emotes'] = []; @@ -84,6 +126,7 @@ export function toChatMessage(m: IrcMessage): ChatMessage | null { displayName: m.tags['display-name'] || login, text, emotes: parseEmotes(m.tags['emotes'], text), + gifs: parseGifs(m.tags['gifs']), isFirst: m.tags['first-msg'] === '1', isReturning: m.tags['returning-chatter'] === '1', isCommand: text.startsWith('!'), @@ -95,10 +138,90 @@ export function toChatMessage(m: IrcMessage): ChatMessage | null { }; } +/** + * Ein USERNOTICE ist alles, was Twitch als Ereignis in den Chat schreibt: + * Abos, verschenkte Abos, Raids, Ankuendigungen. + * + * Warum hier und nicht ueber EventSub: es kommt ohnehin ueber dieselbe + * Verbindung, kostet keinen zusaetzlichen Token und keine zweite + * Wiederverbindung. Was IRC NICHT liefert, sind Follows - dafuer braucht es + * EventSub, und bis dahin bleibt das Overlay bei Follows stumm. + */ +export type UserNoticeKind = + | 'sub' | 'resub' | 'subgift' | 'massgift' | 'raid' | 'announcement' | 'other'; + +export type UserNotice = { + kind: UserNoticeKind; + /** Roher msg-id-Wert, falls Twitch etwas Neues schickt. */ + rawKind: string; + ts: string; + login: string; + displayName: string; + /** Twitchs eigener Satz dazu, schon fertig formuliert. */ + systemMessage: string; + /** Was die Person selbst dazugeschrieben hat. Oft leer. */ + text: string; + months?: number; + /** Prime, 1000, 2000, 3000 - so wie Twitch es nennt. */ + tier?: string; + /** Beschenkte Person bei subgift. */ + recipient?: string; + /** Anzahl: verschenkte Abos oder mitgebrachte Zuschauer beim Raid. */ + count?: number; +}; + +const NOTICE_KINDS: Record = { + sub: 'sub', + resub: 'resub', + subgift: 'subgift', + submysterygift: 'massgift', + raid: 'raid', + announcement: 'announcement', +}; + +function num(v: string | undefined): number | undefined { + const n = Number(v); + return v !== undefined && v !== '' && Number.isFinite(n) ? n : undefined; +} + +export function toUserNotice(m: IrcMessage): UserNotice | null { + if (m.command !== 'USERNOTICE') return null; + const rawKind = m.tags['msg-id'] ?? ''; + const login = m.tags['login'] ?? ''; + const months = num(m.tags['msg-param-cumulative-months']) ?? num(m.tags['msg-param-months']); + // Beim Raid steht die Zuschauerzahl drin, beim Massengeschenk die Anzahl. + const count = num(m.tags['msg-param-viewerCount']) ?? num(m.tags['msg-param-mass-gift-count']); + const recipient = m.tags['msg-param-recipient-display-name'] || + m.tags['msg-param-recipient-user-name']; + const tier = m.tags['msg-param-sub-plan']; + + return { + kind: NOTICE_KINDS[rawKind] ?? 'other', + rawKind, + ts: new Date(Number(m.tags['tmi-sent-ts'] ?? Date.now())).toISOString(), + login, + displayName: m.tags['display-name'] || login, + systemMessage: m.tags['system-msg'] ?? '', + text: m.params[1] ?? '', + ...(months !== undefined ? { months } : {}), + ...(tier ? { tier } : {}), + ...(recipient ? { recipient } : {}), + ...(count !== undefined ? { count } : {}), + }; +} + export type IrcHandlers = { onMessage: (msg: ChatMessage) => void; onNotice?: (text: string) => void; onConnected?: (anonymous: boolean) => void; + /** Abos, Raids, Ankuendigungen. */ + onUserNotice?: (notice: UserNotice) => void; + /** + * Eine Nachricht oder ein ganzer Chatter wurde geloescht. Das Overlay muss + * das mitbekommen, sonst steht eine geloeschte Beleidigung noch eine Minute + * lang im Bild - und zwar in der Aufzeichnung. + */ + onClear?: (what: { userId?: string; messageId?: string }) => void; }; export type IrcClient = { @@ -189,6 +312,22 @@ function connectOnce( continue; } if (m.command === 'NOTICE') { handlers.onNotice?.(m.params[1] ?? ''); continue; } + if (m.command === 'USERNOTICE') { + const notice = toUserNotice(m); + if (notice) handlers.onUserNotice?.(notice); + continue; + } + if (m.command === 'CLEARCHAT') { + // Ohne target-user-id wurde der ganze Chat geleert. + const userId = m.tags['target-user-id']; + handlers.onClear?.(userId ? { userId } : {}); + continue; + } + if (m.command === 'CLEARMSG') { + const messageId = m.tags['target-msg-id']; + if (messageId) handlers.onClear?.({ messageId }); + continue; + } if (m.command === 'PRIVMSG') { const msg = toChatMessage(m); if (msg) handlers.onMessage(msg); diff --git a/scripts/build-package.ts b/scripts/build-package.ts index bac1379..f53a598 100644 --- a/scripts/build-package.ts +++ b/scripts/build-package.ts @@ -156,7 +156,10 @@ async function main(): Promise { for (const dir of ['packages', 'apps']) { for (const rel of await walk(path.join(repo, dir))) { if (rel.endsWith('.test.ts')) continue; - if (!/\.(ts|sql|json)$/.test(rel)) continue; + // html/css/js gehoeren seit den Overlay-Seiten dazu: die liegen in + // apps/overlay-web/public und werden zur Laufzeit von der Platte + // gelesen. Fehlen sie im Paket, liefert der Server 404 statt Overlay. + if (!/\.(ts|sql|json|html|css|js|svg|png|woff2)$/.test(rel)) continue; files.push({ name: `app/${dir}/${rel}`, data: await fs.readFile(path.join(repo, dir, rel)) }); } }