Compare commits

...
7 Commits
Author SHA1 Message Date
ZeasyAlexandClaude Opus 5 0a3d462854 Uebergabe auf den aktuellen Stand
Stand 2026-09-17. Der Dienst laeuft seitdem zentral, der Go-Live-Ping geht
raus, und das Overlay steht.

- Neuer Abschnitt "Neu: der Dienst laeuft zentral" mit der Tabelle, was wo
  laeuft, und dem Hinweis, dass ADR-0005 damit seine Hauptbegruendung
  verliert.
- "Der Go-Live-Ping geht raus": Ablauf, der Fallstrick mit `allowed_mentions`,
  und die drei Sperren gegen den doppelten Ping. Dazu ehrlich, dass er noch
  nie von allein ausgeloest hat.
- "Das Overlay steht": fuenf Seiten, SSE statt WebSocket, und dass Follows
  fehlen.
- Der Discord-Knopf haengt nicht mehr am Daemon - dafuer ein neuer offener
  Punkt: der Bot darf nur an einer Stelle laufen, sonst antworten zwei
  Prozesse auf denselben Klick.
- Redirect-URI der Twitch-App auf die oeffentliche Adresse umgestellt, mit
  dem Hinweis, dass Twitch sie erst nach dem Login prueft und ein Test von
  aussen darueber nichts sagt.
- 162 -> 264 Tests.

config.example.yaml dokumentiert broadcaster, rewards, overlay und publicUrl.
apps/hub bekommt ein README mit dem Schnitt zwischen Hub und Ingest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 00:14:08 +02:00
ZeasyAlexandClaude Opus 5 24f0528ff6 Kanalpunkte: EventSub, Aktionen und Vorlesen mit Freigabe
EventSub ueber WebSocket wurde in ADR-0003 beschlossen und nie gebaut. Jetzt
gebaut, weil Kanalpunkt-Einloesungen ueber IRC nur ankommen, wenn die
Belohnung eine Texteingabe verlangt - und selbst dann ohne Einloese-ID. Ohne
die ID lassen sich Punkte nicht zurueckgeben, wenn eine Aktion scheitert.

Zwei Eigenheiten des Protokolls, die im Code stehen:

- Ein Abonnement muss innerhalb von 10 Sekunden nach dem Willkommensgruss
  stehen, sonst schliesst Twitch wieder.
- Bei `session_reconnect` wird erst die neue Verbindung aufgebaut und dann die
  alte geschlossen. Andersherum faellt genau dazwischen eine Einloesung unter
  den Tisch.

Belohnungen werden ueber ihren Titel zugeordnet, wahlweise ueber die stabile
ID; liegt beides vor, gewinnt die ID. Eine unbekannte Belohnung ist kein
Fehler - es darf welche geben, die das Toolkit nichts angehen.

Zum Vorlesen, und warum es eine Warteschlange hat: chat-control.md schliesst
TTS fuer Ausbaustufe 1 aus, und der Grund gilt. Kanalpunkte sammeln sich beim
Zugucken von allein an und sind damit kaum eine Huerde; ohne Freigabe hat man
eine Vorlesefunktion fuer Fremde im eigenen Stream. Vorgabe ist deshalb die
Freigabe von Hand, dazu Laengengrenze, Wortfilter, Drossel je Person und
Stunde und ein Panikschalter. Zu langer Text wird abgelehnt statt gekuerzt -
ein halb vorgelesener Satz ist schlimmer als gar keiner, weil niemand weiss,
ob der Rest Absicht war.

Gesprochen wird ueber `speechSynthesis`, im Browser eingebaut: kein
Cloud-Dienst, keine Kosten, kein Zuschauertext, der das Haus verlaesst. Ob
OBS' Browser-Quelle das kann, ist offen - CEF wird teils ohne Sprachausgabe
gebaut. Die Seite prueft es beim Start und blendet einen roten Hinweis ein,
statt still zu bleiben.

Kanalpunkte haben eine eigene Einblendung. Zwischendurch stand hier die des
Abos, womit eine eingeloeste Belohnung als "Neues Abo" im Bild gelandet waere -
eine Falschmeldung, die in der Aufzeichnung bleibt. Ein Test haelt das fest.

`rewards.enabled` ist aus, bis jemand bewusst Belohnungen eintraegt. Eine
Aktion, die niemand erwartet, ist im Stream schlimmer als gar keine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 00:13:54 +02:00
ZeasyAlexandClaude Opus 5 70928235b6 Stream-Erkennung: eine geprüfte Funktion statt zweimal ausgeschrieben
Wann ist ein Stream ein *neuer* Stream? Die Entscheidung stand doppelt in Hub
und Ingest und war ungetestet - dabei haengt genau an ihr, ob der Go-Live-Ping
zum richtigen Zeitpunkt und nur einmal rausgeht.

Jetzt rein in stream-state.ts, mit zehn Tests. Darunter die Faelle, die im
Betrieb selten sind und dann mitten im Stream zuschlagen:

- Neue Twitch-ID nach kurzem Aussetzer ist eine Fortsetzung, kein zweiter
  Ping. Twitch vergibt bei einem Verbindungsabbruch eine neue ID.
- Eine einzelne leere Antwort beendet den Stream nicht. Wer daraufhin die
  Sitzung schliesst, hat beim naechsten Abruf einen "neuen" Stream.
- Ein Durchlauf ueber 90 Minuten mit zwei Aussetzern, im Minutentakt
  abgefragt, ergibt genau einen Start und genau ein Ende.

Dazu App-Token fuer Helix: `GET /users` und `GET /streams` kommen ohne
Nutzer-Token aus. Das ist der Unterschied zwischen "laeuft" und "wartet auf
einen Klick" - ohne diese Unterscheidung stand der ganze Dienst still, bis
der Broadcaster sein Konto verbindet. Endpunkte, die etwas tun oder Privates
lesen, verlangen weiterhin ein Nutzer-Token, und `appOk` sagt je Aufruf, was
gilt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 00:13:28 +02:00
ZeasyAlexandClaude Opus 5 bc2c8f6cbe 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>
2026-09-18 00:13:28 +02:00
ZeasyAlexandClaude Opus 5 3b606e36fb Hub: der Dienst laeuft zentral statt auf dem Stream-PC
Zwei Dinge, die Reichweite bringen sollen, hingen bisher daran, dass jemand
vor seinem Rechner sitzt: der Discord-Rollen-Knopf und die Go-Live-
Ankuendigung. Beide brauchen nur Internet.

Der Schnitt verlaeuft entlang einer Frage: braucht die Aufgabe eine Datei
oder ein Programm, das nur auf dem Stream-PC existiert?

  Hub (LXC)                    Ingest (Abdus PC)
  ---------                    -----------------
  Chat, Zuschauerzahlen        OBS-Wiedergabepuffer
  Discord: Knopf und Go-Live   !clip, Rendern, Untertitel
  Overlay-Seiten fuer OBS

Beide schreiben in ihre eigene Datenbank. Eine gemeinsame waere eine
Netzwerkfreigabe fuer SQLite - der zuverlaessigste bekannte Weg zu einer
beschaedigten Datei.

Das kostet ADR-0005 seine Hauptbegruendung: "bindet nur auf 127.0.0.1" gilt
fuer den Hub nicht mehr. An die Stelle treten ein Passwort mit signiertem
Sitzungs-Cookie fuer alles, was ein Mensch bedient, und ein zufaelliger
Schluessel in der Adresse fuer die Browser-Sources. Der Schluessel ist kein
schoenes Verfahren - er steht in jedem OBS-Profil -, aber eine Browser-Source
kann kein Formular ausfuellen und keinen Login durchhalten.

`publicUrl` haengt der OAuth-Rueckruf daran. Twitch vergleicht die
Redirect-URI zeichengenau, und ein fehlender Schraegstrich sieht im Fehlertext
aus wie ein falsches Client-Secret.

Die Routen der Einrichtungsseite sind aus ingest/server.ts herausgeloest,
weil sie jetzt an zwei Servern haengen.

Begruendung und Konsequenzen in ADR-0008.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 00:12:58 +02:00
ZeasyAlexandClaude Opus 5 e18336c57f discord:setup: der zweite Lauf schreibt wirklich nichts mehr
Der Bauplan verspricht, dass ein zweiter Lauf nichts aendert. Zwei Schritte
haben das gebrochen, weil sie ohne Vergleich geschrieben haben: die
Servereinstellungen und die Rangfolge der Rollen.

Aufgefallen beim Einrichten des Technik-Webhooks - der Lauf meldete "GESETZT
abdulive_" und "GESETZT Rangfolge", obwohl die Vorschau zuvor "steht" gesagt
hatte. Vorschau und echter Lauf sagten also Verschiedenes.

Folgenlos war es nicht: jeder Lauf kostete zwei Aufrufe und hinterliess zwei
Eintraege im Audit-Log des Servers, einen davon eine Rollen-Umsortierung.

Jetzt wird beides vorher verglichen. ApiGuild traegt dafuer die vier Felder
aus GUILD_SETTINGS. Ein Test haelt es fest: nach einem Lauf darf der zweite
weder PATCH /guilds/{id} noch PATCH /guilds/{id}/roles schicken.

Am echten Server nachgeprueft - der dritte Lauf meldet keine Aenderung mehr.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 00:12:58 +02:00
ZeasyAlexandClaude Opus 5 c4dc6ee04f Go-Live-Ping: Discord meldet den Streamstart
Der billigste Hebel im Repo, und bis jetzt nur Vorarbeit: die Webhooks lagen
seit der Discord-Einrichtung bereit, gepostet hat nie jemand.

Ablauf: neuer Stream erkannt, eine Minute warten (Twitch setzt Titel und
Kategorie erst kurz nach dem Start, wer sofort postet kuendigt den Titel des
letzten Streams an), Daten frisch holen, Webhook posten, Rolle Live-Ping
anpingen.

Der Ping funktioniert nur mit `allowed_mentions` im Nachrichtenkoerper. `<@&ID>`
allein faerbt die Erwaehnung lila ein und benachrichtigt niemanden - der
Fehler, den man erst nach drei leeren Streams bemerkt. Dagegen steht ein
eigener Test.

Gegen den doppelten Post stehen drei Dinge, denn wer zweimal in zehn Minuten
angepingt wird, nimmt sich die Rolle wieder ab und ist damit fuer immer stumm:

- Tabelle `posts` mit eindeutigem Index je Stream und Plattform. Nur
  *gesendete* Posts sperren; ein fehlgeschlagener Versuch darf wiederholt
  werden, sonst kostet ein Netzwerkaussetzer die ganze Ankuendigung.
- Abklingzeit von sechs Stunden, gegen die neue Stream-ID nach einem
  Verbindungsabbruch.
- Ausgeloest wird nur beim erkannten Neustart, nie im Fortsetzungszweig.

`stream_id` ist bewusst kein Fremdschluessel: ein Post ist die Quittung fuer
etwas, das den Rechner verlassen hat, und muss auch dann schreibbar sein,
wenn es die Sitzung nicht gibt - beim Probelauf etwa.

Dazu ein vierter Webhook in #technik-log. Der Entwurfsmodus postet dieselbe
Nachricht dorthin, ohne Ping und nur fuer Mods sichtbar. So sieht man das
Ergebnis, bevor es der Server sieht.

Die Rollenaufloesung aus selfroles.ts liegt jetzt in roles.ts, weil der Ping
sie auch braucht: ein Webhook kann eine Rolle nicht ueber ihren Namen
anpingen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 00:12:35 +02:00
58 changed files with 6627 additions and 106 deletions

No files matched your search

+19 -1
View File
@@ -1,5 +1,23 @@
# @abdulive/broadcaster # @abdulive/broadcaster
Go-Live-Ankuendigung auf Discord, Bluesky, Mastodon, plus Instagram-Story-Bild. Die Go-Live-Ankuendigung auf Discord.
Wird von `abdulive hub` und von `abdulive ingest` benutzt: sobald ein neuer
Stream erkannt wird, wartet der Ankuendiger eine Minute (Titel und Kategorie
stehen bei Twitch erst kurz nach dem Start), holt die Daten frisch, und postet
ueber den Webhook in `#ankuendigungen` - mit Ping an die Rolle `Live-Ping`.
Der teuerste Fehler hier ist der doppelte Post: wer zweimal in zehn Minuten
angepingt wird, nimmt sich die Rolle wieder ab. Dagegen steht `decide()` in
`src/golive.ts`, die Tabelle `posts` und eine Abklingzeit von sechs Stunden.
Probelauf, ohne auf einen Stream zu warten:
```
abdulive golive:test # in #technik-log, ohne Ping
abdulive golive:test --echt # in #ankuendigungen, mit Ping
```
Spezifikation: [docs/projects/go-live-broadcaster.md](../../docs/projects/go-live-broadcaster.md) Spezifikation: [docs/projects/go-live-broadcaster.md](../../docs/projects/go-live-broadcaster.md)
Noch nicht gebaut: Bluesky, Mastodon und das Instagram-Story-Bild.
+283
View File
@@ -0,0 +1,283 @@
// Der Go-Live-Post geht an Menschen, die sich den Ping selbst geholt haben.
// Zwei Pings fuer denselben Stream kosten Abonnenten der Rolle, und die kommen
// nicht wieder. Deshalb liegt der Schwerpunkt hier auf dem, was NICHT rausgeht.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { DatabaseSync } from 'node:sqlite';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { repos, type Repos } from '#db/repos.ts';
import type { Attachment, PostedMessage, WebhookMessage } from '#discord/webhook.ts';
import {
buildMessage, createAnnouncer, decide, thumbnailUrl,
type GoLiveConfig, type StreamInfo,
} from './golive.ts';
import { pick, OPENERS } from './templates.ts';
const MIGRATIONS = path.join(
path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'packages', 'db', 'migrations');
/** Frische Datenbank im Arbeitsspeicher, mit allen Migrationen. */
function freshRepo(): Repos {
const db = new DatabaseSync(':memory:');
for (const f of fs.readdirSync(MIGRATIONS).filter((n) => n.endsWith('.sql')).sort()) {
db.exec(fs.readFileSync(path.join(MIGRATIONS, f), 'utf8'));
}
return repos(db);
}
const STREAM: StreamInfo = {
id: '48212345',
title: 'Elden Ring, aber nur mit Fäusten',
category: 'Elden Ring',
login: 'abdulive_',
startedAt: '2026-09-17T18:00:00.000Z',
thumbnailUrl: 'https://static-cdn.twitch.tv/previews/abdulive_-{width}x{height}.jpg',
};
const CFG: GoLiveConfig = {
enabled: true, mode: 'live', delaySeconds: 0, cooldownHours: 6,
pingRole: 'Live-Ping', obsScreenshot: false,
};
type Sent = { url: string; message: WebhookMessage; files: Attachment[] };
function fakePost(sink: Sent[], fail?: string) {
return async (url: string, message: WebhookMessage, files: Attachment[] = [])
: Promise<PostedMessage> => {
if (fail) throw new Error(fail);
sink.push({ url, message, files });
return { id: '999', channel_id: '888' };
};
}
const HOOKS = () => ({ live: 'https://live', draft: 'https://draft', guildId: '1' });
const ROLES = { id: async () => '777', forget: () => undefined };
function announcer(over: Partial<Parameters<typeof createAnnouncer>[0]> = {}, cfg = CFG) {
const sent: Sent[] = [];
const a = createAnnouncer({
cfg, repo: freshRepo(), post: fakePost(sent), roles: ROLES,
webhooks: HOOKS, refresh: async (s) => s, wait: async () => undefined,
...over,
});
return { a, sent };
}
// ---------------------------------------------------------------- Entscheidung
test('ein frischer Stream wird angekuendigt', () => {
assert.deepEqual(
decide({ enabled: true, alreadyPosted: false, lastPostAt: null, cooldownHours: 6, now: 0 }),
{ post: true });
});
test('der Kill-Switch haelt', () => {
const d = decide({ enabled: false, alreadyPosted: false, lastPostAt: null, cooldownHours: 6, now: 0 });
assert.equal(d.post, false);
assert.match((d as { reason: string }).reason, /enabled/);
});
test('derselbe Stream wird kein zweites Mal angekuendigt', () => {
const d = decide({ enabled: true, alreadyPosted: true, lastPostAt: null, cooldownHours: 6, now: 0 });
assert.equal(d.post, false);
assert.match((d as { reason: string }).reason, /bereits/);
});
test('ein zweites stream.online kurz nach dem ersten laeuft in die Abklingzeit', () => {
// Der Fall aus dem Betrieb: Twitch vergibt nach einem Aussetzer eine neue
// Stream-ID. Dann greift `alreadyPosted` nicht, und nur die Abklingzeit
// verhindert den zweiten Rundruf.
const vorZehnMinuten = new Date('2026-09-17T18:00:00Z').toISOString();
const d = decide({
enabled: true, alreadyPosted: false, lastPostAt: vorZehnMinuten,
cooldownHours: 6, now: Date.parse('2026-09-17T18:10:00Z'),
});
assert.equal(d.post, false);
assert.match((d as { reason: string }).reason, /Abklingzeit/);
});
test('nach der Abklingzeit geht es wieder', () => {
const d = decide({
enabled: true, alreadyPosted: false, lastPostAt: '2026-09-17T10:00:00Z',
cooldownHours: 6, now: Date.parse('2026-09-17T18:00:00Z'),
});
assert.equal(d.post, true);
});
test('force geht durch die Abklingzeit, aber nicht durch den Kill-Switch', () => {
assert.equal(decide({
enabled: true, alreadyPosted: true, lastPostAt: '2026-09-17T17:59:00Z',
cooldownHours: 6, now: Date.parse('2026-09-17T18:00:00Z'), force: true,
}).post, true);
assert.equal(decide({
enabled: false, alreadyPosted: false, lastPostAt: null,
cooldownHours: 6, now: 0, force: true,
}).post, false);
});
// ---------------------------------------------------------------- Nachricht
test('der Ping steht im Text UND in allowed_mentions', () => {
// Nur im Text faerbt Discord die Erwaehnung ein, benachrichtigt aber
// niemanden. Das ist der Fehler, den man erst nach drei leeren Streams sieht.
const m = buildMessage({ stream: STREAM, roleId: '777' });
assert.match(m.content ?? '', /<@&777>/);
assert.deepEqual(m.allowed_mentions, { parse: [], roles: ['777'] });
});
test('ohne aufgeloeste Rolle geht der Post raus, aber pingt niemanden', () => {
const m = buildMessage({ stream: STREAM });
assert.equal(m.content?.includes('<@&'), false);
assert.deepEqual(m.allowed_mentions, { parse: [] });
assert.equal(m.embeds?.[0]?.title, STREAM.title, 'der Post selbst bleibt vollstaendig');
});
test('ein Entwurf pingt auch dann nicht, wenn die Rolle bekannt ist', () => {
const m = buildMessage({ stream: STREAM, roleId: '777', draft: true });
assert.equal(m.content?.includes('<@&777>'), false);
assert.deepEqual(m.allowed_mentions, { parse: [] });
assert.match(m.content ?? '', /Entwurf/);
});
test('Titel, Kategorie und Link stehen im Embed', () => {
const e = buildMessage({ stream: STREAM }).embeds?.[0];
assert.equal(e?.title, STREAM.title);
assert.equal(e?.url, 'https://twitch.tv/abdulive_');
assert.equal(e?.fields?.[0]?.value, 'Elden Ring');
assert.match(e?.fields?.[1]?.value ?? '', /^<t:\d+:R>$/, 'Startzeit relativ, nicht in MESZ');
});
test('ein Anhang wird per attachment:// referenziert', () => {
const e = buildMessage({ stream: STREAM, attachment: 'vorschau.jpg' }).embeds?.[0];
assert.equal(e?.image?.url, 'attachment://vorschau.jpg');
});
test('Twitchs Vorschau bekommt Groesse und einen Anhaenger gegen den Cache', () => {
const url = thumbnailUrl(STREAM.thumbnailUrl, '48212345');
assert.match(url ?? '', /-1280x720\.jpg\?t=48212345$/);
assert.equal(thumbnailUrl(undefined, 'x'), undefined);
});
test('ein leerer Titel laesst das Embed nicht ohne Titel zurueck', () => {
// Discord lehnt ein Embed mit leerem title ab, und genau das liefert Twitch
// in der ersten Minute nach dem Start.
const e = buildMessage({ stream: { ...STREAM, title: '', category: '' } }).embeds?.[0];
assert.ok((e?.title ?? '').length > 0);
assert.ok((e?.fields?.[0]?.value ?? '').length > 0);
});
test('derselbe Stream bekommt denselben Text, ein anderer einen anderen', () => {
assert.equal(pick(OPENERS, 'abc'), pick(OPENERS, 'abc'));
const texte = new Set(['1', '2', '3', '4', '5', '6', '7', '8'].map((s) => pick(OPENERS, s)));
assert.ok(texte.size > 1, 'die Auswahl darf nicht auf einen Text zusammenfallen');
});
// ---------------------------------------------------------------- Ablauf
test('der Post geht an den Go-Live-Webhook und wird protokolliert', async () => {
const repo = freshRepo();
const { a, sent } = announcer({ repo });
const r = await a.announceNow(STREAM);
assert.equal(r.status, 'gesendet');
assert.equal(sent.length, 1);
assert.equal(sent[0]?.url, 'https://live');
assert.equal(repo.postFor(STREAM.id, 'golive', 'discord')?.status, 'gesendet');
});
test('zweimal derselbe Stream gibt genau einen Post', async () => {
const repo = freshRepo();
const { a, sent } = announcer({ repo });
await a.announceNow(STREAM);
const zweiter = await a.announceNow(STREAM);
assert.equal(sent.length, 1);
assert.equal(zweiter.status, 'uebersprungen');
});
test('im Entwurfsmodus geht es in den internen Kanal', async () => {
const { a, sent } = announcer({}, { ...CFG, mode: 'draft' });
await a.announceNow(STREAM);
assert.equal(sent[0]?.url, 'https://draft');
assert.equal(sent[0]?.message.allowed_mentions?.roles, undefined);
});
test('ohne hinterlegten Webhook wird das gemeldet, nicht verschluckt', async () => {
const repo = freshRepo();
const { a, sent } = announcer({ repo, webhooks: () => ({}) });
const r = await a.announceNow(STREAM);
assert.equal(r.status, 'fehlgeschlagen');
assert.match((r as { reason: string }).reason, /discord:setup/);
assert.equal(sent.length, 0);
assert.equal(repo.postFor(STREAM.id, 'golive', 'discord'), undefined,
'ein Fehlschlag darf den naechsten Versuch nicht sperren');
});
test('ein fehlgeschlagener Post darf wiederholt werden', async () => {
const repo = freshRepo();
const sent: Sent[] = [];
let erster = true;
const a = createAnnouncer({
cfg: CFG, repo, roles: ROLES, webhooks: HOOKS,
refresh: async (s) => s, wait: async () => undefined,
post: async (url, message, files = []) => {
if (erster) { erster = false; throw new Error('Netz weg'); }
sent.push({ url, message, files });
return { id: '1', channel_id: '2' };
},
});
assert.equal((await a.announceNow(STREAM)).status, 'fehlgeschlagen');
assert.equal((await a.announceNow(STREAM)).status, 'gesendet');
assert.equal(sent.length, 1);
});
test('eine nicht aufloesbare Rolle stoppt den Post nicht', async () => {
const { a, sent } = announcer({
roles: { id: async () => { throw new Error('403'); }, forget: () => undefined },
});
const r = await a.announceNow(STREAM);
assert.equal(r.status, 'gesendet');
assert.equal((r as { pinged: boolean }).pinged, false);
assert.equal(sent[0]?.message.content?.includes('<@&'), false);
});
test('der Kill-Switch schickt gar nichts los', async () => {
const { a, sent } = announcer({}, { ...CFG, enabled: false });
assert.equal((await a.announceNow(STREAM)).status, 'uebersprungen');
assert.equal(sent.length, 0);
});
test('ein Probelauf sperrt die naechste echte Ankuendigung nicht', () => {
// Genau das ist am 2026-09-17 passiert: `golive:test --echt` lief 93 Minuten
// vor dem Streamstart, trug sich als gesendeter Post ein, und die
// Abklingzeit hat die richtige Ankuendigung verschluckt. Der Stream lief
// ohne Ping.
const repo = freshRepo();
repo.insertPost({
streamId: 'probe-1', kind: 'golive-test', platform: 'discord',
mode: 'live', status: 'gesendet',
});
// Der Probelauf steht in der Datenbank ...
assert.ok(repo.lastPost('golive-test', 'discord'));
// ... zaehlt aber fuer den Echtbetrieb nicht.
assert.equal(repo.lastPost('golive', 'discord'), undefined);
});
test('zwei echte Ankuendigungen kurz hintereinander bleiben gesperrt', () => {
// Die Abklingzeit soll ja weiter wirken - nur eben nicht gegen Probelaeufe.
const repo = freshRepo();
const { a, sent } = announcer({ repo });
return a.announceNow(STREAM).then(async () => {
const zweiter = await a.announceNow({ ...STREAM, id: 'anderer-stream' });
assert.equal(zweiter.status, 'uebersprungen');
assert.match((zweiter as { reason: string }).reason, /Abklingzeit/);
assert.equal(sent.length, 1);
});
});
+402
View File
@@ -0,0 +1,402 @@
// Die Go-Live-Ankuendigung auf Discord.
//
// Ablauf, wenn der Ingest einen neuen Stream sieht:
//
// stream.online
// -> warten (Titel und Kategorie stehen erst kurz nach dem Start)
// -> Titel und Kategorie frisch von Helix holen
// -> Vorschaubild: OBS-Screenshot, sonst Twitchs Vorschau
// -> pruefen, ob dieser Stream schon angekuendigt wurde
// -> Webhook posten, mit Ping an die Rolle Live-Ping
// -> Ergebnis in `posts` schreiben
//
// Der teuerste Fehler hier ist nicht der ausgefallene Post, sondern der
// doppelte: wer zweimal in zehn Minuten angepingt wird, nimmt die Rolle ab,
// und dann ist der Kanal fuer diese Person fuer immer stumm. Deshalb liegt
// die halbe Datei in `decide()`.
import { logger } from '#core/logger.ts';
import { sleep } from '#core/retry.ts';
import type { Repos } from '#db/repos.ts';
import { Discord } from '#discord/api.ts';
import { createRoleResolver, type RoleResolver } from '#discord/roles.ts';
import { messageUrl, postWebhook, rolePing, type Attachment, type Embed, type WebhookMessage }
from '#discord/webhook.ts';
import type { ObsClient } from '#obs/client.ts';
import { helix, type HelixStream } from '#twitch/helix.ts';
import { readSecrets } from '#twitch/secrets.ts';
import { INVITATIONS, OPENERS, pick } from './templates.ts';
const log = logger('golive');
/** Twitch-Lila. Dieselbe Farbe wie die Rolle "Abdu" im Bauplan. */
const TWITCH_PURPLE = 0x9146ff;
export type GoLiveConfig = {
enabled: boolean;
/** `live` postet in die Ankuendigungen, `draft` in den internen Kanal. */
mode: 'live' | 'draft';
/** Wartezeit nach dem Erkennen. Titel und Kategorie brauchen die. */
delaySeconds: number;
/** Innerhalb dieser Zeit wird kein zweites Mal angekuendigt. */
cooldownHours: number;
/** Rolle, die angepingt wird. Leer heisst: Post ohne Ping. */
pingRole: string;
/** Vorschaubild aus OBS holen. Ohne OBS faellt es auf Twitch zurueck. */
obsScreenshot: boolean;
};
export type StreamInfo = {
id: string;
title: string;
category: string;
login: string;
startedAt: string;
/** Twitchs Vorlage mit {width}/{height} - wird hier eingesetzt. */
thumbnailUrl?: string;
};
// ---------------------------------------------------------------- Entscheidung
export type Decision = { post: true } | { post: false; reason: string };
/**
* Darf dieser Stream angekuendigt werden? Bewusst ohne Netz, Datenbank und
* Uhr im Bauch - alles kommt als Wert herein, damit die Faelle pruefbar sind,
* die im Betrieb hoechstens einmal im Jahr vorkommen.
*/
export function decide(input: {
enabled: boolean;
alreadyPosted: boolean;
lastPostAt: string | null;
cooldownHours: number;
now: number;
/** Ein Testpost geht durch die Abklingzeit hindurch, aber nie an die Rolle. */
force?: boolean;
}): Decision {
if (!input.enabled) return { post: false, reason: 'broadcaster.enabled steht auf false' };
if (input.force) return { post: true };
if (input.alreadyPosted) {
return { post: false, reason: 'dieser Stream wurde bereits angekuendigt' };
}
if (input.lastPostAt) {
const since = input.now - Date.parse(input.lastPostAt);
const cooldown = input.cooldownHours * 3_600_000;
if (Number.isFinite(since) && since < cooldown) {
const rest = Math.ceil((cooldown - since) / 60_000);
return {
post: false,
reason: `letzte Ankuendigung ist ${Math.floor(since / 60_000)} min her, ` +
`Abklingzeit laeuft noch ${rest} min`,
};
}
}
return { post: true };
}
// ---------------------------------------------------------------- Nachricht
/**
* Baut die fertige Webhook-Nachricht. Rein - kein Netz, keine Uhr ausser der
* uebergebenen. `attachment` ist gesetzt, wenn ein Bild mitgeschickt wird;
* dann muss der Dateiname im Anhang genau so heissen.
*/
export function buildMessage(input: {
stream: StreamInfo;
/** Aufgeloeste Rollen-ID, oder undefined fuer einen Post ohne Ping. */
roleId?: string;
/** Name der angehaengten Bilddatei, wenn eines mitgeht. */
attachment?: string;
/** Fertige Bild-URL, wenn kein Anhang mitgeht (Twitchs Vorschau). */
imageUrl?: string;
draft?: boolean;
}): WebhookMessage {
const { stream } = input;
const url = `https://twitch.tv/${stream.login}`;
const ping = rolePing(input.roleId);
const opener = pick(OPENERS, stream.id);
const invitation = pick(INVITATIONS, stream.id + 'x');
// Discord rechnet <t:...:R> in "vor 3 Minuten" um, in der Zeitzone des
// Lesenden. Eine feste Uhrzeit waere hier falsch, sobald jemand nicht in
// Deutschland sitzt.
const startedUnix = Math.floor(Date.parse(stream.startedAt) / 1000);
const embed: Embed = {
author: { name: stream.login, url },
title: stream.title || 'Stream läuft',
url,
description: invitation,
color: TWITCH_PURPLE,
fields: [
{ name: 'Kategorie', value: stream.category || 'ohne Angabe', inline: true },
...(Number.isFinite(startedUnix)
? [{ name: 'Gestartet', value: `<t:${startedUnix}:R>`, inline: true }]
: []),
],
footer: { text: input.draft ? 'Entwurf - so ginge der Post raus' : 'abdulive Toolkit' },
timestamp: new Date(stream.startedAt).toISOString(),
};
const image = input.attachment ? `attachment://${input.attachment}` : input.imageUrl;
if (image) embed.image = { url: image };
const content = [input.draft ? '**Entwurf**' : ping.mention, opener]
.filter(Boolean).join(' ').trim();
return {
content,
embeds: [embed],
// Im Entwurf nie pingen, auch wenn eine Rolle aufgeloest wurde: der
// interne Kanal ist zum Ansehen da, nicht zum Benachrichtigen.
allowed_mentions: input.draft ? { parse: [] } : ping.allowed_mentions,
};
}
/** Twitchs Vorschaubild. Die Vorlage kommt mit Platzhaltern fuer die Groesse. */
export function thumbnailUrl(template: string | undefined, cacheBuster: string): string | undefined {
if (!template) return undefined;
const url = template.replace('{width}', '1280').replace('{height}', '720');
// Discord merkt sich Bilder anhand der URL. Ohne diesen Anhang zeigt der
// zweite Stream das Vorschaubild des ersten.
return `${url}${url.includes('?') ? '&' : '?'}t=${encodeURIComponent(cacheBuster)}`;
}
// ---------------------------------------------------------------- Vorschaubild
/**
* Screenshot der laufenden Programmausgabe. Gibt undefined zurueck, wenn OBS
* nicht verbunden ist oder nicht antwortet - dann bleibt Twitchs Vorschau, und
* die ist kurz nach dem Start haeufig noch schwarz.
*/
export async function obsScreenshot(obs: ObsClient): Promise<Attachment | undefined> {
if (!obs.isConnected()) return undefined;
try {
const scene = await obs.request<{ currentProgramSceneName?: string; sceneName?: string }>(
'GetCurrentProgramScene');
const sourceName = scene.sceneName ?? scene.currentProgramSceneName;
if (!sourceName) return undefined;
const shot = await obs.request<{ imageData: string }>('GetSourceScreenshot', {
sourceName, imageFormat: 'jpg', imageWidth: 1280,
});
// OBS liefert eine data:-URL, kein rohes base64.
const comma = shot.imageData.indexOf(',');
const base64 = comma === -1 ? shot.imageData : shot.imageData.slice(comma + 1);
return { name: 'vorschau.jpg', type: 'image/jpeg', data: Buffer.from(base64, 'base64') };
} catch (err) {
log.warn(`Kein OBS-Screenshot: ${(err as Error).message}`);
return undefined;
}
}
// ---------------------------------------------------------------- Ankuendiger
/**
* Unter welcher Art ein Post in `posts` steht.
*
* Ein Probelauf bekommt eine eigene Art, und das ist kein Schoenheitsfehler:
* zaehlt er als echter Post, sperrt er ueber die Abklingzeit die naechste
* richtige Ankuendigung. Genau das ist am 2026-09-17 passiert - der Testping
* lief 93 Minuten vor dem Streamstart, und die Ankuendigung fiel aus.
*/
export type PostArt = 'golive' | 'golive-test';
export type Announcer = {
/**
* Kuendigt den Stream an. Kehrt sofort zurueck - das Warten und der Post
* laufen im Hintergrund, damit der Ingest nicht daran haengt.
*/
announce: (stream: StreamInfo, opts?: { force?: boolean; immediate?: boolean }) => void;
/** Dasselbe, aber wartbar. Fuer den Testbefehl und fuer Tests. */
announceNow: (stream: StreamInfo, opts?: { force?: boolean; art?: PostArt }) => Promise<Result>;
lastResult: () => Result | null;
};
export type Result =
| { status: 'gesendet'; url: string; mode: 'live' | 'draft'; pinged: boolean }
| { status: 'uebersprungen'; reason: string }
| { status: 'fehlgeschlagen'; reason: string };
export type AnnouncerDeps = {
cfg: GoLiveConfig;
repo: Repos;
/** Ohne OBS gibt es kein eigenes Vorschaubild, sonst aendert sich nichts. */
obs?: ObsClient;
/** Fuer Tests: Webhook-Post und Rollenauflösung unterschieben. */
post?: typeof postWebhook;
roles?: RoleResolver;
/** Webhook-URLs. Vorgabe ist der verschluesselte Speicher. */
webhooks?: () => { live?: string; draft?: string; guildId?: string };
now?: () => number;
/** Fuer Tests: Wartezeit ueberspringen. */
wait?: (ms: number) => Promise<void>;
/** Frische Stream-Daten nach der Wartezeit. Vorgabe ist Helix. */
refresh?: (stream: StreamInfo) => Promise<StreamInfo>;
};
function secretWebhooks(): { live?: string; draft?: string; guildId?: string } {
const d = readSecrets().discord;
return {
...(d?.goLiveWebhook ? { live: d.goLiveWebhook } : {}),
...(d?.technikWebhook ? { draft: d.technikWebhook } : {}),
...(d?.guildId ? { guildId: d.guildId } : {}),
};
}
async function helixRefresh(stream: StreamInfo): Promise<StreamInfo> {
try {
const user = await helix.userByLogin(stream.login);
const live: HelixStream | null = user ? await helix.streamByUserId(user.id) : null;
if (!live) return stream;
const frisch = live.thumbnail_url || stream.thumbnailUrl;
return {
...stream,
title: live.title || stream.title,
category: live.game_name || stream.category,
...(frisch ? { thumbnailUrl: frisch } : {}),
};
} catch (err) {
// Kein Grund, den Post ausfallen zu lassen: die Daten aus dem ersten
// Abruf sind selten falsch, nur manchmal leer.
log.warn(`Konnte Titel und Kategorie nicht nachladen: ${(err as Error).message}`);
return stream;
}
}
export function createAnnouncer(deps: AnnouncerDeps): Announcer {
const { cfg, repo } = deps;
const now = deps.now ?? (() => Date.now());
const wait = deps.wait ?? ((ms: number) => sleep(ms));
const post = deps.post ?? postWebhook;
const refresh = deps.refresh ?? helixRefresh;
const webhooks = deps.webhooks ?? secretWebhooks;
let last: Result | null = null;
// Der Rollen-Resolver braucht das Bot-Token. Fehlt es, gibt es keinen Ping,
// aber sehr wohl einen Post - ein Post ohne Ping ist besser als keiner.
let roles: RoleResolver | null = deps.roles ?? null;
function roleResolver(): RoleResolver | null {
if (roles) return roles;
const d = readSecrets().discord;
if (!d?.botToken || !d.guildId) return null;
// Bremse von einer Minute: hier fragt niemand von Hand nach.
roles = createRoleResolver(new Discord(d.botToken), d.guildId, 60_000);
return roles;
}
async function run(
stream: StreamInfo, opts: { force?: boolean; art?: PostArt } = {},
): Promise<Result> {
const art: PostArt = opts.art ?? 'golive';
const draft = cfg.mode === 'draft';
const hooks = webhooks();
const target = draft ? hooks.draft : hooks.live;
const decision = decide({
enabled: cfg.enabled,
alreadyPosted: Boolean(repo.postFor(stream.id, art, 'discord')),
lastPostAt: repo.lastPost(art, 'discord')?.created_at ?? null,
cooldownHours: cfg.cooldownHours,
now: now(),
...(opts.force ? { force: true } : {}),
});
if (!decision.post) {
log.info(`Keine Go-Live-Ankuendigung: ${decision.reason}`);
last = { status: 'uebersprungen', reason: decision.reason };
return last;
}
if (!target) {
const reason = draft
? 'kein Webhook fuer den internen Kanal - einmal `abdulive discord:setup` laufen lassen'
: 'kein Go-Live-Webhook hinterlegt - einmal `abdulive discord:setup` laufen lassen';
log.warn(`Go-Live-Ankuendigung faellt aus: ${reason}`);
last = { status: 'fehlgeschlagen', reason };
repo.insertPost({
streamId: stream.id, kind: art, platform: 'discord', mode: cfg.mode,
status: 'fehlgeschlagen', title: stream.title, category: stream.category, detail: reason,
});
return last;
}
// Ping erst hier aufloesen: schlaegt es fehl, geht der Post trotzdem raus.
let roleId: string | undefined;
if (cfg.pingRole && !draft) {
try {
roleId = await roleResolver()?.id(cfg.pingRole);
if (!roleId) {
log.warn(`Rolle "${cfg.pingRole}" nicht gefunden - der Post geht ohne Ping raus`);
}
} catch (err) {
log.warn(`Rolle nicht aufloesbar (${(err as Error).message}) - Post ohne Ping`);
}
}
const files: Attachment[] = [];
if (cfg.obsScreenshot && deps.obs) {
const shot = await obsScreenshot(deps.obs);
if (shot) files.push(shot);
}
// Entweder eigener Screenshot als Anhang oder Twitchs Vorschau als URL -
// nie beides, sonst zeigt Discord zwei Bilder.
const fallbackImage = files.length === 0
? thumbnailUrl(stream.thumbnailUrl, stream.id) : undefined;
const message = buildMessage({
stream,
...(roleId ? { roleId } : {}),
...(files[0] ? { attachment: files[0].name } : {}),
...(fallbackImage ? { imageUrl: fallbackImage } : {}),
...(draft ? { draft: true } : {}),
});
try {
const sent = await post(target, message, files);
const url = hooks.guildId ? messageUrl(hooks.guildId, sent) : '';
repo.insertPost({
streamId: stream.id, kind: art, platform: 'discord', mode: cfg.mode,
status: 'gesendet', title: stream.title, category: stream.category, url,
});
log.info(`Go-Live-Post raus${draft ? ' (Entwurf, interner Kanal)' : ''}` +
`${roleId ? `, mit Ping an ${cfg.pingRole}` : ', ohne Ping'}` +
`${files.length ? ', mit OBS-Vorschau' : ''}`);
last = { status: 'gesendet', url, mode: cfg.mode, pinged: Boolean(roleId) };
return last;
} catch (err) {
const reason = (err as Error).message;
log.error(`Go-Live-Post fehlgeschlagen: ${reason}`);
repo.insertPost({
streamId: stream.id, kind: art, platform: 'discord', mode: cfg.mode,
status: 'fehlgeschlagen', title: stream.title, category: stream.category, detail: reason,
});
last = { status: 'fehlgeschlagen', reason };
return last;
}
}
async function delayedRun(
stream: StreamInfo, opts: { force?: boolean; immediate?: boolean; art?: PostArt },
) {
if (!opts.immediate && cfg.delaySeconds > 0) {
log.info(`Stream erkannt - Ankuendigung in ${cfg.delaySeconds} s ` +
'(Titel und Kategorie stehen erst kurz nach dem Start)');
await wait(cfg.delaySeconds * 1000);
}
const fresh = await refresh(stream);
return run(fresh, opts);
}
return {
announce(stream, opts = {}) {
void delayedRun(stream, opts).catch((err: Error) => {
log.error(`Ankuendigung abgebrochen: ${err.message}`);
last = { status: 'fehlgeschlagen', reason: err.message };
});
},
announceNow: (stream, opts = {}) => delayedRun(stream, { ...opts, immediate: true }),
lastResult: () => last,
};
}
+53
View File
@@ -0,0 +1,53 @@
// Textbausteine fuer die Go-Live-Ankuendigung.
//
// Warum mehrere: eine Ankuendigung, die jeden Donnerstag Wort fuer Wort
// dieselbe ist, wird nach drei Wochen nicht mehr gelesen. Das ist kein
// Algorithmus-Aberglaube, sondern gilt genauso fuer Menschen, die die
// Nachricht in der Seitenleiste ueberfliegen.
//
// Die Auswahl ist nicht zufaellig, sondern haengt an der Stream-ID: derselbe
// Stream bekommt bei einem Wiederholungsversuch denselben Text, und Tests
// bekommen ein vorhersagbares Ergebnis. Zwei aufeinanderfolgende Streams
// bekommen verschiedene IDs und damit fast immer verschiedene Texte.
/** Die Zeile ueber dem Embed. Hier steht der Ping, also faellt sie ins Auge. */
export const OPENERS = [
'Abdu ist live.',
'Der Stream läuft.',
'Es geht los.',
'Abdu ist da.',
'Ab jetzt live.',
];
/** Die Zeile im Embed. Darf laenger sein, wird ruhiger gelesen. */
export const INVITATIONS = [
'Schau vorbei.',
'Komm rein, es ist noch Platz.',
'Jetzt einschalten.',
'Der Chat ist offen.',
'Bis gleich im Chat.',
];
/**
* Kleiner, stabiler Hash. Kein crypto: er muss nur gleichmaessig streuen und
* ueber Neustarts hinweg dasselbe Ergebnis liefern.
*/
export function pick<T>(list: readonly T[], seed: string): T {
if (list.length === 0) throw new Error('Keine Textbausteine hinterlegt.');
let h = 2166136261;
for (let i = 0; i < seed.length; i++) {
h ^= seed.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return list[Math.abs(h) % list.length]!;
}
/**
* Platzhalter ersetzen. Unbekannte Platzhalter bleiben stehen - so sieht man
* im Ergebnis, dass einer falsch geschrieben ist, statt eine Luecke zu
* bekommen, die nach Absicht aussieht.
*/
export function fill(template: string, values: Record<string, string>): string {
return template.replace(/\{\{(\w+)\}\}/g, (all, key: string) =>
key in values ? values[key]! : all);
}
+185 -1
View File
@@ -646,6 +646,177 @@ Was jetzt noch von Hand gehoert:
verbinden, dann bekommen Subs automatisch eine Rolle.`); verbinden, dann bekommen Subs automatisch eine Rolle.`);
} }
// ---------------------------------------------------------------- overlay
/**
* Overlay-Server allein starten. Zum Ansehen und Einrichten, ohne dass der
* ganze Hub laeuft. Mit --demo laeuft erfundener Betrieb: anders koennte man
* eine Raid-Einblendung nur pruefen, indem man auf einen echten Raid wartet.
*/
async function overlayOnly(args: string[]): Promise<void> {
const cfg = loadConfig();
const { startOverlayServer } = await import('../overlay-server/src/server.ts');
const { createBus } = await import('../overlay-server/src/bus.ts');
const bus = createBus();
const abort = new AbortController();
process.on('SIGINT', () => { abort.abort(); process.exit(0); });
startOverlayServer({
port: cfg.overlay.port,
channel: cfg.channel.login,
publicUrl: cfg.publicUrl,
bus,
});
if (args.includes('--demo')) {
const { startDemo } = await import('../overlay-server/src/demo.ts');
startDemo(bus, abort.signal);
}
const wo = cfg.publicUrl || `http://127.0.0.1:${cfg.overlay.port}`;
console.log(`
Uebersicht mit allen Adressen fuer OBS: ${wo}/`);
console.log(`${C.dim}Beenden mit Strg+C.${C.off}`);
await new Promise(() => { /* laeuft bis Strg+C */ });
}
/** Passwort fuer die Weboberflaeche setzen. Nur hier, nie ueber das Netz. */
async function webPasswort(args: string[]): Promise<void> {
const { setzePasswort } = await import('../overlay-server/src/auth.ts');
const pw = args[0] || process.env.ABDULIVE_WEB_PASSWORT ||
await promptHidden('Neues Passwort (Eingabe wird nicht angezeigt): ');
setzePasswort(pw.trim());
console.log(`${C.ok}Passwort gesetzt.${C.off} Angemeldet bleibt man 30 Tage.`);
}
// ---------------------------------------------------------------- golive
/**
* Probelauf der Go-Live-Ankuendigung.
*
* Ohne Argument geht der Post in den internen Kanal und pingt niemanden -
* damit laesst sich das Aussehen pruefen, ohne den Server aufzuwecken. Mit
* `--echt` geht er dorthin, wo er im Betrieb hinginge, mit Ping. Das ist der
* einzige Weg, vor dem ersten Stream zu sehen, ob der Ping wirklich ankommt;
* eine Erwaehnung ohne `allowed_mentions` sieht naemlich genauso aus wie eine
* mit, benachrichtigt aber niemanden.
*/
async function goLiveTest(args: string[]): Promise<void> {
const echt = args.includes('--echt');
const cfg = loadConfig();
const { createAnnouncer } = await import('../broadcaster/src/golive.ts');
const { repos } = await import('#db/repos.ts');
const { helix } = await import('#twitch/helix.ts');
const { readSecrets } = await import('#twitch/secrets.ts');
const discord = readSecrets().discord;
const ziel = echt ? discord?.goLiveWebhook : discord?.technikWebhook;
if (!ziel) {
throw new Error(echt
? 'Kein Go-Live-Webhook hinterlegt. Einmal `abdulive discord:setup` laufen lassen.'
: 'Kein Webhook fuer den internen Kanal. Einmal `abdulive discord:setup` laufen lassen ' +
'- der Technik-Webhook ist seit Fassung 0.6 im Bauplan.');
}
// Wenn Abdu gerade live ist, wird der echte Stream angekuendigt. Sonst ein
// erfundener - das prueft denselben Weg, nur mit anderem Text.
let stream = {
id: `probe-${Date.now()}`,
title: 'Probelauf der Go-Live-Ankuendigung',
category: 'Just Chatting',
login: cfg.channel.login,
startedAt: new Date().toISOString(),
} as import('../broadcaster/src/golive.ts').StreamInfo;
try {
const user = await helix.userByLogin(cfg.channel.login);
const live = user ? await helix.streamByUserId(user.id) : null;
if (live) {
stream = {
id: `probe-${live.id}-${Date.now()}`,
title: live.title,
category: live.game_name,
login: cfg.channel.login,
startedAt: live.started_at,
thumbnailUrl: live.thumbnail_url,
};
console.log(`${C.dim}Stream laeuft gerade - der Probelauf nimmt die echten Daten.${C.off}`);
} else {
console.log(`${C.dim}Kein laufender Stream - der Probelauf nimmt erfundene Daten.${C.off}`);
}
} catch (err) {
// Haeufigster Fall ist nicht "Netz weg", sondern "noch keine Twitch-App
// hinterlegt". Der Satz soll das sagen, sonst sucht man am falschen Ende.
console.log(`${C.warn}Twitch nicht abgefragt:${C.off} ${(err as Error).message}`);
console.log(`${C.dim}Der Probelauf nimmt erfundene Daten - der Weg zum Webhook ` +
`wird trotzdem vollstaendig geprueft.${C.off}`);
}
// OBS nur dazunehmen, wenn es laeuft: sonst wartet der Screenshot ins Leere.
let obs: Awaited<ReturnType<typeof withObs>> | null = null;
if (cfg.broadcaster.obsScreenshot && await tcpProbe(OBS_HOST, OBS_PORT, 1500)) {
obs = await withObs();
}
const announcer = createAnnouncer({
cfg: {
enabled: true,
mode: echt ? 'live' : 'draft',
delaySeconds: 0,
cooldownHours: cfg.broadcaster.cooldownHours,
pingRole: cfg.broadcaster.pingRole,
obsScreenshot: Boolean(obs),
},
repo: repos(openDb()),
...(obs ? { obs: obs.client } : {}),
// Ein Probelauf geht durch die Abklingzeit hindurch, sonst koennte man ihn
// nach einem echten Stream sechs Stunden lang nicht wiederholen.
refresh: async (s) => s,
});
// Als eigene Art protokollieren: sonst sperrt der Probelauf ueber die
// Abklingzeit die naechste echte Ankuendigung.
const result = await announcer.announceNow(stream, { force: true, art: 'golive-test' });
obs?.stop();
if (result.status === 'gesendet') {
const wohin = result.mode === 'draft' ? 'interner Kanal' : 'Ankuendigungen';
const ping = result.pinged ? `mit Ping an "${cfg.broadcaster.pingRole}"` : 'ohne Ping';
console.log(`
${C.ok}Post ist raus${C.off} (${wohin}, ${ping}).`);
if (result.url) console.log(` ${result.url}`);
if (echt && !result.pinged) {
console.log(`
${C.warn}Kein Ping gesetzt.${C.off} Pruefen: gibt es die Rolle ` +
`"${cfg.broadcaster.pingRole}" auf dem Server, und ist ein Bot-Token hinterlegt?`);
}
if (!echt) console.log(`
${C.dim}Mit --echt geht derselbe Post in die Ankuendigungen, ` +
`mit Ping.${C.off}`);
} else {
console.log(`
${C.bad}${result.status}${C.off}: ${result.reason}`);
process.exitCode = 1;
}
}
/** OBS kurz verbinden, damit ein Screenshot moeglich ist. */
async function withObs() {
const { createObs } = await import('#obs/client.ts');
const { readSecrets } = await import('#twitch/secrets.ts');
const abort = new AbortController();
const client = createObs(`ws://${OBS_HOST}:${OBS_PORT}`,
() => readSecrets().obs?.password, {});
void client.run(abort.signal);
// Kurz auf die Identifikation warten; ohne sie gibt es keinen Screenshot,
// und das ist kein Grund zum Abbruch.
for (let i = 0; i < 30 && !client.isConnected(); i++) {
await new Promise((r) => setTimeout(r, 100));
}
return { client, stop: () => abort.abort() };
}
async function discordBot(): Promise<void> { async function discordBot(): Promise<void> {
const { createBot } = await import('#discord/bot.ts'); const { createBot } = await import('#discord/bot.ts');
const bot = createBot(); const bot = createBot();
@@ -673,7 +844,10 @@ function help(): void {
console.log(` console.log(`
abdulive - Werkzeugkasten fuer Abdus Stream abdulive - Werkzeugkasten fuer Abdus Stream
ingest Daemon starten (Chat, Zuschauerzahlen, Einrichtungsseite) hub Zentraler Dienst: Chat, Discord, Go-Live, Overlay
ingest Daemon auf dem Stream-PC (OBS, !clip, Rendern)
overlay [--demo] Nur den Overlay-Server, --demo mit erfundenem Betrieb
web:passwort [wort] Passwort fuer die Weboberflaeche setzen
check-env Umgebung pruefen check-env Umgebung pruefen
db:migrate Migrationen anwenden db:migrate Migrationen anwenden
fetch-model [name] Whisper-Modell laden (Vorgabe: ggml-small.bin) fetch-model [name] Whisper-Modell laden (Vorgabe: ggml-small.bin)
@@ -684,6 +858,8 @@ abdulive - Werkzeugkasten fuer Abdus Stream
discord:setup [--server ID] [--vorschau] discord:setup [--server ID] [--vorschau]
Discord-Server nach docs/projects/discord-community.md einrichten Discord-Server nach docs/projects/discord-community.md einrichten
discord:bot Rollen-Knoepfe bedienen (laeuft sonst im Daemon mit) discord:bot Rollen-Knoepfe bedienen (laeuft sonst im Daemon mit)
golive:test [--echt] Go-Live-Post proben: ohne Argument in den internen
Kanal ohne Ping, mit --echt in die Ankuendigungen
help Diese Uebersicht help Diese Uebersicht
Einrichtung nach dem Start: http://localhost:4792/setup Einrichtung nach dem Start: http://localhost:4792/setup
@@ -715,6 +891,14 @@ try {
} }
case 'render': await renderOne(args[0], args[1]); break; case 'render': await renderOne(args[0], args[1]); break;
case 'discord:setup': await discordSetup(args); break; case 'discord:setup': await discordSetup(args); break;
case 'golive:test': await goLiveTest(args); break;
case 'hub': {
const { runHub } = await import('../hub/src/index.ts');
await runHub();
break;
}
case 'overlay': await overlayOnly(args); break;
case 'web:passwort': await webPasswort(args); break;
case 'discord:bot': await discordBot(); break; case 'discord:bot': await discordBot(); break;
case 'help': case undefined: help(); break; case 'help': case undefined: help(); break;
default: default:
+20
View File
@@ -0,0 +1,20 @@
# @abdulive/hub
Der zentrale Dienst. Alles, was nur Internet braucht: Twitch-Chat,
Zuschauerzahlen, der Discord-Rollen-Knopf, die Go-Live-Ankuendigung und die
Overlay-Seiten fuer OBS.
Laeuft im LXC, nicht auf Abdus Rechner - damit der Ping auch dann rausgeht,
wenn der Stream-PC gerade nicht laeuft. Der Schnitt zwischen Hub und Ingest
steht in [ADR-0008](../../docs/decisions/ADR-0008-geteilter-betrieb.md).
```
abdulive hub
```
Bedienung ueber die Weboberflaeche des Overlay-Servers: Uebersicht auf `/`,
Einrichtung auf `/setup`. Beides hinter einem Passwort
(`abdulive web:passwort`).
Nicht hier drin und auch nicht hierher zu holen: OBS, der Wiedergabepuffer,
ffmpeg und whisper. Die bleiben in `abdulive ingest`.
+481
View File
@@ -0,0 +1,481 @@
// Der zentrale Dienst. Laeuft in einem LXC, nicht auf Abdus Rechner.
//
// Was hier drin ist, braucht nur Internet: Twitch-Chat, Twitch-API, Discord
// und die Overlay-Seiten. Das laeuft damit auch dann, wenn der Stream-PC aus
// ist - der Rollen-Knopf auf Discord antwortet nachts um drei, und die
// Go-Live-Ankuendigung haengt nicht daran, dass jemand den Daemon gestartet
// hat.
//
// Was NICHT hier drin ist und auch nicht hierher kann: OBS, der
// Wiedergabepuffer, ffmpeg und whisper. Die haengen an den Dateien auf dem
// Stream-PC und bleiben in `abdulive ingest`.
//
// Hub (LXC) Stream-PC (abdulive ingest)
// --------- --------------------------
// Chat lesen und senden OBS-Wiedergabepuffer
// Zuschauerzahlen, Sitzungen !clip, Rendern, Untertitel
// Discord: Knopf und Go-Live
// Overlay-Seiten fuer OBS
//
// Beide schreiben in ihre eigene Datenbank. Das ist bewusst: eine gemeinsame
// waere eine Netzwerkfreigabe fuer SQLite, und das ist der zuverlaessigste
// Weg zu einer beschaedigten Datei.
import { loadConfig } from '#core/config.ts';
import { logger } from '#core/logger.ts';
import { sleep } from '#core/retry.ts';
import { openDb, closeDb } from '#db/index.ts';
import { repos } from '#db/repos.ts';
import { createBot } from '#discord/bot.ts';
import { helix } from '#twitch/helix.ts';
import { readSecrets } from '#twitch/secrets.ts';
import { createEventSub } from '#twitch/eventsub.ts';
import { createIrc, type UserNotice } from '#twitch/irc.ts';
import { streamWechsel } from '#twitch/stream-state.ts';
import {
ausEventSub, createDrossel, createWarteschlange, entscheiden,
} from './rewards.ts';
import { createAnnouncer, type StreamInfo } from '../../broadcaster/src/golive.ts';
import { createSpikeDetector } from '../../ingest/src/spike.ts';
import { createBus, type AlertEvent, type Bus } from '../../overlay-server/src/bus.ts';
import { startOverlayServer } from '../../overlay-server/src/server.ts';
import { ulid } from '#shared/events.ts';
const log = logger('hub');
export async function runHub(): Promise<void> {
const cfg = loadConfig();
const db = openDb();
const repo = repos(db);
const spikes = createSpikeDetector(cfg.ingest.spike);
const bus = createBus();
const state = {
startedAt: new Date().toISOString(),
streamId: null as string | null,
broadcasterId: null as string | null,
lastSeenLive: 0,
viewers: 0,
title: '',
category: '',
startedLiveAt: null as string | null,
chatConnected: false,
chatAnonymous: true,
messages: 0,
spikeCandidates: 0,
eventsub: false,
lastError: null as string | null,
};
const abort = new AbortController();
const stop = (signal: string): void => {
log.info(`${signal} empfangen, fahre herunter`);
abort.abort();
setTimeout(() => { closeDb(); process.exit(0); }, 500);
};
process.on('SIGINT', () => stop('SIGINT'));
process.on('SIGTERM', () => stop('SIGTERM'));
// ---- Go-Live-Ankuendigung. Ohne OBS gibt es kein eigenes Vorschaubild;
// dann nimmt der Post Twitchs Vorschau, und die ist meist gut genug.
const announcer = createAnnouncer({
cfg: {
enabled: cfg.broadcaster.enabled,
mode: cfg.broadcaster.mode,
delaySeconds: cfg.broadcaster.delaySecondsAfterOnline,
cooldownHours: cfg.broadcaster.cooldownHours,
pingRole: cfg.broadcaster.pingRole,
obsScreenshot: false,
},
repo,
});
// ---- Kanalpunkte
const warteschlange = createWarteschlange();
const drossel = createDrossel();
/** Punkte zurueckgeben. Ohne den Scope nicht moeglich - dann nur melden. */
const erstatten = async (rewardId: string, redemptionId: string): Promise<void> => {
if (!state.broadcasterId) return;
if (!helix.hasScope('channel:manage:redemptions')) {
log.warn('Punkte nicht erstattet: dem verbundenen Konto fehlt ' +
'channel:manage:redemptions. Einmal neu verbinden unter /setup.');
return;
}
try {
await helix.setRedemptionStatus(state.broadcasterId, rewardId, redemptionId, 'CANCELED');
} catch (err) {
log.warn(`Punkte nicht erstattet: ${(err as Error).message}`);
}
};
const vorlesen = (id: string, name: string, text: string): void => {
bus.publish({ kind: 'tts', id, name, text });
log.info(`Wird vorgelesen (${name}): ${text.slice(0, 60)}`);
};
const behandleEinloesung = (roh: Record<string, unknown>): void => {
const e = ausEventSub(roh);
if (!e) { log.warn('Einloesung ohne verwertbare Felder - ignoriert'); return; }
repo.event('points.redeemed', {
reward: e.rewardTitle, kosten: e.kosten, login: e.userName, text: e.text,
}, state.streamId, e.userId, e.ts);
const ent = entscheiden(e, cfg.rewards, drossel.zaehlen(e.userId));
drossel.merken(e.userId);
switch (ent.art) {
case 'unbekannt':
log.debug(`Belohnung "${e.rewardTitle}" hat keine hinterlegte Aktion`);
break;
case 'abgelehnt':
log.info(`Einloesung von ${e.userName} abgelehnt: ${ent.grund}`);
void erstatten(e.rewardId, e.id);
break;
case 'ausfuehren':
// Immer dieselbe Einblendung: sie sagt, wer was eingeloest hat. Die
// Aktion entscheidet nur, was zusaetzlich passiert. Frueher stand
// hier die Abo-Einblendung - eine Belohnung als "Neues Abo" ins Bild
// zu schreiben ist eine Falschmeldung, die in der Aufzeichnung bleibt.
bus.publish({
kind: 'alert', type: 'reward', id: ulid(), name: e.userName,
reward: e.rewardTitle,
...(e.kosten ? { amount: e.kosten } : {}),
...(e.text ? { message: e.text } : {}),
});
if (ent.aktion === 'konfetti') bus.publish({ kind: 'konfetti', staerke: 1 });
if (ent.aktion === 'emote-regen') bus.publish({ kind: 'hype', score: 5, rate: 40 });
log.info(`${e.userName} hat "${e.rewardTitle}" eingeloest -> ${ent.aktion}`);
break;
case 'vorlesen':
vorlesen(ulid(), e.userName, ent.text);
break;
case 'warteschlange':
warteschlange.einreihen({
id: ulid(), name: e.userName, text: ent.text,
rewardId: e.rewardId, redemptionId: e.id, seit: new Date().toISOString(),
});
log.info(`${e.userName} wartet auf Freigabe zum Vorlesen`);
break;
}
};
// ---- EventSub. Nur sinnvoll mit verbundenem Konto: der WebSocket-Weg
// verlangt fuer das Abonnement dasselbe Nutzer-Token.
const eventsub = createEventSub({
onSession: async (sessionId) => {
const id = state.broadcasterId || (await helix.userByLogin(cfg.channel.login))?.id;
if (!id) throw new Error('Broadcaster-ID unbekannt');
state.broadcasterId = id;
await helix.subscribe({
type: 'channel.channel_points_custom_reward_redemption.add',
version: '1',
condition: { broadcaster_user_id: id },
}, sessionId);
log.info('Abonniert: Kanalpunkt-Einloesungen');
},
onNotification: (typ, event) => {
if (typ === 'channel.channel_points_custom_reward_redemption.add') {
behandleEinloesung(event);
}
},
onRevocation: (typ) => log.error(
`Twitch hat "${typ}" entzogen - meist zurueckgezogene Freigabe. Neu verbinden unter /setup.`),
onConnected: () => { state.eventsub = true; },
onClosed: () => { state.eventsub = false; },
});
// ---- Discord (Rollen-Knoepfe)
const bot = createBot();
if (bot) void bot.run(abort.signal);
// ---- Chat
const irc = createIrc(cfg.channel.login, {
onConnected: (anonymous) => {
state.chatConnected = true;
state.chatAnonymous = anonymous;
},
onMessage: (msg) => {
try {
repo.recordMessage(msg, state.streamId);
state.messages++;
repo.event('chat.message', {
text: msg.text, login: msg.login, isFirst: msg.isFirst, bits: msg.bits,
}, state.streamId, msg.userId, msg.ts);
bus.publish({
kind: 'chat',
id: msg.id,
userId: msg.userId,
login: msg.login,
name: msg.displayName,
text: msg.text,
color: msg.color,
emotes: msg.emotes,
gifs: msg.gifs,
first: msg.isFirst,
mod: msg.isMod,
sub: msg.isSub,
broadcaster: msg.isBroadcaster,
bits: msg.bits,
});
if (msg.isFirst) {
repo.event('chat.first_message', { login: msg.login }, state.streamId, msg.userId, msg.ts);
bus.publish({ kind: 'alert', type: 'first', id: ulid(), name: msg.displayName });
log.info(`Erstchatter: ${msg.displayName}`);
}
if (msg.bits > 0) {
repo.event('cheer', { bits: msg.bits, login: msg.login }, state.streamId, msg.userId, msg.ts);
bus.publish({
kind: 'alert', type: 'cheer', id: ulid(),
name: msg.displayName, amount: msg.bits, message: msg.text,
});
}
const spike = spikes.push(msg);
if (spike) {
state.spikeCandidates++;
repo.event('chat.spike', spike, state.streamId, null, msg.ts);
bus.publish({ kind: 'hype', score: spike.score, rate: spike.rate });
log.info(`Spitze im Chat: Punktzahl ${spike.score} (${spike.rate} Nachrichten ` +
`von ${spike.chatters} Leuten, Basislinie ${spike.baseline})`);
}
} catch (err) {
state.lastError = (err as Error).message;
log.error(`Nachricht nicht verarbeitet: ${(err as Error).message}`);
}
},
onUserNotice: (notice) => {
const alert = toAlert(notice);
if (!alert) return;
repo.event(eventTypeFor(notice), notice, state.streamId);
bus.publish(alert);
log.info(notice.systemMessage || `${notice.kind} von ${notice.displayName}`);
},
onClear: (what) => bus.publish({ kind: 'clear', ...what }),
});
void irc.run(abort.signal);
// ---- Die eine oeffentliche Tuer
const server = startOverlayServer({
port: cfg.overlay.port,
channel: cfg.channel.login,
publicUrl: cfg.publicUrl,
bus,
setup: {
channel: cfg.channel.login,
port: cfg.overlay.port,
health: () => ({
...state,
rolle: 'hub',
db: repo.stats(),
discordConnected: bot ? bot.isConnected() : false,
goLive: announcer.lastResult() ?? 'noch nichts',
overlayClients: bus.clients(),
ttsWartend: warteschlange.liste().length,
spikeArmed: spikes.isArmed(),
note: state.chatAnonymous
? 'Chat wird anonym mitgelesen. Zum Senden und fuer Helix: Einrichtung unter /setup.'
: undefined,
}),
},
tts: {
wartend: () => warteschlange.liste()
.map((w) => ({ id: w.id, name: w.name, text: w.text, seit: w.seit })),
freigeben: (id) => {
const w = warteschlange.entnehmen(id);
if (!w) return false;
vorlesen(w.id, w.name, w.text);
return true;
},
ablehnen: (id) => {
const w = warteschlange.entnehmen(id);
if (!w) return false;
log.info(`Vorlesen abgelehnt (${w.name})`);
void erstatten(w.rewardId, w.redemptionId);
return true;
},
alleAblehnen: () => {
const alle = warteschlange.leeren();
for (const w of alle) void erstatten(w.rewardId, w.redemptionId);
if (alle.length) log.info(`${alle.length} wartende Nachricht(en) abgelehnt`);
return alle.length;
},
still: () => {
bus.publish({ kind: 'still' });
const alle = warteschlange.leeren();
for (const w of alle) void erstatten(w.rewardId, w.redemptionId);
log.warn('Panikschalter: Vorlesen gestoppt und Warteschlange geleert');
},
},
});
void server;
// ---- Zuschauerzahlen und Stream-Sitzungen
void pollStream(cfg, repo, state, bus, abort.signal, (stream) => announcer.announce(stream));
// ---- EventSub erst, wenn ein Konto verbunden ist. Ohne Nutzer-Token
// wuerde der Verbindungsversuch im Minutentakt scheitern und nichts als
// Protokollrauschen erzeugen.
if (cfg.rewards.enabled && readSecrets().tokens.broadcaster) {
void eventsub.run(abort.signal);
} else if (cfg.rewards.enabled) {
log.warn('Kanalpunkt-Aktionen sind an, aber kein Konto verbunden - ' +
'EventSub bleibt aus. Verbinden unter /setup.');
}
log.info(`Hub laeuft fuer #${cfg.channel.login}`);
await new Promise<void>((resolve) => abort.signal.addEventListener('abort', () => resolve()));
}
// ---------------------------------------------------------------- Ereignisse
/** USERNOTICE in eine Einblendung uebersetzen. Unbekanntes faellt weg. */
export function toAlert(n: UserNotice): AlertEvent | null {
const gemeinsam = {
kind: 'alert' as const,
id: ulid(),
name: n.displayName,
...(n.months !== undefined ? { months: n.months } : {}),
...(n.tier ? { tier: n.tier } : {}),
...(n.text ? { message: n.text } : {}),
};
switch (n.kind) {
case 'sub': case 'resub':
return { ...gemeinsam, type: n.kind };
case 'subgift':
return { ...gemeinsam, type: 'subgift', ...(n.recipient ? { recipient: n.recipient } : {}) };
case 'massgift':
return { ...gemeinsam, type: 'massgift', ...(n.count ? { amount: n.count } : {}) };
case 'raid':
return { ...gemeinsam, type: 'raid', ...(n.count ? { amount: n.count } : {}) };
default:
// Ankuendigungen und alles Unbekannte bekommen keine Einblendung. Eine
// Einblendung fuer etwas, das man nicht kennt, sieht kaputt aus.
return null;
}
}
function eventTypeFor(n: UserNotice): 'subscribe' | 'subgift' | 'raid.in' | 'chat.message' {
if (n.kind === 'subgift' || n.kind === 'massgift') return 'subgift';
if (n.kind === 'raid') return 'raid.in';
if (n.kind === 'sub' || n.kind === 'resub') return 'subscribe';
return 'chat.message';
}
// ---------------------------------------------------------------- Abfrage
type State = {
streamId: string | null; broadcasterId: string | null; lastSeenLive: number;
viewers: number; title: string; category: string; startedLiveAt: string | null;
lastError: string | null;
};
async function pollStream(
cfg: ReturnType<typeof loadConfig>,
repo: ReturnType<typeof repos>,
state: State,
bus: Bus,
signal: AbortSignal,
onOnline: (stream: StreamInfo) => void
): Promise<void> {
const gapMs = cfg.ingest.sessionGapMinutes * 60_000;
let warnedMissingAuth = false;
const sendState = (live: boolean): void => {
bus.publish({
kind: 'stream', live,
title: state.title, category: state.category,
viewers: state.viewers, startedAt: state.startedLiveAt,
});
};
// Ein laufender Stream wird beim Start uebernommen, damit ein Neustart
// mitten im Stream keine zweite Sitzung anlegt - und keinen zweiten Ping.
const open = repo.latestOpenStream();
if (open) {
state.streamId = open.id;
state.startedLiveAt = open.started_at;
state.lastSeenLive = Date.now();
log.info(`Laufende Sitzung uebernommen: ${open.id} seit ${open.started_at}`);
}
while (!signal.aborted) {
try {
// Client-ID und Secret genuegen: `GET /users` und `GET /streams` laufen
// mit einem App-Token. Auf das verbundene Konto wartet hier niemand.
if (!helix.canReadStream()) {
if (!warnedMissingAuth) {
log.warn('Keine Twitch-App hinterlegt - Zuschauerzahlen, Sitzungen und die ' +
'Go-Live-Ankuendigung bleiben aus. Einrichtung unter /setup.');
warnedMissingAuth = true;
}
} else {
warnedMissingAuth = false;
const userId = state.broadcasterId || cfg.channel.userId ||
(await helix.userByLogin(cfg.channel.login))?.id || '';
if (userId) state.broadcasterId = userId;
const live = userId ? await helix.streamByUserId(userId) : null;
const now = Date.now();
const wechsel = streamWechsel({
live: live ? { id: live.id, startedAt: live.started_at } : null,
laufendeId: state.streamId,
zuletztLive: state.lastSeenLive,
jetzt: now,
luckeMs: gapMs,
});
if (wechsel.art === 'gestartet') {
state.streamId = wechsel.id;
repo.openStream(wechsel.id, wechsel.startedAt);
repo.event('stream.online',
{ title: live!.title, category: live!.game_name }, wechsel.id);
log.info(`Stream online: "${live!.title}" (${live!.game_name}), ` +
`gestartet ${wechsel.startedAt}`);
onOnline({
id: wechsel.id,
title: live!.title,
category: live!.game_name,
login: cfg.channel.login,
startedAt: wechsel.startedAt,
thumbnailUrl: live!.thumbnail_url,
});
} else if (wechsel.art === 'fortsetzung') {
log.info(`Neue Twitch-Stream-ID ${wechsel.neueId}, behandle als Fortsetzung ` +
`von ${state.streamId} - keine zweite Ankuendigung`);
} else if (wechsel.art === 'beendet') {
repo.closeStream(wechsel.id, new Date().toISOString());
repo.event('stream.offline', {}, wechsel.id);
log.info(`Stream offline: ${wechsel.id}`);
state.streamId = null;
state.viewers = 0;
state.startedLiveAt = null;
sendState(false);
}
if (live) {
state.title = live.title;
state.category = live.game_name;
state.startedLiveAt = live.started_at;
state.lastSeenLive = now;
state.viewers = live.viewer_count;
if (state.streamId) {
repo.sample(state.streamId, new Date().toISOString(), live.viewer_count);
}
sendState(true);
}
}
} catch (err) {
state.lastError = (err as Error).message;
log.error(`Abfrage fehlgeschlagen: ${(err as Error).message}`);
}
try { await sleep(cfg.ingest.viewerPollSeconds * 1000, signal); } catch { return; }
}
}
+186
View File
@@ -0,0 +1,186 @@
// Hier entscheidet sich, ob fremder Text im Stream vorgelesen wird. Was hier
// durchrutscht, hoert das Publikum - und die Aufzeichnung behaelt es.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
ausEventSub, createDrossel, createWarteschlange, entscheiden, planFuer,
type Einloesung, type RewardsConfig,
} from './rewards.ts';
const CFG: RewardsConfig = {
enabled: true,
belohnungen: [
{ titel: 'Konfetti', aktion: 'konfetti' },
{ titel: 'Vorlesen', aktion: 'tts' },
{ id: 'abc-123', aktion: 'emote-regen' },
],
tts: { freigabe: true, maxZeichen: 200, blockliste: ['heilhitler'], proPersonProStunde: 3 },
};
function einloesung(over: Partial<Einloesung> = {}): Einloesung {
return {
id: 'r1', rewardId: 'xyz', rewardTitle: 'Vorlesen', userId: 'u1', userName: 'Kevin',
text: 'moin zusammen', kosten: 500, ts: '2026-09-17T20:00:00Z', ...over,
};
}
// ---------------------------------------------------------------- Zuordnung
test('die ID gewinnt gegen den Titel', () => {
// Der Titel kann sich aendern, die ID nicht. Wer beides hinterlegt, meint
// die ID.
const p = planFuer({ rewardId: 'abc-123', rewardTitle: 'Konfetti' }, CFG.belohnungen);
assert.equal(p?.aktion, 'emote-regen');
});
test('der Titel wird ohne Ruecksicht auf Gross-/Kleinschreibung gefunden', () => {
assert.equal(planFuer({ rewardId: 'x', rewardTitle: 'kOnFeTtI' }, CFG.belohnungen)?.aktion,
'konfetti');
});
test('eine unbekannte Belohnung ist kein Fehler', () => {
// Abdu darf Belohnungen anlegen, die mit dem Toolkit nichts zu tun haben.
const e = entscheiden(einloesung({ rewardTitle: 'Nachricht anheften' }), CFG);
assert.equal(e.art, 'unbekannt');
});
// ---------------------------------------------------------------- Einfache Aktionen
test('Konfetti laeuft sofort, ohne Freigabe', () => {
const e = entscheiden(einloesung({ rewardTitle: 'Konfetti', text: '' }), CFG);
assert.deepEqual(e, { art: 'ausfuehren', aktion: 'konfetti', plan: CFG.belohnungen[0] });
});
test('der Kill-Switch haelt alles an', () => {
const e = entscheiden(einloesung({ rewardTitle: 'Konfetti' }), { ...CFG, enabled: false });
assert.equal(e.art, 'abgelehnt');
});
// ---------------------------------------------------------------- TTS
test('mit Freigabe geht Text in die Warteschlange, nicht ins Bild', () => {
const e = entscheiden(einloesung(), CFG);
assert.deepEqual(e, { art: 'warteschlange', text: 'moin zusammen' });
});
test('ohne Freigabe wird sofort vorgelesen', () => {
const e = entscheiden(einloesung(), { ...CFG, tts: { ...CFG.tts, freigabe: false } });
assert.deepEqual(e, { art: 'vorlesen', text: 'moin zusammen' });
});
test('die Einstellung an der Belohnung schlaegt die allgemeine', () => {
const cfg: RewardsConfig = {
...CFG,
belohnungen: [{ titel: 'Vorlesen', aktion: 'tts', freigabe: false }],
tts: { ...CFG.tts, freigabe: true },
};
assert.equal(entscheiden(einloesung(), cfg).art, 'vorlesen');
});
test('zu langer Text wird abgelehnt, nicht abgeschnitten', () => {
// Abgeschnitten waere schlimmer: dann wird ein halber Satz vorgelesen, und
// niemand weiss, ob der Rest Absicht war.
const e = entscheiden(einloesung({ text: 'a'.repeat(201) }), CFG);
assert.equal(e.art, 'abgelehnt');
assert.match((e as { grund: string }).grund, /zu lang/);
});
test('leerer Text wird abgelehnt', () => {
assert.equal(entscheiden(einloesung({ text: ' ' }), CFG).art, 'abgelehnt');
});
test('der Wortfilter greift unabhaengig von Gross-/Kleinschreibung', () => {
const e = entscheiden(einloesung({ text: 'guck mal HeilHitler lol' }), CFG);
assert.equal(e.art, 'abgelehnt');
assert.equal((e as { grund: string }).grund, 'Wortfilter');
});
test('der Ablehnungsgrund verraet den gefilterten Text nicht', () => {
// Der Grund landet im Protokoll und moeglicherweise im Chat. Er darf nicht
// wiederholen, was gerade herausgefiltert wurde.
const e = entscheiden(einloesung({ text: 'heilhitler' }), CFG);
assert.equal((e as { grund: string }).grund.includes('heilhitler'), false);
});
test('wer zu oft einloest, wird gebremst', () => {
assert.equal(entscheiden(einloesung(), CFG, 2).art, 'warteschlange');
assert.equal(entscheiden(einloesung(), CFG, 3).art, 'abgelehnt');
});
// ---------------------------------------------------------------- Warteschlange
test('eingereiht, entnommen, weg', () => {
const w = createWarteschlange();
w.einreihen({ id: 'a', name: 'Kevin', text: 'moin', rewardId: 'r', redemptionId: 'x', seit: '' });
assert.equal(w.liste().length, 1);
assert.equal(w.entnehmen('a')?.text, 'moin');
assert.equal(w.liste().length, 0);
assert.equal(w.entnehmen('a'), undefined, 'zweimal entnehmen geht nicht');
});
test('eine volle Warteschlange wirft das Aelteste raus', () => {
// Eine Schlange, die bei einem Ansturm auf 300 waechst, arbeitet niemand ab.
const w = createWarteschlange(3);
for (const id of ['a', 'b', 'c', 'd']) {
w.einreihen({ id, name: 'x', text: id, rewardId: 'r', redemptionId: id, seit: '' });
}
assert.deepEqual(w.liste().map((x) => x.id), ['b', 'c', 'd']);
});
test('leeren gibt alles zurueck, damit die Punkte erstattet werden koennen', () => {
const w = createWarteschlange();
w.einreihen({ id: 'a', name: 'x', text: '1', rewardId: 'r', redemptionId: 'a', seit: '' });
w.einreihen({ id: 'b', name: 'y', text: '2', rewardId: 'r', redemptionId: 'b', seit: '' });
assert.equal(w.leeren().length, 2);
assert.equal(w.liste().length, 0);
});
// ---------------------------------------------------------------- Drossel
test('die Drossel zaehlt nur das gleitende Fenster', () => {
const d = createDrossel(3_600_000);
const t = Date.parse('2026-09-17T20:00:00Z');
d.merken('u1', t);
d.merken('u1', t + 60_000);
assert.equal(d.zaehlen('u1', t + 120_000), 2);
// Zwei Stunden spaeter ist beides aus dem Fenster gefallen.
assert.equal(d.zaehlen('u1', t + 2 * 3_600_000), 0);
});
test('die Drossel trennt nach Person', () => {
const d = createDrossel();
d.merken('u1');
assert.equal(d.zaehlen('u2'), 0);
});
// ---------------------------------------------------------------- EventSub
test('das EventSub-Ereignis wird in unsere Form gebracht', () => {
const e = ausEventSub({
id: 'red-1',
user_id: '42',
user_name: 'Kevin',
user_input: 'lies das vor',
redeemed_at: '2026-09-17T20:00:00Z',
reward: { id: 'rw-9', title: 'Vorlesen', cost: 500 },
});
assert.equal(e?.id, 'red-1');
assert.equal(e?.rewardId, 'rw-9');
assert.equal(e?.rewardTitle, 'Vorlesen');
assert.equal(e?.text, 'lies das vor');
assert.equal(e?.kosten, 500);
});
test('eine Belohnung ohne Texteingabe hat einen leeren Text, nicht "undefined"', () => {
// Ohne das stuende im Bild woertlich "undefined".
const e = ausEventSub({ id: 'x', user_id: '1', reward: { id: 'r' } });
assert.equal(e?.text, '');
assert.equal(e?.userName, '');
});
test('unvollstaendige Ereignisse werden verworfen', () => {
assert.equal(ausEventSub({}), null);
assert.equal(ausEventSub({ id: 'x' }), null);
assert.equal(ausEventSub({ id: 'x', user_id: '1' }), null, 'ohne Belohnung keine Aktion');
});
+203
View File
@@ -0,0 +1,203 @@
// Was bei einer Kanalpunkt-Einloesung passiert.
//
// Der Weg: EventSub meldet die Einloesung -> hier wird sie einer Aktion aus
// der Konfiguration zugeordnet -> die Aktion laeuft, oder sie landet in der
// Warteschlange und wartet auf Abdus Klick.
//
// Zur Zuordnung: Belohnungen werden ueber ihren Titel gefunden, wahlweise
// ueber die ID. Der Titel ist lesbar und laesst sich in der Konfiguration
// wiedererkennen, kann sich aber aendern; die ID ist stabil, aber
// nichtssagend. Deshalb beides, und die ID gewinnt.
//
// Warum TTS eine Warteschlange hat, steht in docs/projects/chat-control.md:
// Kanalpunkte sammeln sich beim Zugucken von allein an und sind damit kaum
// eine Huerde. Wer beliebigen Fremdtext ungeprueft vorlesen laesst, hat eine
// Vorlesefunktion fuer Fremde im eigenen Stream.
import { logger } from '#core/logger.ts';
const log = logger('rewards');
/** Eine Einloesung, so weit sie hier gebraucht wird. */
export type Einloesung = {
id: string;
rewardId: string;
rewardTitle: string;
userId: string;
userName: string;
/** Der eingegebene Text. Leer, wenn die Belohnung keinen verlangt. */
text: string;
kosten: number;
ts: string;
};
export type AktionsArt = 'konfetti' | 'emote-regen' | 'einblendung' | 'tts' | 'nichts';
export type BelohnungsPlan = {
/** Titel der Belohnung auf Twitch. Gross-/Kleinschreibung egal. */
titel?: string;
/** Stabile Belohnungs-ID. Gewinnt gegen den Titel. */
id?: string;
aktion: AktionsArt;
/** Nur bei `tts`: ohne Freigabe wird sofort vorgelesen. */
freigabe?: boolean;
/** Nur bei `tts`: laengerer Text wird abgelehnt statt gekuerzt. */
maxZeichen?: number;
};
export type RewardsConfig = {
enabled: boolean;
belohnungen: BelohnungsPlan[];
tts: {
/** Vorgabe fuer `freigabe`, wenn eine Belohnung nichts anderes sagt. */
freigabe: boolean;
maxZeichen: number;
/** Woerter, die den Text ohne Nachfrage ablehnen. */
blockliste: string[];
/** So viele Einloesungen je Person und Stunde. */
proPersonProStunde: number;
};
};
/** Was mit einer Einloesung geschehen soll. Rein, damit pruefbar. */
export type Entscheidung =
| { art: 'ausfuehren'; aktion: Exclude<AktionsArt, 'tts' | 'nichts'>; plan: BelohnungsPlan }
| { art: 'vorlesen'; text: string }
| { art: 'warteschlange'; text: string }
| { art: 'abgelehnt'; grund: string }
| { art: 'unbekannt' };
/** Findet den Plan zu einer Einloesung. ID gewinnt, sonst der Titel. */
export function planFuer(
e: Pick<Einloesung, 'rewardId' | 'rewardTitle'>, plaene: BelohnungsPlan[],
): BelohnungsPlan | undefined {
return plaene.find((p) => p.id && p.id === e.rewardId)
?? plaene.find((p) => p.titel && p.titel.toLowerCase() === e.rewardTitle.toLowerCase());
}
export function entscheiden(
e: Einloesung,
cfg: RewardsConfig,
/** Wie oft diese Person in der letzten Stunde schon eingeloest hat. */
bisherigeDieseStunde = 0,
): Entscheidung {
if (!cfg.enabled) return { art: 'abgelehnt', grund: 'Belohnungs-Aktionen sind ausgeschaltet' };
const plan = planFuer(e, cfg.belohnungen);
// Unbekannte Belohnung heisst nicht "Fehler": Abdu darf Belohnungen anlegen,
// die mit dem Toolkit nichts zu tun haben. Sie werden protokolliert und
// sonst in Ruhe gelassen.
if (!plan) return { art: 'unbekannt' };
if (plan.aktion === 'nichts') return { art: 'unbekannt' };
if (plan.aktion !== 'tts') {
return { art: 'ausfuehren', aktion: plan.aktion, plan };
}
// ---- ab hier nur noch TTS
const text = e.text.trim();
if (!text) return { art: 'abgelehnt', grund: 'kein Text eingegeben' };
const max = plan.maxZeichen ?? cfg.tts.maxZeichen;
if (text.length > max) {
// Abschneiden waere schlimmer: dann wird ein halber Satz vorgelesen, und
// niemand weiss, ob der Rest Absicht war.
return { art: 'abgelehnt', grund: `zu lang (${text.length} von ${max} Zeichen)` };
}
const klein = text.toLowerCase();
const treffer = cfg.tts.blockliste.find((w) => w && klein.includes(w.toLowerCase()));
if (treffer) return { art: 'abgelehnt', grund: 'Wortfilter' };
if (bisherigeDieseStunde >= cfg.tts.proPersonProStunde) {
return { art: 'abgelehnt', grund: 'zu viele Einloesungen in der letzten Stunde' };
}
const freigabe = plan.freigabe ?? cfg.tts.freigabe;
return freigabe ? { art: 'warteschlange', text } : { art: 'vorlesen', text };
}
// ---------------------------------------------------------------- Warteschlange
export type Wartend = {
id: string;
name: string;
text: string;
/** Fuer die Erstattung, falls abgelehnt wird. */
rewardId: string;
redemptionId: string;
seit: string;
};
export type Warteschlange = {
einreihen: (w: Wartend) => void;
/** Gibt den Eintrag zurueck, damit der Aufrufer ihn vorlesen lassen kann. */
entnehmen: (id: string) => Wartend | undefined;
liste: () => Wartend[];
leeren: () => Wartend[];
};
/**
* Wartende Nachrichten. Bewusst im Arbeitsspeicher: was beim Neustart
* verloren geht, war ohnehin nicht dringend, und eine Tabelle mit fremdem
* Text, die niemand aufraeumt, will hier niemand.
*/
export function createWarteschlange(maximal = 25): Warteschlange {
const wartend: Wartend[] = [];
return {
einreihen(w) {
wartend.push(w);
// Laeuft sie voll, faellt das Aelteste raus. Eine Warteschlange, die
// bei einem Ansturm auf 300 waechst, arbeitet Abdu nie ab.
while (wartend.length > maximal) {
const weg = wartend.shift();
if (weg) log.warn(`Warteschlange voll, aelteste Nachricht verworfen (${weg.name})`);
}
},
entnehmen(id) {
const i = wartend.findIndex((w) => w.id === id);
return i === -1 ? undefined : wartend.splice(i, 1)[0];
},
liste: () => [...wartend],
leeren: () => wartend.splice(0, wartend.length),
};
}
/**
* Zaehlt Einloesungen je Person in einem gleitenden Fenster.
* Braucht keine Datenbank - nach einem Neustart faengt das Fenster neu an,
* und das ist bei einer Stunde Fensterbreite egal.
*/
export function createDrossel(fensterMs = 3_600_000) {
const gesehen = new Map<string, number[]>();
return {
zaehlen(userId: string, jetzt = Date.now()): number {
const bisher = (gesehen.get(userId) ?? []).filter((t) => jetzt - t < fensterMs);
gesehen.set(userId, bisher);
return bisher.length;
},
merken(userId: string, jetzt = Date.now()): void {
const bisher = (gesehen.get(userId) ?? []).filter((t) => jetzt - t < fensterMs);
bisher.push(jetzt);
gesehen.set(userId, bisher);
},
};
}
/** Das EventSub-Ereignis in unsere Form bringen. */
export function ausEventSub(event: Record<string, unknown>): Einloesung | null {
const reward = event['reward'] as { id?: string; title?: string; cost?: number } | undefined;
const id = event['id'];
const userId = event['user_id'];
if (typeof id !== 'string' || typeof userId !== 'string' || !reward?.id) return null;
return {
id,
rewardId: reward.id,
rewardTitle: reward.title ?? '',
userId,
userName: String(event['user_name'] ?? event['user_login'] ?? ''),
text: String(event['user_input'] ?? ''),
kosten: Number(reward.cost ?? 0),
ts: String(event['redeemed_at'] ?? new Date().toISOString()),
};
}
+68 -27
View File
@@ -16,6 +16,8 @@ import { isWindows } from '#core/platform.ts';
import { helix } from '#twitch/helix.ts'; import { helix } from '#twitch/helix.ts';
import { createIrc } from '#twitch/irc.ts'; import { createIrc } from '#twitch/irc.ts';
import { readSecrets } from '#twitch/secrets.ts'; import { readSecrets } from '#twitch/secrets.ts';
import { streamWechsel } from '#twitch/stream-state.ts';
import { createAnnouncer, type StreamInfo } from '../../broadcaster/src/golive.ts';
import { createClipper } from './clips.ts'; import { createClipper } from './clips.ts';
import { createRenderer } from './render.ts'; import { createRenderer } from './render.ts';
import { createSpikeDetector } from './spike.ts'; import { createSpikeDetector } from './spike.ts';
@@ -42,6 +44,7 @@ export async function runIngest(): Promise<void> {
messages: 0, messages: 0,
spikeCandidates: 0, spikeCandidates: 0,
clipsTriggered: 0, clipsTriggered: 0,
goLive: 'noch nichts' as string,
lastError: null as string | null, lastError: null as string | null,
}; };
@@ -111,6 +114,20 @@ export async function runIngest(): Promise<void> {
onRawReady: (job) => renderer.enqueue(job), onRawReady: (job) => renderer.enqueue(job),
}); });
// ---- Go-Live-Ankuendigung auf Discord
const announcer = createAnnouncer({
cfg: {
enabled: cfg.broadcaster.enabled,
mode: cfg.broadcaster.mode,
delaySeconds: cfg.broadcaster.delaySecondsAfterOnline,
cooldownHours: cfg.broadcaster.cooldownHours,
pingRole: cfg.broadcaster.pingRole,
obsScreenshot: cfg.broadcaster.obsScreenshot,
},
repo,
obs,
});
startServer({ startServer({
channel: cfg.channel.login, channel: cfg.channel.login,
port: cfg.dashboard.port, port: cfg.dashboard.port,
@@ -118,6 +135,7 @@ export async function runIngest(): Promise<void> {
...state, ...state,
db: repo.stats(), db: repo.stats(),
discordConnected: bot ? bot.isConnected() : false, discordConnected: bot ? bot.isConnected() : false,
goLive: announcer.lastResult() ?? state.goLive,
spikeArmed: spikes.isArmed(), spikeArmed: spikes.isArmed(),
clipsWaitingForFile: clipper.pendingCount(), clipsWaitingForFile: clipper.pendingCount(),
renderQueue: renderer.pending(), renderQueue: renderer.pending(),
@@ -177,7 +195,10 @@ export async function runIngest(): Promise<void> {
if (bot) void bot.run(abort.signal); if (bot) void bot.run(abort.signal);
// ---- Zuschauerzahlen und Stream-Sessions // ---- Zuschauerzahlen und Stream-Sessions
void pollStream(cfg, repo, state, abort.signal); void pollStream(cfg, repo, state, abort.signal, (stream) => {
state.goLive = 'laeuft';
announcer.announce(stream);
});
log.info(`Ingest laeuft fuer #${cfg.channel.login}`); log.info(`Ingest laeuft fuer #${cfg.channel.login}`);
await new Promise<void>((resolve) => abort.signal.addEventListener('abort', () => resolve())); await new Promise<void>((resolve) => abort.signal.addEventListener('abort', () => resolve()));
@@ -185,14 +206,16 @@ export async function runIngest(): Promise<void> {
type State = { type State = {
streamId: string | null; broadcasterId: string | null; streamId: string | null; broadcasterId: string | null;
lastSeenLive: number; viewers: number; lastError: string | null; lastSeenLive: number; viewers: number; goLive: string; lastError: string | null;
}; };
async function pollStream( async function pollStream(
cfg: ReturnType<typeof loadConfig>, cfg: ReturnType<typeof loadConfig>,
repo: ReturnType<typeof repos>, repo: ReturnType<typeof repos>,
state: State, state: State,
signal: AbortSignal signal: AbortSignal,
/** Genau einmal je neu erkanntem Stream. Fortsetzungen zaehlen nicht. */
onOnline: (stream: StreamInfo) => void
): Promise<void> { ): Promise<void> {
const gapMs = cfg.ingest.sessionGapMinutes * 60_000; const gapMs = cfg.ingest.sessionGapMinutes * 60_000;
let warnedMissingAuth = false; let warnedMissingAuth = false;
@@ -208,10 +231,12 @@ async function pollStream(
while (!signal.aborted) { while (!signal.aborted) {
try { try {
if (!readSecrets().tokens.broadcaster) { // Client-ID und Secret genuegen: `GET /users` und `GET /streams` laufen
// mit einem App-Token, auch ohne verbundenes Konto.
if (!helix.canReadStream()) {
if (!warnedMissingAuth) { if (!warnedMissingAuth) {
log.warn('Kein Broadcaster-Token - Zuschauerzahlen und Stream-Sitzungen bleiben aus. ' + log.warn('Keine Twitch-App hinterlegt - Zuschauerzahlen und Stream-Sitzungen ' +
'Einrichtung unter /setup.'); 'bleiben aus. Einrichtung unter /setup.');
warnedMissingAuth = true; warnedMissingAuth = true;
} }
} else { } else {
@@ -223,33 +248,49 @@ async function pollStream(
const live = userId ? await helix.streamByUserId(userId) : null; const live = userId ? await helix.streamByUserId(userId) : null;
const now = Date.now(); const now = Date.now();
// Die Entscheidung "neuer Stream oder Fortsetzung" steht in
// #twitch/stream-state.ts und ist dort geprueft. Sie haengt daran,
// ob ein zweiter Rundruf rausgeht.
const wechsel = streamWechsel({
live: live ? { id: live.id, startedAt: live.started_at } : null,
laufendeId: state.streamId,
zuletztLive: state.lastSeenLive,
jetzt: now,
luckeMs: gapMs,
});
if (wechsel.art === 'gestartet') {
state.streamId = wechsel.id;
repo.openStream(wechsel.id, wechsel.startedAt);
repo.event('stream.online',
{ title: live!.title, category: live!.game_name }, wechsel.id);
log.info(`Stream online: "${live!.title}" (${live!.game_name}), ` +
`gestartet ${wechsel.startedAt}`);
onOnline({
id: wechsel.id,
title: live!.title,
category: live!.game_name,
login: cfg.channel.login,
startedAt: wechsel.startedAt,
thumbnailUrl: live!.thumbnail_url,
});
} else if (wechsel.art === 'fortsetzung') {
log.info(`Neue Twitch-Stream-ID ${wechsel.neueId}, behandle als Fortsetzung ` +
`von ${state.streamId}`);
} else if (wechsel.art === 'beendet') {
repo.closeStream(wechsel.id, new Date().toISOString());
repo.event('stream.offline', {}, wechsel.id);
log.info(`Stream offline: ${wechsel.id}`);
state.streamId = null;
state.viewers = 0;
}
if (live) { if (live) {
if (state.streamId !== live.id) {
// Kurze Unterbrechungen gelten als dieselbe Sitzung, sonst
// zerfaellt ein Stream bei jedem Aussetzer in Fragmente.
const wasRecent = now - state.lastSeenLive < gapMs;
if (state.streamId && wasRecent) {
log.info(`Neue Twitch-Stream-ID ${live.id}, behandle als Fortsetzung von ${state.streamId}`);
} else {
state.streamId = live.id;
repo.openStream(live.id, live.started_at);
repo.event('stream.online',
{ title: live.title, category: live.game_name }, live.id);
log.info(`Stream online: "${live.title}" (${live.game_name})`);
}
}
state.lastSeenLive = now; state.lastSeenLive = now;
state.viewers = live.viewer_count; state.viewers = live.viewer_count;
if (state.streamId) { if (state.streamId) {
repo.sample(state.streamId, new Date().toISOString(), live.viewer_count); repo.sample(state.streamId, new Date().toISOString(), live.viewer_count);
} }
} else if (state.streamId && now - state.lastSeenLive > gapMs) {
const ended = new Date().toISOString();
repo.closeStream(state.streamId, ended);
repo.event('stream.offline', {}, state.streamId);
log.info(`Stream offline: ${state.streamId}`);
state.streamId = null;
state.viewers = 0;
} }
} }
} catch (err) { } catch (err) {
+31 -5
View File
@@ -59,18 +59,45 @@ async function handle(
req: http.IncomingMessage, res: http.ServerResponse, deps: ServerDeps req: http.IncomingMessage, res: http.ServerResponse, deps: ServerDeps
): Promise<void> { ): Promise<void> {
const url = new URL(req.url ?? '/', `http://localhost:${deps.port}`); const url = new URL(req.url ?? '/', `http://localhost:${deps.port}`);
// Auf dem Stream-PC ist die Einrichtung das Einzige, was dieser Server
// kann - also faengt die Wurzel dorthin. Im Hub steht auf / die
// Overlay-Uebersicht, deshalb liegt die Weiche hier und nicht in
// `setupRoutes`.
if (url.pathname === '/') {
res.writeHead(303, { location: '/setup' });
res.end();
return;
}
if (await setupRoutes(req, res, deps, url)) return;
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
res.end('Nicht gefunden');
}
/**
* Die Routen der Einrichtung, ohne 404 am Ende.
*
* Ausgelagert, weil sie seit dem zentralen Betrieb an zwei Servern haengen:
* hier auf dem Stream-PC und im Overlay-Server des Hubs. Der Rueckgabewert
* sagt, ob die Anfrage beantwortet wurde - wer sie einbindet, macht sonst
* selbst weiter.
*/
export async function setupRoutes(
req: http.IncomingMessage, res: http.ServerResponse, deps: ServerDeps, url: URL
): Promise<boolean> {
const html = (body: string, code = 200) => { const html = (body: string, code = 200) => {
res.writeHead(code, { 'content-type': 'text/html; charset=utf-8', res.writeHead(code, { 'content-type': 'text/html; charset=utf-8',
'cache-control': 'no-store', 'referrer-policy': 'no-referrer' }); 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' });
res.end(body); res.end(body);
return true;
}; };
const back = (kind: 'ok' | 'err', text: string) => { const back = (kind: 'ok' | 'err', text: string) => {
res.writeHead(303, { location: `/setup?${kind}=${encodeURIComponent(text)}` }); res.writeHead(303, { location: `/setup?${kind}=${encodeURIComponent(text)}` });
res.end(); res.end();
return true;
}; };
// ---- Einrichtungsseite // ---- Einrichtungsseite
if (url.pathname === '/' || url.pathname === '/setup') { if (url.pathname === '/setup') {
const ok = url.searchParams.get('ok'); const ok = url.searchParams.get('ok');
const err = url.searchParams.get('err'); const err = url.searchParams.get('err');
return html(setupPage(secretStatus(), { return html(setupPage(secretStatus(), {
@@ -119,7 +146,7 @@ async function handle(
try { try {
res.writeHead(303, { location: authorizeUrl(role, deps.port) }); res.writeHead(303, { location: authorizeUrl(role, deps.port) });
res.end(); res.end();
return; return true;
} catch (err) { } catch (err) {
return back('err', (err as Error).message); return back('err', (err as Error).message);
} }
@@ -144,9 +171,8 @@ async function handle(
if (url.pathname === '/health') { if (url.pathname === '/health') {
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' }); res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
res.end(JSON.stringify(deps.health(), null, 2)); res.end(JSON.stringify(deps.health(), null, 2));
return; return true;
} }
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }); return false;
res.end('Nicht gefunden');
} }
+1 -1
View File
@@ -19,7 +19,7 @@ function msg(text: string, userId: string, advanceMs = 0): ChatMessage {
clock += advanceMs; clock += advanceMs;
return { return {
id: `m${clock}-${userId}`, ts: new Date(clock).toISOString(), userId, 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('!'), isFirst: false, isReturning: false, isCommand: text.startsWith('!'),
isMod: false, isSub: false, isBroadcaster: false, bits: 0, color: null, isMod: false, isSub: false, isBroadcaster: false, bits: 0, color: null,
}; };
+47 -2
View File
@@ -1,5 +1,50 @@
# @abdulive/overlay-server # @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.
+68
View File
@@ -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));
});
+184
View File
@@ -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) };
});
}
+110
View File
@@ -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);
});
+155
View File
@@ -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,
};
}
+56
View File
@@ -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 });
}
+627
View File
@@ -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">&nbsp;</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} &times; ${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>
&nbsp;·&nbsp; ${bus.clients()} verbundene Quelle(n)
${opts.setup ? '&nbsp;·&nbsp; <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
(&bdquo;ansehen&ldquo; 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">&nbsp;</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 = '&nbsp;'; 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 = '&nbsp;'; }, 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) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;' }[c]!));
}
+117
View File
@@ -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);
});
+203
View File
@@ -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;
}
}
+100 -2
View File
@@ -1,5 +1,103 @@
# @abdulive/overlay-web # @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.
+222
View File
@@ -0,0 +1,222 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>abdulive - Einblendungen</title>
<link rel="stylesheet" href="/theme.css">
<style>
/* Einblendungen laufen nacheinander, nie uebereinander. Zwei Alarme
* gleichzeitig sind unlesbar, und bei einem Massengeschenk kommen zehn
* Stueck in derselben Sekunde. */
.buehne {
position: fixed;
inset: 0;
display: grid;
place-items: center;
pointer-events: none;
}
.karte {
position: relative;
overflow: hidden;
min-width: 620px;
max-width: 1100px;
padding: 34px 54px 38px;
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 0 34px;
border-radius: 26px;
background: var(--tinte-klar);
backdrop-filter: blur(18px) saturate(150%);
border: 1px solid rgba(255, 255, 255, .14);
box-shadow: var(--schatten), var(--glanz), 0 0 90px -20px var(--ton);
/* Die Kante in der Akzentfarbe ist das, was die Einblendung dem Ereignis
* zuordnet, bevor man ein Wort gelesen hat. */
border-left: 8px solid var(--ton);
opacity: 0;
}
.karte.an { animation: herein .75s cubic-bezier(.16, 1.2, .3, 1) both; }
.karte.weg { animation: hinaus .45s cubic-bezier(.5, 0, .75, 0) both; }
@keyframes herein {
from { opacity: 0; transform: translateY(46px) scale(.9); filter: blur(8px); }
to { opacity: 1; transform: none; filter: none; }
}
@keyframes hinaus {
from { opacity: 1; transform: none; }
to { opacity: 0; transform: translateY(-34px) scale(.96); }
}
.zeichen {
font-size: 92px;
line-height: 1;
filter: drop-shadow(0 6px 18px rgba(0, 0, 0, .6));
animation: huepfen .9s cubic-bezier(.2, 1.6, .4, 1) .12s both;
}
@keyframes huepfen {
0% { transform: scale(0) rotate(-25deg); }
70% { transform: scale(1.18) rotate(6deg); }
100% { transform: scale(1) rotate(0); }
}
.kopf {
font-size: 22px;
font-weight: 600;
letter-spacing: .14em;
text-transform: uppercase;
color: var(--ton);
margin-bottom: 4px;
}
.name {
font-size: 58px;
font-weight: 700;
line-height: 1.05;
letter-spacing: -.02em;
}
.zusatz { font-size: 26px; color: var(--schrift-leise); margin-top: 6px; }
.notiz {
margin-top: 14px;
font-size: 22px;
color: var(--schrift-leise);
max-width: 44ch;
/* Zuschauertext. Drei Zeilen, dann Schluss - sonst schiebt jemand mit
* einem Roman die Karte aus dem Bild. */
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.notiz .emote { height: 1.4em; vertical-align: -.3em; }
/* Der Balken, der unter der Karte ablaeuft: zeigt, wie lange sie noch steht. */
.uhr {
position: absolute;
left: 0; right: 0; bottom: 0;
height: 4px;
background: var(--ton);
transform-origin: left;
animation: ablaufen var(--dauer) linear both;
opacity: .8;
}
@keyframes ablaufen { from { transform: scaleX(1); } to { transform: scaleX(0); } }
</style>
</head>
<body>
<div class="buehne"><div id="platz"></div></div>
<script src="/client.js"></script>
<script>
(function () {
'use strict';
var platz = document.getElementById('platz');
var dauer = Overlay.param('sekunden', 7) * 1000;
var warteschlange = [];
var laeuft = false;
/* Ein Eintrag je Art: Farbe, Zeichen, Ueberschrift und die beiden
* Textzeilen. Alles an einem Ort, damit eine neue Art eine Zeile ist. */
var ARTEN = {
sub: { ton: 'var(--lila)', zeichen: '💜', kopf: 'Neues Abo' },
resub: { ton: 'var(--lila)', zeichen: '💜', kopf: 'Abo verlängert' },
subgift: { ton: 'var(--pink)', zeichen: '🎁', kopf: 'Abo verschenkt' },
massgift: { ton: 'var(--pink)', zeichen: '🎁', kopf: 'Abos verschenkt' },
raid: { ton: 'var(--tuerkis)', zeichen: '🚀', kopf: 'Raid' },
cheer: { ton: 'var(--gold)', zeichen: '✨', kopf: 'Bits' },
follow: { ton: 'var(--gruen)', zeichen: '⭐', kopf: 'Neuer Follow' },
first: { ton: 'var(--gruen)', zeichen: '👋', kopf: 'Zum ersten Mal hier' },
clip: { ton: 'var(--tuerkis)', zeichen: '🎬', kopf: 'Clip gespeichert' },
// Eigene Art, eigene Farbe, eigene Ueberschrift. Eine eingeloeste
// Belohnung darf nicht aussehen wie ein Abo.
reward: { ton: 'var(--gold)', zeichen: '🎟️', kopf: 'Belohnung eingelöst' },
};
/** Die zweite Zeile. Deutsch mit richtigen Pluralformen - "1 Monate"
* liest sich wie ein Fehler, und das ist es auch. */
function zusatz(ev) {
switch (ev.type) {
case 'resub':
return ev.months ? ev.months + (ev.months === 1 ? ' Monat' : ' Monate') + ' dabei' : '';
case 'subgift':
return ev.recipient ? 'an ' + Overlay.esc(ev.recipient) : '';
case 'massgift':
return (ev.amount || 1) + (ev.amount === 1 ? ' Abo' : ' Abos') + ' an den Chat';
case 'raid':
return 'mit ' + Overlay.zahl(ev.amount || 0) +
((ev.amount === 1) ? ' Zuschauer' : ' Zuschauern');
case 'cheer':
return Overlay.zahl(ev.amount || 0) + ' Bits';
case 'reward':
// Der Titel der Belohnung ist die Information, nicht die Punktzahl.
return Overlay.esc(ev.reward || '') +
(ev.amount ? ' · ' + Overlay.zahl(ev.amount) + ' Punkte' : '');
default:
return '';
}
}
function zeige(ev) {
var art = ARTEN[ev.type];
if (!art) return naechste();
var karte = document.createElement('div');
karte.className = 'karte an';
karte.style.setProperty('--ton', art.ton);
karte.style.setProperty('--dauer', (dauer / 1000) + 's');
var text = zusatz(ev);
// Die Nachricht nur dort, wo Zuschauer wirklich eine schreiben.
var notiz = (ev.message && (ev.type === 'resub' || ev.type === 'cheer' ||
ev.type === 'sub' || ev.type === 'reward'))
? '<div class="notiz">' + Overlay.emoteHtml(ev.message, []) + '</div>' : '';
karte.innerHTML =
'<div class="zeichen">' + art.zeichen + '</div>' +
'<div>' +
'<div class="kopf">' + art.kopf + '</div>' +
'<div class="name kontur">' + Overlay.esc(ev.name || 'Jemand') + '</div>' +
(text ? '<div class="zusatz">' + text + '</div>' : '') +
notiz +
'</div>' +
'<div class="uhr"></div>';
// Der Lichtstreifen liegt auf der Karte, nicht im Markup - so laeuft er
// genau einmal und stoert das Raster nicht.
karte.classList.add('streifen');
platz.appendChild(karte);
setTimeout(function () {
karte.classList.remove('an');
karte.classList.add('weg');
setTimeout(function () { karte.remove(); naechste(); }, 460);
}, dauer);
}
function naechste() {
var ev = warteschlange.shift();
if (!ev) { laeuft = false; return; }
laeuft = true;
zeige(ev);
}
Overlay.on('alert', function (ev) {
// Erstchatter sind haeufig und sollen den Bildschirm nicht sprengen -
// wer sie nicht will, haengt ?erstchatter=0 an die Adresse.
if (ev.type === 'first' && !Overlay.param('erstchatter', true)) return;
warteschlange.push(ev);
// Eine Warteschlange, die bei einem Bot-Angriff auf 200 waechst, laeuft
// eine halbe Stunde lang durch. Lieber die aeltesten fallen lassen.
if (warteschlange.length > 12) warteschlange.splice(0, warteschlange.length - 12);
if (!laeuft) naechste();
});
// Nach dem Neuverbinden NICHT die alten Alarme nachspielen: ein Abo von
// vor zehn Minuten noch einmal einzublenden ist schlimmer als es zu
// verpassen. Der Schnappschuss wird hier bewusst ignoriert.
Overlay.on('state', function () { });
})();
</script>
</body>
</html>
+196
View File
@@ -0,0 +1,196 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>abdulive - Chat</title>
<link rel="stylesheet" href="/theme.css">
<style>
/* Von unten nach oben, wie im Chat selbst: die neueste Nachricht steht
* unten, wo das Auge sie erwartet. Deshalb column-reverse und nicht ein
* Scrollcontainer - der haette einen Balken und wuerde mitwandern. */
#liste {
position: fixed;
inset: 0;
padding: 12px 14px;
display: flex;
flex-direction: column-reverse;
justify-content: flex-start;
gap: 8px;
overflow: hidden;
}
.zeile {
padding: 9px 14px 10px;
border-radius: 12px;
background: rgba(18, 14, 29, .66);
backdrop-filter: blur(10px);
box-shadow: 0 6px 20px rgba(0, 0, 0, .45);
font-size: 19px;
line-height: 1.38;
word-break: break-word;
animation: herein .42s cubic-bezier(.16, 1.1, .3, 1) both;
transition: opacity .6s, transform .6s;
}
@keyframes herein {
from { opacity: 0; transform: translateX(-26px) scale(.97); }
to { opacity: 1; transform: none; }
}
.zeile.geht { opacity: 0; transform: translateX(-20px); }
.name { font-weight: 700; margin-right: .45em; }
.text .emote { height: 1.55em; vertical-align: -.4em; margin: 0 .05em; }
/* Ein GIF ist kein Emote: es steht fuer sich, in eigener Zeile, und wird
* begrenzt. Ohne Deckel schiebt ein 500 Pixel breites GIF die ganze Spalte
* auf - in einer 460 Pixel schmalen Browser-Source faellt das sofort auf. */
.text .gif {
display: block;
margin: 6px 0 2px;
max-width: 100%;
max-height: 180px;
width: auto;
border-radius: 8px;
background: rgba(0, 0, 0, .35);
}
/* Abzeichen statt Bilder: Twitchs Abzeichen-URLs haengen am Kanal und muessten
* ueber Helix aufgeloest werden. Ein farbiger Punkt sagt dasselbe. */
.abzeichen {
display: inline-block;
width: .5em; height: .5em;
border-radius: 50%;
margin-right: .4em;
vertical-align: .08em;
}
.abzeichen.mod { background: var(--gruen); }
.abzeichen.abo { background: var(--lila); }
.abzeichen.host { background: var(--rot); }
/* Wer zum ersten Mal schreibt, wird hervorgehoben - das ist der Moment, in
* dem eine Begruessung am meisten bringt. Ohne den Balken an der linken Kante
* tragen das jetzt der Farbverlauf und die Zeile darueber allein, deshalb ist
* der Verlauf kraeftiger als vorher. */
.zeile.erstmals {
background: linear-gradient(90deg, rgba(74, 222, 128, .24), rgba(18, 14, 29, .66) 65%);
}
.zeile.erstmals::before {
content: "zum ersten Mal hier";
display: block;
font-size: 12px;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--gruen);
margin-bottom: 3px;
}
.zeile.bits { background: linear-gradient(90deg, rgba(255, 197, 61, .22), rgba(18, 14, 29, .66) 65%); }
.zeile.bits .name { color: var(--gold) !important; }
</style>
</head>
<body>
<div id="liste"></div>
<script src="/client.js"></script>
<script>
(function () {
'use strict';
var liste = document.getElementById('liste');
var maximal = Overlay.param('max', 14);
var lebensdauer = Overlay.param('sekunden', 120) * 1000;
var befehleZeigen = Overlay.param('befehle', false);
/* Twitchs Farben werden auf dunklem Grund teils unlesbar - dunkelblau auf
* Schwarz ist nicht zu entziffern. Deshalb wird die Helligkeit angehoben
* statt die Farbe zu verwerfen: die Farbe ist Wiedererkennung. */
function lesbar(hex) {
if (!hex || !/^#[0-9a-f]{6}$/i.test(hex)) return '#c9a7ff';
var r = parseInt(hex.slice(1, 3), 16), g = parseInt(hex.slice(3, 5), 16),
b = parseInt(hex.slice(5, 7), 16);
var hell = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
if (hell >= 0.5) return hex;
var f = 0.5 / Math.max(hell, 0.08);
var an = function (v) { return Math.min(255, Math.round(v * f)); };
return '#' + [an(r), an(g), an(b)].map(function (v) {
return v.toString(16).padStart(2, '0');
}).join('');
}
function entferne(el) {
// Doppelt aufrufbar: das Zeitlimit und das Nachruecken koennen dieselbe
// Zeile treffen.
if (!el || !el.parentNode || el.classList.contains('geht')) return;
el.classList.add('geht');
setTimeout(function () { el.remove(); }, 620);
}
/**
* Zeilen, die ueber die Obergrenze hinausgehen, ausblenden.
*
* Gezaehlt wird nur, was noch nicht auf dem Weg nach draussen ist: eine
* ausblendende Zeile haengt noch eine halbe Sekunde im DOM. Wer stattdessen
* `liste.children.length` zaehlt und in einer while-Schleife entfernt,
* bekommt eine Endlosschleife - die Laenge sinkt ja erst nach der
* Animation. In OBS friert die Quelle damit beim vierzehnten Chatbeitrag
* ein, und im Browser sieht man einen haengenden Tab.
*/
function aufraeumen() {
var bleibend = [];
var zeilen = liste.children;
for (var i = 0; i < zeilen.length; i++) {
if (!zeilen[i].classList.contains('geht')) bleibend.push(zeilen[i]);
}
// index 0 ist die neueste Zeile, also faellt das Ende zuerst.
for (var j = maximal; j < bleibend.length; j++) entferne(bleibend[j]);
}
function zeige(ev) {
if (!befehleZeigen && ev.text.charAt(0) === '!') return;
var el = document.createElement('div');
el.className = 'zeile' + (ev.first ? ' erstmals' : '') + (ev.bits > 0 ? ' bits' : '');
el.dataset.id = ev.id;
el.dataset.user = ev.userId;
var abzeichen = '';
if (ev.broadcaster) abzeichen += '<span class="abzeichen host"></span>';
else if (ev.mod) abzeichen += '<span class="abzeichen mod"></span>';
if (ev.sub) abzeichen += '<span class="abzeichen abo"></span>';
el.innerHTML =
abzeichen +
'<span class="name" style="color:' + lesbar(ev.color) + '">' +
Overlay.esc(ev.name) + '</span>' +
'<span class="text">' + Overlay.nachrichtHtml(ev.text, ev.gifs, ev.emotes) + '</span>';
// prepend, weil column-reverse die Liste ohnehin umdreht.
liste.insertBefore(el, liste.firstChild);
aufraeumen();
if (lebensdauer > 0) setTimeout(function () { entferne(el); }, lebensdauer);
}
Overlay.on('chat', zeige);
// Loeschen durch einen Mod muss durchschlagen: eine geloeschte Beleidigung,
// die noch zwei Minuten im Bild steht, steht auch in der Aufzeichnung.
Overlay.on('clear', function (ev) {
var alle = Array.prototype.slice.call(liste.children);
for (var i = 0; i < alle.length; i++) {
var el = alle[i];
if (!ev.messageId && !ev.userId) entferne(el);
else if (ev.messageId && el.dataset.id === ev.messageId) entferne(el);
else if (ev.userId && el.dataset.user === ev.userId) entferne(el);
}
});
// Nach dem Neuverbinden den letzten Stand zeigen, damit die Quelle nicht
// leer im Bild steht, bis jemand schreibt.
Overlay.on('state', function (ev) {
liste.innerHTML = '';
var letzte = ev.chat.slice(-maximal);
for (var i = 0; i < letzte.length; i++) zeige(letzte[i]);
});
})();
</script>
</body>
</html>
+168
View File
@@ -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 { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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<img class="emote" src="' + src + '" alt="' + 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 += '<img class="gif" src="' + window.Overlay.esc(g.url) + '" alt="GIF"' +
' onerror="this.replaceWith(document.createTextNode(this.alt))">';
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();
});
})();
+194
View File
@@ -0,0 +1,194 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>abdulive - Effekte</title>
<link rel="stylesheet" href="/theme.css">
<style>
/* Ein Canvas fuer alles. Hunderte DOM-Knoten, die sich pro Bild bewegen,
* kosten in OBS spuerbar Leistung - und Leistung, die hier draufgeht, fehlt
* beim Encoder. */
canvas { position: fixed; inset: 0; width: 100%; height: 100%; display: block; }
/* Der kurze Lichtschein bei einer Chat-Spitze. Liegt hinter den Teilchen und
* ist das, was den Effekt "teuer" wirken laesst. */
#schein {
position: fixed;
inset: 0;
pointer-events: none;
opacity: 0;
background: radial-gradient(ellipse at 50% 60%,
rgba(169, 112, 255, .32), transparent 62%);
}
#schein.an { animation: blitz 1.6s ease-out both; }
@keyframes blitz {
0% { opacity: 0; }
12% { opacity: 1; }
100% { opacity: 0; }
}
</style>
</head>
<body>
<div id="schein"></div>
<canvas id="bild"></canvas>
<script src="/client.js"></script>
<script>
(function () {
'use strict';
var canvas = document.getElementById('bild');
var ctx = canvas.getContext('2d');
var schein = document.getElementById('schein');
var teilchen = [];
var bilder = {}; // Emote-ID -> Image
var letzteEmotes = []; // was gerade im Chat benutzt wird
var laeuft = false;
/** Obergrenze. Ein Raid mit 200 Leuten darf den Encoder nicht umbringen. */
var MAX = Overlay.param('max', 260);
function passeAn() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
passeAn();
window.addEventListener('resize', passeAn);
function bild(id) {
if (bilder[id]) return bilder[id];
var img = new Image();
img.crossOrigin = 'anonymous';
img.src = 'https://static-cdn.jtvnw.net/emoticons/v2/' +
encodeURIComponent(id) + '/default/dark/3.0';
bilder[id] = img;
return img;
}
/** Emote-Regen: faellt von oben, dreht sich leicht, verblasst unten. */
function regen(anzahl) {
var quellen = letzteEmotes.slice(-12);
for (var i = 0; i < anzahl && teilchen.length < MAX; i++) {
var e = quellen.length ? quellen[(Math.random() * quellen.length) | 0] : null;
teilchen.push({
art: e ? 'emote' : 'punkt',
img: e ? bild(e) : null,
farbe: farbe(),
x: Math.random() * canvas.width,
y: -60 - Math.random() * canvas.height * 0.5,
vx: (Math.random() - 0.5) * 0.8,
vy: 2.2 + Math.random() * 3.4,
g: 0.02,
dreh: (Math.random() - 0.5) * 0.06,
winkel: (Math.random() - 0.5) * 0.7,
groesse: 34 + Math.random() * 30,
leben: 1,
zerfall: 0.0016 + Math.random() * 0.0016,
});
}
starte();
}
var PALETTE = ['#a970ff', '#4fd1c5', '#ff4fa3', '#ffc53d', '#4ade80'];
function farbe() { return PALETTE[(Math.random() * PALETTE.length) | 0]; }
/** Konfetti: schiesst aus der unteren Mitte nach oben und faellt zurueck. */
function konfetti(anzahl) {
for (var i = 0; i < anzahl && teilchen.length < MAX; i++) {
var winkel = -Math.PI / 2 + (Math.random() - 0.5) * 1.5;
var tempo = 9 + Math.random() * 11;
teilchen.push({
art: 'schnipsel',
farbe: farbe(),
x: canvas.width / 2 + (Math.random() - 0.5) * 340,
y: canvas.height + 14,
vx: Math.cos(winkel) * tempo,
vy: Math.sin(winkel) * tempo,
g: 0.28,
dreh: (Math.random() - 0.5) * 0.4,
winkel: Math.random() * Math.PI,
groesse: 9 + Math.random() * 9,
leben: 1,
zerfall: 0.0055 + Math.random() * 0.004,
});
}
starte();
}
function starte() {
if (laeuft) return;
laeuft = true;
requestAnimationFrame(schritt);
}
function schritt() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (var i = teilchen.length - 1; i >= 0; i--) {
var t = teilchen[i];
t.vy += t.g;
t.x += t.vx;
t.y += t.vy;
t.winkel += t.dreh;
t.leben -= t.zerfall;
if (t.leben <= 0 || t.y > canvas.height + 120) { teilchen.splice(i, 1); continue; }
ctx.save();
ctx.globalAlpha = Math.max(0, Math.min(1, t.leben));
ctx.translate(t.x, t.y);
ctx.rotate(t.winkel);
if (t.art === 'emote' && t.img && t.img.complete && t.img.naturalWidth) {
ctx.drawImage(t.img, -t.groesse / 2, -t.groesse / 2, t.groesse, t.groesse);
} else if (t.art === 'schnipsel') {
ctx.fillStyle = t.farbe;
// Schmales Rechteck statt Quadrat: sieht in Drehung nach Papier aus.
ctx.fillRect(-t.groesse / 2, -t.groesse / 4, t.groesse, t.groesse / 2);
} else {
ctx.fillStyle = t.farbe;
ctx.beginPath();
ctx.arc(0, 0, t.groesse / 4, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
if (teilchen.length) requestAnimationFrame(schritt);
else { laeuft = false; ctx.clearRect(0, 0, canvas.width, canvas.height); }
}
// ---- Ausloeser
Overlay.on('chat', function (ev) {
for (var i = 0; i < ev.emotes.length; i++) {
if (letzteEmotes.indexOf(ev.emotes[i].id) === -1) letzteEmotes.push(ev.emotes[i].id);
}
if (letzteEmotes.length > 24) letzteEmotes.splice(0, letzteEmotes.length - 24);
});
Overlay.on('hype', function (ev) {
schein.classList.remove('an');
void schein.offsetWidth;
schein.classList.add('an');
// Je hoeher die Punktzahl, desto mehr - aber gedeckelt, sonst steht bei
// einem echten Ausbruch das Bild voll und man sieht das Spiel nicht mehr.
regen(Math.min(110, 34 + Math.round((ev.score || 1) * 9)));
});
// Konfetti als eigenes Ereignis: welche Belohnung Konfetti ausloest,
// entscheidet der Hub - diese Seite muss davon nichts wissen.
Overlay.on('konfetti', function (ev) {
konfetti(Math.round(90 * (ev.staerke || 1)));
});
Overlay.on('alert', function (ev) {
if (ev.type === 'raid') konfetti(Math.min(180, 70 + (ev.amount || 0) * 2));
else if (ev.type === 'massgift') konfetti(Math.min(180, 60 + (ev.amount || 0) * 10));
else if (ev.type === 'sub' || ev.type === 'resub' || ev.type === 'subgift') konfetti(80);
else if (ev.type === 'cheer' && (ev.amount || 0) >= 100) konfetti(60);
});
})();
</script>
</body>
</html>
+156
View File
@@ -0,0 +1,156 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>abdulive - Pausenbild</title>
<link rel="stylesheet" href="/theme.css">
<style>
/* Das einzige Overlay mit deckendem Hintergrund: es ersetzt das Bild, statt
* darueber zu liegen. Gedacht als eigene OBS-Szene fuer "gleich geht es los",
* Pause und Ende. Text ueber ?text= und ?unter=. */
body { background: #0a0813; }
#grund {
position: fixed;
inset: -20%;
background:
radial-gradient(42% 42% at 22% 28%, rgba(169, 112, 255, .55), transparent 70%),
radial-gradient(38% 38% at 78% 32%, rgba(79, 209, 197, .45), transparent 70%),
radial-gradient(45% 45% at 55% 82%, rgba(255, 79, 163, .40), transparent 70%);
filter: blur(48px);
animation: treiben 28s ease-in-out infinite alternate;
}
@keyframes treiben {
0% { transform: translate(0, 0) scale(1) rotate(0deg); }
50% { transform: translate(-3%, 2.5%) scale(1.1) rotate(4deg); }
100% { transform: translate(3%, -2%) scale(1.04) rotate(-3deg); }
}
/* Feines Raster darueber: nimmt dem Verlauf das Billige und gibt dem Bild
* eine Struktur, die auch nach der Twitch-Kompression noch da ist. */
#raster {
position: fixed;
inset: 0;
background-image:
linear-gradient(rgba(255, 255, 255, .045) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, .045) 1px, transparent 1px);
background-size: 64px 64px;
mask-image: radial-gradient(70% 70% at 50% 50%, #000, transparent);
}
#mitte {
position: fixed;
inset: 0;
display: grid;
place-content: center;
justify-items: center;
text-align: center;
gap: 26px;
}
h1 {
margin: 0;
font-size: clamp(64px, 8.5vw, 148px);
font-weight: 800;
letter-spacing: -.035em;
/* Unterlaengen brauchen Platz. `background-clip: text` faerbt nur, was
* innerhalb der Polsterbox liegt - was darueber hinausragt, bleibt
* durchsichtig und sieht abgeschnitten aus. Bei fetter Schrift in dieser
* Groesse reicht eine knappe Zeilenhoehe dafuer nicht.
*
* Nachgemessen am "g" in "Gleich geht es los": mit 1.04 und .06em fehlte
* noch ein Stueck vom Bogen. */
line-height: 1.18;
padding-bottom: .14em;
background: linear-gradient(100deg, #fff 18%, var(--lila) 48%, var(--tuerkis) 78%, #fff 96%);
background-size: 260% 100%;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: schimmern 7s linear infinite;
filter: drop-shadow(0 10px 40px rgba(0, 0, 0, .55));
}
@keyframes schimmern { to { background-position: 260% 0; } }
#unter {
font-size: clamp(20px, 2vw, 30px);
color: var(--schrift-leise);
max-width: 34ch;
}
/* Der Taktgeber: fuenf Balken, die atmen. Ohne bewegtes Element wirkt ein
* Standbild wie ein eingefrorener Stream, und Zuschauer laden neu. */
#takt { display: flex; gap: 12px; align-items: flex-end; height: 68px; margin-top: 10px; }
#takt i {
display: block;
width: 13px;
border-radius: 5px;
background: linear-gradient(180deg, var(--tuerkis), var(--lila));
animation: atmen 1.5s ease-in-out infinite;
}
#takt i:nth-child(1) { height: 40%; animation-delay: 0s; }
#takt i:nth-child(2) { height: 75%; animation-delay: .18s; }
#takt i:nth-child(3) { height: 100%; animation-delay: .36s; }
#takt i:nth-child(4) { height: 65%; animation-delay: .54s; }
#takt i:nth-child(5) { height: 35%; animation-delay: .72s; }
@keyframes atmen {
0%, 100% { transform: scaleY(.35); opacity: .6; }
50% { transform: scaleY(1); opacity: 1; }
}
#fuss {
position: fixed;
left: 0; right: 0; bottom: 42px;
display: flex;
justify-content: center;
gap: 44px;
font-size: 22px;
color: var(--schrift-leise);
}
#fuss b { color: var(--schrift); font-weight: 600; }
#uhr { font-variant-numeric: tabular-nums; }
</style>
</head>
<body>
<div id="grund"></div>
<div id="raster"></div>
<div id="mitte">
<h1 id="gross">Gleich geht es los</h1>
<div id="unter"></div>
<div id="takt"><i></i><i></i><i></i><i></i><i></i></div>
</div>
<div id="fuss">
<span id="uhr"></span>
<span>twitch.tv/<b id="kanal">abdulive_</b></span>
<span id="thema"></span>
</div>
<script src="/client.js"></script>
<script>
(function () {
'use strict';
document.getElementById('gross').textContent = Overlay.param('text', 'Gleich geht es los');
document.getElementById('unter').textContent = Overlay.param('unter', '');
document.getElementById('kanal').textContent = Overlay.param('kanal', 'abdulive_');
var uhr = document.getElementById('uhr');
function zeit() {
uhr.textContent = new Date().toLocaleTimeString('de-DE',
{ hour: '2-digit', minute: '2-digit' });
}
zeit();
setInterval(zeit, 10000);
// Laeuft der Stream schon, steht unten, worum es geht. Das ist der
// Unterschied zwischen einem Pausenbild und einem Standbild.
var thema = document.getElementById('thema');
function stand(s) {
thema.textContent = s.live && s.category ? 'heute: ' + s.category : '';
}
Overlay.on('stream', stand);
Overlay.on('state', function (ev) { stand(ev.stream); });
})();
</script>
</body>
</html>
+141
View File
@@ -0,0 +1,141 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>abdulive - Statusleiste</title>
<link rel="stylesheet" href="/theme.css">
<style>
#leiste {
position: fixed;
left: 28px; right: 28px; top: 22px;
height: 92px;
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 28px;
padding: 0 30px;
border-radius: 20px;
background: var(--tinte-klar);
backdrop-filter: blur(16px) saturate(150%);
border: 1px solid rgba(255, 255, 255, .1);
box-shadow: var(--schatten), var(--glanz);
/* Ein Farbverlauf an der Oberkante statt eines Rahmens: er bindet die
* Leiste an die Marke, ohne einen Kasten um alles zu ziehen. */
overflow: hidden;
}
#leiste::before {
content: "";
position: absolute;
left: 0; right: 0; top: 0;
height: 3px;
background: linear-gradient(90deg, var(--lila), var(--pink), var(--tuerkis), var(--lila));
background-size: 300% 100%;
animation: wandern 9s linear infinite;
}
@keyframes wandern { to { background-position: 300% 0; } }
.marke { display: flex; align-items: center; gap: 12px; }
.punkt {
width: 13px; height: 13px;
border-radius: 50%;
background: var(--rot);
box-shadow: 0 0 0 0 rgba(239, 68, 68, .6);
}
body.live .punkt { background: var(--rot); animation: klopfen 2s ease-out infinite; }
body:not(.live) .punkt { background: #555; }
@keyframes klopfen {
0% { box-shadow: 0 0 0 0 rgba(239, 68, 68, .55); }
70% { box-shadow: 0 0 0 16px rgba(239, 68, 68, 0); }
100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); }
}
.kanal { font-size: 26px; font-weight: 700; letter-spacing: -.01em; }
.mitte { min-width: 0; }
#titel {
font-size: 25px; font-weight: 600;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
#kategorie { font-size: 17px; color: var(--schrift-leise); margin-top: 2px; }
.werte { display: flex; gap: 30px; }
.wert { text-align: right; min-width: 92px; }
.wert .zahl {
font-size: 30px; font-weight: 700;
font-variant-numeric: tabular-nums;
line-height: 1.1;
display: block;
}
.wert .beschriftung {
font-size: 12px; letter-spacing: .14em; text-transform: uppercase;
color: var(--schrift-leise);
}
/* Kurzes Aufleuchten, wenn sich eine Zahl aendert. Ohne das uebersieht man
* jede Veraenderung, weil die Leiste sonst voellig still steht. */
.zahl.neu { animation: aufleuchten .8s ease-out; }
@keyframes aufleuchten {
0% { color: var(--tuerkis); transform: translateY(-3px); }
100% { color: inherit; transform: none; }
}
</style>
</head>
<body>
<div id="leiste">
<div class="marke">
<span class="punkt"></span>
<span class="kanal kontur" id="kanal">abdulive_</span>
</div>
<div class="mitte">
<div id="titel" class="kontur">nicht live</div>
<div id="kategorie"></div>
</div>
<div class="werte">
<div class="wert"><span class="zahl" id="zuschauer">-</span>
<span class="beschriftung">Zuschauer</span></div>
<div class="wert"><span class="zahl" id="laufzeit">-</span>
<span class="beschriftung">live seit</span></div>
</div>
</div>
<script src="/client.js"></script>
<script>
(function () {
'use strict';
var titel = document.getElementById('titel');
var kategorie = document.getElementById('kategorie');
var zuschauer = document.getElementById('zuschauer');
var laufzeit = document.getElementById('laufzeit');
var kanal = document.getElementById('kanal');
var seit = null;
kanal.textContent = Overlay.param('kanal', 'abdulive_');
function setze(el, wert) {
if (el.textContent === wert) return;
el.textContent = wert;
el.classList.remove('neu');
// Neuzeichnen erzwingen, sonst startet die Animation beim zweiten Mal nicht.
void el.offsetWidth;
el.classList.add('neu');
}
function stand(ev) {
document.body.classList.toggle('live', !!ev.live);
titel.textContent = ev.live ? (ev.title || 'ohne Titel') : 'nicht live';
kategorie.textContent = ev.live ? (ev.category || '') : '';
setze(zuschauer, ev.live ? Overlay.zahl(ev.viewers) : '-');
seit = ev.live ? ev.startedAt : null;
tick();
}
function tick() {
laufzeit.textContent = seit ? Overlay.laufzeit(seit) : '-';
}
setInterval(tick, 10000);
Overlay.on('stream', stand);
Overlay.on('state', function (ev) { stand(ev.stream); });
})();
</script>
</body>
</html>
+122
View File
@@ -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. */
+157
View File
@@ -0,0 +1,157 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>abdulive - Vorlesen</title>
<link rel="stylesheet" href="/theme.css">
<style>
/* Die Seite zeigt fast nichts. Sie ist eine Tonquelle mit einer kleinen
* Sichtkontrolle daneben - man soll sehen, was gerade gesprochen wird, und
* zwar auch als Zuschauer. Wer nur hoert, was vorgelesen wird, kann nicht
* nachlesen, wenn er es nicht verstanden hat. */
#kasten {
position: fixed;
left: 16px; right: 16px; bottom: 16px;
padding: 14px 18px;
border-radius: 14px;
background: var(--tinte-klar);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, .1);
box-shadow: var(--schatten), var(--glanz);
opacity: 0;
transform: translateY(14px);
transition: opacity .3s, transform .3s;
}
#kasten.an { opacity: 1; transform: none; }
.wer {
font-size: 13px;
letter-spacing: .14em;
text-transform: uppercase;
color: var(--tuerkis);
margin-bottom: 3px;
}
.was { font-size: 22px; line-height: 1.35; }
/* Ein Hinweis nur fuer den Fall, dass die Browser-Quelle keine Sprachausgabe
* kann. Im Stream soll das auffallen, nicht stillschweigend fehlen. */
#fehlt {
position: fixed;
left: 16px; top: 16px;
padding: 10px 14px;
border-radius: 10px;
background: rgba(239, 68, 68, .9);
font: 600 15px/1.3 var(--font-zahl);
max-width: 520px;
display: none;
}
</style>
</head>
<body>
<div id="fehlt">
Diese Browser-Quelle kann nicht sprechen (keine Sprachausgabe eingebaut).
Vorgelesen wird nichts.
</div>
<div id="kasten"><div class="wer" id="wer"></div><div class="was" id="was"></div></div>
<script src="/client.js"></script>
<script>
(function () {
'use strict';
var kasten = document.getElementById('kasten');
var werEl = document.getElementById('wer');
var wasEl = document.getElementById('was');
var sprache = Overlay.param('sprache', 'de-DE');
var tempo = Overlay.param('tempo', 1);
var lautstaerke = Overlay.param('laut', 1);
var warteschlange = [];
var laeuft = false;
/* Ob die Browser-Quelle ueberhaupt sprechen kann, weiss man erst zur
* Laufzeit: OBS' eingebauter Browser wird teils ohne Sprachausgabe gebaut.
* Ohne diese Pruefung bliebe es im Stream einfach still, und niemand wuesste
* warum. */
var kannSprechen = typeof window.speechSynthesis !== 'undefined' &&
typeof window.SpeechSynthesisUtterance !== 'undefined';
if (!kannSprechen) {
document.getElementById('fehlt').style.display = 'block';
console.error('[tts] Diese Browser-Quelle hat keine Sprachausgabe.');
}
/* Stimmen werden asynchron geladen. Beim ersten Aufruf ist die Liste oft
* noch leer - deshalb bei jedem Sprechen neu nachsehen statt einmal am
* Anfang. */
function stimme() {
if (!kannSprechen) return null;
var alle = window.speechSynthesis.getVoices() || [];
var passend = alle.filter(function (v) { return v.lang && v.lang.indexOf(sprache.slice(0, 2)) === 0; });
return passend[0] || alle[0] || null;
}
function zeige(name, text) {
werEl.textContent = name;
wasEl.textContent = text;
kasten.classList.add('an');
}
function verstecke() { kasten.classList.remove('an'); }
function naechste() {
var n = warteschlange.shift();
if (!n) { laeuft = false; verstecke(); return; }
laeuft = true;
zeige(n.name, n.text);
if (!kannSprechen) {
// Wenigstens anzeigen, und nach der geschaetzten Lesedauer weiter.
setTimeout(naechste, Math.min(15000, 2000 + n.text.length * 60));
return;
}
var rede = new SpeechSynthesisUtterance(n.text);
rede.lang = sprache;
rede.rate = tempo;
rede.volume = lautstaerke;
var v = stimme();
if (v) rede.voice = v;
var weiter = false;
var gehWeiter = function () {
if (weiter) return;
weiter = true;
setTimeout(naechste, 400);
};
rede.onend = gehWeiter;
rede.onerror = function (e) {
console.error('[tts] Sprachausgabe fehlgeschlagen', e && e.error);
gehWeiter();
};
// Notbremse: bleibt die Sprachausgabe haengen, steht die Warteschlange
// sonst fuer den Rest des Streams. Grosszuegig bemessen.
setTimeout(gehWeiter, 5000 + n.text.length * 250);
window.speechSynthesis.speak(rede);
}
Overlay.on('tts', function (ev) {
warteschlange.push({ name: ev.name, text: ev.text });
if (warteschlange.length > 10) warteschlange.splice(0, warteschlange.length - 10);
if (!laeuft) naechste();
});
// Panikschalter: sofort still, Warteschlange weg.
Overlay.on('still', function () {
warteschlange.length = 0;
if (kannSprechen) { try { window.speechSynthesis.cancel(); } catch (e) { /* egal */ } }
laeuft = false;
verstecke();
});
// Nach dem Neuverbinden NICHT nachholen: eine Nachricht von vor zehn
// Minuten vorzulesen ist schlimmer, als sie zu verpassen.
Overlay.on('state', function () { });
})();
</script>
</body>
</html>
+61 -3
View File
@@ -70,16 +70,30 @@ clipper:
# Der Hotkey wird in OBS auf "Wiedergabepuffer speichern" gelegt. Es gibt # Der Hotkey wird in OBS auf "Wiedergabepuffer speichern" gelegt. Es gibt
# bewusst keinen eigenen Hotkey-Dienst - siehe ADR-0004, Punkt 4. # bewusst keinen eigenen Hotkey-Dienst - siehe ADR-0004, Punkt 4.
# Go-Live-Ankuendigung auf Discord.
#
# mode: live -> Post in #ankuendigungen, mit Ping an die Rolle Live-Ping
# draft -> derselbe Post in #technik-log, ohne Ping. Zum Zusehen.
#
# Zum Ausprobieren, ohne auf einen Stream zu warten:
# abdulive golive:test (interner Kanal, ohne Ping)
# abdulive golive:test --echt (echter Kanal, mit Ping)
broadcaster: broadcaster:
enabled: true enabled: true
mode: draft # draft | live mode: live
# Twitch setzt Titel und Kategorie erst kurz nach dem Start. Wer sofort
# postet, kuendigt regelmaessig den Titel des letzten Streams an.
delaySecondsAfterOnline: 60 delaySecondsAfterOnline: 60
# Innerhalb dieser Zeit wird kein zweites Mal angekuendigt. Faengt den Fall
# ab, dass Twitch nach einem Aussetzer eine neue Stream-ID vergibt.
cooldownHours: 6 cooldownHours: 6
pingRole: Live-Ping # leer = Post ohne Ping
obsScreenshot: true # Vorschaubild aus OBS statt von Twitch
platforms: platforms:
discord: true discord: true
bluesky: true bluesky: false # noch nicht gebaut
mastodon: false mastodon: false
instagramStoryImage: true instagramStoryImage: false
community: community:
greetings: greetings:
@@ -109,8 +123,52 @@ recap:
loyalty: loyalty:
enabled: false # spaetere Ausbaustufe enabled: false # spaetere Ausbaustufe
# Kanalpunkt-Belohnungen und was sie ausloesen.
#
# Zugeordnet wird ueber den Titel der Belohnung auf Twitch (Gross-/Klein-
# schreibung egal) oder ueber die stabile ID; liegt beides vor, gewinnt die ID.
# Belohnungen, die hier nicht stehen, werden protokolliert und sonst in Ruhe
# gelassen - nicht jede Belohnung geht das Toolkit etwas an.
#
# Aktionen: konfetti | emote-regen | einblendung | tts | nichts
rewards:
enabled: false # aus, bis hier bewusst Belohnungen stehen
belohnungen: []
# Beispiel:
# belohnungen:
# - titel: Konfetti
# aktion: konfetti
# - titel: Nachricht vorlesen
# aktion: tts
# maxZeichen: 150
tts:
# Vorgabe ist die Freigabe von Hand, und das aus gutem Grund: Kanalpunkte
# sammeln sich beim Zugucken von allein an und sind damit kaum eine
# Huerde. Auf `false` gestellt hat man eine Vorlesefunktion fuer Fremde
# im eigenen Stream. Siehe docs/projects/chat-control.md.
freigabe: true
maxZeichen: 200
# Zu langer Text wird abgelehnt, nicht gekuerzt - ein halb vorgelesener
# Satz ist schlimmer als gar keiner.
blockliste: []
proPersonProStunde: 3
# Overlay-Server. Zugleich die einzige Tuer nach aussen, wenn der Dienst
# zentral laeuft: Uebersicht, Einrichtung und OAuth-Rueckruf haengen daran.
overlay: overlay:
port: 4791 port: 4791
enabled: true
# Oeffentliche Adresse, ohne Schraegstrich am Ende.
#
# Leer = der Dienst laeuft lokal und bindet an 127.0.0.1. Ist etwas gesetzt,
# bindet er an 0.0.0.0 (der Reverse-Proxy sitzt in einem anderen Container)
# und der OAuth-Rueckruf von Twitch geht an <publicUrl>/callback - genau so
# muss er auch in der Twitch-App hinterlegt sein.
#
# Die Umgebungsvariable PUBLIC_URL gewinnt gegen diesen Wert.
publicUrl: ""
dashboard: dashboard:
port: 4792 port: 4792
ingestServer: ingestServer:
@@ -0,0 +1,100 @@
# ADR-0008: Geteilter Betrieb - Hub im LXC, Ingest am Stream-PC
Datum: 2026-09-17
Status: akzeptiert
Berührt: [ADR-0005](ADR-0005-zugangsdaten.md), [ADR-0006](ADR-0006-ein-dach.md),
[ADR-0007](ADR-0007-native-windows.md)
## Kontext
Bis hierher lief alles in einem Prozess auf Abdus Rechner (ADR-0007), und
alles band an `127.0.0.1` (ADR-0005). Das war richtig, solange jede Aufgabe
etwas mit OBS zu tun hatte.
Zwei neue Aufgaben haben damit aber nichts zu tun:
- **Der Rollen-Knopf auf Discord.** Er hängt an einer Gateway-Verbindung im
Ingest-Daemon. Ist Abdus Rechner aus, klickt jemand auf den Knopf und
bekommt "Interaktion fehlgeschlagen". Ein Knopf, der nur läuft, während
der Besitzer am Rechner sitzt, ist kein Knopf.
- **Die Go-Live-Ankündigung.** Sie braucht Twitch und Discord, sonst nichts.
Dazu kam das Overlay. Eine Browser-Source in OBS holt ihre Seite über HTTP;
ob die von `127.0.0.1` kommt oder aus dem Netz, ist ihr gleich.
## Entscheidung
Das Toolkit läuft ab jetzt an zwei Stellen, mit einem klaren Schnitt:
| | Hub (LXC 141, `abdu.dimension47.de`) | Ingest (Abdus PC) |
|---|---|---|
| Befehl | `abdulive hub` | `abdulive ingest` |
| Twitch-Chat lesen und senden | ja | ja |
| Zuschauerzahlen, Stream-Sitzungen | ja | ja |
| Discord: Rollen-Knopf, Go-Live | **ja** | nein |
| Overlay-Seiten ausliefern | **ja** | nein |
| OBS, Wiedergabepuffer, `!clip` | nein | **ja** |
| Rendern, Untertitel | nein | **ja** |
Der Schnitt verläuft entlang einer einzigen Frage: **braucht die Aufgabe eine
Datei oder ein Programm, das nur auf dem Stream-PC existiert?** Alles, was mit
Nein zu beantworten ist, gehört in den Hub.
Der Hub ist damit der einzige Teil, der von außen erreichbar ist. Er bindet
auf `0.0.0.0`, hinter dem vorhandenen Nginx Proxy Manager, und bringt Overlay,
Einrichtungsseite und OAuth-Rückruf unter einer Adresse zusammen.
## Begründung
**Warum nicht alles zentral.** Der Wiedergabepuffer von OBS liegt als Datei
auf Abdus Platte, ffmpeg und whisper arbeiten darauf, und ein Clip ist
mehrere hundert Megabyte. Das über das Netz zu schieben wäre teurer als es
bringt, und obs-websocket gehört ohnehin nicht ins Internet.
**Warum nicht alles lokal bleiben.** Genau die zwei Dinge, die Reichweite
bringen sollen - der Ping und der Knopf - sind die, die durchlaufen müssen.
Sie an den Stream-PC zu hängen heißt, sie an die Verfügbarkeit einer Person zu
hängen.
**Warum zwei Datenbanken und nicht eine.** Beide Seiten schreiben in ihre
eigene SQLite-Datei. Eine gemeinsame wäre eine Netzwerkfreigabe für SQLite,
und das ist der zuverlässigste bekannte Weg zu einer beschädigten Datei. Was
zusammengeführt werden muss, wird später über Ereignisse zusammengeführt, nicht
über geteilten Dateizugriff.
**Warum der Bruch mit ADR-0005 vertretbar ist.** Dort war die Begründung: was
nur auf `127.0.0.1` hört, ist gegen alles geschützt, was nicht vor dem Rechner
sitzt. Diese Eigenschaft geht verloren, und der Schutz wandert auf zwei Dinge:
- Passwort mit Sitzungs-Cookie für Übersicht, Einrichtung und Status.
- Ein zufälliger Schlüssel in der Adresse jeder Browser-Source. Er darf nur
lesen, nie schreiben, und lässt sich auf der Übersichtsseite neu würfeln.
Der Schlüssel in der URL ist kein schönes Verfahren. Er steht in jedem
OBS-Profil und in jedem Screenshot der Einstellungen. Er ist trotzdem der
richtige: eine Browser-Source kann kein Formular ausfüllen, keinen Login
durchhalten und keinen zweiten Faktor bedienen. Jedes Verfahren, das mehr
verlangt, wird spätestens beim ersten Ausfall mitten im Stream abgeschaltet.
**Warum die Zugangsdaten trotzdem verschlüsselt neben der Datenbank liegen.**
Daran ändert sich nichts. Der Hub hat seinen eigenen Speicher, mit eigenem
Schlüssel, und die Twitch-Apps brauchen jetzt eine zweite Redirect-URI
(`https://abdu.dimension47.de/callback`) neben der bisherigen.
## Konsequenzen
- Die Twitch-App braucht eine zweite Redirect-URI. Twitch vergleicht
zeichengenau; ein fehlender Schrägstrich sieht im Fehlertext aus wie ein
falsches Client-Secret.
- Der Discord-Bot darf nur an **einer** Stelle laufen. Laufen Hub und Ingest
beide mit hinterlegtem Bot-Token, antworten zwei Prozesse auf denselben
Klick. Auf dem Stream-PC bleibt das Discord-Token deshalb leer.
- `abdulive ingest` verliert nichts, macht aber weniger. Der Go-Live-Post ist
dort weiterhin eingebaut - wer nur lokal fährt, hat damit alles an einer
Stelle, und dort gibt es zusätzlich das OBS-Vorschaubild.
- Der Hub kennt OBS nicht und kann deshalb keinen Screenshot für die
Ankündigung machen. Er nimmt Twitchs Vorschaubild. Das ist kurz nach dem
Start manchmal noch schwarz - der Preis dafür, dass der Post auch dann
rausgeht, wenn der Daemon am Stream-PC nicht läuft.
- ADR-0006 ("ein Dach") gilt weiter, aber jetzt je Seite: der Hub hat ein
Dach, der Stream-PC hat eins.
+1
View File
@@ -9,6 +9,7 @@
| [0005](ADR-0005-zugangsdaten.md) | Zugangsdaten ueber Einrichtungs-Assistent statt .env | akzeptiert | | [0005](ADR-0005-zugangsdaten.md) | Zugangsdaten ueber Einrichtungs-Assistent statt .env | akzeptiert |
| [0006](ADR-0006-ein-dach.md) | Ein Dach: lokale Weboberflaeche, lokale Arbeitsprozesse | akzeptiert | | [0006](ADR-0006-ein-dach.md) | Ein Dach: lokale Weboberflaeche, lokale Arbeitsprozesse | akzeptiert |
| [0007](ADR-0007-native-windows.md) | Native Windows-Anwendung statt Container | akzeptiert | | [0007](ADR-0007-native-windows.md) | Native Windows-Anwendung statt Container | akzeptiert |
| [0008](ADR-0008-geteilter-betrieb.md) | Geteilter Betrieb: Hub im LXC, Ingest am Stream-PC | akzeptiert |
Neue Entscheidung: Datei `ADR-<nummer>-<kurzname>.md`, Aufbau Kontext / Neue Entscheidung: Datei `ADR-<nummer>-<kurzname>.md`, Aufbau Kontext /
Entscheidung / Begruendung / Konsequenzen. Entscheidung / Begruendung / Konsequenzen.
+138 -24
View File
@@ -1,6 +1,6 @@
# Übergabe # Übergabe
Stand **2026-09-15**, letztes gebautes Paket 0.5.4. Wer hier weitermacht, Stand **2026-09-17**, letztes gebautes Paket 0.5.4. Wer hier weitermacht,
liest zuerst diese Seite, dann [`07-mvp-plan.md`](07-mvp-plan.md). Für Abdu liest zuerst diese Seite, dann [`07-mvp-plan.md`](07-mvp-plan.md). Für Abdu
selbst gibt es das [Handbuch](handbuch/handbuch.pdf). selbst gibt es das [Handbuch](handbuch/handbuch.pdf).
@@ -20,9 +20,85 @@ Auslöser bis zum fertigen Short steht im Code vollständig. Geprüft ist sie
| S5 Onboarding + Sichtung | offen | | | S5 Onboarding + Sichtung | offen | |
| S6 Erstchatter-Begrüßung | offen | | | S6 Erstchatter-Begrüßung | offen | |
162 Tests, alle grün. Was noch fehlt, steht vollständig in 193 Tests, alle grün. Was noch fehlt, steht vollständig in
[`offene-punkte.md`](offene-punkte.md). [`offene-punkte.md`](offene-punkte.md).
## Neu: der Dienst läuft zentral
Seit 2026-09-17 läuft ein Teil des Toolkits nicht mehr auf Abdus Rechner,
sondern in einem LXC auf dem Proxmox-Host. Der Schnitt und seine Begründung
stehen in [ADR-0008](decisions/ADR-0008-geteilter-betrieb.md); die Kurzfassung
ist: alles, was nur Internet braucht, gehört dorthin, wo durchgehend Strom
ist.
| | Hub (LXC 141, `192.168.2.15`) | Ingest (Abdus PC) |
|---|---|---|
| Befehl | `abdulive hub` | `abdulive ingest` |
| Chat, Zuschauerzahlen | ja | ja |
| Discord-Knopf, Go-Live-Ping | **ja** | nein |
| Overlay für OBS | **ja** | nein |
| OBS, `!clip`, Rendern | nein | **ja** |
Erreichbar unter `https://abdu.dimension47.de`, hinter dem vorhandenen Nginx
Proxy Manager (Proxy-Eintrag 42, eigenes Let's-Encrypt-Zertifikat, Pufferung
abgeschaltet - ohne das sammelt nginx die Overlay-Ereignisse und liefert sie
gebündelt aus). Ausgerollt wird mit `scripts/deploy-hub.sh`, gestartet von
systemd (`abdulive-hub`). Der Container hat keinen Zugang zum Repo - das
Skript schiebt den Arbeitsstand als Archiv hinüber.
Der Discord-Teil der Zugangsdaten ist übertragen, der Bot hängt am Hub und
meldet beim Start "Rollen-Knöpfe sind scharf". Die Twitch-Broadcaster-App ist
hinterlegt und geprüft (Client-ID registriert, Secret passt - Twitch stellt
darauf ein App-Token aus). **Was fehlt, ist der Klick von Abdu**, siehe unten.
**Das kostet ADR-0005 seine Hauptbegründung.** "Bindet nur auf 127.0.0.1" gilt
für den Hub nicht mehr. An seine Stelle treten ein Passwort für alles, was ein
Mensch bedient, und ein Schlüssel in der Adresse für die Browser-Sources.
## Der Go-Live-Ping geht raus
War in der letzten Übergabe noch Vorarbeit, ist jetzt gebaut
(`apps/broadcaster`). Ablauf: neuer Stream erkannt, eine Minute warten, Titel
und Kategorie frisch holen, Webhook posten, Rolle `Live-Ping` anpingen.
Der Ping funktioniert nur mit `allowed_mentions` im Nachrichtenkörper. `<@&ID>`
allein färbt die Erwähnung ein und benachrichtigt niemanden - das ist der
Fehler, den man erst nach drei leeren Streams bemerkt, und dagegen steht ein
eigener Test.
Gegen den doppelten Ping stehen drei Dinge, denn wer zweimal in zehn Minuten
angepingt wird, nimmt sich die Rolle wieder ab: die Tabelle `posts` mit einem
eindeutigen Index je Stream, eine Abklingzeit von sechs Stunden, und der
Umstand, dass der Fortsetzungszweig im Polling gar nicht erst auslöst.
Der Hub steht auf `mode: draft` - der Post geht vollständig, aber in
`#technik-log` und ohne Ping. Ein Probelauf ist dort gelandet, der Weg zum
Webhook ist damit nachgewiesen. Zum Scharfschalten: `mode: live` in
`/var/lib/abdulive/config/config.yaml`.
**Ausgelöst hat er noch nie**, und das liegt nicht am Broadcaster, sondern an
Twitch: die Erkennung des Stream-Starts hängt am Helix-Polling, und das
braucht ein Broadcaster-Token. Solange das fehlt, wartet der Ping auf ein
Ereignis, das nie kommt.
## Das Overlay steht
Fünf Browser-Sources unter `apps/overlay-web/public`, ausgeliefert vom
Overlay-Server: Einblendungen, Chat, Statusleiste, Effekte, Pausenbild. Kein
Build, kein Framework - OBS lädt die Dateien direkt.
Die Ereignisse kommen über Server-Sent Events statt WebSocket. Warum, und was
gegen die tote Browser-Source nach einem Neustart getan ist, steht in
[`apps/overlay-server/README.md`](../apps/overlay-server/README.md).
Abos, Raids und Bits kommen über IRC-USERNOTICE herein, das wird jetzt
ausgewertet. **Follows fehlen** - die gibt es nur über EventSub.
Zum Ansehen ohne Stream: auf der Übersichtsseite gibt es unter
**Ausprobieren** für jede Einblendung, jede Chat-Variante und jeden Effekt
einen Knopf. Die Ereignisse gehen nur an die Browser-Sources. Dasselbe im
Dauerbetrieb: `abdulive overlay --demo` oder der Knopf „Vorführung starten".
## Der Discord-Server steht ## Der Discord-Server steht
Dazugekommen seit der letzten Übergabe, und kein Teil des MVP: der Dazugekommen seit der letzten Übergabe, und kein Teil des MVP: der
@@ -44,10 +120,14 @@ die Kurzfassung ist, dass bei 5 bis 30 Zuschauern fünf sichtbare Kanäle besser
sind als zwanzig leere. sind als zwanzig leere.
Ein Teil davon läuft dauerhaft: der Knopf unter dem Willkommenstext, mit dem Ein Teil davon läuft dauerhaft: der Knopf unter dem Willkommenstext, mit dem
man sich die Rolle `Live-Ping` selbst gibt. Er hängt als Gateway-Verbindung im man sich die Rolle `Live-Ping` selbst gibt. Er hängt als Gateway-Verbindung mit
Ingest-Daemon mit drin und meldet sich als `discordConnected` auf der im Prozess und meldet sich als `discordConnected` auf der Statusseite. Seit dem
Statusseite. **Läuft der Daemon nicht, tut der Knopf nichts** - der Klick Umzug in den Hub läuft er durch, auch wenn Abdus Rechner aus ist.
läuft dann in Discords Zeitüberschreitung.
Dazugekommen ist ein vierter Webhook: `technikWebhook` in `#technik-log`. Dort
landet der Go-Live-Post im Entwurfsmodus - dieselbe Nachricht wie im Betrieb,
nur ohne Ping und nur für Mods sichtbar. Wer den Server vor dieser Fassung
eingerichtet hat, muss `abdulive discord:setup` einmal erneut laufen lassen.
## Was jemand wissen muss, bevor er etwas anfasst ## Was jemand wissen muss, bevor er etwas anfasst
@@ -58,9 +138,10 @@ keine Parameter-Properties im Konstruktor, keine `namespace`.
`erasableSyntaxOnly` in der `tsconfig.json` erzwingt das und hat schon einen `erasableSyntaxOnly` in der `tsconfig.json` erzwingt das und hat schon einen
echten Fehler gefangen. echten Fehler gefangen.
**Alles läuft in einem Prozess.** Schwere Arbeit (ffmpeg, whisper) läuft als **Alles läuft in einem Prozess** - aber es gibt jetzt zwei davon, Hub und
Kindprozess und darf abstürzen. Der Ingest darf nie sterben. Was er nicht Ingest (siehe oben). Schwere Arbeit (ffmpeg, whisper) läuft als Kindprozess und
mitschreibt, ist für immer weg. darf abstürzen. Der Chat-Prozess darf nie sterben. Was er nicht mitschreibt,
ist für immer weg.
**Zugangsdaten kommen aus der Weboberfläche**, nicht aus einer Datei. Sie **Zugangsdaten kommen aus der Weboberfläche**, nicht aus einer Datei. Sie
liegen verschlüsselt neben der Datenbank, aber bewusst nicht *in* ihr. Die liegen verschlüsselt neben der Datenbank, aber bewusst nicht *in* ihr. Die
@@ -97,9 +178,16 @@ Zum Entwickeln reicht ein Checkout und Node 24 oder neuer:
```powershell ```powershell
node apps/cli/main.ts check-env node apps/cli/main.ts check-env
node --test "packages/**/*.test.ts" "apps/**/*.test.ts" node --test "packages/**/*.test.ts" "apps/**/*.test.ts"
node apps/cli/main.ts overlay --demo # Overlay ohne Stream ansehen
node scripts/build-package.ts --version 0.5.5 # baut dist/ node scripts/build-package.ts --version 0.5.5 # baut dist/
``` ```
Den Hub ausrollen (braucht SSH auf den Proxmox-Host):
```bash
scripts/deploy-hub.sh
```
Typprüfung, das Einzige wofür npm gebraucht wird: Typprüfung, das Einzige wofür npm gebraucht wird:
```powershell ```powershell
@@ -214,12 +302,25 @@ oben.
## Die drei Dinge, die Abdu tun muss ## Die drei Dinge, die Abdu tun muss
1. **Broadcaster-Konto verbinden.** Die beiden Twitch-Apps 1. **Broadcaster-Konto verbinden.** Das ist jetzt der Flaschenhals: ohne
(dev.twitch.tv/console/apps, Redirect-URI `http://localhost:4792/callback`) Broadcaster-Token gibt es keine Zuschauerzahlen, keine Stream-Sitzungen und
kann auch jemand anders anlegen, sie hängen nicht am Kanal. Was nur Abdu damit auch keinen Go-Live-Ping.
kann: auf der Einrichtungsseite eingeloggt als er selbst auf „Mit Twitch
verbinden" klicken. Dafür muss die Anwendung auf seinem Rechner laufen, Die beiden Twitch-Apps (dev.twitch.tv/console/apps) kann auch jemand anders
die Seite hört nur auf `127.0.0.1`. anlegen, sie hängen nicht am Kanal. Seit dem Umzug in den Hub brauchen sie
als Redirect-URI **`https://abdu.dimension47.de/callback`**; die alte
`http://localhost:4792/callback` bleibt daneben stehen, wenn auf dem
Stream-PC weiter eingerichtet werden soll. Twitch vergleicht zeichengenau.
Die App ist inzwischen hinterlegt. Was nur Abdu kann: auf
`https://abdu.dimension47.de/setup` eingeloggt als er selbst auf „Mit
Twitch verbinden" klicken. Der Vorteil gegenüber vorher: das geht von jedem
Gerät, der Stream-PC muss nicht laufen.
Ob die Redirect-URI in der App richtig eingetragen ist, zeigt sich erst bei
diesem Klick. Twitch prüft sie erst nach dem Login und gibt vorher auch bei
einer falschen URI ein freundliches 302 zurück - von außen ist das nicht
nachweisbar.
2. **OBS einrichten**: obs-websocket an (Port 4455), Wiedergabepuffer 90 s, 2. **OBS einrichten**: obs-websocket an (Port 4455), Wiedergabepuffer 90 s,
Hotkey auf „Wiedergabepuffer speichern", getrennte Audiospuren. Hotkey auf „Wiedergabepuffer speichern", getrennte Audiospuren.
3. **Layout umstellen** nach [`obs-layout.md`](obs-layout.md), danach 3. **Layout umstellen** nach [`obs-layout.md`](obs-layout.md), danach
@@ -248,10 +349,14 @@ in der ganzen Kette, und alles Weitere hängt daran. Seit es `abdulive sources`
gibt, ist auch die Frage beantwortbar, welche OBS-Quelle fremden Text ins Bild gibt, ist auch die Frage beantwortbar, welche OBS-Quelle fremden Text ins Bild
malt. Das war vorher nicht zu erraten. malt. Das war vorher nicht zu erraten.
**Discord braucht jetzt nichts mehr.** Der Server steht, die Webhooks liegen **Den Go-Live-Ping scharf schalten.** Er steht auf `draft`. Einmal
bereit. Wer als Nächstes `apps/broadcaster` oder `apps/recap` baut, findet `abdulive golive:test --echt` laufen lassen, prüfen ob das Handy klingelt, dann
alles vor und muss nur noch posten. Das wäre auch ein guter Nebenweg, wenn der `mode: live`. Das ist der kleinste verbleibende Handgriff mit der größten
Stream-PC gerade nicht verfügbar ist, denn anders als S2 hängt daran nichts. Wirkung.
**`apps/recap` bauen.** Der Webhook liegt bereit, der Hub hat die Daten, und
anders als S2 hängt daran nichts. Guter Nebenweg, wenn der Stream-PC gerade
nicht verfügbar ist.
**Danach S5.** Nicht wegen des Codes, sondern weil das Werkzeug ohne **Danach S5.** Nicht wegen des Codes, sondern weil das Werkzeug ohne
Sichtungsseite für Abdu nicht bedienbar ist. Die Kette funktioniert, aber sie Sichtungsseite für Abdu nicht bedienbar ist. Die Kette funktioniert, aber sie
@@ -277,11 +382,20 @@ konkrete Verbesserungen stehen in [`offene-punkte.md`](offene-punkte.md).
Solange nur Abdu installiert, reicht ein Klick. Sobald jemand Drittes es Solange nur Abdu installiert, reicht ein Klick. Sobald jemand Drittes es
bekommt, nicht mehr. bekommt, nicht mehr.
**Der Discord-Knopf hängt am Daemon.** Läuft der Ingest nicht, passiert beim **Der Discord-Knopf hängt am Daemon** - das war der Punkt aus der letzten
Klick nichts, und die klickende Person sieht nur „Interaktion fehlgeschlagen". Übergabe und ist mit dem Hub erledigt. Dafür gibt es jetzt einen neuen: der
Das ist vertretbar, solange der Daemon ohnehin durchläuft, aber es ist eine Bot darf nur an **einer** Stelle laufen. Steht auf dem Stream-PC ein
stille Abhängigkeit zwischen zwei Dingen, die sonst nichts miteinander zu tun Discord-Bot-Token im Speicher und läuft dort `ingest`, antworten zwei Prozesse
haben. auf denselben Klick. Auf dem Stream-PC bleibt das Discord-Token leer.
**Der Overlay-Schlüssel steht in der Adresse.** Damit steht er in jedem
OBS-Profil und auf jedem Screenshot der Einstellungen. Neu würfeln geht auf
der Übersichtsseite, aber danach müssen alle fünf Adressen in OBS neu
eingetragen werden.
**Das Overlay hat nie unter Last gelaufen.** Geprüft ist es gegen den
Vorführmodus und im Browser. Wie es sich bei 200 Nachrichten pro Minute
verhält, und was der Emote-Regen beim Encoder kostet, ist offen.
**Der Discord-Bot ist nie unter Last gelaufen.** Getestet sind die Bausteine **Der Discord-Bot ist nie unter Last gelaufen.** Getestet sind die Bausteine
und ein Klickweg gegen einen nachgebauten Server; am echten Server ist die und ein Klickweg gegen einen nachgebauten Server; am echten Server ist die
+71 -1
View File
@@ -45,6 +45,37 @@ export type Config = {
ffmpeg: { encoder: string; preset: string; crf: number; niceness: number }; ffmpeg: { encoder: string; preset: string; crf: number; niceness: number };
whisper: { model: string; language: string; threads: number }; whisper: { model: string; language: string; threads: number };
}; };
broadcaster: {
enabled: boolean;
/** `live` postet in die Ankuendigungen, `draft` in den internen Kanal. */
mode: 'live' | 'draft';
delaySecondsAfterOnline: number;
cooldownHours: number;
/** Rollenname aus dem Discord-Bauplan. Leer heisst: Post ohne Ping. */
pingRole: string;
obsScreenshot: boolean;
};
/** Kanalpunkt-Belohnungen und was sie ausloesen. Siehe apps/hub/src/rewards.ts. */
rewards: {
enabled: boolean;
belohnungen: Array<{
titel?: string; id?: string;
aktion: 'konfetti' | 'emote-regen' | 'einblendung' | 'tts' | 'nichts';
freigabe?: boolean; maxZeichen?: number;
}>;
tts: {
freigabe: boolean; maxZeichen: number;
blockliste: string[]; proPersonProStunde: number;
};
};
overlay: { port: number; enabled: boolean };
/**
* Oeffentliche Adresse des Dienstes, ohne Schraegstrich am Ende. Nur noetig,
* wenn er nicht auf demselben Rechner wie der Browser laeuft - dann haengt
* daran der OAuth-Rueckruf von Twitch, der exakt zur eingetragenen
* Redirect-URI passen muss.
*/
publicUrl: string;
ingestServer: { port: number }; ingestServer: { port: number };
dashboard: { port: number }; dashboard: { port: number };
raw: YamlMap; raw: YamlMap;
@@ -81,6 +112,40 @@ const DEFAULTS = {
ffmpeg: { encoder: 'libx264', preset: 'veryfast', crf: 20, niceness: 15 }, ffmpeg: { encoder: 'libx264', preset: 'veryfast', crf: 20, niceness: 15 },
whisper: { model: 'ggml-small.bin', language: 'de', threads: 4 }, whisper: { model: 'ggml-small.bin', language: 'de', threads: 4 },
}, },
broadcaster: {
enabled: true,
// Vorgabe ist `live`: der Sinn der Sache ist der Ping. Wer erst zusehen
// will, stellt `draft` ein - dann landet dieselbe Nachricht ohne Ping im
// internen Kanal. Zum Ausprobieren gibt es `abdulive golive:test`.
mode: 'live' as const,
// Twitch setzt Titel und Kategorie erst kurz nach dem Start. Wer sofort
// postet, kuendigt regelmaessig den Titel des letzten Streams an.
delaySecondsAfterOnline: 60,
cooldownHours: 6,
pingRole: 'Live-Ping',
obsScreenshot: true,
},
rewards: {
// Aus, bis jemand bewusst Belohnungen eintraegt. Eine Aktion, die
// niemand erwartet, ist im Stream schlimmer als gar keine.
enabled: false,
belohnungen: [] as Array<{
titel?: string; id?: string;
aktion: 'konfetti' | 'emote-regen' | 'einblendung' | 'tts' | 'nichts';
freigabe?: boolean; maxZeichen?: number;
}>,
tts: {
// Vorgabe ist die Freigabe durch Abdu. Kanalpunkte sammeln sich beim
// Zugucken von allein an und sind damit kaum eine Huerde; ohne diesen
// Schalter hat man eine Vorlesefunktion fuer Fremde im eigenen Stream.
freigabe: true,
maxZeichen: 200,
blockliste: [] as string[],
proPersonProStunde: 3,
},
},
overlay: { port: 4791, enabled: true },
publicUrl: '',
ingestServer: { port: 4790 }, ingestServer: { port: 4790 },
dashboard: { port: 4792 }, dashboard: { port: 4792 },
}; };
@@ -105,7 +170,12 @@ export function loadConfig(): Config {
if (fs.existsSync(file)) { if (fs.existsSync(file)) {
raw = parseYaml(fs.readFileSync(file, 'utf8')); raw = parseYaml(fs.readFileSync(file, 'utf8'));
} }
cached = { ...merge(DEFAULTS, raw), raw } as Config; const merged = merge(DEFAULTS, raw) as Omit<Config, 'raw'>;
// Die Umgebung gewinnt: im Container steht die oeffentliche Adresse in der
// Unit-Datei, nicht in einer YAML, die mitversioniert wird.
if (process.env.PUBLIC_URL) merged.publicUrl = process.env.PUBLIC_URL;
merged.publicUrl = merged.publicUrl.replace(/\/+$/, '');
cached = { ...merged, raw } as Config;
return cached; return cached;
} }
+34
View File
@@ -0,0 +1,34 @@
-- Was das Toolkit nach draussen geschickt hat. Eine Zeile je Plattform und
-- Anlass.
--
-- Der Zweck ist nicht Statistik, sondern Verhinderung: Twitch schickt nach
-- einem Verbindungsabbruch ein zweites stream.online, und ohne diese Tabelle
-- bekommt der Discord-Server dann einen zweiten Rundruf. Ein Ping zu viel
-- kostet Abonnenten der Rolle, ein Ping zu wenig kostet nur einen Stream.
-- stream_id ist bewusst KEIN Fremdschluessel. Ein Post ist die Quittung fuer
-- etwas, das den Rechner verlassen hat; die muss auch dann schreibbar sein,
-- wenn es die Sitzung in `streams` nicht gibt - beim Probelauf von
-- `golive:test` etwa. Ein Fremdschluessel wuerde hier ausgerechnet das
-- Protokoll verhindern, das erklaeren soll, was passiert ist.
CREATE TABLE posts (
id TEXT PRIMARY KEY,
stream_id TEXT,
kind TEXT NOT NULL, -- golive | recap | clip
platform TEXT NOT NULL, -- discord | bluesky | mastodon | ...
mode TEXT NOT NULL, -- live | draft
created_at TEXT NOT NULL,
status TEXT NOT NULL, -- gesendet | fehlgeschlagen | entwurf
title TEXT,
category TEXT,
url TEXT, -- Nachricht auf der Zielplattform
detail TEXT -- Fehlertext, wenn es schiefging
);
-- Die Frage, die vor jedem Post gestellt wird: gab es das hier schon?
-- Nur gesendete Posts sperren; ein fehlgeschlagener Versuch darf wiederholt
-- werden, sonst kostet ein Netzwerkaussetzer die Ankuendigung des Streams.
CREATE UNIQUE INDEX idx_posts_einmal
ON posts(stream_id, kind, platform)
WHERE stream_id IS NOT NULL AND status = 'gesendet';
CREATE INDEX idx_posts_kind ON posts(kind, created_at);
+1 -1
View File
@@ -21,7 +21,7 @@ function msg(over: Partial<ChatMessage> = {}): ChatMessage {
id: `m-${Math.random().toString(36).slice(2)}`, id: `m-${Math.random().toString(36).slice(2)}`,
ts: '2026-09-09T20:00:00.000Z', ts: '2026-09-09T20:00:00.000Z',
userId: '42', login: 'maxi_2k', displayName: 'maxi_2k', 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, isCommand: false, isMod: false, isSub: false, isBroadcaster: false,
bits: 0, color: null, ...over, bits: 0, color: null, ...over,
}; };
+52
View File
@@ -19,6 +19,12 @@ export type ClipRow = {
status: string; score: number | null; status: string; score: number | null;
}; };
export type PostRow = {
id: string; stream_id: string | null; kind: string; platform: string;
mode: string; created_at: string; status: string;
title: string | null; category: string | null; url: string | null; detail: string | null;
};
export type StreamRow = { export type StreamRow = {
id: string; started_at: string; ended_at: string | null; id: string; started_at: string; ended_at: string | null;
peak_viewers: number; unique_chatters: number; peak_viewers: number; unique_chatters: number;
@@ -84,6 +90,21 @@ export function repos(db: Db) {
clipsOfStream: db.prepare( clipsOfStream: db.prepare(
'SELECT * FROM clips WHERE stream_id IS ? ORDER BY created_at DESC' 'SELECT * FROM clips WHERE stream_id IS ? ORDER BY created_at DESC'
), ),
insertPost: db.prepare(`
INSERT INTO posts
(id, stream_id, kind, platform, mode, created_at, status, title, category, url, detail)
VALUES (?,?,?,?,?,?,?,?,?,?,?)
`),
postFor: db.prepare(`
SELECT * FROM posts
WHERE stream_id IS ? AND kind = ? AND platform = ? AND status = 'gesendet'
LIMIT 1
`),
lastPost: db.prepare(`
SELECT * FROM posts
WHERE kind = ? AND platform = ? AND status = 'gesendet'
ORDER BY created_at DESC LIMIT 1
`),
stats: db.prepare(` stats: db.prepare(`
SELECT SELECT
(SELECT COUNT(*) FROM chat_messages) AS messages, (SELECT COUNT(*) FROM chat_messages) AS messages,
@@ -160,6 +181,37 @@ export function repos(db: Db) {
.run(...keys.map((k) => fields[k] ?? null), id); .run(...keys.map((k) => fields[k] ?? null), id);
}, },
/**
* Haelt fest, was rausgegangen ist. Wirft nicht, wenn der eindeutige Index
* zuschlaegt - dann war jemand anders schneller, und genau das soll er.
*/
insertPost(p: {
streamId: string | null; kind: string; platform: string; mode: string;
status: string; title?: string | null; category?: string | null;
url?: string | null; detail?: string | null;
}): string | null {
const id = ulid();
try {
stmt.insertPost.run(id, p.streamId, p.kind, p.platform, p.mode,
new Date().toISOString(), p.status,
p.title ?? null, p.category ?? null, p.url ?? null, p.detail ?? null);
return id;
} catch (err) {
if (/UNIQUE/i.test((err as Error).message)) return null;
throw err;
}
},
/** Gab es fuer diesen Stream schon einen gesendeten Post dieser Art? */
postFor(streamId: string | null, kind: string, platform: string) {
return stmt.postFor.get(streamId, kind, platform) as PostRow | undefined;
},
/** Der letzte gesendete Post dieser Art - fuer die Abklingzeit. */
lastPost(kind: string, platform: string) {
return stmt.lastPost.get(kind, platform) as PostRow | undefined;
},
getClip(id: string) { return stmt.getClip.get(id) as ClipRow | undefined; }, getClip(id: string) { return stmt.getClip.get(id) as ClipRow | undefined; },
clipsOfStream(streamId: string | null) { clipsOfStream(streamId: string | null) {
return stmt.clipsOfStream.all(streamId) as ClipRow[]; return stmt.clipsOfStream.all(streamId) as ClipRow[];
+9 -1
View File
@@ -162,7 +162,15 @@ export type RestFull = Rest & {
export type ApiRole = { id: string; name: string; permissions: string; position: number; color: number; hoist: boolean; mentionable: boolean; managed: boolean }; export type ApiRole = { id: string; name: string; permissions: string; position: number; color: number; hoist: boolean; mentionable: boolean; managed: boolean };
export type ApiOverwrite = { id: string; type: 0 | 1; allow: string; deny: string }; export type ApiOverwrite = { id: string; type: 0 | 1; allow: string; deny: string };
export type ApiChannel = { id: string; name: string; type: number; position: number; parent_id: string | null; topic?: string | null; permission_overwrites?: ApiOverwrite[] }; export type ApiChannel = { id: string; name: string; type: number; position: number; parent_id: string | null; topic?: string | null; permission_overwrites?: ApiOverwrite[] };
export type ApiGuild = { id: string; name: string; owner_id: string; roles: ApiRole[]; features: string[] }; export type ApiGuild = {
id: string; name: string; owner_id: string; roles: ApiRole[]; features: string[];
// Die Felder aus GUILD_SETTINGS, damit sich vergleichen laesst, statt bei
// jedem Lauf blind zu schreiben.
verification_level?: number;
explicit_content_filter?: number;
default_message_notifications?: number;
system_channel_flags?: number;
};
export type ApiWebhook = { id: string; name: string; channel_id: string; url?: string; token?: string }; export type ApiWebhook = { id: string; name: string; channel_id: string; url?: string; token?: string };
export type ApiUser = { id: string; username: string; bot?: boolean }; export type ApiUser = { id: string; username: string; bot?: boolean };
+33 -6
View File
@@ -109,7 +109,13 @@ class FakeDiscord implements Rest {
async patch<T>(path: string, body: unknown): Promise<T> { async patch<T>(path: string, body: unknown): Promise<T> {
this.calls.push(`PATCH ${path}`); this.calls.push(`PATCH ${path}`);
if (path === `/guilds/${GUILD}`) return {} as T; if (path === `/guilds/${GUILD}`) {
// Wie der echte Server: gesetzte Einstellungen stehen danach auch drin.
// Ohne das liesse sich nicht pruefen, ob ein zweiter Lauf sie in Ruhe
// laesst.
Object.assign(this.guild, body as Record<string, unknown>);
return structuredClone(this.guild) as T;
}
if (path === `/guilds/${GUILD}/roles`) { // Rangfolge if (path === `/guilds/${GUILD}/roles`) { // Rangfolge
for (const e of body as Array<{ id: string; position: number }>) { for (const e of body as Array<{ id: string; position: number }>) {
const r = this.guild.roles.find((x) => x.id === e.id); const r = this.guild.roles.find((x) => x.id === e.id);
@@ -187,7 +193,7 @@ test('der erste Lauf legt Rollen, Kategorien, Kanaele und Webhooks an', async ()
for (const c of CHANNELS) { for (const c of CHANNELS) {
assert.ok(api.channels.find((x) => x.name === c.name), `Kanal ${c.name} fehlt`); assert.ok(api.channels.find((x) => x.name === c.name), `Kanal ${c.name} fehlt`);
} }
assert.equal(res.webhooks, 3); assert.equal(res.webhooks, 4);
assert.equal(api.automod.length, 2); assert.equal(api.automod.length, 2);
assert.equal(counts(res.steps)['uebersprungen'] ?? 0, 0); assert.equal(counts(res.steps)['uebersprungen'] ?? 0, 0);
}); });
@@ -259,7 +265,7 @@ test('der zweite Lauf aendert nichts', async () => {
`unerwartete Schreibzugriffe: ${schreibend.join(', ')}`); `unerwartete Schreibzugriffe: ${schreibend.join(', ')}`);
assert.equal(counts(res.steps)['erstellt'] ?? 0, 0); assert.equal(counts(res.steps)['erstellt'] ?? 0, 0);
assert.equal(res.webhooks, 3, 'die bestehenden Webhook-URLs muessen wiedergefunden werden'); assert.equal(res.webhooks, 4, 'die bestehenden Webhook-URLs muessen wiedergefunden werden');
}); });
test('die Begruessung wird nicht ein zweites Mal gepostet', async () => { test('die Begruessung wird nicht ein zweites Mal gepostet', async () => {
@@ -320,7 +326,7 @@ test('ein von Hand angelegter Webhook wird gemeldet, nicht ueberschrieben', asyn
const step = res.steps.find((s) => s.kind === 'Webhook' && s.name.startsWith('Go-Live')); const step = res.steps.find((s) => s.kind === 'Webhook' && s.name.startsWith('Go-Live'));
assert.equal(step?.change, 'uebersprungen'); assert.equal(step?.change, 'uebersprungen');
assert.match(step?.note ?? '', /nicht auslesbar/); assert.match(step?.note ?? '', /nicht auslesbar/);
assert.equal(res.webhooks, 2); assert.equal(res.webhooks, 3, 'die drei uebrigen muessen trotzdem durchkommen');
}); });
test('eine von Discord verwaltete Rolle wird in Ruhe gelassen', async () => { test('eine von Discord verwaltete Rolle wird in Ruhe gelassen', async () => {
@@ -375,7 +381,7 @@ test('Bot-Rolle ganz unten: Rangfolge wird gemeldet statt zu scheitern', async (
assert.match(step?.note ?? '', /nach oben ziehen/); assert.match(step?.note ?? '', /nach oben ziehen/);
// Alles andere muss trotzdem stehen. // Alles andere muss trotzdem stehen.
assert.equal(api.channels.length, 3 + CHANNELS.length); assert.equal(api.channels.length, 3 + CHANNELS.length);
assert.equal(res.webhooks, 3); assert.equal(res.webhooks, 4);
}); });
// ---------------------------------------------------------------- Umbenennen // ---------------------------------------------------------------- Umbenennen
@@ -421,10 +427,31 @@ test('haengt Discord beim Umbenennen im Rate Limit, laeuft der Rest weiter', asy
assert.equal(step?.change, 'uebersprungen'); assert.equal(step?.change, 'uebersprungen');
assert.match(step?.note ?? '', /530 s/); assert.match(step?.note ?? '', /530 s/);
// Die uebrigen Kanaele und die Webhooks muessen trotzdem entstehen. // Die uebrigen Kanaele und die Webhooks muessen trotzdem entstehen.
assert.equal(res.webhooks, 3); assert.equal(res.webhooks, 4);
assert.equal(api.automod.length, 2); assert.equal(api.automod.length, 2);
}); });
test('der zweite Lauf schreibt nichts mehr - auch nicht Einstellungen und Rangfolge', async () => {
// Der Bauplan verspricht, dass ein zweiter Lauf nichts aendert. Zwei
// Schritte haben das lange gebrochen, weil sie ohne Vergleich geschrieben
// haben: die Servereinstellungen und die Rangfolge der Rollen. Folgenlos
// war das nicht - jeder Lauf hinterliess zwei Eintraege im Audit-Log und
// liess den Bericht so aussehen, als sei etwas passiert.
const api = new FakeDiscord();
await run(api);
api.calls.length = 0;
const res = await run(api);
assert.equal(api.calls.some((c) => c === `PATCH /guilds/${GUILD}`), false,
'Servereinstellungen standen schon');
assert.equal(api.calls.some((c) => c === `PATCH /guilds/${GUILD}/roles`), false,
'die Rangfolge stand schon');
assert.equal(res.steps.find((s) => s.kind === 'Server')?.change, 'unveraendert');
assert.equal(res.steps.find((s) => s.name === 'Rangfolge')?.change, 'unveraendert');
assert.equal(res.steps.some((s) => s.change === 'erstellt'), false, 'nichts neu angelegt');
});
test('ein geaenderter Blocktext wird auf eine bestehende Regel nachgezogen', async () => { test('ein geaenderter Blocktext wird auf eine bestehende Regel nachgezogen', async () => {
const api = new FakeDiscord(); const api = new FakeDiscord();
await run(api); await run(api);
+22 -3
View File
@@ -193,9 +193,11 @@ async function applyRoleOrder(
if (dry || wanted.length === 0) return; if (dry || wanted.length === 0) return;
let botTop: number; let botTop: number;
let istPosition = new Map<string, number>();
try { try {
const member = await api.get<{ roles: string[] }>(`/guilds/${guild.id}/members/${botUserId}`); const member = await api.get<{ roles: string[] }>(`/guilds/${guild.id}/members/${botUserId}`);
const fresh = await api.get<ApiRole[]>(`/guilds/${guild.id}/roles`); const fresh = await api.get<ApiRole[]>(`/guilds/${guild.id}/roles`);
istPosition = new Map(fresh.map((r) => [r.id, r.position]));
botTop = fresh botTop = fresh
.filter((r) => member.roles.includes(r.id)) .filter((r) => member.roles.includes(r.id))
.reduce((max, r) => Math.max(max, r.position), 0); .reduce((max, r) => Math.max(max, r.position), 0);
@@ -211,6 +213,15 @@ async function applyRoleOrder(
return; return;
} }
// Stimmt die Rangfolge schon, nicht schreiben. Ein PATCH mit denselben
// Werten ist zwar folgenlos, kostet aber bei jedem Lauf einen Aufruf und
// einen Eintrag im Audit-Log - und laesst den Lauf so aussehen, als haette
// er etwas geaendert.
if (wanted.every((id, i) => istPosition.get(id) === fit.positions[i])) {
steps.push({ kind: 'Rollen', name: 'Rangfolge', change: 'unveraendert' });
return;
}
try { try {
await api.patch(`/guilds/${guild.id}/roles`, await api.patch(`/guilds/${guild.id}/roles`,
wanted.map((id, i) => ({ id, position: fit.positions[i] })), 'Rangfolge aus dem Bauplan'); wanted.map((id, i) => ({ id, position: fit.positions[i] })), 'Rangfolge aus dem Bauplan');
@@ -480,9 +491,17 @@ export async function applyPlan(
const me = await api.get<ApiUser>('/users/@me'); const me = await api.get<ApiUser>('/users/@me');
log.info(`Server "${guild.name}"${dry ? ' (Vorschau, es wird nichts geaendert)' : ''}`); log.info(`Server "${guild.name}"${dry ? ' (Vorschau, es wird nichts geaendert)' : ''}`);
// 1. Servereinstellungen // 1. Servereinstellungen. Vergleichen statt blind schreiben: sonst meldet
if (!dry) await api.patch(`/guilds/${guild.id}`, GUILD_SETTINGS, 'Servereinstellungen aus dem Bauplan'); // jeder Lauf eine Aenderung, auch wenn es keine gab, und die Vorschau sagt
steps.push({ kind: 'Server', name: guild.name, change: dry ? 'unveraendert' : 'angepasst' }); // etwas anderes als der echte Lauf.
const abweichend = (Object.entries(GUILD_SETTINGS) as Array<[keyof ApiGuild, number]>)
.some(([k, v]) => guild[k] !== v);
if (abweichend) {
if (!dry) await api.patch(`/guilds/${guild.id}`, GUILD_SETTINGS, 'Servereinstellungen aus dem Bauplan');
steps.push({ kind: 'Server', name: guild.name, change: dry ? 'erstellt' : 'angepasst' });
} else {
steps.push({ kind: 'Server', name: guild.name, change: 'unveraendert' });
}
// 2. Rollen // 2. Rollen
const roleIds = await applyRoles(api, guild, me.id, steps, dry); const roleIds = await applyRoles(api, guild, me.id, steps, dry);
+13 -1
View File
@@ -150,8 +150,20 @@ test('alle internen Kanaele liegen wirklich in der internen Kategorie', () => {
}); });
test('jeder Webhook-Platz kommt genau einmal vor', () => { test('jeder Webhook-Platz kommt genau einmal vor', () => {
// Zweimal derselbe Platz hiesse: der zweite Kanal ueberschreibt die URL des
// ersten im Zugangsdaten-Speicher, und einer der beiden postet ins Leere.
const slots = CHANNELS.flatMap((c) => (c.webhooks ?? []).map((w) => w.slot)); const slots = CHANNELS.flatMap((c) => (c.webhooks ?? []).map((w) => w.slot));
assert.deepEqual([...slots].sort(), ['clipsWebhook', 'goLiveWebhook', 'recapWebhook']); assert.deepEqual([...slots].sort(),
['clipsWebhook', 'goLiveWebhook', 'recapWebhook', 'technikWebhook']);
assert.equal(new Set(slots).size, slots.length);
});
test('der Entwurfsmodus postet in einen Kanal, den Mitglieder nicht sehen', () => {
// `broadcaster.mode: draft` schickt dieselbe Nachricht wie im Betrieb,
// nur ohne Ping. Laege der Kanal oeffentlich, waere jeder Probelauf eine
// falsche Ankuendigung im Server.
const kanal = CHANNELS.find((c) => (c.webhooks ?? []).some((w) => w.slot === 'technikWebhook'));
assert.equal(kanal?.category, 'intern');
}); });
test('Webhooks haengen nur an Kanaelen, in denen Mitglieder nicht dazwischenfunken', () => { test('Webhooks haengen nur an Kanaelen, in denen Mitglieder nicht dazwischenfunken', () => {
+5 -1
View File
@@ -21,7 +21,7 @@ import { CH } from './api.ts';
// und der erste Eindruck eines Servers ist der Text darin. // und der erste Eindruck eines Servers ist der Text darin.
/** Schluessel, unter denen eine Webhook-URL im Zugangsdaten-Speicher landet. */ /** Schluessel, unter denen eine Webhook-URL im Zugangsdaten-Speicher landet. */
export type WebhookSlot = 'goLiveWebhook' | 'recapWebhook' | 'clipsWebhook'; export type WebhookSlot = 'goLiveWebhook' | 'recapWebhook' | 'clipsWebhook' | 'technikWebhook';
export type RolePlan = { export type RolePlan = {
name: string; name: string;
@@ -236,6 +236,10 @@ export const CHANNELS: ChannelPlan[] = [
type: CH.TEXT, type: CH.TEXT,
category: 'intern', category: 'intern',
topic: 'Meldungen des Toolkits: Probeläufe, Fehler, AutoMod-Treffer.', topic: 'Meldungen des Toolkits: Probeläufe, Fehler, AutoMod-Treffer.',
// Der Entwurfsmodus des Go-Live-Posts landet hier: dieselbe Nachricht,
// die sonst in die Ankuendigungen ginge, nur ohne Ping und nur fuer Mods.
// So sieht man das Ergebnis, bevor es der Server sieht.
webhooks: [{ name: 'Technik', slot: 'technikWebhook' }],
}, },
]; ];
+54
View File
@@ -0,0 +1,54 @@
// Rollennamen zu Rollen-IDs. Der Bauplan verweist auf Namen, Discord auf IDs.
//
// Bewusst eigenstaendig: die Knopfleiste braucht das, und der Go-Live-Ping
// braucht es auch - der muss `<@&ID>` in die Nachricht schreiben, weil ein
// Webhook eine Rolle nicht ueber ihren Namen anpingen kann.
import type { ApiRole, Rest } from './api.ts';
export type RoleResolver = {
/**
* Gross-/Kleinschreibung spielt keine Rolle. Ein Fehlschlag laedt die Liste
* einmal neu, bevor er `undefined` zurueckgibt: eine Rolle kann nach dem
* Start des Daemons angelegt worden sein, und ein Neustart ist eine
* schlechte Antwort auf eine neue Rolle.
*/
id: (name: string) => Promise<string | undefined>;
/** Vergisst die gemerkte Liste - fuer Tests und nach einem Umbau. */
forget: () => void;
};
/**
* `reloadAfterMs` bremst das Nachladen nach einem Fehlschlag. Vorgabe ist 0:
* jeder Fehlschlag laedt neu, denn ein Klick kommt von einem Menschen und ist
* selten. Wer im Hintergrund fragt - der Go-Live-Ping etwa - setzt eine
* Bremse, sonst fragt ein Tippfehler im Bauplan im Sekundentakt bei Discord
* nach, und zwar genau dann, wenn es dort keine Antwort gibt.
*/
export function createRoleResolver(
api: Rest, guildId: string, reloadAfterMs = 0
): RoleResolver {
let cache = new Map<string, string>();
let loaded = false;
let lastLoad = 0;
async function load(): Promise<void> {
const roles = await api.get<ApiRole[]>(`/guilds/${guildId}/roles`);
cache = new Map(roles.map((r) => [r.name.toLowerCase(), r.id]));
loaded = true;
lastLoad = Date.now();
}
return {
async id(name) {
const key = name.toLowerCase();
if (!loaded) await load();
const hit = cache.get(key);
if (hit) return hit;
if (reloadAfterMs > 0 && Date.now() - lastLoad < reloadAfterMs) return undefined;
await load();
return cache.get(key);
},
forget() { loaded = false; cache = new Map(); },
};
}
+6 -15
View File
@@ -7,9 +7,10 @@
// Knopf unter dem Willkommenstext. // Knopf unter dem Willkommenstext.
import { logger } from '#core/logger.ts'; import { logger } from '#core/logger.ts';
import type { ApiRole, RestFull } from './api.ts'; import type { RestFull } from './api.ts';
import type { Interaction } from './gateway.ts'; import type { Interaction } from './gateway.ts';
import { SELF_ROLES, type SelfRolePlan } from './plan.ts'; import { SELF_ROLES, type SelfRolePlan } from './plan.ts';
import { createRoleResolver } from './roles.ts';
const log = logger('discord:rollen'); const log = logger('discord:rollen');
@@ -42,19 +43,9 @@ export type RoleButtons = {
}; };
export function createRoleButtons(api: RestFull, guildId: string): RoleButtons { export function createRoleButtons(api: RestFull, guildId: string): RoleButtons {
// Rollennamen einmal aufloesen und merken. Kommt ein Name nicht vor, wird // Ohne Bremse: ein Klick kommt von einem Menschen, und wenn eine Rolle
// einmal neu geladen - eine Rolle kann nach dem Start angelegt worden sein. // gerade erst angelegt wurde, soll der naechste Klick sie finden.
let cache: Map<string, string> | null = null; const roles = createRoleResolver(api, guildId);
async function roleId(name: string, force = false): Promise<string | undefined> {
if (!cache || force) {
const roles = await api.get<ApiRole[]>(`/guilds/${guildId}/roles`);
cache = new Map(roles.map((r) => [r.name.toLowerCase(), r.id]));
}
const hit = cache.get(name.toLowerCase());
if (hit || force) return hit;
return roleId(name, true);
}
async function reply(interaction: Interaction, content: string): Promise<void> { async function reply(interaction: Interaction, content: string): Promise<void> {
await api.post(`/interactions/${interaction.id}/${interaction.token}/callback`, { await api.post(`/interactions/${interaction.id}/${interaction.token}/callback`, {
@@ -78,7 +69,7 @@ export function createRoleButtons(api: RestFull, guildId: string): RoleButtons {
} }
try { try {
const id = await roleId(plan.role); const id = await roles.id(plan.role);
if (!id) { if (!id) {
await reply(interaction, `Die Rolle **${plan.role}** gibt es gerade nicht. Sag einem Mod Bescheid.`); await reply(interaction, `Die Rolle **${plan.role}** gibt es gerade nicht. Sag einem Mod Bescheid.`);
log.error(`Rolle "${plan.role}" nicht gefunden - Bauplan und Server laufen auseinander`); log.error(`Rolle "${plan.role}" nicht gefunden - Bauplan und Server laufen auseinander`);
+159
View File
@@ -0,0 +1,159 @@
// Posten ueber einen Webhook. Kein Bot-Token, kein Gateway - die URL allein
// genuegt, und genau deshalb legt `discord:setup` sie an.
//
// Eigener Client statt `Discord` aus api.ts: Webhooks haben eigene
// Drosselungs-Eimer, brauchen keinen Authorization-Kopf, und sie koennen als
// Einzige eine Datei anhaengen. Gemeinsam ist nur der Umgang mit 429.
import { logger } from '#core/logger.ts';
import { retry, sleep } from '#core/retry.ts';
const log = logger('discord:webhook');
/**
* Laenger warten wir nicht. Ein Go-Live-Post, der eine Viertelstunde spaeter
* kommt, ist schlimmer als gar keiner - dann steht in den Ankuendigungen
* "jetzt live", waehrend der Stream laeuft und keiner mehr kommt.
*/
const MAX_WAIT_MS = 30_000;
export type Embed = {
title?: string;
description?: string;
url?: string;
color?: number;
timestamp?: string;
footer?: { text: string; icon_url?: string };
image?: { url: string };
thumbnail?: { url: string };
author?: { name: string; url?: string; icon_url?: string };
fields?: Array<{ name: string; value: string; inline?: boolean }>;
};
export type WebhookMessage = {
content?: string;
embeds?: Embed[];
username?: string;
avatar_url?: string;
/**
* Ohne dieses Feld entscheidet Discord selbst, was aus `@everyone` und
* `<@&...>` im Text wird. Wir entscheiden das immer selbst - ein Rundruf
* aus Versehen ist der einzige Fehler hier, den man nicht zuruecknehmen kann.
*/
allowed_mentions?: { parse: string[]; roles?: string[]; users?: string[] };
/** Komponenten braucht der Go-Live-Post nicht, der Willkommenstext schon. */
components?: Array<Record<string, unknown>>;
};
export type Attachment = { name: string; type: string; data: Uint8Array };
/** Was Discord von einer gesendeten Nachricht zurueckmeldet. */
export type PostedMessage = { id: string; channel_id: string };
/**
* Baut eine Erwaehnung, die auch wirklich pingt.
*
* `<@&ID>` allein reicht nicht: ohne `allowed_mentions.roles` faerbt Discord
* die Erwaehnung zwar ein, schickt aber keine Benachrichtigung. Das ist der
* Fehler, den man erst bemerkt, wenn drei Streams lang niemand gekommen ist.
*/
export type Ping = {
/** Was in den Text gehoert. Leer, wenn es nichts zu pingen gibt. */
mention: string;
allowed_mentions: { parse: string[]; roles?: string[] };
};
export function rolePing(roleId: string | undefined): Ping {
if (!roleId) return { mention: '', allowed_mentions: { parse: [] } };
return { mention: `<@&${roleId}>`, allowed_mentions: { parse: [], roles: [roleId] } };
}
function isWebhookUrl(url: string): boolean {
return /^https:\/\/(canary\.|ptb\.)?discord\.com\/api\/webhooks\/\d+\/[\w-]+/.test(url);
}
/**
* Schickt die Nachricht. Mit `?wait=true`, damit Discord die angelegte
* Nachricht zurueckgibt - ohne das kommt ein 204 ohne Inhalt, und wir koennten
* hinterher nicht sagen, wo der Post steht.
*/
export async function postWebhook(
url: string,
message: WebhookMessage,
files: Attachment[] = []
): Promise<PostedMessage> {
if (!isWebhookUrl(url)) {
// Die URL kommt aus dem verschluesselten Speicher. Steht dort etwas
// anderes, ist das ein Einrichtungsfehler und kein Netzwerkproblem.
throw new Error('Das ist keine Discord-Webhook-URL.');
}
const target = `${url}${url.includes('?') ? '&' : '?'}wait=true`;
return await retry(async () => {
let body: BodyInit;
let headers: Record<string, string> = {};
if (files.length === 0) {
body = JSON.stringify(message);
headers = { 'content-type': 'application/json' };
} else {
// Mit Anhang verlangt Discord multipart: die Nachricht als
// `payload_json`, die Dateien als `files[n]`. Die Namen in
// `attachment://...` eines Embeds muessen exakt den Dateinamen treffen.
const form = new FormData();
form.set('payload_json', JSON.stringify(message));
files.forEach((f, i) => {
form.set(`files[${i}]`, new File([f.data as BlobPart], f.name, { type: f.type }));
});
body = form;
}
const res = await fetch(target, {
method: 'POST',
headers: { 'User-Agent': 'abdulive-toolkit (https://git.zeasy.dev/ZeasyAlex/abdulive)', ...headers },
body,
});
if (res.status === 429) {
const info = await res.json().catch(() => ({})) as { retry_after?: number };
const waitMs = Math.max(1000, (info.retry_after ?? 1) * 1000 + 100);
if (waitMs > MAX_WAIT_MS) {
throw Object.assign(new Error(
`Discord verlangt ${Math.round(waitMs / 1000)} s Pause - Post faellt aus`
), { final: true });
}
log.warn(`Rate Limit am Webhook, warte ${Math.round(waitMs)} ms`);
await sleep(waitMs);
throw new Error('Rate Limit erreicht');
}
if (!res.ok) {
const text = (await res.text()).slice(0, 300);
const err = Object.assign(
new Error(`Webhook: HTTP ${res.status} ${text}`),
// 401/403/404 heisst: Webhook geloescht oder Kanal weg. Wiederholen
// hilft nicht, und der Text soll sagen, was zu tun ist.
res.status === 401 || res.status === 403 || res.status === 404
? { final: true } : {}
);
if (res.status === 404) {
err.message += ' - der Webhook existiert nicht mehr. Neu anlegen mit: abdulive discord:setup';
}
throw err;
}
return await res.json() as PostedMessage;
}, {
attempts: 4,
onRetry: (err, n, ms) => {
if ((err as { final?: boolean }).final) throw err;
log.warn(`Wiederhole den Webhook-Post (${n}) in ${ms} ms`, { err: String(err) });
},
});
}
/** Link auf eine gepostete Nachricht - fuers Protokoll und die Statusseite. */
export function messageUrl(guildId: string, m: PostedMessage): string {
return `https://discord.com/channels/${guildId}/${m.channel_id}/${m.id}`;
}
+5
View File
@@ -30,6 +30,11 @@ export type ChatMessage = {
displayName: string; displayName: string;
text: string; text: string;
emotes: Array<{ id: string; name: string; count: number }>; 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 isFirst: boolean; // first-msg Tag
isReturning: boolean; // returning-chatter Tag isReturning: boolean; // returning-chatter Tag
isCommand: boolean; isCommand: boolean;
+98
View File
@@ -0,0 +1,98 @@
// EventSub liefert Ereignisse, die sich nicht wiederholen lassen. Wer eine
// Nachricht falsch deutet, verpasst eine Einloesung - und der Zuschauer hat
// die Punkte bezahlt. Deshalb ist das Deuten hier von der Verbindung getrennt
// und einzeln geprueft.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { deuten, type EventSubNachricht } from './eventsub.ts';
const n = (o: unknown): EventSubNachricht => o as EventSubNachricht;
test('der Willkommensgruss traegt Sitzung und Zeitlimit', () => {
const f = deuten(n({
metadata: { message_id: '1', message_type: 'session_welcome' },
payload: { session: { id: 'sess-1', keepalive_timeout_seconds: 10, reconnect_url: null } },
}));
assert.equal(f.art, 'sitzung');
assert.equal((f as { sessionId: string }).sessionId, 'sess-1');
// 10 s Zeitlimit plus Puffer: der Wachhund soll eine tote Verbindung finden,
// nicht eine langsame abwuergen.
assert.equal((f as { keepaliveMs: number }).keepaliveMs, 15_000);
});
test('ohne Angabe gilt Twitchs dokumentierte Vorgabe', () => {
const f = deuten(n({
metadata: { message_type: 'session_welcome' },
payload: { session: { id: 's', keepalive_timeout_seconds: null, reconnect_url: null } },
}));
assert.equal((f as { keepaliveMs: number }).keepaliveMs, 15_000);
});
test('eine Benachrichtigung traegt Typ und Ereignis', () => {
const f = deuten(n({
metadata: {
message_type: 'notification',
subscription_type: 'channel.channel_points_custom_reward_redemption.add',
},
payload: { event: { id: 'red-1', user_name: 'Kevin' } },
}));
assert.equal(f.art, 'ereignis');
assert.match((f as { typ: string }).typ, /reward_redemption\.add$/);
assert.equal(((f as { event: Record<string, unknown> }).event)['user_name'], 'Kevin');
});
test('fehlt der Typ in den Kopfdaten, wird er aus dem Abonnement genommen', () => {
const f = deuten(n({
metadata: { message_type: 'notification' },
payload: { subscription: { id: 's', type: 'channel.follow', status: 'enabled' },
event: { user_name: 'x' } },
}));
assert.equal((f as { typ: string }).typ, 'channel.follow');
});
test('eine Benachrichtigung ohne Ereignis wird nicht durchgereicht', () => {
const f = deuten(n({
metadata: { message_type: 'notification', subscription_type: 'channel.follow' },
payload: {},
}));
assert.equal(f.art, 'unbekannt');
});
test('der Umzug nennt die neue Adresse', () => {
const f = deuten(n({
metadata: { message_type: 'session_reconnect' },
payload: { session: { id: 's', keepalive_timeout_seconds: 10,
reconnect_url: 'wss://eventsub.wss.twitch.tv/ws?id=neu' } },
}));
assert.deepEqual(f, { art: 'umziehen', url: 'wss://eventsub.wss.twitch.tv/ws?id=neu' });
});
test('ein Umzug ohne Adresse wird ignoriert statt zu einer leeren Verbindung zu fuehren', () => {
const f = deuten(n({
metadata: { message_type: 'session_reconnect' },
payload: { session: { id: 's', keepalive_timeout_seconds: 10, reconnect_url: null } },
}));
assert.equal(f.art, 'unbekannt');
});
test('ein Entzug wird als solcher erkannt', () => {
// Passiert, wenn Abdu die App-Freigabe zurueckzieht. Wiederverbinden hilft
// dagegen nichts, deshalb darf es nicht wie ein Netzwerkfehler aussehen.
const f = deuten(n({
metadata: { message_type: 'revocation' },
payload: { subscription: { id: 's', type: 'channel.follow', status: 'authorization_revoked' } },
}));
assert.deepEqual(f, { art: 'entzogen', typ: 'channel.follow', grund: 'authorization_revoked' });
});
test('ein Lebenszeichen ist ein Lebenszeichen', () => {
assert.deepEqual(deuten(n({ metadata: { message_type: 'session_keepalive' }, payload: {} })),
{ art: 'lebenszeichen' });
});
test('Unsinn wird verworfen, nicht geraten', () => {
for (const muell of [{}, { metadata: {} }, { metadata: { message_type: 'gibtsnicht' }, payload: {} }]) {
assert.equal(deuten(n(muell)).art, 'unbekannt');
}
});
+229
View File
@@ -0,0 +1,229 @@
// EventSub ueber WebSocket. Beschlossen in ADR-0003, gebaut erst jetzt.
//
// Warum ueberhaupt, wo der Chat doch schon da ist: Kanalpunkt-Einloesungen
// kommen ueber IRC nur dann an, wenn die Belohnung eine Texteingabe verlangt -
// und selbst dann ohne Einloese-ID. Ohne die ID lassen sich Punkte nicht
// zurueckgeben, wenn eine Aktion schiefgeht. Wer Punkte nimmt und nichts
// liefert, verliert das Vertrauen schneller als durch jeden Fehler im Bild.
//
// Ablauf des Protokolls:
//
// verbinden -> session_welcome (traegt session.id und das Zeitlimit)
// -> Abonnements ueber Helix anlegen, mit dieser session.id
// -> notification ... notification ... session_keepalive ...
//
// Zwei Eigenheiten, die man kennen muss:
//
// * Ein Abonnement muss innerhalb von 10 Sekunden nach dem Willkommensgruss
// stehen, sonst schliesst Twitch die Verbindung wieder.
// * `session_reconnect` nennt eine neue Adresse. Die alte Verbindung bleibt
// offen, bis die neue steht - erst verbinden, dann schliessen, sonst
// entsteht genau in dieser Luecke ein verpasstes Ereignis.
import { logger } from '#core/logger.ts';
import { forever } from '#core/retry.ts';
const log = logger('eventsub');
export const EVENTSUB_URL = 'wss://eventsub.wss.twitch.tv/ws';
/**
* Wie lange ohne ein Lebenszeichen gewartet wird, bevor neu verbunden wird.
* Twitch nennt sein eigenes Zeitlimit im Willkommensgruss (ueblich 10 s); wir
* legen Luft drauf, damit ein kurzer Netzhaenger nicht sofort neu verbindet.
*/
const KEEPALIVE_PUFFER_MS = 5_000;
export type EventSubNachricht = {
metadata: {
message_id: string;
message_type: 'session_welcome' | 'session_keepalive' | 'notification'
| 'session_reconnect' | 'revocation';
subscription_type?: string;
subscription_version?: string;
};
payload: {
session?: { id: string; keepalive_timeout_seconds: number | null; reconnect_url: string | null };
subscription?: { id: string; type: string; status: string };
event?: Record<string, unknown>;
};
};
/** Ein Abonnement, so wie Helix es entgegennimmt. */
export type Abonnement = {
type: string;
version: string;
condition: Record<string, string>;
};
export type EventSubHandlers = {
/** Die Sitzung steht. Hier gehoeren die Abonnements angelegt. */
onSession: (sessionId: string) => Promise<void>;
onNotification: (typ: string, event: Record<string, unknown>) => void;
/**
* Twitch hat ein Abonnement entzogen - etwa weil das Token zurueckgezogen
* wurde. Das ist kein Netzwerkproblem und geht durch Wiederverbinden nicht
* weg, deshalb bekommt es einen eigenen Weg.
*/
onRevocation?: (typ: string, grund: string) => void;
onConnected?: () => void;
onClosed?: () => void;
};
/**
* Was aus einer eingehenden Nachricht folgen soll. Ausgelagert und rein,
* damit sich die Faelle pruefen lassen, die im Betrieb selten sind und dann
* mitten im Stream zuschlagen.
*/
export type Folge =
| { art: 'sitzung'; sessionId: string; keepaliveMs: number }
| { art: 'ereignis'; typ: string; event: Record<string, unknown> }
| { art: 'umziehen'; url: string }
| { art: 'entzogen'; typ: string; grund: string }
| { art: 'lebenszeichen' }
| { art: 'unbekannt' };
export function deuten(n: EventSubNachricht): Folge {
switch (n.metadata?.message_type) {
case 'session_welcome': {
const s = n.payload?.session;
if (!s?.id) return { art: 'unbekannt' };
// Ohne Angabe 10 Sekunden annehmen, so wie Twitch es dokumentiert.
const sek = s.keepalive_timeout_seconds ?? 10;
return { art: 'sitzung', sessionId: s.id, keepaliveMs: sek * 1000 + KEEPALIVE_PUFFER_MS };
}
case 'session_reconnect': {
const url = n.payload?.session?.reconnect_url;
return url ? { art: 'umziehen', url } : { art: 'unbekannt' };
}
case 'notification': {
const typ = n.metadata.subscription_type ?? n.payload?.subscription?.type ?? '';
const event = n.payload?.event;
if (!typ || !event) return { art: 'unbekannt' };
return { art: 'ereignis', typ, event };
}
case 'revocation':
return {
art: 'entzogen',
typ: n.payload?.subscription?.type ?? 'unbekannt',
grund: n.payload?.subscription?.status ?? 'unbekannt',
};
case 'session_keepalive':
return { art: 'lebenszeichen' };
default:
return { art: 'unbekannt' };
}
}
export type EventSubClient = {
run: (signal: AbortSignal) => Promise<void>;
isConnected: () => boolean;
};
export function createEventSub(handlers: EventSubHandlers): EventSubClient {
let verbunden = false;
return {
run: (signal) => forever(
async (sig) => { await einmalVerbinden(EVENTSUB_URL, handlers, sig, (v) => { verbunden = v; }); },
{
signal,
onWait: (ms, n) =>
log.warn(`EventSub getrennt, neuer Versuch in ${Math.round(ms / 1000)} s (${n}.)`),
},
),
isConnected: () => verbunden,
};
}
function einmalVerbinden(
url: string,
handlers: EventSubHandlers,
signal: AbortSignal,
setzeStatus: (v: boolean) => void,
): Promise<void> {
return new Promise<void>((_erfuellt, ablehnen) => {
const ws = new WebSocket(url);
let wachhund: ReturnType<typeof setTimeout> | null = null;
let umzug: WebSocket | null = null;
// Twitch nennt sein Zeitlimit im Willkommensgruss. Bis dahin grosszuegig.
let frist = 30_000;
const abbrechen = (warum: string): void => {
if (wachhund) clearTimeout(wachhund);
setzeStatus(false);
handlers.onClosed?.();
try { ws.close(); } catch { /* schon zu */ }
try { umzug?.close(); } catch { /* schon zu */ }
ablehnen(new Error(warum));
};
signal.addEventListener('abort', () => abbrechen('abgebrochen'), { once: true });
/** Kommt zu lange nichts, ist die Verbindung tot, ohne es zu melden. */
const wachhundStellen = (ms: number): void => {
if (wachhund) clearTimeout(wachhund);
wachhund = setTimeout(() => abbrechen('kein Lebenszeichen mehr'), ms);
};
ws.addEventListener('open', () => wachhundStellen(30_000));
ws.addEventListener('message', (ev) => {
let nachricht: EventSubNachricht;
try { nachricht = JSON.parse(String(ev.data)); } catch { return; }
const folge = deuten(nachricht);
switch (folge.art) {
case 'sitzung':
frist = folge.keepaliveMs;
wachhundStellen(frist);
setzeStatus(true);
handlers.onConnected?.();
// Die Abonnements muessen jetzt angelegt werden. Scheitert das,
// haelt Twitch die Verbindung ohnehin nicht lange.
void handlers.onSession(folge.sessionId).catch((err: Error) => {
log.error(`Abonnements nicht angelegt: ${err.message}`);
abbrechen('Abonnements fehlgeschlagen');
});
break;
case 'lebenszeichen':
wachhundStellen(frist);
break;
case 'ereignis':
wachhundStellen(frist);
try { handlers.onNotification(folge.typ, folge.event); }
catch (err) { log.error(`Ereignis nicht verarbeitet: ${(err as Error).message}`); }
break;
case 'umziehen': {
// Erst die neue Verbindung aufbauen, dann die alte schliessen.
// Andersherum faellt genau dazwischen ein Ereignis unter den Tisch.
log.info('Twitch bittet um Umzug auf eine neue Verbindung');
umzug = new WebSocket(folge.url);
umzug.addEventListener('open', () => {
log.info('Umzug steht, alte Verbindung wird geschlossen');
abbrechen('umgezogen');
});
umzug.addEventListener('error', () => {
log.warn('Umzug fehlgeschlagen, bleibe auf der alten Verbindung');
umzug = null;
});
break;
}
case 'entzogen':
log.error(`Abonnement entzogen: ${folge.typ} (${folge.grund})`);
handlers.onRevocation?.(folge.typ, folge.grund);
break;
default:
break;
}
});
ws.addEventListener('error', () => abbrechen('WebSocket-Fehler'));
ws.addEventListener('close', (ev) => abbrechen(`Verbindung geschlossen (${ev.code})`));
});
}
+79 -6
View File
@@ -4,7 +4,7 @@
import { logger } from '#core/logger.ts'; import { logger } from '#core/logger.ts';
import { retry, sleep } from '#core/retry.ts'; import { retry, sleep } from '#core/retry.ts';
import { accessToken } from './oauth.ts'; import { accessToken, appToken } from './oauth.ts';
import { readSecrets, type Role } from './secrets.ts'; import { readSecrets, type Role } from './secrets.ts';
const log = logger('helix'); const log = logger('helix');
@@ -49,13 +49,33 @@ export type HelixStream = {
export type HelixUser = { id: string; login: string; display_name: string }; export type HelixUser = { id: string; login: string; display_name: string };
async function call<T>(role: Role, endpoint: string, init: RequestInit = {}): Promise<T> { /**
const app = readSecrets().apps[role]; * `appOk` heisst: dieser Endpunkt kommt auch ohne Nutzer-Token aus.
*
* Das ist kein Detail, sondern der Unterschied zwischen "laeuft" und "wartet
* auf einen Klick": Stream-Erkennung und damit die Go-Live-Ankuendigung
* brauchen nur `GET /users` und `GET /streams`, und beide sind oeffentlich.
* Ohne diese Unterscheidung stuende der ganze Dienst still, bis der
* Broadcaster sein Konto verbindet.
*/
type CallOpts = { appOk?: boolean };
async function call<T>(
role: Role, endpoint: string, init: RequestInit = {}, opts: CallOpts = {}
): Promise<T> {
const secrets = readSecrets();
const app = secrets.apps[role];
if (!app?.clientId) throw new Error(`Fuer "${role}" ist keine Client-ID hinterlegt.`); if (!app?.clientId) throw new Error(`Fuer "${role}" ist keine Client-ID hinterlegt.`);
const hatNutzerToken = Boolean(secrets.tokens[role]);
if (!hatNutzerToken && !opts.appOk) {
throw new Error(`"${role}" ist nicht verbunden - Einrichtung unter /setup.`);
}
return await retry(async () => { return await retry(async () => {
await bucket.take(); await bucket.take();
const token = await accessToken(role); // Das Nutzer-Token gewinnt, wenn es da ist: es sieht mehr (Abos, Chatter)
// und zaehlt auf ein eigenes Kontingent.
const token = hatNutzerToken ? await accessToken(role) : await appToken(role);
const res = await fetch(`${BASE}${endpoint}`, { const res = await fetch(`${BASE}${endpoint}`, {
...init, ...init,
headers: { headers: {
@@ -85,15 +105,26 @@ async function call<T>(role: Role, endpoint: string, init: RequestInit = {}): Pr
export const helix = { export const helix = {
async userByLogin(login: string): Promise<HelixUser | null> { async userByLogin(login: string): Promise<HelixUser | null> {
const r = await call<{ data: HelixUser[] }>('broadcaster', `/users?login=${encodeURIComponent(login)}`); const r = await call<{ data: HelixUser[] }>(
'broadcaster', `/users?login=${encodeURIComponent(login)}`, {}, { appOk: true });
return r.data[0] ?? null; return r.data[0] ?? null;
}, },
async streamByUserId(userId: string): Promise<HelixStream | null> { async streamByUserId(userId: string): Promise<HelixStream | null> {
const r = await call<{ data: HelixStream[] }>('broadcaster', `/streams?user_id=${userId}`); const r = await call<{ data: HelixStream[] }>(
'broadcaster', `/streams?user_id=${userId}`, {}, { appOk: true });
return r.data[0] ?? null; return r.data[0] ?? null;
}, },
/**
* Reicht das Hinterlegte, um den Stream-Zustand abzufragen? Client-ID und
* Secret genuegen dafuer - ein verbundenes Konto ist nicht noetig.
*/
canReadStream(): boolean {
const app = readSecrets().apps.broadcaster;
return Boolean(app?.clientId && app.clientSecret);
},
async chatters(broadcasterId: string, moderatorId: string): Promise<string[]> { async chatters(broadcasterId: string, moderatorId: string): Promise<string[]> {
const out: string[] = []; const out: string[] = [];
let cursor = ''; let cursor = '';
@@ -108,6 +139,48 @@ export const helix = {
return out; return out;
}, },
/**
* Abonnement fuer die WebSocket-Sitzung anlegen.
*
* Zwingend mit dem Nutzer-Token: Twitch verlangt fuer den WebSocket-Weg
* dasselbe Token, mit dem die Sitzung geoeffnet wurde. Ein App-Token wird
* hier abgelehnt, und die Fehlermeldung sagt nicht, warum.
*/
async subscribe(
sub: { type: string; version: string; condition: Record<string, string> },
sessionId: string,
): Promise<{ id: string }> {
const r = await call<{ data: Array<{ id: string }> }>('broadcaster', '/eventsub/subscriptions', {
method: 'POST',
body: JSON.stringify({ ...sub, transport: { method: 'websocket', session_id: sessionId } }),
});
const angelegt = r.data[0];
if (!angelegt) throw new Error(`Abonnement ${sub.type} wurde nicht angelegt`);
return angelegt;
},
/**
* Eine Einloesung abschliessen oder die Punkte zurueckgeben.
*
* Braucht `channel:manage:redemptions`; `channel:read:redemptions` allein
* genuegt nicht. Fehlt der Scope, meldet Twitch 401 - der Aufrufer faengt
* das ab und macht weiter, statt die Aktion daran scheitern zu lassen.
*/
async setRedemptionStatus(
broadcasterId: string, rewardId: string, redemptionId: string,
status: 'FULFILLED' | 'CANCELED',
): Promise<void> {
await call('broadcaster',
`/channel_points/custom_rewards/redemptions?broadcaster_id=${broadcasterId}` +
`&reward_id=${encodeURIComponent(rewardId)}&id=${encodeURIComponent(redemptionId)}`,
{ method: 'PATCH', body: JSON.stringify({ status }) });
},
/** Hat das verbundene Konto diesen Scope freigegeben? */
hasScope(scope: string): boolean {
return (readSecrets().tokens.broadcaster?.scopes ?? []).includes(scope);
},
async createClip(broadcasterId: string): Promise<{ id: string; edit_url: string }> { async createClip(broadcasterId: string): Promise<{ id: string; edit_url: string }> {
const r = await call<{ data: Array<{ id: string; edit_url: string }> }>( const r = await call<{ data: Array<{ id: string; edit_url: string }> }>(
'broadcaster', `/clips?broadcaster_id=${broadcasterId}`, { method: 'POST' }); 'broadcaster', `/clips?broadcaster_id=${broadcasterId}`, { method: 'POST' });
+48 -1
View File
@@ -1,6 +1,6 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { parseLine, toChatMessage } from './irc.ts'; import { parseLine, toChatMessage, parseGifs } from './irc.ts';
const LINE = const LINE =
'@badge-info=subscriber/8;badges=broadcaster/1,subscriber/6;color=#1E90FF;' + '@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?.command, 'PING');
assert.equal(m?.params[0], 'tmi.twitch.tv'); 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`);
}
});
+139
View File
@@ -52,6 +52,48 @@ export function parseLine(line: string): IrcMessage | null {
return { tags, prefix, command, params: parts }; 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'] { function parseEmotes(tag: string | undefined, text: string): ChatMessage['emotes'] {
if (!tag) return []; if (!tag) return [];
const out: ChatMessage['emotes'] = []; const out: ChatMessage['emotes'] = [];
@@ -84,6 +126,7 @@ export function toChatMessage(m: IrcMessage): ChatMessage | null {
displayName: m.tags['display-name'] || login, displayName: m.tags['display-name'] || login,
text, text,
emotes: parseEmotes(m.tags['emotes'], text), emotes: parseEmotes(m.tags['emotes'], text),
gifs: parseGifs(m.tags['gifs']),
isFirst: m.tags['first-msg'] === '1', isFirst: m.tags['first-msg'] === '1',
isReturning: m.tags['returning-chatter'] === '1', isReturning: m.tags['returning-chatter'] === '1',
isCommand: text.startsWith('!'), 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<string, UserNoticeKind> = {
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 = { export type IrcHandlers = {
onMessage: (msg: ChatMessage) => void; onMessage: (msg: ChatMessage) => void;
onNotice?: (text: string) => void; onNotice?: (text: string) => void;
onConnected?: (anonymous: boolean) => 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 = { export type IrcClient = {
@@ -189,6 +312,22 @@ function connectOnce(
continue; continue;
} }
if (m.command === 'NOTICE') { handlers.onNotice?.(m.params[1] ?? ''); 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') { if (m.command === 'PRIVMSG') {
const msg = toChatMessage(m); const msg = toChatMessage(m);
if (msg) handlers.onMessage(msg); if (msg) handlers.onMessage(msg);
+64 -3
View File
@@ -1,7 +1,8 @@
// OAuth-Autorisierungscode-Flow gegen Twitch. Rueckruf laeuft auf denselben // OAuth-Autorisierungscode-Flow gegen Twitch. Rueckruf laeuft auf denselben
// Server wie die Einrichtungsseite (Port 4792) - siehe ADR-0005. // Server wie die Einrichtungsseite - siehe ADR-0005.
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import { loadConfig } from '#core/config.ts';
import { logger } from '#core/logger.ts'; import { logger } from '#core/logger.ts';
import { readSecrets, updateSecrets, type Role, type TokenSet } from './secrets.ts'; import { readSecrets, updateSecrets, type Role, type TokenSet } from './secrets.ts';
@@ -19,6 +20,10 @@ export const SCOPES: Record<Role, string[]> = {
'moderator:read:chatters', 'moderator:read:chatters',
'channel:manage:raids', 'channel:manage:raids',
'channel:read:redemptions', 'channel:read:redemptions',
// Zum Zurueckgeben der Punkte, wenn eine Aktion scheitert oder abgelehnt
// wird. Wer Punkte nimmt und nichts liefert, verliert schneller Vertrauen
// als durch jeden Fehler im Bild.
'channel:manage:redemptions',
'bits:read', 'bits:read',
], ],
bot: ['chat:read', 'chat:edit'], bot: ['chat:read', 'chat:edit'],
@@ -27,9 +32,19 @@ export const SCOPES: Record<Role, string[]> = {
/** Kurzlebige, einmalig einloesbare `state`-Werte gegen CSRF. */ /** Kurzlebige, einmalig einloesbare `state`-Werte gegen CSRF. */
const pending = new Map<string, { role: Role; created: number }>(); const pending = new Map<string, { role: Role; created: number }>();
/**
* Muss exakt so in der Twitch-App hinterlegt sein - Twitch vergleicht
* zeichengenau, und ein fehlender Schraegstrich sieht im Fehlertext aus wie
* ein falsches Client-Secret.
*
* Laeuft der Dienst zentral, steht in `publicUrl` die oeffentliche Adresse.
* Dann ist der Rueckruf `https://.../callback`, und Twitch verlangt dort
* ohnehin HTTPS: `http://localhost` ist die einzige Ausnahme, die Twitch
* ohne Zertifikat akzeptiert.
*/
export function redirectUri(port: number): string { export function redirectUri(port: number): string {
// Muss exakt so in der Twitch-App hinterlegt sein. const base = loadConfig().publicUrl;
return `http://localhost:${port}/callback`; return base ? `${base}/callback` : `http://localhost:${port}/callback`;
} }
export function authorizeUrl(role: Role, port: number): string { export function authorizeUrl(role: Role, port: number): string {
@@ -86,6 +101,52 @@ export async function handleCallback(code: string, state: string, port: number):
return role; return role;
} }
/**
* App-Token (client_credentials). Gehoert keinem Nutzer und braucht keine
* Freigabe - es beweist nur, dass die Anfrage von dieser App kommt.
*
* Damit laufen die oeffentlichen Endpunkte: `GET /users` und `GET /streams`.
* Genau die beiden traegt die Stream-Erkennung, und deshalb muss sie nicht
* darauf warten, dass der Broadcaster sein Konto verbindet. Alles, was ueber
* einen Kanal etwas *tut* oder Privates liest - Clips anlegen, Chatter
* auflisten, Abos sehen - verlangt weiterhin ein Nutzer-Token.
*
* Gemerkt wird nur im Arbeitsspeicher: das Token gilt rund zwei Monate, und
* nach einem Neustart ist es eine einzige Anfrage.
*/
const appTokens = new Map<Role, { token: string; bis: number }>();
export async function appToken(role: Role): Promise<string> {
const gemerkt = appTokens.get(role);
// Fuenf Minuten Puffer, damit kein Aufruf mitten im Ablauf landet
if (gemerkt && gemerkt.bis - Date.now() > 300_000) return gemerkt.token;
const app = readSecrets().apps[role];
if (!app?.clientId || !app.clientSecret) {
throw new Error(`Fuer "${role}" fehlen Client-ID und Secret - Einrichtung unter /setup.`);
}
const res = await fetch(TOKEN, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: app.clientId,
client_secret: app.clientSecret,
grant_type: 'client_credentials',
}),
});
if (!res.ok) {
throw new Error(`App-Token fuer "${role}" nicht erhalten (HTTP ${res.status}). ` +
'Stimmen Client-ID und Secret?');
}
const body = await res.json() as { access_token: string; expires_in: number };
appTokens.set(role, { token: body.access_token, bis: Date.now() + body.expires_in * 1000 });
log.info(`App-Token fuer ${role} geholt (gilt ${Math.round(body.expires_in / 86400)} Tage)`);
return body.access_token;
}
/** Vergisst gemerkte App-Tokens. Fuer Tests und nach einem App-Wechsel. */
export function forgetAppTokens(): void { appTokens.clear(); }
export async function validate(accessToken: string) { export async function validate(accessToken: string) {
const res = await fetch(VALIDATE, { headers: { Authorization: `OAuth ${accessToken}` } }); const res = await fetch(VALIDATE, { headers: { Authorization: `OAuth ${accessToken}` } });
if (!res.ok) throw new Error(`Token ungueltig: HTTP ${res.status}`); if (!res.ok) throw new Error(`Token ungueltig: HTTP ${res.status}`);
+18
View File
@@ -41,6 +41,24 @@ export type SecretStore = {
goLiveWebhook?: string; goLiveWebhook?: string;
recapWebhook?: string; recapWebhook?: string;
clipsWebhook?: string; clipsWebhook?: string;
/** Interner Kanal: Entwuerfe und Fehlermeldungen, nur fuer Mods sichtbar. */
technikWebhook?: string;
};
/**
* Zugang zur Weboberflaeche. Erst noetig, seit der Dienst nicht mehr auf
* 127.0.0.1 haengt - vorher war "wer am Rechner sitzt, darf" die Regel.
*/
web?: {
passwordHash?: string;
passwordSalt?: string;
/** Steht in der Adresse jeder Browser-Source. Darf nur lesen. */
overlayKey?: string;
/**
* Signiert die Sitzungs-Cookies. Liegt hier und nicht im Arbeitsspeicher,
* damit ein Neustart niemanden abmeldet - der Dienst startet bei jedem
* Ausrollen neu.
*/
sessionKey?: string;
}; };
bluesky?: { handle: string; appPassword: string }; bluesky?: { handle: string; appPassword: string };
/** Nur noetig, wenn die Pakete hinter einer Anmeldung liegen. */ /** Nur noetig, wenn die Pakete hinter einer Anmeldung liegen. */
+119
View File
@@ -0,0 +1,119 @@
// Diese Funktion entscheidet, wann der Go-Live-Ping rausgeht. Ein Fehler hier
// ist entweder ein verpasster Stream oder ein zweiter Rundruf an Leute, die
// sich daraufhin die Rolle abnehmen. Beides merkt man erst Tage spaeter.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { streamWechsel, type StreamBeobachtung } from './stream-state.ts';
const LUECKE = 5 * 60_000; // fuenf Minuten, wie in der Vorgabe
const T = Date.parse('2026-09-17T20:00:00Z');
function lage(over: Partial<StreamBeobachtung> = {}): StreamBeobachtung {
return {
live: null, laufendeId: null, zuletztLive: 0, jetzt: T, luckeMs: LUECKE, ...over,
};
}
test('kein Stream, nichts laeuft: nichts passiert', () => {
assert.deepEqual(streamWechsel(lage()), { art: 'unveraendert' });
});
test('der erste Stream wird als Start erkannt', () => {
const w = streamWechsel(lage({ live: { id: '48212345', startedAt: '2026-09-17T19:58:00Z' } }));
assert.deepEqual(w, { art: 'gestartet', id: '48212345', startedAt: '2026-09-17T19:58:00Z' });
});
test('derselbe Stream in der naechsten Abfrage loest nichts aus', () => {
// Bei 60 Sekunden Abfrageabstand passiert das den ganzen Stream lang.
// Wuerde es hier "gestartet" heissen, kaeme jede Minute ein Ping.
const w = streamWechsel(lage({
live: { id: '48212345', startedAt: '2026-09-17T19:58:00Z' },
laufendeId: '48212345', zuletztLive: T - 60_000,
}));
assert.deepEqual(w, { art: 'unveraendert' });
});
test('neue Twitch-ID nach kurzem Aussetzer ist eine Fortsetzung', () => {
// Der Fall aus dem Betrieb: das Internet hakt zwei Minuten, Twitch vergibt
// eine neue Stream-ID. Derselbe Stream, kein zweiter Ping.
const w = streamWechsel(lage({
live: { id: '48299999', startedAt: '2026-09-17T20:02:00Z' },
laufendeId: '48212345', zuletztLive: T - 2 * 60_000,
}));
assert.deepEqual(w, { art: 'fortsetzung', neueId: '48299999' });
});
test('neue ID nach langer Pause ist ein neuer Stream', () => {
const w = streamWechsel(lage({
live: { id: '48299999', startedAt: '2026-09-17T20:00:00Z' },
laufendeId: '48212345', zuletztLive: T - 6 * 60_000,
}));
assert.equal(w.art, 'gestartet');
});
test('genau an der Grenze gilt es noch als Fortsetzung', () => {
// Die Grenze liegt bei "kuerzer als die Luecke". Wer sie auf <= verschiebt,
// baut sich einen Fall, der mal so und mal so ausgeht.
const knappDrunter = streamWechsel(lage({
live: { id: 'neu', startedAt: 'x' }, laufendeId: 'alt', zuletztLive: T - (LUECKE - 1),
}));
assert.equal(knappDrunter.art, 'fortsetzung');
const knappDrueber = streamWechsel(lage({
live: { id: 'neu', startedAt: 'x' }, laufendeId: 'alt', zuletztLive: T - (LUECKE + 1),
}));
assert.equal(knappDrueber.art, 'gestartet');
});
test('ein einzelner fehlgeschlagener Abruf beendet den Stream nicht', () => {
// Twitch antwortet gelegentlich mit einer leeren Liste, obwohl der Stream
// laeuft. Wer daraufhin die Sitzung schliesst, bekommt beim naechsten
// Abruf einen "neuen" Stream - und einen zweiten Ping.
const w = streamWechsel(lage({ laufendeId: '48212345', zuletztLive: T - 60_000 }));
assert.deepEqual(w, { art: 'unveraendert' });
});
test('nach der Luecke ohne Meldung gilt der Stream als beendet', () => {
const w = streamWechsel(lage({ laufendeId: '48212345', zuletztLive: T - 6 * 60_000 }));
assert.deepEqual(w, { art: 'beendet', id: '48212345' });
});
test('ein beendeter Stream wird nicht zweimal beendet', () => {
const nachEnde = streamWechsel(lage({ laufendeId: null, zuletztLive: T - 60 * 60_000 }));
assert.deepEqual(nachEnde, { art: 'unveraendert' });
});
test('der Ablauf eines ganzen Streams ergibt genau einen Start und ein Ende', () => {
// Der Durchlauf, der zaehlt: 90 Minuten Stream, zwei Aussetzer, alle 60 s
// eine Abfrage. Am Ende muss genau ein Ping stehen.
let laufendeId: string | null = null;
let zuletztLive = 0;
const starts: string[] = [];
const enden: string[] = [];
// Minute -> was Twitch meldet. null = Aussetzer.
const verlauf: Array<string | null> = [
...Array(20).fill('A'), // 20 min normal
null, null, // 2 min Aussetzer
...Array(30).fill('B'), // Twitch vergibt eine neue ID
null, // kurzer Hakler
...Array(20).fill('B'),
...Array(10).fill(null), // Stream zu Ende
];
verlauf.forEach((id, minute) => {
const jetzt = T + minute * 60_000;
const w = streamWechsel({
live: id ? { id, startedAt: '2026-09-17T20:00:00Z' } : null,
laufendeId, zuletztLive, jetzt, luckeMs: LUECKE,
});
if (id) zuletztLive = jetzt;
if (w.art === 'gestartet') { laufendeId = w.id; starts.push(w.id); }
if (w.art === 'fortsetzung') { /* ID bewusst behalten */ }
if (w.art === 'beendet') { enden.push(w.id); laufendeId = null; }
});
assert.deepEqual(starts, ['A'], 'genau ein Ping, nicht drei');
assert.deepEqual(enden, ['A'], 'genau ein Streamende');
});
+58
View File
@@ -0,0 +1,58 @@
// Wann ist ein Stream ein *neuer* Stream?
//
// Das klingt trivial und ist es nicht. Twitch vergibt bei einem
// Verbindungsabbruch eine neue Stream-ID, auch wenn der Stream aus Sicht aller
// Beteiligten derselbe ist. Wer das fuer einen Neustart haelt, legt eine
// zweite Sitzung an und schickt einen zweiten Rundruf an alle, die die Rolle
// Live-Ping tragen. Wer zweimal in zehn Minuten angepingt wird, nimmt sich die
// Rolle ab, und dann ist der Kanal fuer diese Person fuer immer stumm.
//
// Deshalb steht die Entscheidung hier, als reine Funktion, mit Tests - statt
// wie vorher zweimal ausgeschrieben in Hub und Ingest, wo sie auseinander
// laufen kann, ohne dass es jemandem auffaellt.
export type StreamBeobachtung = {
/** Was Twitch gerade meldet. `null` heisst: gerade kein Stream. */
live: { id: string; startedAt: string } | null;
/** Die Sitzung, die wir gerade fuehren. */
laufendeId: string | null;
/** Wann Twitch zuletzt "live" gemeldet hat (ms seit Epoche). */
zuletztLive: number;
jetzt: number;
/** Kuerzere Luecken gelten als derselbe Stream. */
luckeMs: number;
};
export type StreamWechsel =
/** Twitch meldet dieselbe Sitzung wie bisher. Nichts zu tun. */
| { art: 'unveraendert' }
/** Ein neuer Stream. Nur hier wird angekuendigt. */
| { art: 'gestartet'; id: string; startedAt: string }
/**
* Neue Twitch-ID, aber die Unterbrechung war kurz genug. Die Sitzung laeuft
* unter der bisherigen ID weiter - und es wird nicht erneut angekuendigt.
*/
| { art: 'fortsetzung'; neueId: string }
/** Lange genug nichts mehr gemeldet: die Sitzung ist zu Ende. */
| { art: 'beendet'; id: string };
export function streamWechsel(b: StreamBeobachtung): StreamWechsel {
if (b.live) {
if (b.laufendeId === b.live.id) return { art: 'unveraendert' };
// Es lief schon etwas, und die Unterbrechung war kurz: Fortsetzung.
// Ohne diesen Zweig zerfaellt ein Stream mit zwei Aussetzern in drei
// Sitzungen - und erzeugt drei Ankuendigungen.
if (b.laufendeId !== null && b.jetzt - b.zuletztLive < b.luckeMs) {
return { art: 'fortsetzung', neueId: b.live.id };
}
return { art: 'gestartet', id: b.live.id, startedAt: b.live.startedAt };
}
// Kein Stream gemeldet. Erst nach Ablauf der Luecke gilt er als beendet -
// sonst beendet ein einzelner fehlgeschlagener Abruf die Sitzung.
if (b.laufendeId !== null && b.jetzt - b.zuletztLive > b.luckeMs) {
return { art: 'beendet', id: b.laufendeId };
}
return { art: 'unveraendert' };
}
+4 -1
View File
@@ -156,7 +156,10 @@ async function main(): Promise<void> {
for (const dir of ['packages', 'apps']) { for (const dir of ['packages', 'apps']) {
for (const rel of await walk(path.join(repo, dir))) { for (const rel of await walk(path.join(repo, dir))) {
if (rel.endsWith('.test.ts')) continue; 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)) }); files.push({ name: `app/${dir}/${rel}`, data: await fs.readFile(path.join(repo, dir, rel)) });
} }
} }
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# Den Hub auf den LXC ausrollen.
#
# Warum ein Skript und kein `git pull` im Container: der Container hat keinen
# Zugang zum Repo, und er soll auch keinen bekommen. Ausgerollt wird, was
# hier im Arbeitsverzeichnis liegt - damit ist immer nachvollziehbar, welcher
# Stand laeuft, auch wenn gerade etwas nicht gepusht ist.
#
# Aufruf: scripts/deploy-hub.sh
# Umgebung (mit Vorgaben):
# PVE_HOST=192.168.2.20 Proxmox-Host
# CT=141 Container-ID
set -euo pipefail
PVE_HOST="${PVE_HOST:-192.168.2.20}"
CT="${CT:-141}"
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
STAND="$(date -u +%Y%m%d-%H%M%S)"
echo "Packe den Arbeitsstand ..."
cd "$REPO"
# node_modules bleibt draussen: das Projekt hat keine Laufzeit-Abhaengigkeiten,
# TypeScript wird nur fuer die Typpruefung gebraucht und nicht im Betrieb.
tar czf "/tmp/abdulive-${STAND}.tgz" \
--exclude='./node_modules' \
--exclude='./.git' \
--exclude='./dist' \
--exclude='./docs/handbuch/build' \
apps packages config scripts package.json tsconfig.json
echo "Schiebe nach ${PVE_HOST} -> CT ${CT} ..."
scp -q "/tmp/abdulive-${STAND}.tgz" "root@${PVE_HOST}:/tmp/abdulive.tgz"
ssh "root@${PVE_HOST}" "pct push ${CT} /tmp/abdulive.tgz /tmp/abdulive.tgz && rm -f /tmp/abdulive.tgz"
rm -f "/tmp/abdulive-${STAND}.tgz"
echo "Entpacke und starte neu ..."
ssh "root@${PVE_HOST}" "pct exec ${CT} -- bash -lc '
set -e
# In einen frischen Ordner entpacken und erst danach umhaengen: bricht das
# Entpacken ab, laeuft der alte Stand unveraendert weiter.
rm -rf /opt/abdulive.neu
mkdir -p /opt/abdulive.neu
tar xzf /tmp/abdulive.tgz -C /opt/abdulive.neu --no-same-owner
rm -f /tmp/abdulive.tgz
rm -rf /opt/abdulive.alt
[ -d /opt/abdulive ] && mv /opt/abdulive /opt/abdulive.alt || true
mv /opt/abdulive.neu /opt/abdulive
systemctl restart abdulive-hub 2>/dev/null || true
'"
echo "Fertig. Stand ${STAND}."
ssh "root@${PVE_HOST}" "pct exec ${CT} -- systemctl is-active abdulive-hub" || true