31 Commits
Author SHA1 Message Date
ZeasyAlexandClaude Opus 5.5 8be195d938 Pult: Handy als Stream Deck mit Soundboard und Szenenwechsel
Paket bauen und veroeffentlichen / paket (push) Failing after 1m21s
- /pult als installierbare Web-App (Manifest, Service Worker, Symbole)
- Soundboard: Sounds vom Handy hochladen, bearbeiten, im Stream spielen
  ueber die neue Browser-Source /sounds
- Szenen, Clip und Stummschalter: der Stream-PC waehlt sich mit eigenem
  Schluessel beim Hub ein (fern:verbinden), Befehle warten auf OBS
- Browser-Sources melden, welche Seite sie sind

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 11:51:48 +01:00
ZeasyAlexandClaude Opus 5.5 70448967f5 Uebergabe: Foren, Spielvorschlaege eingepflegt, Ankuendigung
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 02:15:57 +02:00
ZeasyAlexandClaude Opus 5.5 5494c6d24d Discord: eigener Kanal fuer Beitritte, Spielvorschlaege und Reactions als Forum
Beitrittsmeldungen liefen nach #allgemein, weil Discord ohne Angabe den
ersten Kanal nimmt. Jetzt gehen sie nach #begruessung (SYSTEM_CHANNEL).

Die von Abdu angelegten Textkanaele spielvorschlaege und
reaction-einsendungen sind jetzt Foren im Bauplan: ein Beitrag je Spiel
bzw. Clip, 👍 als Standard-Reaktion, Stand-Tags nur fuer Mods. apply.ts
kann dafuer Tags und Standard-Reaktion anlegen und nachziehen; fremde
Tags bleiben mit ihrer ID stehen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:52:02 +02:00
ZeasyAlexandClaude Opus 5.5 81ab865de0 Uebergabe: Liegestuetze, Vorlese-Freigabe, naechste Sitzung Overlay neu
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 02:47:07 +02:00
ZeasyAlexandClaude Opus 5.5 094a791da2 Liegestuetze: pro Stream mitzaehlen, wie viele gemacht sind
Herunterzaehlen mit -1/-5/-10 zaehlt als gemacht, Setzen bleibt eine
Korrektur und zaehlt nicht. Der Stand "gemacht" beginnt mit jedem neuen
Stream bei 0 (nicht beim Neustart des Hubs mitten im Stream), steht im
Overlay unter der offenen Zahl und laesst sich auf der Uebersicht
korrigieren.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 00:46:32 +02:00
ZeasyAlexandClaude Opus 5.5 eb9eab949e Liegestuetze: Zaehler aus Kanalpunkten, Overlay und Korrektur von Hand
Neue Aktion `liegestuetze` fuer Belohnungen: jede Einloesung zaehlt um
`anzahl` hoch (Vorgabe 5). Der Stand liegt in der Datenbank und
uebersteht einen Neustart. Browser-Source /liegestuetze zeigt ihn, die
Uebersichtsseite hat Knoepfe zum Herunterzaehlen und Setzen.

Nebenbei: POST /tts landete bei der Overlay-Seite statt bei der
Freigabe. Seiten werden jetzt nur noch bei GET ausgeliefert.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 00:15:50 +02:00
ZeasyAlexandClaude Opus 5.5 5622499877 Vorlesen: Wortfilter trifft nicht mehr jedes w, Stimme Thorsten
"www" in der Blockliste wurde beim Zusammenziehen gezogener Buchstaben zu
"w" und traf als kurzer Eintrag jedes Wort, das mit w anfaengt. Im Stream
flog damit fast jede Nachricht raus ("wie", "weg"), die Punkte gingen
zurueck. Was nach dem Normalisieren unter 3 Buchstaben hat, trifft jetzt
nur noch als ganzes Wort.

de_DE-mls-medium, Sprecher 0, klang verwaschen. Die Phoneme stimmten, es
lag am Modell. Vorgabe ist jetzt de_DE-thorsten-medium; im Hub schon so
eingestellt.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 00:17:17 +02:00
ZeasyAlexandClaude Opus 5.5 58e1d98351 Uebergabe: Vorlesen, Piper, Kamerarahmen
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 22:58:36 +02:00
ZeasyAlexandClaude Opus 5.5 2c298a3427 Overlay: Kamerarahmen als Entwurf
Rahmen rechts und unten um die Facecam, darunter eine Platte mit Kanal,
Kategorie, Zuschauern und Laufzeit. Soll die Statusleiste abloesen.

Ueber der Kamera liegt nichts: genau 0,0 480x270 wird fuer die Shorts
ausgeschnitten, das Gameplay ab x=515. Der Rahmen nutzt nur den Streifen
dazwischen und den Platz darunter. Bei Abo, Raid und Bits glueht er kurz
in der Farbe des Ereignisses.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 22:58:36 +02:00
ZeasyAlexandClaude Opus 5.5 63ea40d5a8 Vorlesen: Piper im Hub statt Sprachausgabe im Browser
In OBS' Browser-Quelle kam `speechSynthesis` amerikanisch heraus: der
eingebaute Browser sah keine deutsche Stimme, und die Seite nahm die erste
beliebige. Welche Stimmen es gibt, haengt am Rechner, der streamt.

Jetzt spricht der Hub mit Piper (lokal im LXC, 200 Zeichen in 0,7 s). Die
WAV liegt kurz im Speicher, `/tts` holt sie unter /tts/audio/<id> mit dem
Overlay-Schluessel und spielt sie ueber <audio> ab - das laeuft sicher ueber
OBS' Tonspur. Der Browser springt nur noch mit einer deutschen Stimme ein,
sonst wird nur angezeigt. Stimme und Sprecher stehen in rewards.tts.

Dazu:

- `abdulive rewards:anlegen`: Belohnung ueber die API anlegen. Nur die App,
  die sie angelegt hat, darf Einloesungen abschliessen oder erstatten.
- Einloesungen werden nach der Aktion als FULFILLED markiert, statt in
  Twitchs Warteschlange liegen zu bleiben.
- Wortfilter normalisiert vorher: Leetspeak, Punkte dazwischen, gezogene
  Buchstaben, Akzente. Kurze Eintraege nur am Wortanfang.
- Der Testknopf "Vorlesen" geht durch Piper und prueft so die echte Stimme.
- scripts/install-piper.sh richtet Piper und Stimme im LXC ein.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 22:58:36 +02:00
ZeasyAlexandClaude Opus 5.5 b1cf0f8fab YAML: Listen aus Maps
`- titel: X` zerfiel in Zeichenketten wie "titel: X". Jede Kanalpunkt-
Belohnung in der Konfiguration war damit ohne jede Meldung unwirksam, auch
das Beispiel in config.example.yaml.

Ein Listeneintrag der Form `schluessel: wert` beginnt jetzt eine Map; die
folgenden, tiefer eingerueckten Zeilen gehoeren dazu. `- http://x` bleibt
Text, weil nach dem Doppelpunkt kein Leerzeichen steht.

layout.yaml, config.example.yaml und die Hub-Konfiguration werden
nachweislich gleich gelesen wie vorher.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 22:58:35 +02:00
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
ZeasyAlexandClaude Opus 5 e87879de9e Uebergabe auf den aktuellen Stand
Stand 2026-09-15. Der Discord-Server ist dazugekommen, der Rest ist
unveraendert: S2 wartet weiter auf ein laufendes OBS.

- Neuer Abschnitt "Der Discord-Server steht": was da ist, und dass die drei
  Webhooks fuer Broadcaster, Recap und Clipper bereitliegen. Wer diese
  Werkzeuge baut, muss nur noch posten.
- Neuer Abschnitt "Discord: was gemessen ist", nach dem Vorbild der
  Windows-Fallen. Vier Dinge, die nicht in der Doku stehen, in der man
  zuerst sucht: das Limit von zwei Umbenennungen je zehn Minuten, die
  Rangfolge-Regel hinter dem irrefuehrenden "Missing Permissions", die
  Schwelle fuer Onboarding und die Namensnormalisierung.
- Zwei Handgriffe fuer Abdu ergaenzt: sich selbst die Rolle geben, und den
  Einladungslink verteilen. An Letzterem haengt, ob der Server ueberhaupt
  jemanden bekommt.
- Ehrlich dazugeschrieben, was offen ist: der Knopf haengt am Daemon, und
  der Bot ist nie unter Last gelaufen.
- Der Hinweis auf die zwei Remotes, damit sie nicht auseinanderlaufen.
- 93 -> 162 Tests.

offene-punkte.md bekommt einen Discord-Abschnitt, die Roadmap den Hinweis,
dass die Discord-Seite von M3 schon steht.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 22:05:03 +02:00
ZeasyAlexandClaude Opus 5 994ce4faf0 Discord: Knopf fuer die Live-Ping-Rolle statt Onboarding
Live-Ping nuetzt nur, wenn man sie sich selbst geben kann. Discords
Onboarding waere der vorgesehene Weg, laesst sich hier aber nicht
einschalten - gemessen, nicht vermutet:

  HTTP 400  Cannot enable onboarding, requirements are not met  (350000)
  sichtbar fuer @everyone: 5   davon beschreibbar: 3
  Discord verlangt:        7   davon beschreibbar: 5

Zwei Kanaele anzulegen, damit eine Schwelle passt, macht den Server leerer
statt besser. Stattdessen ein Knopf unter dem Willkommenstext: ein Druck
holt die Rolle, der naechste gibt sie ab, die Antwort sieht nur, wer
geklickt hat.

Der Knopf braucht eine dauerhafte Verbindung, weil Discord Klicks ans
Gateway oder an eine oeffentliche HTTPS-Adresse liefert und der Stream-PC
keine hat:

- gateway.ts   WebSocket mit Herzschlag, Fortsetzen und Wiederverbinden.
               Bei 4004 und Verwandten wird aufgegeben statt im Kreis zu
               laufen - dagegen hilft nur eine Aenderung an der App.
               intents: 0, der Bot sieht weder Nachrichten noch Mitglieder.
- selfroles.ts Knopfleiste und Rollenwechsel.
- bot.ts       beides zusammen, laeuft im Ingest-Daemon mit. Ohne
               hinterlegtes Token entfaellt er wortlos.

Neuer Befehl `abdulive discord:bot` zum Ausprobieren; im Normalbetrieb
haengt der Bot im Daemon und meldet sich als discordConnected auf der
Statusseite.

Die Knopfleiste gehoert zum Soll-Zustand des Willkommenstextes: fehlt sie,
wird sie nachgereicht, ohne die Nachricht neu zu posten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 21:59:08 +02:00
ZeasyAlexandClaude Opus 5 3b744caf6a Discord-Community-Server: Bauplan und Einrichtungsbefehl
Der Server ist kein Tool aus der Neunerliste, aber Broadcaster, Recap und
Clipper posten dorthin. Deshalb steht die Struktur als Code im Repo und
nicht nur in einer Oberflaeche.

packages/discord:
- permissions.ts  Rechtebits als BigInt, das Feld ist 64 Bit breit
- api.ts          REST-Client: Warteschlange, Rate Limits, lesbare Fehler
- plan.ts         Rollen, Kanaele, Rechte, AutoMod, Willkommenstext
- apply.ts        traegt den Bauplan auf einen bestehenden Server auf

Neuer Befehl `abdulive discord:setup [--server ID] [--vorschau]`.

Zugeschnitten auf 5 bis 30 gleichzeitige Zuschauer: fuenf sichtbare Kanaele
statt zwanzig, und eine selbst vergebene Rolle Live-Ping statt eines
Rundrufs an alle bei jedem Stream.

Wiederholbar statt einmalig: gefunden wird ueber Namen und frueheren Namen,
danach wird angeglichen. Der zweite Lauf aendert nichts. Geloescht wird nie,
und von Hand gesetzte Rechte-Ausnahmen bleiben stehen.

Drei Dinge, die am echten Server nachgemessen und nicht angenommen wurden:
- Discord schreibt Textkanaele klein und macht aus Leerzeichen Bindestriche;
  Emoji, Umlaute und U+2502 bleiben. normalizeName() bildet das ab, sonst
  geht bei jedem Lauf dieselbe Umbenennung erneut raus.
- Kanalnamen lassen sich nur zweimal je zehn Minuten aendern, danach
  verlangt Discord bis zu neun Minuten Pause. Der Lauf wartet hoechstens
  eine Minute und meldet den Rest.
- Eine Rolle kann nur Rollen unterhalb der eigenen verschieben, auch mit
  Administrator. rolePositions() rechnet den Platz unter der Bot-Rolle aus,
  statt blind 3, 2, 1 zu verlangen.

Zugangsdaten laufen ueber den verschluesselten Speicher (ADR-0005): der
Bot-Token wird verdeckt abgefragt, die drei Webhook-URLs werden nie
ausgegeben und nie geloggt.

Im Quelltext bleibt es bei ae/oe/ue, alles was ein Mitglied auf Discord
liest steht in richtigem Deutsch. Ein Test haelt das fest.

Begruendung, Fallstricke und die Schritte von Hand:
docs/projects/discord-community.md

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 20:43:21 +02:00
ZeasyAlexandClaude Opus 5 d231e1c722 Uebergabe auf den aktuellen Stand
Version 0.5.4, 93 Tests, acht ADRs. Neu darin:

- Auslieferung: was build-package.ts tut, wie die Veroeffentlichung
  entsteht, und dass die CI-Pipeline noch nie gelaufen ist
- Vier Windows-Fallen, die je einen Fehler verursacht haben und im
  Fehlerfall nicht nach ihrer Ursache aussehen: stderr als
  NativeCommandError, Rueckgabewert von & plus $LASTEXITCODE, localhost
  gegen 127.0.0.1, ffmpeg-Filterpfade
- Untertitel: die gemessenen Zahlen statt der alten Schaetzung
- Hinweis, dass das Repo oeffentlich ist

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AhVSfMFdWnXKCv5JfLnArZ
2026-09-09 20:54:58 +02:00
ZeasyAlexandClaude Opus 5 7775c689be Handbuch als LaTeX, mit Bauskript
docs/handbuch/handbuch.tex plus bauen.ps1. Acht Seiten: was das Werkzeug
tut, Installation, Einrichtung Schritt fuer Schritt, OBS, Betrieb,
Updates, Sicherung, Deinstallation, Fehlersuche, Dateiablage.

Inhalte aus dem Quelltext gezogen, nicht geschaetzt: Abklingzeiten,
Tonspuren, Ausgabedateien, Routen der Einrichtungsseite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AhVSfMFdWnXKCv5JfLnArZ
2026-09-09 20:12:26 +02:00
ZeasyAlexandClaude Opus 5 04da6ef802 Doku: Abschnitt zur Deinstallation
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AhVSfMFdWnXKCv5JfLnArZ
2026-09-09 20:05:39 +02:00
ZeasyAlexandClaude Opus 5 bc5d8ad07b Deinstallation: es gab keine
Wer das Toolkit testweise installiert hatte, konnte es nur von Hand wieder
abraeumen - zwei Aufgaben im Aufgabenplaner, ein laufender Prozess, zwei
Ordner. Das ist jetzt ein Skript.

Die Daten bleiben per Vorgabe stehen: in der Datenbank liegen Chatverlaeufe,
und die sind nach einer versehentlichen Deinstallation weg. -AuchDaten
raeumt auch die ab.

Getestet an einer laufenden Installation: Aufgaben weg, Prozess beendet,
Programm geloescht, Port 4792 frei.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AhVSfMFdWnXKCv5JfLnArZ
2026-09-09 20:05:12 +02:00
ZeasyAlexandClaude Opus 5 2cdb4690e5 Doku: der Installer macht die drei Schritte selbst
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AhVSfMFdWnXKCv5JfLnArZ
2026-09-09 18:20:16 +02:00
ZeasyAlexandClaude Opus 5 3f21f1c725 Installer meldete Fehler, wo keine waren
Zwei Fehler, beide in scripts/install.ps1:

1. Die Hilfsfunktion Ruf gab die Programmausgabe mit zurueck. `return
   $LASTEXITCODE` nach `& $Datei` liefert ein Array aus allen Ausgabe-
   zeilen plus Exit-Code - jeder Vergleich damit ist falsch. Deshalb galt
   ein erfolgreiches fetch-model als fehlgeschlagen und die check-env-
   Ausgabe verschwand. Jetzt ueber Out-Host.

2. Die Bereitschaftspruefung fragte http://localhost:4792/health ab. Der
   Daemon lauscht nur auf IPv4, Windows loest localhost aber zuerst nach
   ::1 auf: gemessen 2066 ms bis zum Rueckfall gegenueber 15 ms direkt.
   Bei einem Zeitlimit von 2 s lief die Pruefung jedes Mal ab, obwohl der
   Daemon laengst lief. Jetzt 127.0.0.1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AhVSfMFdWnXKCv5JfLnArZ
2026-09-09 18:18:15 +02:00
ZeasyAlexandClaude Opus 5 18421a1af0 Starter brachte den Daemon bei seiner ersten Logzeile um
Die Aufgabe scheiterte mit Code 1, abdulive.log gab es gar nicht. Ursache:

  & $node $main ingest 2>&1 | ForEach-Object { ... }

Windows PowerShell 5.1 verpackt jede stderr-Zeile eines nativen Programms
in einen ErrorRecord. Zusammen mit $ErrorActionPreference = Stop beendet
das sofort das ganze Skript - und der Daemon schreibt im Normalbetrieb
Warnungen auf stderr ("Kein Broadcaster-Token ..."). Er starb also an
seiner eigenen ersten Meldung, bevor irgendetwas ins Protokoll kam.

- launcher.ps1 startet jetzt ueber Start-Process mit getrennten
  Umleitungen fuer stdout und stderr, je Lauf rotiert
- install.ps1 ruft native Programme ueber eine Hilfsfunktion auf, die
  ErrorActionPreference kurz zurueckstellt
- NODE_OPTIONS=--disable-warning=ExperimentalWarning fehlte im Paket; im
  Container stand es in der Dockerfile

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AhVSfMFdWnXKCv5JfLnArZ
2026-09-09 18:13:00 +02:00
ZeasyAlexandClaude Opus 5 56af15cf3f Installer macht alles: Modell, Selbsttest, Start, Browser
Vorher endete die Installation mit drei Befehlen zum Abtippen. Es gibt
keinen Grund dafuer - der Installer laedt jetzt das Sprachmodell, laeuft
den Selbsttest, startet den Daemon, wartet auf /health und oeffnet die
Einrichtungsseite.

Ein fehlgeschlagener Modell-Download bricht nichts ab: das Toolkit laeuft
auch ohne, es entstehen dann Clips ohne Untertitel. Schalter -OhneModell
und -KeinBrowser fuer den Fall, dass man das nicht will.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AhVSfMFdWnXKCv5JfLnArZ
2026-09-09 18:06:39 +02:00
ZeasyAlexandClaude Opus 5 92f14eaa62 Doku: Installation mit einem Befehl
Getestet wie Abdu es tippt, in frischer Umgebung: laedt, prueft die
Pruefsumme, installiert, legt die Aufgaben an. check-env danach ohne
harte Fehler.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AhVSfMFdWnXKCv5JfLnArZ
2026-09-09 18:01:50 +02:00
ZeasyAlexandClaude Opus 5 55ee11cef8 Installer: Einzeiler funktioniert, seit das Repo oeffentlich ist
Die Vorgabe fuer -Quelle zeigt jetzt auf die gepruefte Veroeffentlichung.
Damit reicht ein Befehl:

  irm .../scripts/install.ps1 | iex

Die Pruefung auf HTML statt JSON bleibt drin - sie war der Grund, warum
der erste Versuch als PowerShell-Syntaxfehler endete statt als Fehlermeldung.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AhVSfMFdWnXKCv5JfLnArZ
2026-09-09 17:59:13 +02:00
108 changed files with 15253 additions and 212 deletions

No files matched your search

+7
View File
@@ -25,3 +25,10 @@ Thumbs.db
# Zwischenspeicher des Paketbaus und sein Ergebnis # Zwischenspeicher des Paketbaus und sein Ergebnis
.cache/ .cache/
dist/ dist/
# LaTeX-Zwischendateien
docs/handbuch/*.aux
docs/handbuch/*.log
docs/handbuch/*.out
docs/handbuch/*.toc
docs/handbuch/*.png
+10 -18
View File
@@ -43,6 +43,7 @@ Dazu der Dienst, der unter allen anderen liegt:
- [`docs/offene-punkte.md`](docs/offene-punkte.md) — was noch fehlt - [`docs/offene-punkte.md`](docs/offene-punkte.md) — was noch fehlt
- [`docs/obs-layout.md`](docs/obs-layout.md) — die Layout-Vorgabe für OBS - [`docs/obs-layout.md`](docs/obs-layout.md) — die Layout-Vorgabe für OBS
- [`docs/projects/`](docs/projects/) — je ein Spec-Dokument pro Tool - [`docs/projects/`](docs/projects/) — je ein Spec-Dokument pro Tool
- [`docs/projects/discord-community.md`](docs/projects/discord-community.md) — Aufbau des Community-Servers
- [`docs/decisions/`](docs/decisions/) — Architekturentscheidungen (ADRs) - [`docs/decisions/`](docs/decisions/) — Architekturentscheidungen (ADRs)
## Auf dem Stream-PC installieren ## Auf dem Stream-PC installieren
@@ -51,30 +52,21 @@ Eine native Windows-Anwendung. Node, ffmpeg, whisper.cpp und die Schriften
sind mitgeliefert — vorausgesetzt wird nur OBS. Kein Docker, kein WSL2, nichts sind mitgeliefert — vorausgesetzt wird nur OBS. Kein Docker, kein WSL2, nichts
zu übersetzen ([ADR-0007](docs/decisions/ADR-0007-native-windows.md)). zu übersetzen ([ADR-0007](docs/decisions/ADR-0007-native-windows.md)).
Das Paket herunterladen, entpacken, darin `install.ps1` ausführen. Ohne Ein Befehl, ohne Administratorrechte. Programm und Daten landen im
Administratorrechte; Programm und Daten landen im Benutzerprofil. Benutzerprofil.
```powershell ```powershell
Expand-Archive .\abdulive-0.5.0-win-x64.zip -DestinationPath .\abdulive irm https://git.zeasy.dev/ZeasyAlex/abdulive/raw/branch/main/scripts/install.ps1 | iex
.\abdulive\install.ps1
``` ```
> Einen `irm … | iex`-Einzeiler gibt es nicht: `git.zeasy.dev` verlangt Kein Netz auf dem Stream-PC? Dann das Paket von der Releases-Seite laden,
> instanzweit eine Anmeldung und antwortet auf **jede** Adresse mit einer entpacken und darin `install.ps1` ausführen.
> Umleitung auf `/user/login`. `iex` führt die Anmeldeseite dann als
> PowerShell aus. Wo die Pakete künftig liegen sollen, ist noch offen —
> siehe [`offene-punkte.md`](docs/offene-punkte.md).
Danach einmalig: Der Installer lädt danach das Sprachmodell, läuft den Selbsttest, startet
den Daemon und öffnet die Einrichtungsseite. Ein Befehl, und du bist da.
```powershell Dort die Twitch-Zugangsdaten eintragen. Sie werden verschlüsselt abgelegt,
abdulive fetch-model # Sprachmodell, ca. 465 MB nicht in einer Datei im Projekt
abdulive check-env # Selbsttest
Start-ScheduledTask -TaskName abdulive
```
Dann **http://localhost:4792/setup** öffnen und die Twitch-Zugangsdaten
eintragen. Sie werden verschlüsselt abgelegt, nicht in einer Datei im Projekt
([ADR-0005](docs/decisions/ADR-0005-zugangsdaten.md)). ([ADR-0005](docs/decisions/ADR-0005-zugangsdaten.md)).
Ausführlich: [`docs/05-setup.md`](docs/05-setup.md). Ausführlich: [`docs/05-setup.md`](docs/05-setup.md).
+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);
}
+407 -1
View File
@@ -557,13 +557,399 @@ async function listSources(profileName = 'default'): Promise<void> {
const rectStr = (r: Rect) => `x ${r.x}..${r.x + r.w}, y ${r.y}..${r.y + r.h}`; const rectStr = (r: Rect) => `x ${r.x}..${r.x + r.w}, y ${r.y}..${r.y + r.h}`;
// ---------------------------------------------------------------- discord
/** Liest eine Eingabe, ohne sie im Terminal anzuzeigen. */
function promptHidden(frage: string): Promise<string> {
return new Promise((resolve, reject) => {
const input = process.stdin;
if (!input.isTTY) {
reject(new Error(`${frage.trim()} - dafuer wird ein Terminal gebraucht.`));
return;
}
process.stdout.write(frage);
input.setRawMode(true);
input.resume();
input.setEncoding('utf8');
let buf = '';
const stop = (): void => { input.off('data', onData); input.setRawMode(false); input.pause(); };
const onData = (chunk: string): void => {
for (const c of chunk) {
if (c === '\r' || c === '\n') { stop(); process.stdout.write('\n'); resolve(buf.trim()); return; }
if (c === '\u0003') { stop(); process.stdout.write('\n'); reject(new Error('Abgebrochen.')); return; }
if (c === '\u007f' || c === '\b') { buf = buf.slice(0, -1); continue; }
buf += c;
}
};
input.on('data', onData);
});
}
async function discordSetup(args: string[]): Promise<void> {
const dry = args.includes('--vorschau');
const { readSecrets, updateSecrets } = await import('#twitch/secrets.ts');
const stored = readSecrets().discord ?? {};
const at = args.indexOf('--server');
const fromArgs = at >= 0 ? args[at + 1] : undefined;
const guildId = fromArgs ?? stored.guildId ?? process.env.DISCORD_GUILD_ID;
if (!guildId || guildId.startsWith('--')) {
throw new Error(
'Keine Server-ID. Aufruf: abdulive discord:setup --server <ID>\n' +
'Die ID steht in Discord im Rechtsklickmenue des Servers unter "Server-ID kopieren"\n' +
'(dafuer in den Einstellungen unter "Erweitert" den Entwicklermodus einschalten).'
);
}
const token = stored.botToken || process.env.DISCORD_BOT_TOKEN ||
await promptHidden('Discord-Bot-Token (Eingabe wird nicht angezeigt): ');
if (!token) throw new Error('Ohne Bot-Token geht es nicht.');
const { Discord } = await import('#discord/api.ts');
const { applyPlan } = await import('#discord/apply.ts');
const result = await applyPlan(new Discord(token), guildId, { dryRun: dry });
if (!dry) updateSecrets((s) => { s.discord = { ...s.discord, botToken: token, guildId }; });
const mark: Record<string, string> = {
erstellt: `${C.ok} NEU ${C.off}`,
angepasst: `${C.ok} GESETZT${C.off}`,
unveraendert: `${C.dim} steht ${C.off}`,
uebersprungen: `${C.warn} UEBERSPRUNGEN${C.off}`,
};
console.log(`\nServer: ${result.guild}${dry ? ` ${C.dim}(Vorschau - nichts geaendert)${C.off}` : ''}\n`);
let kind = '';
for (const s of result.steps) {
if (s.kind !== kind) { console.log(`${C.dim}${s.kind}${C.off}`); kind = s.kind; }
const note = s.note ? ` ${C.dim}(${s.note})${C.off}` : '';
console.log(` ${mark[s.change] ?? s.change} ${s.name}${note}`);
}
const skipped = result.steps.filter((s) => s.change === 'uebersprungen').length;
console.log('');
if (dry) {
console.log('Nichts geaendert. Ohne --vorschau wird es angewendet.');
return;
}
console.log(`${result.webhooks} Webhook-URLs im verschluesselten Speicher abgelegt.`);
if (skipped) console.log(`${C.warn}${skipped} Punkt(e) uebersprungen - siehe Hinweise oben.${C.off}`);
console.log(`
Was jetzt noch von Hand gehoert:
1. Dir selbst die Rolle "Abdu" geben und die Bot-Rolle in den
Servereinstellungen ueber "Abdu" ziehen.
2. Server-Einstellungen > Onboarding: "Live-Ping" als waehlbare Rolle
anbieten, damit sie sich jeder selbst holen kann.
3. Einladungslink erzeugen (ohne Ablaufdatum) und in Twitch-Panel,
Kanalinfo und Chat-Befehl eintragen.
4. Twitch-Konto in den Servereinstellungen unter "Integrationen"
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.`);
}
/**
* Den Stream-PC beim Hub anmelden. Prueft vorher, ob der Hub den Schluessel
* annimmt - ein Tippfehler soll hier auffallen und nicht erst im Stream, wenn
* der Szenenknopf am Handy nichts tut.
*/
async function fernVerbinden(args: string[]): Promise<void> {
const { readSecrets, updateSecrets } = await import('#twitch/secrets.ts');
const [adresse, schluessel] = args;
if (adresse === 'aus') {
updateSecrets((s) => { delete s.fern; });
console.log('Fernsteuerung getrennt. Wirkt nach dem naechsten Start von abdulive ingest.');
return;
}
if (!adresse || !schluessel) {
const da = readSecrets().fern;
console.log(da ? `Eingerichtet fuer ${da.url}.` : 'Nicht eingerichtet.');
console.log('Aufruf: abdulive fern:verbinden <adresse> <schluessel> (steht auf der Uebersichtsseite)');
return;
}
const url = adresse.replace(/\/+$/, '');
if (!/^https?:\/\//.test(url)) throw new Error('Die Adresse muss mit https:// anfangen.');
const probe = new AbortController();
let status: number;
try {
const res = await fetch(`${url}/fern/befehle`, {
headers: { authorization: `Bearer ${schluessel}` },
signal: AbortSignal.any([probe.signal, AbortSignal.timeout(10_000)]),
});
status = res.status;
} catch (err) {
throw new Error(`Hub unter ${url} nicht erreichbar: ${(err as Error).message}`);
} finally {
// Die Leitung nur oeffnen, um den Schluessel zu pruefen. Offen gelassen
// haette sie der Hub als verbundenen PC gezaehlt.
probe.abort();
}
if (status === 403) throw new Error('Der Hub lehnt den Schluessel ab. Neu von der Uebersichtsseite kopieren.');
if (status !== 200) throw new Error(`Der Hub antwortet ${status}. Laeuft dort eine Fassung mit Pult?`);
updateSecrets((s) => { s.fern = { url, key: schluessel }; });
console.log(`${C.ok}Verbunden.${C.off} Der Hub nimmt den Schluessel an.`);
console.log('Jetzt abdulive ingest neu starten - danach steuert das Pult OBS.');
}
// ---------------------------------------------------------------- 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> {
const { createBot } = await import('#discord/bot.ts');
const bot = createBot();
if (!bot) {
throw new Error('Kein Discord-Bot hinterlegt. Erst einrichten: abdulive discord:setup');
}
const abort = new AbortController();
const stop = (sig: string): void => {
console.log(`\n${sig} empfangen, trenne die Verbindung.`);
abort.abort();
setTimeout(() => process.exit(0), 300);
};
process.on('SIGINT', () => stop('SIGINT'));
process.on('SIGTERM', () => stop('SIGTERM'));
console.log('Discord-Bot laeuft. Beenden mit Strg+C.');
console.log(`${C.dim}Im Normalbetrieb laeuft er im Ingest-Daemon mit; dieser Befehl ` +
`ist zum Ausprobieren.${C.off}`);
await bot.run(abort.signal);
}
// ---------------------------------------------------------------- rewards:anlegen
/**
* Eine Kanalpunkt-Belohnung ueber die API anlegen statt im Dashboard.
*
* Der Grund ist nicht Bequemlichkeit: Twitch laesst nur die App, die eine
* Belohnung angelegt hat, deren Einloesungen aendern. Eine im Dashboard
* angelegte Belohnung kann das Toolkit weder erstatten noch als erledigt
* markieren. Wiederholbar: steht die Belohnung schon, wird nur ihre ID
* gezeigt.
*/
async function rewardsAnlegen(args: string[]): Promise<void> {
const wert = (name: string): string | undefined => {
const i = args.indexOf(name);
return i === -1 ? undefined : args[i + 1];
};
const titel = wert('--titel');
const kosten = Number(wert('--kosten'));
if (!titel || !Number.isInteger(kosten) || kosten < 1) {
throw new Error('Aufruf: rewards:anlegen --titel "Nachricht vorlesen" --kosten 1000 ' +
'[--text] [--hinweis "..."] [--abklingzeit SEKUNDEN]');
}
const cfg = loadConfig();
const { helix } = await import('#twitch/helix.ts');
const user = await helix.userByLogin(cfg.channel.login);
if (!user) throw new Error(`Kanal ${cfg.channel.login} nicht gefunden`);
const alle = await helix.customRewards(user.id);
const eigene = new Set((await helix.customRewards(user.id, true)).map((r) => r.id));
const da = alle.find((r) => r.title.toLowerCase() === titel.toLowerCase());
if (da) {
console.log(`Steht schon: "${da.title}" (${da.cost} Punkte), ID ${da.id}`);
if (!eigene.has(da.id)) {
console.log('Achtung: im Dashboard angelegt, nicht vom Toolkit. Erstatten und ' +
'Abschliessen gehen damit nicht. Im Dashboard loeschen und hier neu anlegen.');
}
return;
}
const abklingzeit = Number(wert('--abklingzeit') ?? 0);
const neu = await helix.createCustomReward(user.id, {
title: titel,
cost: kosten,
...(args.includes('--text') ? { is_user_input_required: true } : {}),
...(wert('--hinweis') ? { prompt: wert('--hinweis')! } : {}),
...(abklingzeit > 0
? { is_global_cooldown_enabled: true, global_cooldown_seconds: abklingzeit } : {}),
should_redemptions_skip_request_queue: false,
});
console.log(`Angelegt: "${neu.title}" (${neu.cost} Punkte), ID ${neu.id}`);
console.log('In config.yaml unter rewards.belohnungen mit `id:` eintragen - die ID gewinnt gegen den Titel.');
}
// ---------------------------------------------------------------- help // ---------------------------------------------------------------- help
function help(): void { 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
fern:verbinden <adresse> <schluessel>
Stream-PC beim Hub anmelden, damit das Pult OBS
steuern kann. Der Befehl steht fertig auf der
Uebersichtsseite des Hubs. "aus" statt Adresse trennt.
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)
@@ -571,6 +957,14 @@ abdulive - Werkzeugkasten fuer Abdus Stream
sources [profil] OBS-Quellen auflisten: was landet im 9:16-Ausschnitt? sources [profil] OBS-Quellen auflisten: was landet im 9:16-Ausschnitt?
update [--force] Nach einer neuen Fassung sehen und sie bereitlegen update [--force] Nach einer neuen Fassung sehen und sie bereitlegen
render <datei> [profil] Rohdatei nach 9:16 rendern (zum Nachrendern) render <datei> [profil] Rohdatei nach 9:16 rendern (zum Nachrendern)
discord:setup [--server ID] [--vorschau]
Discord-Server nach docs/projects/discord-community.md einrichten
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
rewards:anlegen --titel T --kosten N [--text] [--hinweis H] [--abklingzeit S]
Kanalpunkt-Belohnung ueber die API anlegen (noetig
zum Erstatten, siehe apps/hub/src/rewards.ts)
help Diese Uebersicht help Diese Uebersicht
Einrichtung nach dem Start: http://localhost:4792/setup Einrichtung nach dem Start: http://localhost:4792/setup
@@ -601,6 +995,18 @@ try {
break; break;
} }
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 'golive:test': await goLiveTest(args); break;
case 'rewards:anlegen': await rewardsAnlegen(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 'fern:verbinden': await fernVerbinden(args); break;
case 'discord:bot': await discordBot(); break;
case 'help': case undefined: help(); break; case 'help': case undefined: help(); break;
default: default:
console.error(`Unbekannter Befehl "${cmd}".`); console.error(`Unbekannter Befehl "${cmd}".`);
+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`.
+36
View File
@@ -0,0 +1,36 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createAudioAblage } from './audio-ablage.ts';
const wav = (n: number): Buffer => Buffer.alloc(44 + n);
test('abgelegter Ton ist unter seiner ID abholbar', () => {
const a = createAudioAblage();
a.ablegen('x', wav(1));
assert.equal(a.holen('x')?.length, 45);
assert.equal(a.holen('y'), undefined);
});
test('laeuft sie voll, faellt das Aelteste heraus', () => {
const a = createAudioAblage({ maximal: 2 });
a.ablegen('1', wav(1));
a.ablegen('2', wav(2));
a.ablegen('3', wav(3));
assert.equal(a.holen('1'), undefined);
assert.ok(a.holen('2'));
assert.ok(a.holen('3'));
});
test('abgelaufener Ton ist weg', async () => {
const a = createAudioAblage({ haltbarMs: 5 });
a.ablegen('x', wav(1));
await new Promise((r) => setTimeout(r, 20));
assert.equal(a.holen('x'), undefined);
});
test('leeren wirft alles weg - fuer den Panikschalter', () => {
const a = createAudioAblage();
a.ablegen('x', wav(1));
a.leeren();
assert.equal(a.holen('x'), undefined);
});
+43
View File
@@ -0,0 +1,43 @@
// Fertig gesprochene Nachrichten, bis die Browser-Quelle sie abholt.
//
// Warum nicht direkt im Ereignis: eine WAV von 200 Zeichen hat gut 500 KB.
// Der Ereignisstrom geht an alle Browser-Sources, auch an Chat und
// Statusleiste - die sollen das nicht mitschleppen. Also geht nur die ID
// hinaus, und `/tts` holt sich den Ton einzeln.
//
// Bewusst im Arbeitsspeicher und kurzlebig: niemand braucht eine vorgelesene
// Nachricht von gestern, und fremder Text soll nicht auf der Platte liegen.
export type AudioAblage = {
ablegen: (id: string, wav: Buffer) => void;
holen: (id: string) => Buffer | undefined;
leeren: () => void;
};
export function createAudioAblage(opts: { maximal?: number; haltbarMs?: number } = {}): AudioAblage {
const maximal = opts.maximal ?? 20;
const haltbarMs = opts.haltbarMs ?? 10 * 60_000;
// Map haelt die Einfuegereihenfolge; das Aelteste steht vorn.
const ablage = new Map<string, { wav: Buffer; seit: number }>();
const aufraeumen = (jetzt: number): void => {
for (const [id, e] of ablage) if (jetzt - e.seit > haltbarMs) ablage.delete(id);
while (ablage.size > maximal) {
const aeltestes = ablage.keys().next().value;
if (aeltestes === undefined) break;
ablage.delete(aeltestes);
}
};
return {
ablegen(id, wav) {
ablage.set(id, { wav, seit: Date.now() });
aufraeumen(Date.now());
},
holen(id) {
aufraeumen(Date.now());
return ablage.get(id)?.wav;
},
leeren: () => ablage.clear(),
};
}
+564
View File
@@ -0,0 +1,564 @@
// 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 { piperFehlt, sprechen, stimmenPfad } from '#media/piper.ts';
import {
ausEventSub, createDrossel, createWarteschlange, entscheiden,
} from './rewards.ts';
import { createAudioAblage } from './audio-ablage.ts';
import { createLiegestuetze } from './liegestuetze.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();
const liegestuetze = createLiegestuetze(db);
// Gleich zu Beginn in den Bus, sonst zeigt eine frisch verbundene
// Browser-Source 0, bis die naechste Einloesung kommt.
bus.publish({ kind: 'liegestuetze', ...liegestuetze.stand() });
/**
* Eine Einloesung abschliessen (FULFILLED) oder die Punkte zurueckgeben
* (CANCELED). Geht nur bei Belohnungen, die das Toolkit selbst angelegt hat
* (`abdulive rewards:anlegen`) - sonst lehnt Twitch ab, und es bleibt bei
* einer Warnung.
*/
const einloesungSetzen = async (
rewardId: string, redemptionId: string, status: 'FULFILLED' | 'CANCELED',
): Promise<void> => {
const was = status === 'CANCELED' ? 'Punkte nicht erstattet' : 'Einloesung nicht abgeschlossen';
if (!state.broadcasterId) return;
if (!helix.hasScope('channel:manage:redemptions')) {
log.warn(`${was}: dem verbundenen Konto fehlt ` +
'channel:manage:redemptions. Einmal neu verbinden unter /setup.');
return;
}
try {
await helix.setRedemptionStatus(state.broadcasterId, rewardId, redemptionId, status);
} catch (err) {
log.warn(`${was}: ${(err as Error).message}`);
}
};
const erstatten = (rewardId: string, redemptionId: string): Promise<void> =>
einloesungSetzen(rewardId, redemptionId, 'CANCELED');
// ---- Vorlesen. Die Stimme entsteht hier im Hub (Piper), nicht im Browser:
// dort haengt sie am Rechner, der streamt, und kam in OBS amerikanisch.
const stimme = {
model: stimmenPfad(cfg.rewards.tts.stimme),
...(typeof cfg.rewards.tts.sprecher === 'number' ? { speaker: cfg.rewards.tts.sprecher } : {}),
};
const audio = createAudioAblage();
const stimmeFehlt = piperFehlt(stimme);
if (stimmeFehlt.length) {
log.warn(`Vorlesen ohne Stimme - es fehlt: ${stimmeFehlt.join(', ')}. ` +
'Einrichten mit scripts/install-piper.sh. Bis dahin wird nur angezeigt.');
}
const vorlesen = async (
id: string, name: string, text: string,
/** Fehlt beim Testknopf - dann gibt es bei Twitch nichts abzuschliessen. */
einloesung?: { rewardId: string; redemptionId: string },
): Promise<void> => {
let mitTon = false;
try {
audio.ablegen(id, await sprechen(text, stimme));
mitTon = true;
} catch (err) {
// Lieber stumm anzeigen als gar nicht: wer Punkte ausgegeben hat, soll
// seine Nachricht wenigstens im Bild sehen.
log.warn(`Nicht gesprochen, nur angezeigt: ${(err as Error).message}`);
}
bus.publish({ kind: 'tts', id, name, text, audio: mitTon });
log.info(`Wird vorgelesen (${name}): ${text.slice(0, 60)}`);
// Auch ohne Ton abgeschlossen: angezeigt wurde es, und eine Einloesung,
// die ewig in Twitchs Warteschlange haengt, raeumt niemand auf.
if (einloesung) void einloesungSetzen(einloesung.rewardId, einloesung.redemptionId, 'FULFILLED');
};
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 });
if (ent.aktion === 'liegestuetze') {
const dazu = ent.plan.anzahl ?? 5;
bus.publish({ kind: 'liegestuetze', ...liegestuetze.aendern(dazu), dazu, name: e.userName });
}
log.info(`${e.userName} hat "${e.rewardTitle}" eingeloest -> ${ent.aktion}`);
void einloesungSetzen(e.rewardId, e.id, 'FULFILLED');
break;
case 'vorlesen':
void vorlesen(ulid(), e.userName, ent.text, { rewardId: e.rewardId, redemptionId: e.id });
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,
liegestuetze: liegestuetze.stand(),
ttsStimme: stimmeFehlt.length ? `fehlt: ${stimmeFehlt.join(', ')}` : 'bereit',
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;
void vorlesen(w.id, w.name, w.text, { rewardId: w.rewardId, redemptionId: w.redemptionId });
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;
},
probe: (name, text) => { void vorlesen(ulid(), name, text); },
audio: (id) => audio.holen(id),
still: () => {
bus.publish({ kind: 'still' });
audio.leeren();
const alle = warteschlange.leeren();
for (const w of alle) void erstatten(w.rewardId, w.redemptionId);
log.warn('Panikschalter: Vorlesen gestoppt und Warteschlange geleert');
},
},
liegestuetze: {
stand: () => liegestuetze.stand(),
aendern: (n) => {
const neu = liegestuetze.aendern(n);
bus.publish({ kind: 'liegestuetze', ...neu });
log.info(`Liegestuetze von Hand ${n > 0 ? '+' : ''}${n} -> ` +
`${neu.offen} offen, ${neu.gemacht} gemacht`);
return neu;
},
setzen: (n) => {
const neu = liegestuetze.setzen(n);
bus.publish({ kind: 'liegestuetze', ...neu });
log.info(`Liegestuetze von Hand gesetzt -> ${neu.offen} offen`);
return neu;
},
gemachtSetzen: (n) => {
const neu = liegestuetze.gemachtSetzen(n);
bus.publish({ kind: 'liegestuetze', ...neu });
log.info(`Liegestuetze gemacht von Hand gesetzt -> ${neu.gemacht}`);
return neu;
},
},
});
void server;
// ---- Zuschauerzahlen und Stream-Sitzungen
void pollStream(cfg, repo, state, bus, abort.signal, (stream) => {
announcer.announce(stream);
// Nur bei einem wirklich neuen Stream, nicht beim Neustart des Hubs
// mitten im Stream: dann wird die Sitzung uebernommen, nicht gestartet.
bus.publish({ kind: 'liegestuetze', ...liegestuetze.neuerStream() });
});
// ---- 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; }
}
}
+89
View File
@@ -0,0 +1,89 @@
// Der Zaehler haelt fest, was Zuschauer mit Kanalpunkten bezahlt haben und
// was Abdu davon geschafft hat. Er darf weder beim Neustart vergessen noch
// ins Negative laufen, und eine Korrektur ist keine Liegestuetze.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import { migrate } from '#db/index.ts';
import { createLiegestuetze, HOECHSTENS } from './liegestuetze.ts';
import { entscheiden, type RewardsConfig } from './rewards.ts';
import { zahlAus } from '../../overlay-server/src/server.ts';
function datei(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'abdulive-lz-'));
return path.join(dir, 't.db');
}
function oeffnen(file: string): DatabaseSync {
const db = new DatabaseSync(file);
migrate(db);
return db;
}
test('Liegestuetze: zaehlt hoch und herunter, nie unter 0', () => {
const lz = createLiegestuetze(oeffnen(datei()));
assert.deepEqual(lz.stand(), { offen: 0, gemacht: 0 });
assert.equal(lz.aendern(10).offen, 10);
assert.equal(lz.aendern(10).offen, 20);
assert.equal(lz.aendern(-5).offen, 15);
assert.equal(lz.aendern(-100).offen, 0);
assert.equal(lz.setzen(42).offen, 42);
assert.equal(lz.setzen(-3).offen, 0);
assert.equal(lz.setzen(10_000_000).offen, HOECHSTENS);
});
test('Liegestuetze: Herunterzaehlen ist gemacht, Setzen nicht', () => {
const lz = createLiegestuetze(oeffnen(datei()));
lz.aendern(20);
assert.deepEqual(lz.aendern(-5), { offen: 15, gemacht: 5 });
// Nur was wirklich offen war, kann gemacht sein.
assert.deepEqual(lz.aendern(-100), { offen: 0, gemacht: 20 });
lz.aendern(10);
assert.deepEqual(lz.setzen(3), { offen: 3, gemacht: 20 }, 'Korrektur zaehlt nicht');
assert.deepEqual(lz.gemachtSetzen(7), { offen: 3, gemacht: 7 });
});
test('Liegestuetze: neuer Stream setzt gemacht zurueck, offen bleibt', () => {
const lz = createLiegestuetze(oeffnen(datei()));
lz.aendern(30);
lz.aendern(-10);
assert.deepEqual(lz.neuerStream(), { offen: 20, gemacht: 0 });
});
test('Liegestuetze: der Stand ueberlebt einen Neustart', () => {
const file = datei();
const erst = oeffnen(file);
const lz = createLiegestuetze(erst);
lz.aendern(30);
lz.aendern(-5);
erst.close();
assert.deepEqual(createLiegestuetze(oeffnen(file)).stand(), { offen: 25, gemacht: 5 });
});
test('Belohnung mit Aktion liegestuetze wird ausgefuehrt und traegt die Anzahl', () => {
const cfg: RewardsConfig = {
enabled: true,
belohnungen: [{ titel: '5 Liegestütze', aktion: 'liegestuetze', anzahl: 5 }],
tts: { freigabe: true, maxZeichen: 200, blockliste: [], proPersonProStunde: 3 },
};
const ent = entscheiden({
id: 'r1', rewardId: 'x', rewardTitle: '5 liegestütze', userId: 'u1', userName: 'Kevin',
text: '', kosten: 2000, ts: '2026-10-04T20:00:00Z',
}, cfg);
assert.equal(ent.art, 'ausfuehren');
assert.ok(ent.art === 'ausfuehren' && ent.aktion === 'liegestuetze' && ent.plan.anzahl === 5);
});
test('zahlAus: nur ganze Zahlen, alles andere ist null', () => {
assert.equal(zahlAus('10'), 10);
assert.equal(zahlAus(' -5 '), -5);
assert.equal(zahlAus('+3'), 3);
for (const x of ['', 'abc', '1.5', '1e3', '9999999', null, undefined]) {
assert.equal(zahlAus(x), null, String(x));
}
});
+63
View File
@@ -0,0 +1,63 @@
// Liegestuetze aus Kanalpunkten: was offen ist und was in diesem Stream
// schon gemacht wurde.
//
// Eine Einloesung zaehlt `offen` hoch. Abdu zaehlt auf der Uebersichtsseite
// herunter, wenn er sie gemacht hat - das wandert nach `gemacht`. Setzen ist
// dagegen eine Korrektur und zaehlt nicht als gemacht: wer einen Tippfehler
// behebt, hat dabei keine Liegestuetze gemacht.
//
// Beides liegt in der Datenbank: was die Zuschauer bezahlt haben, darf ein
// Neustart nicht loeschen. `gemacht` beginnt mit jedem neuen Stream bei 0.
import type { Db } from '#db/index.ts';
/** Mehr glaubt niemand, und ein Tippfehler mit sechs Nullen soll nicht im Bild stehen. */
export const HOECHSTENS = 99_999;
export type LiegestuetzStand = { offen: number; gemacht: number };
export type Liegestuetze = {
stand: () => LiegestuetzStand;
/**
* Zaehlt `offen` um `n` hoch oder (negativ) herunter, nie unter 0. Was
* dabei wirklich abgezogen wurde, kommt zu `gemacht`.
*/
aendern: (n: number) => LiegestuetzStand;
/** Setzt `offen` fest. Korrektur, zaehlt nicht als gemacht. */
setzen: (n: number) => LiegestuetzStand;
/** Setzt `gemacht` fest. Korrektur. */
gemachtSetzen: (n: number) => LiegestuetzStand;
/** Neuer Stream: `gemacht` auf 0, `offen` bleibt. */
neuerStream: () => LiegestuetzStand;
};
export function createLiegestuetze(db: Db, name = 'liegestuetze'): Liegestuetze {
const lesen = db.prepare('SELECT wert FROM zaehler WHERE name = ?');
const schreiben = db.prepare(`
INSERT INTO zaehler (name, wert, geaendert) VALUES (?, ?, ?)
ON CONFLICT(name) DO UPDATE SET wert = excluded.wert, geaendert = excluded.geaendert
`);
const OFFEN = name;
const GEMACHT = `${name}-gemacht`;
const wert = (n: string): number => (lesen.get(n) as { wert: number } | undefined)?.wert ?? 0;
const schreibe = (n: string, w: number): number => {
const sauber = Math.min(HOECHSTENS, Math.max(0, Math.trunc(w)));
schreiben.run(n, sauber, new Date().toISOString());
return sauber;
};
const stand = (): LiegestuetzStand => ({ offen: wert(OFFEN), gemacht: wert(GEMACHT) });
return {
stand,
aendern(n) {
const vorher = wert(OFFEN);
const nachher = schreibe(OFFEN, vorher + n);
if (nachher < vorher) schreibe(GEMACHT, wert(GEMACHT) + (vorher - nachher));
return stand();
},
setzen(n) { schreibe(OFFEN, n); return stand(); },
gemachtSetzen(n) { schreibe(GEMACHT, n); return stand(); },
neuerStream() { schreibe(GEMACHT, 0); return stand(); },
};
}
+221
View File
@@ -0,0 +1,221 @@
// 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, fuerFilter, planFuer,
wortfilterTreffer, type Einloesung, type RewardsConfig,
} from './rewards.ts';
// ---- Wortfilter. Vorgelesen wird ohne Freigabe, also muss er halten.
const LISTE = ['hitler', 'nazi'];
test('Wortfilter: Leetspeak, Punkte und gezogene Buchstaben helfen nicht', () => {
for (const t of ['H1tl3r war super', 'h.i.t.l.e.r', 'H I T L E R', 'hiiiitlerrr', 'HİTLER']) {
assert.equal(wortfilterTreffer(t, LISTE), 'hitler', t);
}
});
test('Wortfilter: kurze Eintraege nur am Wortanfang, sonst trifft es Harmloses', () => {
assert.equal(wortfilterTreffer('ich kann azubi werden', LISTE), undefined);
assert.equal(wortfilterTreffer('Gymnasium', LISTE), undefined);
assert.equal(wortfilterTreffer('alles Nazis hier', LISTE), 'nazi');
assert.equal(wortfilterTreffer('N4Z1', LISTE), 'nazi');
});
test('Wortfilter: "www" wird zu "w" und darf nur als ganzes Wort treffen', () => {
// So im Stream passiert: jede Nachricht mit "wie" oder "weg" flog raus.
const liste = ['www', 'http'];
assert.equal(wortfilterTreffer('wie kann es sein, dass ich nicht mod bin?!', liste), undefined);
assert.equal(wortfilterTreffer('Ladiesman hat einen weg', liste), undefined);
assert.equal(wortfilterTreffer('geh auf www.example.de', liste), 'www');
assert.equal(wortfilterTreffer('https://example.de', liste), 'http');
});
test('Wortfilter: normaler Text geht durch', () => {
assert.equal(wortfilterTreffer('Grüße an Abdu, geiler Stream!', LISTE), undefined);
});
test('fuerFilter: Umlaute und ß werden zu dem, was man hoert', () => {
assert.equal(fuerFilter('Größe', false), 'grose');
assert.equal(fuerFilter('a-b c', true), 'a b c');
});
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');
});
+251
View File
@@ -0,0 +1,251 @@
// 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' | 'liegestuetze' | '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;
/** Nur bei `liegestuetze`: so viele kommen je Einloesung dazu. Vorgabe 5. */
anzahl?: 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)` };
}
if (wortfilterTreffer(text, cfg.tts.blockliste)) 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 };
}
// ---------------------------------------------------------------- Wortfilter
const LEET: Record<string, string> = {
'0': 'o', '1': 'i', '3': 'e', '4': 'a', '5': 's', '7': 't', '8': 'b',
'@': 'a', '$': 's', '!': 'i', '|': 'i',
};
/**
* Text auf das bringen, was man hoert: klein, ohne Akzente, Leetspeak
* zurueckuebersetzt, Buchstabenketten auf einen gekuerzt. Wer "N 3 G G 3 R"
* oder "h.i.t.l.e.r" schreibt, will am Filter vorbei - vorgelesen klingt es
* trotzdem wie das Original.
*
* `mitLuecken` laesst Wortgrenzen als Leerzeichen stehen, sonst wird alles
* zusammengezogen.
*/
export function fuerFilter(text: string, mitLuecken: boolean): string {
return text
.toLowerCase()
.replace(/ß/g, 'ss')
.normalize('NFKD').replace(/\p{M}/gu, '')
.replace(/[0134578@$!|]/g, (c) => LEET[c] ?? c)
.replace(/[^a-z]+/g, mitLuecken ? ' ' : '')
.replace(/([a-z])\1+/g, '$1')
.trim();
}
/**
* Trifft der Text ein Wort der Blockliste?
*
* Lange Eintraege (ab 5 Buchstaben) werden im zusammengezogenen Text
* gesucht, damit Punkte und Leerzeichen dazwischen nicht helfen. Kurze nur
* am Wortanfang: zusammengezogen stuende "nazi" sonst auch in "kann azubi".
* Was danach kuerzer als 3 Buchstaben ist, nur als ganzes Wort: aus "www"
* wird "w", und das stuende am Anfang von "wie" und "weg".
*/
export function wortfilterTreffer(text: string, blockliste: string[]): string | undefined {
const dicht = fuerFilter(text, false);
const woerter = fuerFilter(text, true).split(' ');
return blockliste.find((eintrag) => {
const e = fuerFilter(eintrag, false);
if (!e) return false;
if (e.length < 3) return woerter.includes(e);
return e.length >= 5 ? dicht.includes(e) : woerter.some((w) => w.startsWith(e));
});
}
// ---------------------------------------------------------------- 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()),
};
}
+69
View File
@@ -0,0 +1,69 @@
// Die Leitung zum Hub, Seite des Stream-PCs.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import type { ObsClient } from '#obs/client.ts';
import { befehlAus, sseZerleger, standLesen } from './fern.ts';
test('SSE: Nachrichten, die zerteilt oder zu zweit ankommen', () => {
const z = sseZerleger();
assert.deepEqual(z('retry: 2000\n\n'), [], 'retry allein ist keine Nachricht');
assert.deepEqual(z('data: {"a"'), []);
assert.deepEqual(z(':1}\n\ndata: 2\n\nda'), ['{"a":1}', '2']);
assert.deepEqual(z('ta: 3\r\n\r\n'), ['3'], 'auch mit Windows-Zeilenenden');
});
test('nur bekannte Befehle mit ID werden ausgefuehrt', () => {
assert.deepEqual(befehlAus({ id: 'x', art: 'szene', szene: 'Pause' }), { id: 'x', art: 'szene', szene: 'Pause' });
assert.deepEqual(befehlAus({ id: 'x', art: 'stumm', quelle: 'Mikro', stumm: true }),
{ id: 'x', art: 'stumm', quelle: 'Mikro', stumm: true });
assert.equal(befehlAus({ art: 'clip' }), null, 'ohne ID gibt es niemanden, dem man antwortet');
assert.equal(befehlAus({ id: 'x', art: 'beat' }), null);
assert.equal(befehlAus({ id: 'x', art: 'StopStream' }), null);
});
function falschesObs(antworten: Record<string, (d: Record<string, unknown>) => unknown>): ObsClient {
return {
run: async () => {},
isConnected: () => true,
request: async <T>(type: string, data?: unknown): Promise<T> => {
const f = antworten[type];
if (!f) throw new Error(`OBS lehnt ab (604): ${type}`);
return f((data ?? {}) as Record<string, unknown>) as T;
},
};
}
test('Stand: Szenen in OBS-Reihenfolge, nur Eingaenge mit Ton', async () => {
const obs = falschesObs({
GetSceneList: () => ({
currentProgramSceneName: 'Gameplay',
// OBS zaehlt von unten: Index 0 steht im Fenster ganz unten.
scenes: [{ sceneName: 'Pause', sceneIndex: 0 }, { sceneName: 'Gameplay', sceneIndex: 2 },
{ sceneName: 'Just Chatting', sceneIndex: 1 }],
}),
GetInputList: () => ({ inputs: [{ inputName: 'Mikro' }, { inputName: 'Facecam' }, { inputName: 'Desktop' }] }),
GetInputMute: (d) => {
if (d['inputName'] === 'Facecam') throw new Error('kein Ton');
return { inputMuted: d['inputName'] === 'Desktop' };
},
GetReplayBufferStatus: () => ({ outputActive: true }),
});
assert.deepEqual(await standLesen(obs), {
obs: true,
szenen: ['Gameplay', 'Just Chatting', 'Pause'],
aktuell: 'Gameplay',
ton: [{ name: 'Mikro', stumm: false }, { name: 'Desktop', stumm: true }],
puffer: true,
});
});
test('Stand ohne Wiedergabepuffer: kein Clip-Knopf, aber Szenen', async () => {
const obs = falschesObs({
GetSceneList: () => ({ currentProgramSceneName: 'A', scenes: [{ sceneName: 'A', sceneIndex: 0 }] }),
GetInputList: () => ({ inputs: [] }),
});
const s = await standLesen(obs);
assert.equal(s.puffer, false);
assert.deepEqual(s.szenen, ['A']);
});
+247
View File
@@ -0,0 +1,247 @@
// Fernsteuerung fuer OBS, Seite des Stream-PCs.
//
// Das Pult am Handy redet mit dem Hub, OBS laeuft hier. Der Hub kommt an
// diesen Rechner nicht heran (fremder Router, keine Portfreigabe), also waehlt
// sich der PC beim Hub ein und haelt die Leitung offen:
//
// GET /fern/befehle SSE, Befehle kommen herein (Hub -> PC)
// POST /fern/stand Szenen, aktuelle Szene, Ton (PC -> Hub)
// POST /fern/antwort ob ein Befehl geklappt hat (PC -> Hub)
//
// Gegenstueck: apps/overlay-server/src/fern.ts. Eingerichtet wird mit
// `abdulive fern:verbinden <adresse> <schluessel>`; beides steht fertig auf
// der Uebersichtsseite des Hubs.
import { logger } from '#core/logger.ts';
import { forever, sleep } from '#core/retry.ts';
import type { ObsClient } from '#obs/client.ts';
const log = logger('fern');
/** Der Hub schickt alle 15 s einen Herzschlag. Drei verpasste heisst: tot. */
const TOT_NACH_MS = 45_000;
/** Nach diesen OBS-Ereignissen stimmt der gemeldete Stand nicht mehr. */
const STAND_EREIGNISSE = new Set([
'CurrentProgramSceneChanged', 'SceneListChanged', 'SceneCreated', 'SceneRemoved',
'SceneNameChanged', 'InputMuteStateChanged', 'InputCreated', 'InputRemoved',
'InputNameChanged', 'ReplayBufferStateChanged',
]);
export type Stand = {
obs: boolean;
szenen: string[];
aktuell: string | null;
ton: Array<{ name: string; stumm: boolean }>;
puffer: boolean;
};
export type Befehl =
| { id: string; art: 'szene'; szene: string }
| { id: string; art: 'clip' }
| { id: string; art: 'stumm'; quelle: string; stumm: boolean };
/**
* Zerlegt einen SSE-Strom in Nachrichten. Bekommt beliebige Stuecke - eine
* Nachricht kann mitten im Wort geteilt ankommen, und zwei koennen in einem
* Stueck stecken.
*/
export function sseZerleger(): (stueck: string) => string[] {
let rest = '';
return (stueck) => {
rest += stueck.replace(/\r\n?/g, '\n');
const fertig: string[] = [];
let i: number;
while ((i = rest.indexOf('\n\n')) !== -1) {
const block = rest.slice(0, i);
rest = rest.slice(i + 2);
const daten = block.split('\n')
.filter((z) => z.startsWith('data:'))
.map((z) => z.slice(5).replace(/^ /, ''));
if (daten.length) fertig.push(daten.join('\n'));
}
return fertig;
};
}
/** Was vom Hub kommt, pruefen. Unbekanntes wird nicht ausgefuehrt. */
export function befehlAus(roh: unknown): Befehl | null {
if (!roh || typeof roh !== 'object') return null;
const r = roh as Record<string, unknown>;
const id = typeof r['id'] === 'string' ? r['id'] : '';
if (!id) return null;
if (r['art'] === 'szene' && typeof r['szene'] === 'string') return { id, art: 'szene', szene: r['szene'] };
if (r['art'] === 'clip') return { id, art: 'clip' };
if (r['art'] === 'stumm' && typeof r['quelle'] === 'string') {
return { id, art: 'stumm', quelle: r['quelle'], stumm: r['stumm'] === true };
}
return null;
}
/** Szenen, aktuelle Szene, stummschaltbare Eingaenge und der Puffer. */
export async function standLesen(obs: ObsClient): Promise<Stand> {
const leer: Stand = { obs: false, szenen: [], aktuell: null, ton: [], puffer: false };
if (!obs.isConnected()) return leer;
try {
const liste = await obs.request<{
currentProgramSceneName: string;
scenes: Array<{ sceneName: string; sceneIndex: number }>;
}>('GetSceneList');
// OBS zaehlt von unten. Im Fenster steht die mit dem hoechsten Index oben,
// und in dieser Reihenfolge erwartet man sie auch am Handy.
const szenen = [...liste.scenes]
.sort((a, b) => b.sceneIndex - a.sceneIndex).map((s) => s.sceneName);
const eingaenge = await obs.request<{ inputs: Array<{ inputName: string }> }>('GetInputList');
// Welcher Eingang Ton hat, sagt OBS nicht direkt. Wer auf GetInputMute
// antwortet, hat welchen; Bildquellen lehnen ab.
const stumm = await Promise.allSettled(eingaenge.inputs.map((e) =>
obs.request<{ inputMuted: boolean }>('GetInputMute', { inputName: e.inputName })
.then((r) => ({ name: e.inputName, stumm: r.inputMuted }))));
const ton = stumm.flatMap((r) => (r.status === 'fulfilled' ? [r.value] : []));
const puffer = await obs.request<{ outputActive: boolean }>('GetReplayBufferStatus')
.then((r) => r.outputActive, () => false);
return { obs: true, szenen, aktuell: liste.currentProgramSceneName || null, ton, puffer };
} catch (err) {
log.debug(`Stand nicht gelesen: ${(err as Error).message}`);
return { ...leer, obs: obs.isConnected() };
}
}
export type FernDeps = {
url: string;
key: string;
obs: ObsClient;
/** Den Wiedergabepuffer sichern, wie bei !clip, nur ohne Abklingzeit. */
clip: () => Promise<{ ok: boolean; reason?: string }>;
};
export type FernAnschluss = {
run: (signal: AbortSignal) => Promise<void>;
/** Nach einem OBS-Ereignis rufen. Sammelt kurz, dann geht ein Stand raus. */
obsEreignis: (type: string) => void;
/** OBS verbunden oder getrennt. */
obsWechsel: () => void;
verbunden: () => boolean;
};
export function createFernanschluss(deps: FernDeps): FernAnschluss {
const basis = deps.url.replace(/\/+$/, '');
const kopf = { authorization: `Bearer ${deps.key}` };
let verbunden = false;
let sammler: NodeJS.Timeout | null = null;
async function post(pfad: string, body: unknown): Promise<void> {
try {
const res = await fetch(`${basis}${pfad}`, {
method: 'POST',
headers: { ...kopf, 'content-type': 'application/json' },
body: JSON.stringify(body),
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) log.warn(`${pfad}: Hub antwortet ${res.status}`);
} catch (err) {
log.debug(`${pfad} nicht zugestellt: ${(err as Error).message}`);
}
}
const standSenden = async (): Promise<void> => {
if (!verbunden) return;
await post('/fern/stand', await standLesen(deps.obs));
};
const spaeterSenden = (): void => {
// OBS feuert beim Szenenwechsel mehrere Ereignisse kurz hintereinander.
// Ein Stand danach reicht.
if (sammler) clearTimeout(sammler);
sammler = setTimeout(() => { sammler = null; void standSenden(); }, 300);
};
async function ausfuehren(b: Befehl): Promise<{ ok: boolean; text: string }> {
if (b.art === 'clip') {
const r = await deps.clip();
return r.ok ? { ok: true, text: 'Clip wird gesichert.' }
: { ok: false, text: r.reason ?? 'Clip ging nicht.' };
}
if (!deps.obs.isConnected()) return { ok: false, text: 'OBS ist auf dem Stream-PC nicht verbunden.' };
try {
if (b.art === 'szene') {
await deps.obs.request('SetCurrentProgramScene', { sceneName: b.szene });
return { ok: true, text: `Szene: ${b.szene}` };
}
await deps.obs.request('SetInputMute', { inputName: b.quelle, inputMuted: b.stumm });
return { ok: true, text: `${b.quelle} ${b.stumm ? 'stumm' : 'wieder an'}` };
} catch (err) {
return { ok: false, text: (err as Error).message };
}
}
async function einmal(signal: AbortSignal): Promise<void> {
const wachhund = new AbortController();
const beides = AbortSignal.any([signal, wachhund.signal]);
let zuletzt = Date.now();
const takt = setInterval(() => {
if (Date.now() - zuletzt > TOT_NACH_MS) {
log.warn('Vom Hub nichts mehr gehoert, verbinde neu');
wachhund.abort();
}
}, 10_000);
try {
const res = await fetch(`${basis}/fern/befehle`, {
headers: { ...kopf, accept: 'text/event-stream' }, signal: beides,
});
if (res.status === 403) {
log.error('Der Hub lehnt den Schluessel ab. Neu einrichten mit dem Befehl ' +
'von der Uebersichtsseite: abdulive fern:verbinden ...');
// Nicht im Sekundentakt anklopfen, der Schluessel wird nicht von allein richtig.
await sleep(5 * 60_000, signal).catch(() => {});
throw new Error('Schluessel abgelehnt');
}
if (!res.ok || !res.body) throw new Error(`Hub antwortet ${res.status}`);
verbunden = true;
log.info(`Mit dem Hub verbunden (${basis}) - das Pult kann OBS jetzt steuern`);
void standSenden();
const zerleger = sseZerleger();
const decoder = new TextDecoder();
for await (const stueck of res.body as unknown as AsyncIterable<Uint8Array>) {
zuletzt = Date.now();
for (const daten of zerleger(decoder.decode(stueck, { stream: true }))) {
let roh: unknown;
try { roh = JSON.parse(daten); } catch { continue; }
const b = befehlAus(roh);
if (!b) continue;
void ausfuehren(b).then((a) => {
log.info(`Pult: ${b.art} -> ${a.ok ? 'ok' : 'abgelehnt'} (${a.text})`);
return post('/fern/antwort', { id: b.id, ...a });
}).then(spaeterSenden);
}
}
throw new Error('Hub hat die Leitung geschlossen');
} catch (err) {
if (!signal.aborted) log.debug(`Leitung zum Hub weg: ${(err as Error).message}`);
throw err;
} finally {
clearInterval(takt);
if (verbunden) log.info('Leitung zum Hub getrennt');
verbunden = false;
}
}
return {
run: (signal) => forever(einmal, {
signal, baseMs: 2000, maxMs: 60_000,
onWait: (ms, n) => {
const zeile = `Hub nicht erreichbar, neuer Versuch in ${Math.round(ms / 1000)} s`;
if (n >= 3) log.warn(zeile); else log.debug(zeile);
},
}),
obsEreignis: (type) => { if (STAND_EREIGNISSE.has(type)) spaeterSenden(); },
obsWechsel: spaeterSenden,
verbunden: () => verbunden,
};
}
+93 -29
View File
@@ -10,12 +10,16 @@ import { logger } from '#core/logger.ts';
import { sleep } from '#core/retry.ts'; import { sleep } from '#core/retry.ts';
import { openDb, closeDb } from '#db/index.ts'; import { openDb, closeDb } from '#db/index.ts';
import { repos } from '#db/repos.ts'; import { repos } from '#db/repos.ts';
import { createBot } from '#discord/bot.ts';
import { createObs } from '#obs/client.ts'; import { createObs } from '#obs/client.ts';
import { isWindows } from '#core/platform.ts'; 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 { createFernanschluss } from './fern.ts';
import { createRenderer } from './render.ts'; import { createRenderer } from './render.ts';
import { createSpikeDetector } from './spike.ts'; import { createSpikeDetector } from './spike.ts';
import { startServer } from './server.ts'; import { startServer } from './server.ts';
@@ -41,6 +45,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,
}; };
@@ -58,8 +63,9 @@ export async function runIngest(): Promise<void> {
cfg.obs.url, cfg.obs.url,
() => readSecrets().obs?.password, () => readSecrets().obs?.password,
{ {
onIdentified: () => { state.obsConnected = true; }, onIdentified: () => { state.obsConnected = true; fern?.obsWechsel(); },
onClosed: () => { state.obsConnected = false; }, onClosed: () => { state.obsConnected = false; fern?.obsWechsel(); },
onEvent: (type) => fern?.obsEreignis(type),
onSceneChanged: (scene) => { onSceneChanged: (scene) => {
state.obsScene = scene; state.obsScene = scene;
repo.event('obs.scene_changed', { scene }, state.streamId); repo.event('obs.scene_changed', { scene }, state.streamId);
@@ -110,14 +116,45 @@ export async function runIngest(): Promise<void> {
onRawReady: (job) => renderer.enqueue(job), onRawReady: (job) => renderer.enqueue(job),
}); });
// ---- Fernsteuerung vom Pult (Szenen, Clip, Ton). Nur, wenn mit
// `abdulive fern:verbinden` eingerichtet - sonst laeuft alles wie vorher.
const fernZugang = readSecrets().fern;
const fern = fernZugang?.url && fernZugang.key
? createFernanschluss({
url: fernZugang.url,
key: fernZugang.key,
obs,
clip: () => clipper.trigger('manual', { login: 'Pult' }),
})
: null;
if (fern) void fern.run(abort.signal);
else log.info('Pult-Fernsteuerung nicht eingerichtet (abdulive fern:verbinden)');
// ---- 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,
health: () => ({ health: () => ({
...state, ...state,
db: repo.stats(), db: repo.stats(),
discordConnected: bot ? bot.isConnected() : false,
goLive: announcer.lastResult() ?? state.goLive,
spikeArmed: spikes.isArmed(), spikeArmed: spikes.isArmed(),
clipsWaitingForFile: clipper.pendingCount(), clipsWaitingForFile: clipper.pendingCount(),
pult: fern ? (fern.verbunden() ? 'verbunden' : 'getrennt') : 'nicht eingerichtet',
renderQueue: renderer.pending(), renderQueue: renderer.pending(),
rendering: renderer.currentJob(), rendering: renderer.currentJob(),
note: state.chatAnonymous note: state.chatAnonymous
@@ -170,8 +207,15 @@ export async function runIngest(): Promise<void> {
void irc.run(abort.signal); void irc.run(abort.signal);
void obs.run(abort.signal); void obs.run(abort.signal);
// ---- Discord (Rollen-Knoepfe). Fehlt die Einrichtung, entfaellt er.
const bot = createBot();
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()));
@@ -179,14 +223,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;
@@ -202,10 +248,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 {
@@ -217,33 +265,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,
}; };
+53 -2
View File
@@ -1,5 +1,56 @@
# @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
/pult Steuerpult fürs Handy (PWA) Passwort
/soundboard/… Sounds hochladen, ändern, spielen Passwort (Datei: Schlüssel)
/fern/… Leitung zum Stream-PC für OBS eigener Schlüssel im Kopf
```
Der Fern-Schlüssel ist der dritte Zugang und der einzige, der etwas schaltet:
er steht deshalb nie in einer Adresse, sondern in `authorization: Bearer …`.
## 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.
+79
View File
@@ -0,0 +1,79 @@
// 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));
});
test('der Schluessel des Stream-PCs kommt aus dem Bearer-Kopf', async () => {
// Hier fehlten beim ersten Wurf die Backslashes in der Regex - jeder
// richtige Schluessel wurde abgelehnt, und nichts im Log sah danach aus.
const { bearer } = await import('./auth.ts');
assert.equal(bearer('Bearer 33qg61wQf_ad-x9v'), '33qg61wQf_ad-x9v');
assert.equal(bearer('Bearer abc'), 'abc');
assert.equal(bearer('Basic abc'), null);
assert.equal(bearer('Bearer'), null);
assert.equal(bearer(undefined), null);
});
+213
View File
@@ -0,0 +1,213 @@
// 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;
/** Schluessel des Stream-PCs, im Kopf `authorization: Bearer ...`. */
darfFern: (req: http.IncomingMessage) => boolean;
fernKey: () => string;
neuerFernKey: () => 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');
}
/** Der Schluessel aus `authorization: Bearer ...`, sonst `null`. */
export function bearer(kopf: string | undefined): string | null {
return /^Bearer\s+(\S+)$/.exec(kopf ?? '')?.[1] ?? null;
}
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;
}
function fernKey(): string {
const vorhanden = store().fernKey;
if (vorhanden) return vorhanden;
const neu = crypto.randomBytes(24).toString('base64url');
updateSecrets((s) => { s.web = { ...s.web, fernKey: neu }; });
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,
darfFern(req) {
const gegeben = bearer(req.headers.authorization);
return Boolean(gegeben && gleich(gegeben, fernKey()));
},
fernKey,
neuerFernKey() {
const neu = crypto.randomBytes(24).toString('base64url');
updateSecrets((s) => { s.web = { ...s.web, fernKey: neu }; });
log.info('Schluessel fuer den Stream-PC neu gewuerfelt - dort neu eintragen');
return neu;
},
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) };
});
}
+118
View File
@@ -0,0 +1,118 @@
// 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);
});
test('der Schnappschuss traegt den letzten Stand der Liegestuetze', () => {
const bus = createBus();
assert.deepEqual(schnappschuss(bus).liegestuetze, { offen: 0, gemacht: 0 });
bus.publish({ kind: 'liegestuetze', offen: 20, gemacht: 0, dazu: 10, name: 'Kevin' });
bus.publish({ kind: 'liegestuetze', offen: 15, gemacht: 5 });
assert.deepEqual(schnappschuss(bus).liegestuetze, { offen: 15, gemacht: 5 });
});
+216
View File
@@ -0,0 +1,216 @@
// 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;
};
/**
* Neuer Stand der Liegestuetze: offen und in diesem Stream gemacht. `dazu`
* und `name` nur bei einer Einloesung - eine Korrektur von Hand soll nicht
* aussehen wie eine.
*/
export type LiegestuetzeEvent = {
kind: 'liegestuetze';
offen: number;
gemacht: number;
dazu?: number;
name?: string;
};
/**
* Was der Stream-PC ueber OBS meldet. Kommt ueber die Fernsteuerung
* (fern.ts) herein und steht im Schnappschuss, damit das Pult nach dem
* Neuverbinden sofort die Szenen kennt.
*/
export type ObsStandEvent = {
kind: 'obs';
/** Ist der Stream-PC ueberhaupt mit dem Hub verbunden? */
pc: boolean;
/** Und hat er OBS erreicht? */
obs: boolean;
szenen: string[];
aktuell: string | null;
/** Toneingaenge, die sich stummschalten lassen. */
ton: Array<{ name: string; stumm: boolean }>;
/** Laeuft der Wiedergabepuffer? Ohne ihn gibt es keinen Clip. */
puffer: boolean;
};
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;
/** Liegt unter /tts/audio/<id> bereit. Ohne Ton wird nur angezeigt. */
audio?: boolean;
}
/** Alles sofort still. Fuer den Panikschalter. */
| { kind: 'still' }
| LiegestuetzeEvent
| { kind: 'scene'; scene: string }
/**
* Ein Sound vom Pult. Wie beim Vorlesen geht nur die ID hinaus; die Datei
* holt sich die Seite /sounds einzeln, damit Chat und Statusleiste keine
* Megabytes mitschleppen.
*/
| { kind: 'sound'; id: string; name: string; laut: number }
/** Alle laufenden Sounds abbrechen. Vorlesen bleibt davon unberuehrt. */
| { kind: 'sound-stopp' }
/** Die Liste der Sounds hat sich geaendert - offene Pulte laden neu. */
| { kind: 'sounds-geaendert' }
| ObsStandEvent
| { kind: 'clear'; userId?: string; messageId?: string }
| { kind: 'beat'; t: number }
| {
kind: 'state'; stream: StreamEvent; chat: ChatEvent[]; alerts: AlertEvent[];
/** Liegestuetze, damit der Zaehler nach dem Neuverbinden stimmt. */
liegestuetze: { offen: number; gemacht: number };
obs: ObsStandEvent;
};
/**
* 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,
};
let liegestuetze = { offen: 0, gemacht: 0 };
let obs: ObsStandEvent = {
kind: 'obs', pc: false, obs: false, szenen: [], aktuell: null, ton: [], puffer: false,
};
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 === 'liegestuetze') {
liegestuetze = { offen: ev.offen, gemacht: ev.gemacht };
} else if (ev.kind === 'obs') {
obs = 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], liegestuetze, obs }),
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 });
}
+116
View File
@@ -0,0 +1,116 @@
// Die Leitung zum Stream-PC, Hub-Seite. Geprueft wird ohne Netz: die
// SSE-Antwort ist ein nachgebautes Objekt, das mitschreibt.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import type http from 'node:http';
import { createBus, type ObsStandEvent } from './bus.ts';
import { createFernsteuerung, standAus } from './fern.ts';
import { befehlAus, sichererPfad } from './server.ts';
function leitung() {
const req = new EventEmitter();
const res = Object.assign(new EventEmitter(), {
geschrieben: [] as string[],
writeHead() { return this; },
write(s: string) { this.geschrieben.push(s); return true; },
end() { req.emit('close'); },
});
const befehle = (): Array<Record<string, unknown>> => res.geschrieben
.filter((s) => s.startsWith('data: '))
.map((s) => JSON.parse(s.slice(6)) as Record<string, unknown>)
.filter((b) => b['art'] !== 'beat');
return {
req: req as unknown as http.IncomingMessage,
res: res as unknown as http.ServerResponse,
befehle,
trennen: () => req.emit('close'),
};
}
function obsStand(bus: ReturnType<typeof createBus>): ObsStandEvent {
const s = bus.snapshot();
if (s.kind !== 'state') throw new Error('kein Schnappschuss');
return s.obs;
}
test('ohne Stream-PC sagt ein Befehl ehrlich, dass es nicht ging', async () => {
const fern = createFernsteuerung(createBus());
const a = await fern.senden({ art: 'szene', szene: 'Pause' });
assert.equal(a.ok, false);
assert.match(a.text, /nicht verbunden/);
});
test('Befehl geht raus, Antwort kommt zurueck', async () => {
const bus = createBus();
const fern = createFernsteuerung(bus);
const l = leitung();
fern.annehmen(l.req, l.res);
assert.equal(obsStand(bus).pc, true);
const warten = fern.senden({ art: 'szene', szene: 'Just Chatting' });
const [b] = l.befehle();
assert.equal(b?.['art'], 'szene');
assert.equal(b?.['szene'], 'Just Chatting');
fern.antwort({ id: b!['id'], ok: true, text: 'Szene: Just Chatting' });
assert.deepEqual(await warten, { ok: true, text: 'Szene: Just Chatting' });
});
test('reisst die Leitung ab, wartet niemand weiter', async () => {
const bus = createBus();
const fern = createFernsteuerung(bus);
const l = leitung();
fern.annehmen(l.req, l.res);
fern.melden({ obs: true, szenen: ['A'], aktuell: 'A', ton: [], puffer: true });
assert.deepEqual(obsStand(bus).szenen, ['A']);
const warten = fern.senden({ art: 'clip' });
l.trennen();
const a = await warten;
assert.equal(a.ok, false);
assert.equal(fern.verbunden(), false);
// Alte Szenen stehen sonst als Knoepfe da, die nichts mehr tun.
assert.deepEqual(obsStand(bus), { kind: 'obs', pc: false, obs: false, szenen: [], aktuell: null, ton: [], puffer: false });
});
test('ein zweiter PC ersetzt den ersten', () => {
const bus = createBus();
const fern = createFernsteuerung(bus);
const alt = leitung();
const neu = leitung();
fern.annehmen(alt.req, alt.res);
fern.annehmen(neu.req, neu.res);
// Das Schliessen der alten Leitung darf die neue nicht als getrennt melden.
assert.equal(fern.verbunden(), true);
assert.equal(obsStand(bus).pc, true);
void fern.senden({ art: 'clip' });
assert.equal(neu.befehle().length, 1);
assert.equal(alt.befehle().length, 0);
});
test('der Stand vom PC wird auf die erwartete Form gebracht', () => {
assert.deepEqual(standAus({
obs: true, szenen: ['A', 7, 'B'], aktuell: 'A',
ton: [{ name: 'Mikro', stumm: true }, { name: 3 }, null], puffer: 'ja',
}), { obs: true, szenen: ['A', 'B'], aktuell: 'A', ton: [{ name: 'Mikro', stumm: true }], puffer: false });
assert.deepEqual(standAus('quatsch'), { obs: false, szenen: [], aktuell: null, ton: [], puffer: false });
});
test('vom Pult kommen nur bekannte Befehle durch', () => {
const f = (o: Record<string, string>): URLSearchParams => new URLSearchParams(o);
assert.deepEqual(befehlAus(f({ was: 'szene', szene: 'Pause' })), { art: 'szene', szene: 'Pause' });
assert.deepEqual(befehlAus(f({ was: 'stumm', quelle: 'Mikro', stumm: '1' })),
{ art: 'stumm', quelle: 'Mikro', stumm: true });
assert.deepEqual(befehlAus(f({ was: 'clip' })), { art: 'clip' });
assert.equal(befehlAus(f({ was: 'szene' })), null);
assert.equal(befehlAus(f({ was: 'StopStream' })), null);
});
test('nach der Anmeldung geht es nur auf bekannte Seiten', () => {
assert.equal(sichererPfad('/pult'), '/pult');
assert.equal(sichererPfad('https://boese.example'), '/');
assert.equal(sichererPfad('//boese.example'), '/');
assert.equal(sichererPfad(null), '/');
});
+155
View File
@@ -0,0 +1,155 @@
// Fernsteuerung fuer OBS, Hub-Seite.
//
// OBS laeuft auf dem Stream-PC, das Pult am Handy spricht mit dem Hub. Der
// Hub kommt an den Stream-PC nicht heran - der steht hinter einem fremden
// Router. Also waehlt sich der PC beim Hub ein, nicht umgekehrt:
//
// Handy --POST /pult/obs--> Hub --SSE /fern/befehle--> Stream-PC --> OBS
// Handy <--SSE /events---- Hub <--POST /fern/stand---- Stream-PC
//
// Dieselbe Bauform wie bei den Browser-Sources (SSE statt WebSocket, siehe
// README), nur mit einem eigenen Schluessel: der fuer OBS darf nur lesen,
// dieser hier schaltet Szenen um.
//
// Jeder Befehl wartet auf eine Antwort vom PC. Ein Knopf, der "erledigt"
// meldet, waehrend OBS gar nicht erreichbar war, ist am Handy schlimmer als
// einer, der ehrlich sagt, dass es nicht ging.
import type http from 'node:http';
import crypto from 'node:crypto';
import { logger } from '#core/logger.ts';
import type { Bus, ObsStandEvent } from './bus.ts';
const log = logger('overlay:fern');
const HEARTBEAT_MS = 15_000;
const ANTWORT_MS = 6_000;
export type FernBefehl =
| { art: 'szene'; szene: string }
| { art: 'clip' }
| { art: 'stumm'; quelle: string; stumm: boolean };
export type FernAntwort = { ok: boolean; text: string };
export type Fernsteuerung = {
/** Die SSE-Verbindung des Stream-PCs. Eine neue ersetzt die alte. */
annehmen: (req: http.IncomingMessage, res: http.ServerResponse) => void;
/** Stand von OBS, wie ihn der PC meldet. */
melden: (roh: unknown) => void;
antwort: (roh: unknown) => void;
senden: (b: FernBefehl) => Promise<FernAntwort>;
verbunden: () => boolean;
};
const LEER: Omit<ObsStandEvent, 'kind'> = {
pc: false, obs: false, szenen: [], aktuell: null, ton: [], puffer: false,
};
/** Was vom PC kommt, auf die erwartete Form bringen. Fremdes faellt weg. */
export function standAus(roh: unknown): Omit<ObsStandEvent, 'kind' | 'pc'> {
const r = (roh && typeof roh === 'object' ? roh : {}) as Record<string, unknown>;
const text = (v: unknown): string | null => (typeof v === 'string' && v.length <= 200 ? v : null);
const szenen = Array.isArray(r['szenen'])
? r['szenen'].map(text).filter((s): s is string => s !== null).slice(0, 100) : [];
const ton = Array.isArray(r['ton'])
? r['ton'].flatMap((t) => {
const name = text((t as Record<string, unknown>)?.['name']);
return name === null ? [] : [{ name, stumm: (t as Record<string, unknown>)['stumm'] === true }];
}).slice(0, 50) : [];
return {
obs: r['obs'] === true,
szenen,
aktuell: text(r['aktuell']),
ton,
puffer: r['puffer'] === true,
};
}
export function createFernsteuerung(bus: Bus): Fernsteuerung {
let leitung: http.ServerResponse | null = null;
const offen = new Map<string, { fertig: (a: FernAntwort) => void; timer: NodeJS.Timeout }>();
const veroeffentlichen = (stand: Omit<ObsStandEvent, 'kind'>): void => {
bus.publish({ kind: 'obs', ...stand });
};
return {
annehmen(req, res) {
if (leitung) {
// Zwei PCs zugleich hiesse: zwei OBS schalten auf denselben Knopf.
// Der neuere gewinnt, meist ist der alte ohnehin eine tote Leitung.
log.info('Stream-PC hat sich neu verbunden, alte Leitung geschlossen');
leitung.end();
}
res.writeHead(200, {
'content-type': 'text/event-stream; charset=utf-8',
'cache-control': 'no-cache, no-transform',
connection: 'keep-alive',
'x-accel-buffering': 'no',
});
res.write('retry: 2000\n\n');
leitung = res;
log.info('Stream-PC verbunden');
veroeffentlichen({ ...LEER, pc: true });
const beat = setInterval(
() => res.write(`data: ${JSON.stringify({ art: 'beat', t: Date.now() })}\n\n`),
HEARTBEAT_MS);
beat.unref();
const ende = (): void => {
clearInterval(beat);
if (leitung !== res) return; // schon durch eine neuere ersetzt
leitung = null;
log.info('Stream-PC getrennt');
veroeffentlichen(LEER);
for (const [id, o] of offen) {
clearTimeout(o.timer);
o.fertig({ ok: false, text: 'Verbindung zum Stream-PC abgerissen.' });
offen.delete(id);
}
};
req.on('close', ende);
res.on('error', ende);
},
melden(roh) {
if (!leitung) return;
veroeffentlichen({ pc: true, ...standAus(roh) });
},
antwort(roh) {
const r = (roh && typeof roh === 'object' ? roh : {}) as Record<string, unknown>;
const id = typeof r['id'] === 'string' ? r['id'] : '';
const o = offen.get(id);
if (!o) return;
offen.delete(id);
clearTimeout(o.timer);
o.fertig({
ok: r['ok'] === true,
text: typeof r['text'] === 'string' ? r['text'].slice(0, 300) : '',
});
},
senden(b) {
const res = leitung;
if (!res) {
return Promise.resolve({
ok: false, text: 'Der Stream-PC ist nicht verbunden. Läuft dort abdulive?',
});
}
const id = crypto.randomBytes(6).toString('hex');
return new Promise<FernAntwort>((fertig) => {
const timer = setTimeout(() => {
offen.delete(id);
fertig({ ok: false, text: 'Der Stream-PC hat nicht geantwortet.' });
}, ANTWORT_MS);
offen.set(id, { fertig, timer });
res.write(`data: ${JSON.stringify({ id, ...b })}\n\n`);
});
},
verbunden: () => leitung !== null,
};
}
File diff suppressed because it is too large. Load diff
+89
View File
@@ -0,0 +1,89 @@
// Der Sound-Speicher nimmt Dateien vom Handy an und liefert sie unter der
// eigenen Adresse wieder aus. Hier wird vor allem geprueft, dass er nur Ton
// annimmt - und dass die Liste einen Neustart uebersteht.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { createSoundboard, erkenneTyp, nameAusDatei, MAX_BYTES } from './sounds.ts';
const ordner = (): string => fs.mkdtempSync(path.join(os.tmpdir(), 'abdulive-sounds-'));
const MP3 = Buffer.concat([Buffer.from('ID3'), Buffer.alloc(64, 1)]);
const WAV = Buffer.concat([Buffer.from('RIFF'), Buffer.alloc(4), Buffer.from('WAVE'), Buffer.alloc(32)]);
const M4A = Buffer.concat([Buffer.alloc(4), Buffer.from('ftypM4A '), Buffer.alloc(32)]);
test('Tontypen werden an den ersten Bytes erkannt', () => {
assert.equal(erkenneTyp(MP3), 'audio/mpeg');
assert.equal(erkenneTyp(Buffer.from([0xff, 0xfb, 0x90, 0x64, 0, 0, 0, 0, 0, 0, 0, 0])), 'audio/mpeg');
assert.equal(erkenneTyp(WAV), 'audio/wav');
assert.equal(erkenneTyp(Buffer.concat([Buffer.from('OggS'), Buffer.alloc(20)])), 'audio/ogg');
assert.equal(erkenneTyp(M4A), 'audio/mp4');
});
test('HTML ist kein Ton, auch wenn der Browser es behauptet', () => {
// Sonst waere das eine Ablage fuer beliebige Seiten unter der eigenen Adresse.
assert.equal(erkenneTyp(Buffer.from('<!doctype html><script>alert(1)</script>')), null);
assert.equal(erkenneTyp(Buffer.from('kurz')), null);
const sb = createSoundboard(ordner());
assert.throws(() => sb.hinzufuegen('x', Buffer.from('<html><body>hallo welt</body></html>')), /Kein Ton/);
assert.equal(sb.liste().length, 0);
});
test('zu gross wird abgelehnt, bevor etwas auf der Platte landet', () => {
const dir = ordner();
const sb = createSoundboard(dir);
const riesig = Buffer.concat([MP3, Buffer.alloc(MAX_BYTES)]);
assert.throws(() => sb.hinzufuegen('lied', riesig), /Zu gross/);
assert.deepEqual(fs.readdirSync(dir), []);
});
test('hochladen, abholen, nach Neustart noch da', () => {
const dir = ordner();
const sb = createSoundboard(dir);
const s = sb.hinzufuegen('Tusch', MP3);
assert.equal(s.typ, 'audio/mpeg');
assert.ok(s.datei.endsWith('.mp3'));
assert.deepEqual(sb.datei(s.id)?.daten, MP3);
const nachNeustart = createSoundboard(dir);
assert.equal(nachNeustart.liste()[0]?.name, 'Tusch');
assert.deepEqual(nachNeustart.datei(s.id)?.daten, MP3);
});
test('aendern nimmt nur, was erlaubt ist', () => {
const sb = createSoundboard(ordner());
const s = sb.hinzufuegen('Tusch', MP3);
const neu = sb.aendern(s.id, {
name: ' Ba\ndum tss ', emoji: '🥁', farbe: 'url(javascript:x)', laut: 3,
})!;
assert.equal(neu.name, 'Ba dum tss');
assert.equal(neu.emoji, '🥁');
assert.equal(neu.farbe, s.farbe, 'freie Farbwerte waeren CSS-Einschleusung');
assert.equal(neu.laut, 1);
assert.equal(sb.aendern(s.id, { name: ' ' })?.name, 'Ba dum tss', 'leerer Name behaelt den alten');
});
test('verschieben und loeschen', () => {
const dir = ordner();
const sb = createSoundboard(dir);
const a = sb.hinzufuegen('A', MP3);
const b = sb.hinzufuegen('B', WAV);
sb.hinzufuegen('C', M4A);
assert.equal(sb.verschieben(b.id, -1), true);
assert.deepEqual(sb.liste().map((s) => s.name), ['B', 'A', 'C']);
assert.equal(sb.verschieben(b.id, -1), false, 'vor dem ersten ist nichts');
assert.equal(sb.entfernen(a.id), true);
assert.deepEqual(sb.liste().map((s) => s.name), ['B', 'C']);
assert.equal(fs.existsSync(path.join(dir, a.datei)), false, 'die Datei geht mit');
assert.equal(sb.entfernen(a.id), false);
});
test('Name aus dem Dateinamen', () => {
assert.equal(nameAusDatei('ba_dum_tss.mp3'), 'ba dum tss');
assert.equal(nameAusDatei('.mp3'), 'Sound');
assert.equal(nameAusDatei('Aufnahme 12.m4a'), 'Aufnahme 12');
});
+187
View File
@@ -0,0 +1,187 @@
// Die Sounds fuer das Pult.
//
// Hochgeladen wird vom Handy aus, abgespielt in OBS ueber die Browser-Source
// /sounds. Dazwischen liegt nur dieser Ordner:
//
// <db>/sounds/sounds.json Name, Emoji, Farbe, Lautstaerke, Reihenfolge
// <db>/sounds/<id>.<endung> die Datei selbst
//
// Bewusst neben der Datenbank und nicht in ihr: ein Ordner mit Dateien laesst
// sich von Hand durchsehen und sichern, und die Datenbank bleibt klein. Und
// bewusst nicht unter /opt/abdulive: das wird bei jedem Ausrollen ersetzt.
//
// Der Dateityp wird an den ersten Bytes erkannt, nicht an dem, was der Browser
// behauptet. Ausgeliefert wird nur, was hier als Ton erkannt wurde - sonst
// waere das eine Ablage fuer beliebige Dateien unter der eigenen Adresse.
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
export type Sound = {
id: string;
name: string;
emoji: string;
/** Eine der FARBEN unten. Freie Werte gaebe es nur fuer CSS-Einschleusung. */
farbe: string;
/** 0 bis 1. Multipliziert mit der Lautstaerke der Browser-Source. */
laut: number;
datei: string;
typ: string;
bytes: number;
seit: string;
};
export const FARBEN = ['lila', 'tuerkis', 'pink', 'gold', 'gruen', 'rot', 'blau', 'grau'] as const;
/** Groesser ist kein Soundeffekt mehr, sondern ein Lied. */
export const MAX_BYTES = 8 * 1024 * 1024;
export const MAX_SOUNDS = 120;
const ENDUNG: Record<string, string> = {
'audio/mpeg': '.mp3',
'audio/wav': '.wav',
'audio/ogg': '.ogg',
'audio/webm': '.webm',
'audio/mp4': '.m4a',
};
/**
* Tontyp an den ersten Bytes erkennen. `null` heisst: kein Ton, den OBS'
* Browser sicher abspielt.
*/
export function erkenneTyp(b: Buffer): string | null {
if (b.length < 12) return null;
const ascii = (von: number, bis: number): string => b.subarray(von, bis).toString('latin1');
if (ascii(0, 3) === 'ID3') return 'audio/mpeg';
// MPEG-Rahmen ohne ID3-Kopf: 11 gesetzte Synchronbits.
if (b[0] === 0xff && (b[1]! & 0xe0) === 0xe0) return 'audio/mpeg';
if (ascii(0, 4) === 'RIFF' && ascii(8, 12) === 'WAVE') return 'audio/wav';
if (ascii(0, 4) === 'OggS') return 'audio/ogg';
if (b[0] === 0x1a && b[1] === 0x45 && b[2] === 0xdf && b[3] === 0xa3) return 'audio/webm';
// MP4/M4A: das Kaestchen `ftyp` an Stelle 4. So nimmt auch das iPhone auf.
if (ascii(4, 8) === 'ftyp') return 'audio/mp4';
return null;
}
/** Name aus dem Dateinamen: ohne Endung, ohne Unterstriche, nicht zu lang. */
export function nameAusDatei(datei: string): string {
const ohne = datei.replace(/\.[A-Za-z0-9]{1,5}$/, '').replace(/[_]+/g, ' ').trim();
return saeubern(ohne) || 'Sound';
}
function saeubern(s: string, max = 40): string {
// Steuerzeichen raus. Angezeigt wird ueber textContent, aber ein
// Zeilenumbruch im Namen zerreisst trotzdem jede Kachel.
return Array.from(s.replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim())
.slice(0, max).join('');
}
function emojiOk(s: string): string {
// Ein Emoji kann aus mehreren Codepoints bestehen (Hautfarbe, Familien).
// Zehn reichen fuer alles, was man auf eine Kachel setzt.
return Array.from(saeubern(s, 10)).slice(0, 10).join('');
}
export type Soundboard = {
liste: () => Sound[];
holen: (id: string) => Sound | undefined;
datei: (id: string) => { daten: Buffer; typ: string } | undefined;
hinzufuegen: (name: string, daten: Buffer) => Sound;
aendern: (id: string, was: Partial<Pick<Sound, 'name' | 'emoji' | 'farbe' | 'laut'>>) => Sound | undefined;
entfernen: (id: string) => boolean;
/** Um eine Stelle nach vorn (-1) oder hinten (+1). */
verschieben: (id: string, um: number) => boolean;
};
export function createSoundboard(ordner: string): Soundboard {
const index = path.join(ordner, 'sounds.json');
const sounds: Sound[] = lesen();
function lesen(): Sound[] {
try {
const roh = JSON.parse(fs.readFileSync(index, 'utf8')) as { sounds?: Sound[] };
return Array.isArray(roh.sounds) ? roh.sounds : [];
} catch {
return [];
}
}
function schreiben(): void {
fs.mkdirSync(ordner, { recursive: true });
// Erst daneben schreiben, dann umbenennen: stirbt der Prozess mittendrin,
// bleibt die alte Liste ganz statt einer halben neuen.
const tmp = `${index}.${process.pid}.tmp`;
fs.writeFileSync(tmp, JSON.stringify({ sounds }, null, 2));
fs.renameSync(tmp, index);
}
const holen = (id: string): Sound | undefined => sounds.find((s) => s.id === id);
return {
liste: () => sounds.map((s) => ({ ...s })),
holen,
datei(id) {
const s = holen(id);
if (!s) return undefined;
try {
return { daten: fs.readFileSync(path.join(ordner, s.datei)), typ: s.typ };
} catch {
return undefined;
}
},
hinzufuegen(name, daten) {
if (daten.length > MAX_BYTES) {
throw new Error(`Zu gross: höchstens ${MAX_BYTES / 1024 / 1024} MB.`);
}
if (sounds.length >= MAX_SOUNDS) throw new Error(`Höchstens ${MAX_SOUNDS} Sounds.`);
const typ = erkenneTyp(daten);
if (!typ) throw new Error('Kein Ton, den OBS abspielt. Erlaubt sind MP3, WAV, OGG, WebM und M4A.');
const id = crypto.randomBytes(6).toString('hex');
const datei = `${id}${ENDUNG[typ]}`;
fs.mkdirSync(ordner, { recursive: true });
fs.writeFileSync(path.join(ordner, datei), daten);
const s: Sound = {
id, name: saeubern(name) || 'Sound', emoji: '', farbe: FARBEN[sounds.length % FARBEN.length]!,
laut: 0.8, datei, typ, bytes: daten.length, seit: new Date().toISOString(),
};
sounds.push(s);
schreiben();
return { ...s };
},
aendern(id, was) {
const s = holen(id);
if (!s) return undefined;
if (was.name !== undefined) s.name = saeubern(was.name) || s.name;
if (was.emoji !== undefined) s.emoji = emojiOk(was.emoji);
if (was.farbe !== undefined && (FARBEN as readonly string[]).includes(was.farbe)) s.farbe = was.farbe;
if (was.laut !== undefined && Number.isFinite(was.laut)) {
s.laut = Math.round(Math.max(0, Math.min(1, was.laut)) * 100) / 100;
}
schreiben();
return { ...s };
},
entfernen(id) {
const i = sounds.findIndex((s) => s.id === id);
if (i === -1) return false;
const [weg] = sounds.splice(i, 1);
schreiben();
try { fs.unlinkSync(path.join(ordner, weg!.datei)); } catch { /* schon weg */ }
return true;
},
verschieben(id, um) {
const i = sounds.findIndex((s) => s.id === id);
const ziel = i + Math.sign(um);
if (i === -1 || ziel < 0 || ziel >= sounds.length) return false;
[sounds[i], sounds[ziel]] = [sounds[ziel]!, sounds[i]!];
schreiben();
return true;
},
};
}
+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;
}
}
+150 -2
View File
@@ -1,5 +1,153 @@
# @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. |
| `/sounds` | 400×120 | Spielt die Sounds vom Pult. Unsichtbar, **mit Ton**. |
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 bei Einblendungen.** Es liegen keine Audiodateien im Repo, und ein
Overlay, das ungefragt Krach macht, ist schlimmer als eines, das still ist.
Sounds gibt es nur auf Knopfdruck, über das Pult.
## Das Pult (`/pult`)
Keine Browser-Source, sondern eine App fürs Handy: wie ein Stream Deck, nur
ohne Hardware. Mit Passwort, wie die Übersicht. Im Browser des Handys
„Zum Startbildschirm hinzufügen“ (iPhone) bzw. „App installieren“ (Android),
dann startet es ohne Adressleiste. `manifest.webmanifest` und `sw.js` machen
das möglich; der Service Worker hält nur die Hülle vor und beantwortet nie
einen Knopfdruck aus dem Speicher.
| Reiter | Was |
|---|---|
| Sounds | Tasten fürs Soundboard. „+ Sound“ lädt MP3, WAV, OGG, WebM oder M4A vom Handy hoch (bis 8 MB). „Bearbeiten“: Name, Emoji, Farbe, Lautstärke, Reihenfolge, Löschen, am Handy anhören. „Stopp“ bricht alle Sounds ab. |
| Szenen | OBS-Szenen umschalten, Clip aus dem Wiedergabepuffer speichern, Toneingänge stumm schalten. Braucht den Stream-PC (siehe unten). |
| Stream | Liegestütze zählen, Vorlesen freigeben, „Sofort still“. |
| Test | Dieselben Testknöpfe wie auf der Übersicht. |
Die Sonne oben rechts hält den Bildschirm an (Wake Lock), solange das Pult
offen ist.
**Sounds** liegen auf dem Hub neben der Datenbank (`<db>/sounds/`), nicht im
Repo und nicht unter `/opt/abdulive` - sie überleben jedes Ausrollen. Der
Dateityp wird an den ersten Bytes erkannt; was kein Ton ist, wird abgelehnt.
Gespielt wird in OBS über die Browser-Source `/sounds`, „Audio über OBS
steuern“ an. Ist sie nicht verbunden, sagt das Pult es in Gelb. Höchstens vier
Sounds gleichzeitig (`?max=`), `?laut=` dämpft alle.
**Szenen** gehen über den Stream-PC, weil der Hub OBS nicht erreicht. Der PC
wählt sich mit eigenem Schlüssel beim Hub ein (`apps/ingest/src/fern.ts` und
`apps/overlay-server/src/fern.ts`). Einmal auf dem Stream-PC:
```
abdulive fern:verbinden https://abdu.dimension47.de <schlüssel>
```
Der fertige Befehl steht auf der Übersicht unter „Stream-PC verbinden“.
Danach `abdulive ingest` neu starten. Jeder Knopf wartet auf die Antwort von
OBS und sagt ehrlich, wenn es nicht ging.
Die App-Symbole zeichnet `node scripts/make-icons.ts`.
## 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 im Hub mit Piper, nicht im Browser. Die eingebaute
Sprachausgabe von OBS' Browser-Quelle sah keine deutsche Stimme und las
amerikanisch; welche Stimmen es gibt, hängt am Rechner, der streamt. Piper
läuft lokal im LXC, kostet nichts, und kein Zuschauertext verlässt das Haus.
Die Seite holt sich die fertige WAV unter `/tts/audio/<id>` und spielt sie
über ein `<audio>` ab, das sicher über OBS' Tonspur läuft.
Kann der Hub nicht sprechen, springt `speechSynthesis` ein, aber nur mit
einer deutschen Stimme. Gibt es keine, wird die Nachricht nur angezeigt,
mit rotem Hinweis.
Stimme und Sprecher: `rewards.tts.stimme` und `rewards.tts.sprecher` in der
Konfiguration, eingerichtet mit `scripts/install-piper.sh`.
Einstellungen: `?laut=1` (0 bis 1). Besser im OBS-Mixer regeln.
`?sprache=de-DE` und `?tempo=1` gelten nur für den Notnagel im Browser.
Mit `freigabe: true` wird nur vorgelesen, 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>
+172
View File
@@ -0,0 +1,172 @@
/* 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 */ } }
// `quelle` sagt dem Server, welche Seite zuhoert. Er zaehlt damit nur -
// das Pult will wissen, ob die Sound-Quelle in OBS ueberhaupt offen ist.
var quelleName = location.pathname.replace(/^\/+|\.html$/g, '') || 'index';
var url = '/events?quelle=' + encodeURIComponent(quelleName) +
(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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

+227
View File
@@ -0,0 +1,227 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>abdulive - Kamerarahmen</title>
<link rel="stylesheet" href="/theme.css">
<style>
/* Rahmen um die Facecam, dazu die Werte der alten Statusleiste auf einer
* Platte darunter.
*
* Die Kamera liegt bündig oben links, 480x270 (docs/obs-layout.md). Genau
* dieser Bereich wird für die Shorts ausgeschnitten, und das Gameplay ab
* x=515. Deshalb liegt hier NICHTS über der Kamera: der Rahmen nutzt nur den
* Streifen rechts (480 bis 515) und den freien Platz darunter. Wer den
* Rahmen nach innen zieht, hat ihn in jedem Short.
*
* Oben und links gibt es keinen Rahmen - dort ist der Bildrand.
*
* In OBS: Browser-Quelle 520x380, Position 0,0, über der Kamera. */
:root {
--cam-w: 480px;
--cam-h: 270px;
--dicke: 7px;
--abstand: 4px; /* Luft zwischen Kamera und Rahmen */
--platte-h: 74px;
--verlauf: linear-gradient(135deg, var(--lila), var(--pink), var(--tuerkis), var(--lila));
}
/* Der Rahmen: ein L um die rechte und untere Kante, außen abgerundet.
* Gezeichnet als Fläche mit ausgespartem Inneren, damit der Farbverlauf
* ohne Stoß um die Ecke läuft. */
#rahmen {
position: fixed;
left: 0; top: 0;
width: calc(var(--cam-w) + var(--abstand) + var(--dicke));
height: calc(var(--cam-h) + var(--abstand) + var(--dicke));
border-bottom-right-radius: 22px;
background: var(--verlauf);
background-size: 300% 300%;
animation: wandern 10s linear infinite;
-webkit-mask:
linear-gradient(#000 0 0) top left / calc(var(--cam-w) + var(--abstand)) calc(var(--cam-h) + var(--abstand)) no-repeat,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
filter: drop-shadow(0 0 10px rgba(169, 112, 255, .45));
transition: filter .4s;
}
@keyframes wandern { to { background-position: 300% 300%; } }
/* Platte unter der Kamera. Hängt am Rahmen, damit beides als ein Stück
* gelesen wird und nicht als zwei Overlays, die zufällig nah beieinander
* liegen. */
#platte {
position: fixed;
left: 0;
top: calc(var(--cam-h) + var(--abstand) + var(--dicke));
width: calc(var(--cam-w) + var(--abstand) + var(--dicke));
height: var(--platte-h);
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 16px;
padding: 0 18px 0 16px;
background: var(--tinte-klar);
backdrop-filter: blur(14px) saturate(140%);
border-bottom-left-radius: 0;
border-bottom-right-radius: 16px;
box-shadow: var(--schatten), var(--glanz);
}
.links { min-width: 0; }
.zeile1 { display: flex; align-items: center; gap: 10px; }
.punkt {
width: 11px; height: 11px; flex: none;
border-radius: 50%;
background: #555;
}
body.live .punkt { background: var(--rot); animation: klopfen 2s ease-out infinite; }
@keyframes klopfen {
0% { box-shadow: 0 0 0 0 rgba(239, 68, 68, .55); }
70% { box-shadow: 0 0 0 12px rgba(239, 68, 68, 0); }
100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); }
}
.kanal { font-size: 23px; font-weight: 700; letter-spacing: -.01em; line-height: 1.1; }
#kategorie {
font-size: 15px; color: var(--schrift-leise); margin-top: 3px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.werte { display: flex; gap: 18px; }
.wert { text-align: right; }
.wert .zahl {
display: flex; align-items: center; justify-content: flex-end; gap: 6px;
font: 700 22px/1.1 var(--font-zahl);
font-variant-numeric: tabular-nums;
}
.wert svg { width: 17px; height: 17px; opacity: .8; }
.wert .beschriftung {
font-size: 10.5px; letter-spacing: .14em; text-transform: uppercase;
color: var(--schrift-leise);
}
.zahl.neu { animation: aufleuchten .8s ease-out; }
@keyframes aufleuchten {
0% { color: var(--tuerkis); transform: translateY(-3px); }
100% { color: inherit; transform: none; }
}
/* Nicht live: der Rahmen wird ruhig und grau. So sieht man in der
* Vorschau sofort, ob der Hub den Stream erkannt hat. */
body:not(.live) #rahmen {
background: linear-gradient(135deg, #3a3550, #57507a);
animation: none;
filter: none;
}
/* Aufleuchten bei Abo, Raid, Bits: der Rahmen glüht kurz in der Farbe des
* Ereignisses. Die große Einblendung macht alerts.html - das hier ist nur
* das Echo an der Kamera, damit der Blick dorthin geht, wo Abdu reagiert. */
body.puls #rahmen { animation: wandern 1.2s linear infinite; }
body.puls-lila #rahmen { filter: drop-shadow(0 0 22px var(--lila)); }
body.puls-pink #rahmen { filter: drop-shadow(0 0 22px var(--pink)); }
body.puls-gold #rahmen { filter: drop-shadow(0 0 22px var(--gold)); }
body.puls-tuerkis #rahmen { filter: drop-shadow(0 0 22px var(--tuerkis)); }
/* Nur zum Ansehen im Browser (?probe=1): ein Platzhalter, wo die Kamera
* sitzt. In OBS liegt dort das echte Bild. */
#kamera-probe {
display: none;
position: fixed; left: 0; top: 0;
width: var(--cam-w); height: var(--cam-h);
background:
radial-gradient(40% 55% at 50% 60%, #4a3b6b, transparent 70%),
linear-gradient(160deg, #2b2440, #17131f);
color: var(--schrift-leise);
font: 500 14px/1 var(--font-zahl);
display: none; place-items: center;
}
body.probe #kamera-probe { display: grid; }
</style>
</head>
<body>
<div id="kamera-probe">Facecam 480 × 270</div>
<div id="rahmen"></div>
<div id="platte">
<div class="links">
<div class="zeile1">
<span class="punkt"></span>
<span class="kanal kontur" id="kanal">abdulive_</span>
</div>
<div id="kategorie">offline</div>
</div>
<div class="werte">
<div class="wert">
<span class="zahl" id="zuschauer">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/>
</svg><span>-</span>
</span>
<span class="beschriftung">Zuschauer</span>
</div>
<div class="wert">
<span class="zahl" id="laufzeit"><span>-</span></span>
<span class="beschriftung">live seit</span>
</div>
</div>
</div>
<div class="getrennt">keine Verbindung</div>
<script src="/client.js"></script>
<script>
(function () {
'use strict';
if (Overlay.param('probe', false)) document.body.classList.add('probe');
var kategorie = document.getElementById('kategorie');
var zuschauer = document.getElementById('zuschauer');
var laufzeit = document.getElementById('laufzeit');
document.getElementById('kanal').textContent = Overlay.param('kanal', 'abdulive_');
var seit = null;
function setze(el, wert) {
var ziel = el.lastElementChild;
if (ziel.textContent === wert) return;
ziel.textContent = wert;
el.classList.remove('neu');
void el.offsetWidth;
el.classList.add('neu');
}
function stand(ev) {
document.body.classList.toggle('live', !!ev.live);
// Die Kategorie statt des Titels: der Titel ist meist zu lang für 490 px
// und steht ohnehin auf der Twitch-Seite direkt unter dem Bild.
kategorie.textContent = ev.live ? (ev.category || 'live') : 'offline';
setze(zuschauer, ev.live ? Overlay.zahl(ev.viewers) : '-');
seit = ev.live ? ev.startedAt : null;
tick();
}
function tick() { laufzeit.lastElementChild.textContent = seit ? Overlay.laufzeit(seit) : '-'; }
setInterval(tick, 10000);
var FARBE = {
sub: 'lila', resub: 'lila', subgift: 'pink', massgift: 'pink',
raid: 'tuerkis', cheer: 'gold', follow: 'tuerkis', reward: 'gold',
};
var pulsTimer = null;
function puls(farbe) {
var b = document.body;
b.className = b.className.replace(/\bpuls(-\w+)?\b/g, '').trim();
void b.offsetWidth;
b.classList.add('puls', 'puls-' + farbe);
clearTimeout(pulsTimer);
pulsTimer = setTimeout(function () {
b.className = b.className.replace(/\bpuls(-\w+)?\b/g, '').trim();
}, 4000);
}
Overlay.on('stream', stand);
Overlay.on('state', function (ev) { stand(ev.stream); });
Overlay.on('alert', function (ev) { puls(FARBE[ev.type] || 'lila'); });
})();
</script>
</body>
</html>
+233
View File
@@ -0,0 +1,233 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>abdulive - Liegestütze</title>
<link rel="stylesheet" href="/theme.css">
<style>
/* Liegestütze aus Kanalpunkten: groß die offenen, darunter die in diesem
* Stream schon gemachten.
*
* Eine Einlösung zählt hoch und zeigt kurz, wer es war. Wenn Abdu auf der
* Übersichtsseite herunterzählt, blinkt die Zahl grün und "gemacht" wächst -
* ohne Namen, damit das nicht wie eine Einlösung aussieht.
*
* In OBS: Browser-Quelle 420x200. Einstellungen in der Adresse:
* ?verstecken=1 ausblenden, solange nichts offen und nichts gemacht ist
* ?gemacht=0 die Zeile "gemacht" weglassen
* ?titel=... andere Beschriftung als "Liegestütze" */
#karte {
position: absolute;
left: 12px; bottom: 12px;
display: flex; align-items: center; gap: 18px;
padding: 16px 26px 16px 18px;
overflow: hidden;
transition: opacity .5s, transform .5s, box-shadow .4s, border-color .4s;
}
#karte.weg { opacity: 0; transform: translateY(14px); }
.symbol {
width: 58px; height: 58px; flex: none;
display: grid; place-items: center;
border-radius: 14px;
background: linear-gradient(135deg, var(--lila), var(--pink));
box-shadow: 0 6px 18px rgba(169, 112, 255, .45);
}
.symbol svg { width: 36px; height: 36px; }
.text { min-width: 0; }
#titel {
font: 600 13px/1 var(--font-zahl);
letter-spacing: .16em; text-transform: uppercase;
color: var(--schrift-leise);
}
#zahl {
display: block;
font: 800 54px/1 var(--font);
font-variant-numeric: tabular-nums;
letter-spacing: -.02em;
margin-top: 6px;
}
#zahl.erledigt { font-size: 30px; color: var(--gruen); padding: 8px 0 6px; }
#gemacht {
display: flex; align-items: center; gap: 6px;
margin-top: 7px;
font: 600 16px/1 var(--font-zahl);
font-variant-numeric: tabular-nums;
color: var(--schrift-leise);
}
#gemacht b { color: var(--gruen); font-weight: 800; }
#gemacht svg { width: 15px; height: 15px; color: var(--gruen); }
#gemacht.aus { display: none; }
#gemacht.mehr b { animation: gemacht-mehr .9s ease-out; display: inline-block; }
@keyframes gemacht-mehr {
0% { transform: scale(1.35); }
100% { transform: none; }
}
/* Einlösung: Karte glüht gold, Zahl springt. */
#karte.mehr { border-color: var(--gold); box-shadow: var(--schatten), 0 0 28px rgba(255, 197, 61, .55); }
#zahl.springen { animation: springen .6s cubic-bezier(.3, 1.6, .5, 1); }
@keyframes springen {
0% { transform: scale(1); }
40% { transform: scale(1.22); color: var(--gold); }
100% { transform: scale(1); }
}
/* Korrektur nach unten: kurz grün, das heißt "gemacht". */
#zahl.weniger { animation: weniger .9s ease-out; }
@keyframes weniger {
0% { color: var(--gruen); transform: translateY(-4px); }
100% { color: inherit; transform: none; }
}
/* "+5 von Name", steigt über der Karte auf. */
#plus {
position: absolute;
left: 12px; bottom: 140px;
max-width: 396px;
padding: 8px 14px;
font: 700 20px/1.1 var(--font);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
opacity: 0;
}
#plus b { color: var(--gold); margin-right: 6px; }
#plus.zeigen { animation: aufsteigen 3.6s ease-out both; }
@keyframes aufsteigen {
0% { opacity: 0; transform: translateY(16px); }
12% { opacity: 1; transform: none; }
80% { opacity: 1; transform: none; }
100% { opacity: 0; transform: translateY(-10px); }
}
</style>
</head>
<body>
<div id="plus" class="glas"></div>
<div id="karte" class="glas">
<div class="symbol" aria-hidden="true">
<!-- Figur im Liegestütz: Kopf, Rücken, Arme, Boden. -->
<svg viewBox="0 0 48 48" fill="none" stroke="#fff" stroke-width="3.4"
stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="17" r="4" fill="#fff" stroke="none"/>
<path d="M15 21 L41 29"/>
<path d="M17 22 L15 33"/>
<path d="M41 29 L42 33"/>
<path d="M6 37 H44" stroke-width="2.4" opacity=".7"/>
</svg>
</div>
<div class="text">
<div id="titel" class="kontur">Liegestütze</div>
<span id="zahl" class="kontur">0</span>
<div id="gemacht" class="kontur">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M5 12.5l4.5 4.5L19 7.5"/>
</svg>
<span><b>0</b> gemacht</span>
</div>
</div>
</div>
<script src="/client.js"></script>
<script>
(function () {
'use strict';
var karte = document.getElementById('karte');
var zahl = document.getElementById('zahl');
var plus = document.getElementById('plus');
var gemacht = document.getElementById('gemacht');
var gemachtZahl = gemacht.querySelector('b');
if (!Overlay.param('gemacht', true)) gemacht.classList.add('aus');
var verstecken = Overlay.param('verstecken', false);
document.getElementById('titel').textContent = Overlay.param('titel', 'Liegestütze');
var angezeigt = null; // was gerade im Bild steht
var gemachtBisher = null;
var lauf = null; // laufendes Hochzählen
var glimmen = null;
function schreibe(n) {
if (n === 0 && !verstecken) {
zahl.textContent = 'erledigt';
zahl.classList.add('erledigt');
} else {
zahl.textContent = Overlay.zahl(n);
zahl.classList.remove('erledigt');
}
}
function sichtbarkeit() {
karte.classList.toggle('weg', verstecken && angezeigt === 0 && !gemachtBisher);
}
function zeigeGemacht(g) {
if (typeof g !== 'number') return;
var alt = gemachtBisher;
gemachtBisher = g;
gemachtZahl.textContent = Overlay.zahl(g);
if (alt !== null && g > alt) nochmal(gemacht, 'mehr');
}
function nochmal(el, klasse) {
el.classList.remove(klasse);
void el.offsetWidth;
el.classList.add(klasse);
}
/** Zählt sichtbar von der alten zur neuen Zahl hoch. Bei großen Sprüngen
* nicht jede einzelne Zahl, sonst dauert ein Setzen auf 500 ewig. */
function hochzaehlen(von, bis) {
clearInterval(lauf);
var schritte = Math.min(bis - von, 20);
var i = 0;
lauf = setInterval(function () {
i++;
schreibe(i >= schritte ? bis : Math.round(von + (bis - von) * i / schritte));
if (i >= schritte) clearInterval(lauf);
}, 45);
}
function stand(n, einloesung) {
if (typeof n !== 'number') return;
var alt = angezeigt;
angezeigt = n;
// Erster Stand nach dem Verbinden: einfach hinschreiben, ohne Effekt.
if (alt === null || alt === n) { clearInterval(lauf); schreibe(n); return; }
if (n > alt) {
hochzaehlen(alt, n);
nochmal(zahl, 'springen');
karte.classList.add('mehr');
clearTimeout(glimmen);
glimmen = setTimeout(function () { karte.classList.remove('mehr'); }, 2500);
} else {
clearInterval(lauf);
schreibe(n);
nochmal(zahl, 'weniger');
}
if (einloesung && einloesung.name) {
plus.innerHTML = '<b>+' + Overlay.esc(einloesung.dazu) + '</b>' +
'von ' + Overlay.esc(einloesung.name);
nochmal(plus, 'zeigen');
}
}
Overlay.on('state', function (ev) {
if (!ev.liegestuetze) return;
stand(ev.liegestuetze.offen);
zeigeGemacht(ev.liegestuetze.gemacht);
sichtbarkeit();
});
Overlay.on('liegestuetze', function (ev) {
stand(ev.offen, ev.dazu ? { dazu: ev.dazu, name: ev.name } : null);
zeigeGemacht(ev.gemacht);
sichtbarkeit();
});
})();
</script>
</body>
</html>
@@ -0,0 +1,21 @@
{
"name": "abdulive Pult",
"short_name": "Pult",
"description": "Soundboard, Szenen, Liegestütze und Vorlesen für den Stream von abdulive_.",
"lang": "de",
"id": "/pult",
"start_url": "/pult",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#0d0b14",
"theme_color": "#0d0b14",
"icons": [
{ "src": "/icons/pult-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icons/pult-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/icons/pult-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
],
"shortcuts": [
{ "name": "Szenen", "url": "/pult#szenen", "icons": [{ "src": "/icons/pult-192.png", "sizes": "192x192" }] }
]
}
+408
View File
@@ -0,0 +1,408 @@
/* Das Pult. Fuers Handy gebaut, nicht fuer OBS: anders als die
* Browser-Sources hat es einen deckenden Hintergrund und will angefasst
* werden. Farben wie in theme.css, damit Pult und Overlay zusammengehoeren.
*
* Grundregel fuer alles, was man im Stream mit einem Daumen trifft: eine
* Taste ist mindestens 48 px hoch, und was passiert ist, sieht man an der
* Taste selbst - nicht nur in einer Meldung am Rand. */
:root {
color-scheme: dark;
--grund: #0d0b14;
--flaeche: #171327;
--flaeche-2: #1f1a33;
--linie: #2c2642;
--linie-stark: #3a3352;
--schrift: #ece9f6;
--leise: #9b94b3;
--lila: #a970ff;
--lila-tief: #6441a5;
--tuerkis: #4fd1c5;
--pink: #ff4fa3;
--gold: #ffc53d;
--gruen: #4ade80;
--rot: #ef4444;
--blau: #60a5fa;
--grau: #8b84a3;
--rund: 16px;
--kopf-h: 56px;
--reiter-h: 64px;
--oben: env(safe-area-inset-top, 0px);
--unten: env(safe-area-inset-bottom, 0px);
--font: "Segoe UI Variable Text", "Segoe UI", system-ui, -apple-system, Roboto, sans-serif;
--font-zahl: "Segoe UI Variable Display", "Segoe UI", system-ui, -apple-system, Roboto, sans-serif;
}
* { box-sizing: border-box; }
html { background: var(--grund); }
body {
margin: 0;
min-height: 100vh;
min-height: 100dvh;
background: var(--grund);
color: var(--schrift);
font: 16px/1.45 var(--font);
-webkit-font-smoothing: antialiased;
-webkit-tap-highlight-color: transparent;
overscroll-behavior-y: contain;
}
a { color: var(--lila); }
code {
font: .85em "Cascadia Mono", ui-monospace, Consolas, monospace;
background: var(--flaeche-2); padding: .1em .35em; border-radius: 6px;
}
svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 2;
stroke-linecap: round; stroke-linejoin: round; flex: none; }
[hidden] { display: none !important; }
/* ------------------------------------------------------------ Kopf */
.kopf {
position: sticky; top: 0; z-index: 20;
display: flex; align-items: center; justify-content: space-between; gap: 12px;
height: calc(var(--kopf-h) + var(--oben));
padding: var(--oben) 16px 0;
background: rgba(13, 11, 20, .88);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border-bottom: 1px solid var(--linie);
}
.kanal { display: flex; align-items: center; gap: 12px; min-width: 0; }
.marke { font: 600 17px/1 var(--font-zahl); letter-spacing: -.01em; }
.marke b { color: var(--lila); font-weight: 700; }
.live {
display: inline-flex; align-items: center; gap: 6px;
padding: 4px 10px; border-radius: 999px;
font: 600 13px/1.2 var(--font-zahl); font-variant-numeric: tabular-nums;
background: rgba(239, 68, 68, .16); color: #ffb3b3;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.live i { width: 8px; height: 8px; border-radius: 50%; background: var(--rot);
animation: puls 1.6s ease-in-out infinite; flex: none; }
.live.aus { background: var(--flaeche-2); color: var(--leise); }
.live.aus i { background: var(--grau); animation: none; }
.kopf-rechts { display: flex; align-items: center; gap: 8px; }
.rund {
display: grid; place-items: center; width: 40px; height: 40px;
border-radius: 50%; border: 1px solid var(--linie); background: transparent;
color: var(--leise); cursor: pointer;
}
.rund[aria-pressed="true"] { color: var(--gold); border-color: rgba(255, 197, 61, .45);
background: rgba(255, 197, 61, .1); }
.netz { display: grid; place-items: center; width: 24px; height: 40px; }
.netz i { width: 10px; height: 10px; border-radius: 50%; background: var(--rot);
box-shadow: 0 0 0 4px rgba(239, 68, 68, .15); }
.netz.an i { background: var(--gruen); box-shadow: 0 0 0 4px rgba(74, 222, 128, .14); }
.banner {
position: sticky; top: calc(var(--kopf-h) + var(--oben)); z-index: 19;
padding: 10px 16px; font-size: 14px;
background: #3b1d1d; color: #ffd0d0; border-bottom: 1px solid #5c2a2a;
}
@keyframes puls { 50% { opacity: .35; } }
/* ------------------------------------------------------------ Inhalt */
main {
padding: 16px 16px calc(var(--reiter-h) + var(--unten) + 88px);
max-width: 980px; margin: 0 auto;
}
.tab { display: none; }
.tab.an { display: block; animation: rein .18s ease-out; }
@keyframes rein { from { opacity: 0; transform: translateY(4px); } }
.leiste { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; }
.luecke { flex: 1; }
.abschnitt {
display: flex; align-items: center; gap: 8px;
margin: 26px 0 10px; font: 600 13px/1 var(--font); letter-spacing: .08em;
text-transform: uppercase; color: var(--leise);
}
.abschnitt:first-child { margin-top: 4px; }
.klein { font-size: 13px; color: var(--leise); margin: 10px 0; }
.klein.mitte { text-align: center; }
.karte {
background: var(--flaeche); border: 1px solid var(--linie);
border-radius: var(--rund); padding: 16px; margin-bottom: 16px;
}
.karte .abschnitt { margin-top: 0; }
.hinweis {
border-radius: 12px; padding: 12px 14px; margin-bottom: 14px; font-size: 14px;
background: var(--flaeche); border: 1px solid var(--linie);
}
.hinweis.warn { background: rgba(255, 197, 61, .08); border-color: rgba(255, 197, 61, .4);
color: #ffe2a0; }
.hinweis.warn a { color: var(--gold); }
/* ------------------------------------------------------------ Knoepfe */
.knopf {
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
min-height: 44px; padding: 0 16px;
font: 600 15px/1 var(--font); color: var(--schrift);
background: var(--flaeche-2); border: 1px solid var(--linie-stark);
border-radius: 12px; cursor: pointer; user-select: none; -webkit-user-select: none;
text-decoration: none; touch-action: manipulation;
transition: transform .08s, background .15s, border-color .15s;
}
.knopf:active { transform: scale(.96); }
.knopf:disabled { opacity: .4; cursor: not-allowed; transform: none; }
.knopf.primaer { background: var(--lila-tief); border-color: var(--lila); color: #fff; }
.knopf.warnung { color: #ffb3b3; border-color: rgba(239, 68, 68, .5); background: rgba(239, 68, 68, .1); }
.knopf.gross { min-height: 52px; padding: 0 22px; font-size: 16px; }
.knopf.breit { width: 100%; }
.knopf[aria-pressed="true"] { background: var(--schrift); color: var(--grund); border-color: var(--schrift); }
.knopf.wartet { opacity: .7; pointer-events: none; }
.knopf:focus-visible, .kachel:focus-visible, .szene:focus-visible, .rund:focus-visible,
.reiter button:focus-visible { outline: 2px solid var(--lila); outline-offset: 2px; }
/* ------------------------------------------------------------ Soundraster */
.raster {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(104px, 1fr));
gap: 10px;
}
@media (min-width: 640px) { .raster { grid-template-columns: repeat(auto-fill, minmax(132px, 1fr)); gap: 12px; } }
.kachel {
--c: var(--lila);
position: relative;
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px;
aspect-ratio: 1; padding: 10px 8px; min-width: 0;
border-radius: 18px; cursor: pointer; overflow: hidden;
color: #fff; font: 600 14px/1.2 var(--font); text-align: center;
background:
radial-gradient(120% 90% at 30% 0%, color-mix(in oklab, var(--c) 42%, transparent), transparent 70%),
color-mix(in oklab, var(--c) 14%, var(--flaeche));
border: 1px solid color-mix(in oklab, var(--c) 45%, transparent);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .08);
touch-action: manipulation; user-select: none; -webkit-user-select: none;
transition: transform .08s ease-out;
}
.kachel:active { transform: scale(.93); }
.kachel .emoji { font-size: 34px; line-height: 1; }
.kachel .name {
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
overflow: hidden; overflow-wrap: anywhere; max-width: 100%;
text-shadow: 0 1px 2px rgba(0, 0, 0, .5);
}
.kachel.ohne-emoji .name { font-size: 16px; -webkit-line-clamp: 3; }
.kachel::after {
content: ""; position: absolute; inset: 0; border-radius: inherit; pointer-events: none;
background: color-mix(in oklab, var(--c) 70%, #fff); opacity: 0;
}
.kachel.knall::after { animation: knall .6s ease-out; }
@keyframes knall { 0% { opacity: .55; } 100% { opacity: 0; } }
.kachel.knall { animation: wippen .35s ease-out; }
@keyframes wippen { 40% { transform: scale(1.04); } }
.raster.bearbeiten .kachel { animation: zittern .5s ease-in-out infinite alternate; }
.raster.bearbeiten .kachel:nth-child(2n) { animation-delay: -.25s; }
@keyframes zittern { from { transform: rotate(-.8deg); } to { transform: rotate(.8deg); } }
.raster.bearbeiten .kachel::before {
content: "✎"; position: absolute; top: 6px; right: 8px; font-size: 14px; opacity: .85;
}
.kachel[data-farbe="lila"], .farbe[data-farbe="lila"] { --c: var(--lila); }
.kachel[data-farbe="tuerkis"], .farbe[data-farbe="tuerkis"] { --c: var(--tuerkis); }
.kachel[data-farbe="pink"], .farbe[data-farbe="pink"] { --c: var(--pink); }
.kachel[data-farbe="gold"], .farbe[data-farbe="gold"] { --c: var(--gold); }
.kachel[data-farbe="gruen"], .farbe[data-farbe="gruen"] { --c: var(--gruen); }
.kachel[data-farbe="rot"], .farbe[data-farbe="rot"] { --c: var(--rot); }
.kachel[data-farbe="blau"], .farbe[data-farbe="blau"] { --c: var(--blau); }
.kachel[data-farbe="grau"], .farbe[data-farbe="grau"] { --c: var(--grau); }
.kachel.laedt { opacity: .55; pointer-events: none; }
.kachel.laedt .name::after { content: " …"; }
.leer { text-align: center; padding: 32px 12px; color: var(--leise); }
.leer h2 { color: var(--schrift); font-size: 20px; margin: 18px 0 6px; }
.leer p { max-width: 34ch; margin: 0 auto 20px; }
.leer-bild { display: inline-grid; grid-template-columns: repeat(3, 34px); gap: 6px; }
.leer-bild span { aspect-ratio: 1; border-radius: 9px; border: 1.5px dashed var(--linie-stark); }
.leer-bild span:nth-child(2) { border-color: var(--lila); background: rgba(169, 112, 255, .12); }
.leer-bild span:nth-child(6) { border-color: var(--tuerkis); background: rgba(79, 209, 197, .1); }
/* ------------------------------------------------------------ Szenen */
.szenen { display: grid; gap: 8px; }
@media (min-width: 640px) { .szenen { grid-template-columns: repeat(2, 1fr); } }
.szene {
display: flex; align-items: center; justify-content: space-between; gap: 12px;
min-height: 64px; padding: 0 18px; width: 100%;
font: 600 17px/1.2 var(--font); color: var(--schrift); text-align: left;
background: var(--flaeche); border: 1px solid var(--linie-stark); border-radius: var(--rund);
cursor: pointer; touch-action: manipulation; transition: transform .08s, background .15s;
}
.szene:active { transform: scale(.98); }
.szene .szene-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.szene .marke-an {
font: 700 11px/1 var(--font); letter-spacing: .1em; text-transform: uppercase;
padding: 5px 8px; border-radius: 6px; background: rgba(255, 255, 255, .16); display: none;
}
.szene.aktiv { background: var(--lila-tief); border-color: var(--lila); }
.szene.aktiv .marke-an { display: inline-block; }
.szene.wartet { opacity: .6; }
.knopf.clip {
width: 100%; min-height: 64px; font-size: 17px; border-radius: var(--rund);
background: rgba(255, 79, 163, .14); border-color: rgba(255, 79, 163, .55); color: #ffd1e8;
}
.knopf.clip svg { width: 24px; height: 24px; }
.ton { display: grid; gap: 8px; }
.ton-zeile {
display: flex; align-items: center; gap: 12px; width: 100%;
min-height: 56px; padding: 0 14px 0 16px;
font: 500 15px/1.2 var(--font); color: var(--schrift); text-align: left;
background: var(--flaeche); border: 1px solid var(--linie); border-radius: 14px; cursor: pointer;
}
.ton-zeile .ton-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ton-zeile .ton-stand {
font: 600 12px/1 var(--font); letter-spacing: .06em; text-transform: uppercase;
padding: 7px 10px; border-radius: 8px; background: rgba(74, 222, 128, .14); color: var(--gruen);
}
.ton-zeile.stumm { border-color: rgba(239, 68, 68, .45); }
.ton-zeile.stumm .ton-stand { background: rgba(239, 68, 68, .16); color: #ffb3b3; }
.ton-zeile.stumm svg { color: #ffb3b3; }
.leer-karte { text-align: center; padding: 28px 18px; }
.leer-karte h2 { font-size: 18px; margin: 0 0 8px; }
.leer-karte p { color: var(--leise); margin: 0; font-size: 14px; }
/* ------------------------------------------------------------ Stream */
.lz-zahlen { display: flex; gap: 28px; margin: 4px 0 16px; }
.lz-zahlen div { display: flex; flex-direction: column; }
.lz-zahlen b { font: 700 52px/1 var(--font-zahl); font-variant-numeric: tabular-nums; }
.lz-zahlen span { color: var(--leise); font-size: 13px; margin-top: 4px; }
.lz-zahlen .gemacht b { color: var(--gruen); }
.lz-knoepfe { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.lz-knoepfe .knopf { min-height: 56px; font-size: 18px; font-variant-numeric: tabular-nums; }
.zaehler {
display: inline-grid; place-items: center; min-width: 22px; height: 22px; padding: 0 6px;
border-radius: 999px; background: var(--pink); color: #fff; font-size: 12px; letter-spacing: 0;
}
.wartend { display: grid; gap: 10px; margin-bottom: 12px; }
.wartend .leer-zeile { color: var(--leise); font-size: 14px; }
.nachricht { background: var(--flaeche-2); border-radius: 12px; padding: 12px; }
.nachricht .wer { font-weight: 600; color: var(--tuerkis); font-size: 14px; }
.nachricht .was { margin: 4px 0 10px; overflow-wrap: anywhere; }
.nachricht .reihe { display: flex; gap: 8px; }
.nachricht .reihe .knopf { flex: 1; }
.knopf.panik {
width: 100%; min-height: 64px; margin-top: 8px; font-size: 18px; border-radius: var(--rund);
background: var(--rot); border-color: var(--rot); color: #fff;
}
.knopf.panik svg { width: 24px; height: 24px; }
/* ------------------------------------------------------------ Test */
.gruppe { margin-bottom: 4px; }
.gruppe .knoepfe { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 8px; }
.gruppe .knopf { min-height: 48px; font-weight: 500; font-size: 14px; padding: 0 10px; }
/* ------------------------------------------------------------ Reiter */
.reiter {
position: fixed; left: 0; right: 0; bottom: 0; z-index: 30;
display: grid; grid-template-columns: repeat(4, 1fr);
height: calc(var(--reiter-h) + var(--unten));
padding-bottom: var(--unten);
background: rgba(18, 14, 29, .94);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border-top: 1px solid var(--linie);
}
.reiter button {
position: relative;
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px;
background: none; border: 0; color: var(--leise); cursor: pointer;
font: 500 12px/1 var(--font); touch-action: manipulation;
}
.reiter button svg { width: 24px; height: 24px; }
.reiter button.an { color: var(--schrift); }
.reiter button.an svg { color: var(--lila); }
.reiter .punkt { position: absolute; top: 10px; left: calc(50% + 10px); width: 9px; height: 9px;
border-radius: 50%; background: var(--pink); border: 2px solid var(--grund); }
/* ------------------------------------------------------------ Meldung */
.meldung {
position: fixed; left: 50%; z-index: 40;
bottom: calc(var(--reiter-h) + var(--unten) + 14px);
transform: translate(-50%, 12px);
max-width: min(92vw, 460px); padding: 12px 16px; border-radius: 12px;
background: #241f35; border: 1px solid var(--linie-stark); color: var(--schrift);
font-size: 15px; box-shadow: 0 12px 40px rgba(0, 0, 0, .5);
opacity: 0; pointer-events: none; transition: opacity .2s, transform .2s;
}
.meldung.an { opacity: 1; transform: translate(-50%, 0); }
.meldung.ok { border-color: rgba(74, 222, 128, .5); }
.meldung.fehler { border-color: rgba(255, 197, 61, .6); color: #ffe2a0; }
/* ------------------------------------------------------------ Blatt */
.blatt {
width: 100%; max-width: 560px; margin: auto auto 0; padding: 0;
border: 1px solid var(--linie-stark); border-bottom: 0; border-radius: 22px 22px 0 0;
background: var(--flaeche); color: var(--schrift);
}
@media (min-width: 640px) { .blatt { margin: auto; border-radius: 22px; border-bottom: 1px solid var(--linie-stark); } }
.blatt::backdrop { background: rgba(5, 4, 9, .65); backdrop-filter: blur(2px); }
.blatt[open] { animation: hoch .22s ease-out; }
@keyframes hoch { from { transform: translateY(40px); opacity: 0; } }
.blatt form { padding: 10px 18px calc(18px + var(--unten)); display: grid; gap: 16px; }
.griff { width: 40px; height: 4px; border-radius: 4px; background: var(--linie-stark); margin: 0 auto; }
.blatt-kopf { display: flex; gap: 10px; }
.blatt input:not([type=range]) {
font: inherit; color: var(--schrift); background: var(--flaeche-2);
border: 1px solid var(--linie-stark); border-radius: 12px; min-height: 52px; padding: 0 14px;
}
.blatt input:focus { outline: 2px solid var(--lila); outline-offset: 0; }
.emoji-feld { width: 64px; text-align: center; font-size: 26px !important; padding: 0 !important; }
.name-feld { flex: 1; min-width: 0; font-size: 17px !important; }
.schnell { display: flex; gap: 6px; overflow-x: auto; padding-bottom: 2px; scrollbar-width: none; }
.schnell button {
flex: none; width: 42px; height: 42px; border-radius: 10px; font-size: 22px;
background: var(--flaeche-2); border: 1px solid var(--linie); cursor: pointer;
}
.feld { display: grid; gap: 10px; }
.feld-name { font-size: 13px; color: var(--leise); display: flex; justify-content: space-between; }
.feld-name b { color: var(--schrift); font-variant-numeric: tabular-nums; }
.farben { display: flex; gap: 8px; flex-wrap: wrap; }
.farbe {
--c: var(--lila);
width: 36px; height: 36px; border-radius: 50%; cursor: pointer;
background: var(--c); border: 3px solid var(--flaeche); box-shadow: 0 0 0 1px var(--linie-stark);
}
.farbe[aria-checked="true"] { box-shadow: 0 0 0 2px var(--schrift); }
input[type=range] { width: 100%; accent-color: var(--lila); height: 32px; }
.blatt-reihe { display: flex; gap: 8px; align-items: center; }
.blatt-reihe #b-hoeren { flex: 1; }
/* ------------------------------------------------------------ Abgemeldet */
.abgemeldet {
position: fixed; inset: 0; z-index: 50; display: grid; place-items: center;
background: rgba(13, 11, 20, .96); text-align: center; padding: 24px;
}
.abgemeldet p { color: var(--leise); max-width: 32ch; margin: 8px auto 22px; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation: none !important; transition: none !important; }
}
+195
View File
@@ -0,0 +1,195 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0d0b14">
<meta name="color-scheme" content="dark">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Pult">
<title>Pult · abdulive</title>
<link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/icons/pult-192.png" type="image/png">
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
<link rel="stylesheet" href="/pult.css">
</head>
<body>
<header class="kopf">
<div class="kanal">
<span class="marke">abdu<b>live</b></span>
<span id="live" class="live aus"><i></i><span id="live-text">offline</span></span>
</div>
<div class="kopf-rechts">
<button id="wach" class="rund" type="button" aria-pressed="false" title="Bildschirm anlassen">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v2M12 19v2M4.2 4.2l1.4 1.4M18.4 18.4l1.4 1.4M3 12h2M19 12h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4"/><circle cx="12" cy="12" r="4"/></svg>
</button>
<span id="netz" class="netz" title="Verbindung zum Hub"><i></i></span>
</div>
</header>
<div id="banner" class="banner" hidden></div>
<main>
<!-- ======================================================== Sounds -->
<section id="tab-sounds" class="tab an" aria-labelledby="nav-sounds">
<div class="leiste">
<button id="stopp" class="knopf warnung" type="button">
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
Stopp
</button>
<span class="luecke"></span>
<button id="bearbeiten" class="knopf" type="button" aria-pressed="false">Bearbeiten</button>
<label class="knopf primaer" for="datei">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>
Sound
</label>
<input id="datei" type="file" accept="audio/*,.mp3,.wav,.ogg,.m4a,.webm" multiple hidden>
</div>
<div id="quelle-fehlt" class="hinweis warn" hidden>
In OBS ist keine Sound-Quelle verbunden – die Zuschauer hören nichts. Browser-Source
<code>/sounds</code> anlegen, die Adresse steht auf der <a href="/">Übersicht</a>.
</div>
<div id="raster" class="raster" aria-live="polite"></div>
<div id="sounds-leer" class="leer" hidden>
<div class="leer-bild" aria-hidden="true">
<span></span><span></span><span></span><span></span><span></span><span></span>
</div>
<h2>Noch keine Sounds</h2>
<p>MP3, WAV, OGG oder M4A vom Handy hochladen. Jeder Sound wird eine Taste;
ein Druck spielt ihn im Stream.</p>
<label class="knopf primaer gross" for="datei">Ersten Sound hinzufügen</label>
</div>
</section>
<!-- ======================================================== Szenen -->
<section id="tab-szenen" class="tab" aria-labelledby="nav-szenen">
<div id="obs-fehlt" class="karte leer-karte" hidden>
<h2 id="obs-fehlt-titel">Stream-PC nicht verbunden</h2>
<p id="obs-fehlt-text"></p>
</div>
<div id="obs-da" hidden>
<h3 class="abschnitt">Szenen</h3>
<div id="szenen" class="szenen"></div>
<h3 class="abschnitt">Clip</h3>
<button id="clip" class="knopf clip" type="button">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h3l2-3h6l2 3h3v12H4z"/><circle cx="12" cy="13" r="3.5"/></svg>
<span>Clip speichern</span>
</button>
<p id="clip-hinweis" class="klein"></p>
<h3 class="abschnitt">Ton</h3>
<div id="ton" class="ton"></div>
</div>
</section>
<!-- ======================================================== Stream -->
<section id="tab-stream" class="tab" aria-labelledby="nav-stream">
<div id="lz-karte" class="karte" hidden>
<h3 class="abschnitt">Liegestütze</h3>
<div class="lz-zahlen">
<div><b id="lz-offen">0</b><span>offen</span></div>
<div class="gemacht"><b id="lz-gemacht">0</b><span>gemacht</span></div>
</div>
<div class="lz-knoepfe">
<button class="knopf" data-um="-10" type="button">−10</button>
<button class="knopf" data-um="-5" type="button">−5</button>
<button class="knopf" data-um="-1" type="button">−1</button>
<button class="knopf" data-um="1" type="button">+1</button>
<button class="knopf" data-um="5" type="button">+5</button>
<button class="knopf" data-um="10" type="button">+10</button>
</div>
<p class="klein">Minus zählt als gemacht. Korrekturen ohne Zählung auf der <a href="/">Übersicht</a>.</p>
</div>
<div id="tts-karte" class="karte" hidden>
<h3 class="abschnitt">Vorlesen <span id="tts-anzahl" class="zaehler" hidden></span></h3>
<div id="wartend" class="wartend"></div>
<button id="alle-ablehnen" class="knopf" type="button">Alle ablehnen</button>
</div>
<button id="still" class="knopf panik" type="button">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M11 5 6 9H3v6h3l5 4zM16 9l5 6M21 9l-5 6"/></svg>
Sofort still
</button>
<p class="klein mitte">Bricht Vorlesen und alle Sounds ab und leert die Warteschlange.</p>
</section>
<!-- ======================================================== Test -->
<section id="tab-test" class="tab" aria-labelledby="nav-test">
<div id="test-live" class="hinweis warn" hidden>Der Stream läuft. Was du hier auslöst, sehen die Zuschauer.</div>
<p class="klein">Erfundene Ereignisse, nur für die Browser-Sources. Nichts davon landet
in der Datenbank, auf Discord oder bei Twitch.</p>
<div id="test-gruppen"></div>
<h3 class="abschnitt">Dauerbetrieb</h3>
<button id="demo" class="knopf breit" type="button" data-art="demo-an">Vorführung starten</button>
<p class="klein"><a href="/">Übersicht und OBS-Adressen</a> · <a href="/setup">Einrichtung</a> · <a href="/logout">Abmelden</a></p>
</section>
</main>
<div id="meldung" class="meldung" role="status" aria-live="polite"></div>
<nav class="reiter" aria-label="Bereiche">
<button id="nav-sounds" data-tab="sounds" class="an" type="button">
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="3" width="7.5" height="7.5" rx="2"/><rect x="13.5" y="3" width="7.5" height="7.5" rx="2"/><rect x="3" y="13.5" width="7.5" height="7.5" rx="2"/><rect x="13.5" y="13.5" width="7.5" height="7.5" rx="2"/></svg>
Sounds
</button>
<button id="nav-szenen" data-tab="szenen" type="button">
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="2.5" y="5" width="19" height="13" rx="2"/><path d="M8 21h8"/></svg>
Szenen
</button>
<button id="nav-stream" data-tab="stream" type="button">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 20V10M10 20V4M16 20v-7M22 20H2"/></svg>
Stream
<i id="nav-tts" class="punkt" hidden></i>
</button>
<button id="nav-test" data-tab="test" type="button">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 3h6M10 3v6l-5 9a2 2 0 0 0 1.8 3h10.4a2 2 0 0 0 1.8-3l-5-9V3"/></svg>
Test
</button>
</nav>
<dialog id="blatt" class="blatt">
<form method="dialog" id="blatt-form">
<div class="griff" aria-hidden="true"></div>
<div class="blatt-kopf">
<input id="b-emoji" class="emoji-feld" maxlength="10" placeholder="🔊" aria-label="Emoji" autocomplete="off">
<input id="b-name" class="name-feld" maxlength="40" placeholder="Name" aria-label="Name" autocomplete="off">
</div>
<div id="b-schnell" class="schnell" aria-label="Emoji-Vorschläge"></div>
<div class="feld">
<span class="feld-name">Farbe</span>
<div id="b-farben" class="farben" role="radiogroup" aria-label="Farbe"></div>
</div>
<label class="feld">
<span class="feld-name">Lautstärke <b id="b-laut-zahl">80 %</b></span>
<input id="b-laut" type="range" min="0" max="100" step="5" value="80">
</label>
<div class="blatt-reihe">
<button type="button" id="b-hoeren" class="knopf">Am Handy anhören</button>
<button type="button" id="b-vor" class="knopf" aria-label="Nach vorn">◀</button>
<button type="button" id="b-zurueck" class="knopf" aria-label="Nach hinten">▶</button>
</div>
<div class="blatt-reihe">
<button type="button" id="b-loeschen" class="knopf warnung">Löschen</button>
<span class="luecke"></span>
<button type="button" id="b-abbrechen" class="knopf">Abbrechen</button>
<button type="submit" id="b-speichern" class="knopf primaer">Speichern</button>
</div>
</form>
</dialog>
<div id="abgemeldet" class="abgemeldet" hidden>
<div>
<h2>Abgemeldet</h2>
<p>Die Anmeldung ist abgelaufen oder das Passwort wurde geändert.</p>
<a class="knopf primaer gross" href="/pult">Neu anmelden</a>
</div>
</div>
<script src="/pult.js"></script>
</body>
</html>
+638
View File
@@ -0,0 +1,638 @@
/* Das Pult: Soundboard, Szenen, Liegestuetze, Vorlesen - am Handy.
*
* Zwei Wege zum Hub:
* - der Ereignisstrom /events (wie bei den Browser-Sources), damit zwei
* offene Pulte und die Uebersichtsseite nicht auseinanderlaufen;
* - einfache POSTs fuer jeden Druck. Jede Antwort sagt, ob es geklappt
* hat, und genau das steht danach an der Taste.
*
* Fremder Text (Soundnamen, Szenen, Chat) kommt nur ueber textContent ins
* DOM, nie ueber innerHTML. */
(function () {
'use strict';
var $ = function (id) { return document.getElementById(id); };
var FARBEN = ['lila', 'tuerkis', 'pink', 'gold', 'gruen', 'rot', 'blau', 'grau'];
var SCHNELL = ['🔊', '😂', '💀', '👏', '🎺', '🥁', '💥', '🚨', '🤡', '😱', '🔥', '❤️', '👀', '🐐', '🫡', '🎉'];
var zustand = {
sounds: [],
obs: { pc: false, obs: false, szenen: [], aktuell: null, ton: [], puffer: false },
live: false,
bearbeiten: false,
ttsAn: false,
};
// ------------------------------------------------------------ Hilfen
function el(tag, klasse, text) {
var e = document.createElement(tag);
if (klasse) e.className = klasse;
if (text !== undefined) e.textContent = text;
return e;
}
function tippen() {
if (navigator.vibrate) { try { navigator.vibrate(12); } catch (e) { /* egal */ } }
}
var meldungTimer;
function melden(text, art) {
var m = $('meldung');
if (!text) return;
m.textContent = text;
m.className = 'meldung an' + (art ? ' ' + art : '');
clearTimeout(meldungTimer);
meldungTimer = setTimeout(function () { m.className = 'meldung'; }, art === 'fehler' ? 4500 : 2200);
}
/** POST mit Formular, Antwort als JSON. Wirft nie; Fehler stehen in `ok`. */
function rufen(pfad, felder, roh) {
var opts = { method: 'POST', credentials: 'same-origin' };
if (roh) {
opts.body = roh;
} else {
opts.headers = { 'content-type': 'application/x-www-form-urlencoded' };
opts.body = new URLSearchParams(felder || {});
}
return fetch(pfad, opts).then(function (res) {
return res.json().catch(function () { return { text: 'Fehler ' + res.status }; })
.then(function (body) {
if (res.status === 401) abgemeldet();
body.ok = res.ok && body.ok !== false;
return body;
});
}, function () {
return { ok: false, text: 'Keine Verbindung zum Hub.' };
});
}
function holen(pfad) {
return fetch(pfad, { credentials: 'same-origin' }).then(function (res) {
if (res.status === 401) { abgemeldet(); throw new Error('abgemeldet'); }
if (!res.ok) throw new Error('Fehler ' + res.status);
return res.json();
});
}
function abgemeldet() { $('abgemeldet').hidden = false; }
/** Taste waehrend eines Rufs sperren, damit ein Doppeltipp nicht doppelt schaltet. */
function mitWarten(knopf, versprechen) {
knopf.classList.add('wartet');
return versprechen.then(function (r) { knopf.classList.remove('wartet'); return r; });
}
// ------------------------------------------------------------ Reiter
function zeigeReiter(name) {
var reiter = document.querySelectorAll('.reiter button');
for (var i = 0; i < reiter.length; i++) {
reiter[i].classList.toggle('an', reiter[i].dataset.tab === name);
}
var tabs = document.querySelectorAll('.tab');
for (var j = 0; j < tabs.length; j++) tabs[j].classList.toggle('an', tabs[j].id === 'tab-' + name);
try { localStorage.setItem('pult-reiter', name); } catch (e) { /* privat */ }
if (name === 'stream') ladeWartend();
window.scrollTo(0, 0);
}
document.querySelector('.reiter').addEventListener('click', function (e) {
var b = e.target.closest('button[data-tab]');
if (b) { tippen(); zeigeReiter(b.dataset.tab); }
});
// ------------------------------------------------------------ Kopf
var laufzeitTimer;
function zeigeStream(s) {
zustand.live = !!s.live;
var live = $('live');
live.classList.toggle('aus', !s.live);
clearInterval(laufzeitTimer);
var schreiben = function () {
if (!s.live) { $('live-text').textContent = 'offline'; return; }
var teile = ['LIVE'];
if (s.startedAt) {
var min = Math.max(0, Math.floor((Date.now() - Date.parse(s.startedAt)) / 60000));
teile.push(Math.floor(min / 60) + ':' + String(min % 60).padStart(2, '0'));
}
teile.push((s.viewers || 0).toLocaleString('de-DE') + ' 👁');
$('live-text').textContent = teile.join(' · ');
};
schreiben();
if (s.live) laufzeitTimer = setInterval(schreiben, 30000);
$('test-live').hidden = !s.live;
var gesperrt = document.querySelectorAll('#test-gruppen [data-offline]');
for (var i = 0; i < gesperrt.length; i++) gesperrt[i].disabled = !!s.live;
var demo = $('demo');
if (demo.dataset.art === 'demo-an') demo.disabled = !!s.live;
}
// Bildschirm anlassen. Ein Pult, das nach 30 s schwarz wird, ist mitten
// im Stream keins. Der Wunsch wird gemerkt; das Handy gibt die Sperre bei
// jedem Wegwischen frei, also wird sie beim Zurueckkommen neu geholt.
var sperre = null;
var wachGewuenscht = false;
try { wachGewuenscht = localStorage.getItem('pult-wach') === '1'; } catch (e) { /* privat */ }
function wachHolen() {
if (!wachGewuenscht || !('wakeLock' in navigator) || document.visibilityState !== 'visible') {
$('wach').setAttribute('aria-pressed', String(!!sperre));
return;
}
navigator.wakeLock.request('screen').then(function (s) {
sperre = s;
$('wach').setAttribute('aria-pressed', 'true');
s.addEventListener('release', function () {
sperre = null;
$('wach').setAttribute('aria-pressed', 'false');
});
}, function () { $('wach').setAttribute('aria-pressed', 'false'); });
}
$('wach').addEventListener('click', function () {
tippen();
if (!('wakeLock' in navigator)) { melden('Dieser Browser kann den Bildschirm nicht wach halten.', 'fehler'); return; }
wachGewuenscht = !sperre;
try { localStorage.setItem('pult-wach', wachGewuenscht ? '1' : '0'); } catch (e) { /* privat */ }
if (sperre) { sperre.release(); sperre = null; melden('Bildschirm darf wieder ausgehen.'); }
else { wachHolen(); melden('Bildschirm bleibt an.', 'ok'); }
});
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'visible') { wachHolen(); ladeSounds(); }
});
// ------------------------------------------------------------ Sounds
function ladeSounds() {
return holen('/soundboard/liste').then(zeigeSounds, function () { /* Meldung kommt woanders */ });
}
function zeigeSounds(body) {
if (body.sounds) zustand.sounds = body.sounds;
if (typeof body.quellen === 'number') $('quelle-fehlt').hidden = body.quellen > 0;
var raster = $('raster');
raster.textContent = '';
raster.classList.toggle('bearbeiten', zustand.bearbeiten);
$('sounds-leer').hidden = zustand.sounds.length > 0;
$('bearbeiten').hidden = zustand.sounds.length === 0;
if (!zustand.sounds.length && zustand.bearbeiten) setzeBearbeiten(false);
zustand.sounds.forEach(function (s) {
var k = el('button', 'kachel' + (s.emoji ? '' : ' ohne-emoji'));
k.type = 'button';
k.dataset.id = s.id;
k.dataset.farbe = FARBEN.indexOf(s.farbe) !== -1 ? s.farbe : 'lila';
if (s.emoji) k.appendChild(el('span', 'emoji', s.emoji));
k.appendChild(el('span', 'name', s.name));
k.setAttribute('aria-label', (zustand.bearbeiten ? 'Bearbeiten: ' : 'Abspielen: ') + s.name);
raster.appendChild(k);
});
}
function knall(id) {
var k = document.querySelector('.kachel[data-id="' + CSS.escape(id) + '"]');
if (!k) return;
k.classList.remove('knall');
void k.offsetWidth; // Animation neu starten, auch beim Doppeltipp
k.classList.add('knall');
}
$('raster').addEventListener('click', function (e) {
var k = e.target.closest('.kachel');
if (!k) return;
tippen();
var id = k.dataset.id;
if (zustand.bearbeiten) { oeffneBlatt(id); return; }
knall(id);
rufen('/soundboard/spielen', { id: id }).then(function (r) {
if (!r.ok) { melden(r.text, 'fehler'); return; }
$('quelle-fehlt').hidden = r.quellen > 0;
if (!r.quellen) melden(r.text, 'fehler');
});
});
$('stopp').addEventListener('click', function () {
tippen();
rufen('/soundboard/stopp').then(function (r) { melden(r.text, r.ok ? 'ok' : 'fehler'); });
});
function setzeBearbeiten(an) {
zustand.bearbeiten = an;
$('bearbeiten').setAttribute('aria-pressed', String(an));
$('bearbeiten').textContent = an ? 'Fertig' : 'Bearbeiten';
$('raster').classList.toggle('bearbeiten', an);
var kacheln = document.querySelectorAll('.kachel');
for (var i = 0; i < kacheln.length; i++) {
var s = zustand.sounds[i];
if (s) kacheln[i].setAttribute('aria-label', (an ? 'Bearbeiten: ' : 'Abspielen: ') + s.name);
}
}
$('bearbeiten').addEventListener('click', function () { tippen(); setzeBearbeiten(!zustand.bearbeiten); });
// Hochladen: roh im Koerper, eine Datei nach der anderen. Parallel waere
// schneller, aber dann kommt die Reihenfolge durcheinander.
$('datei').addEventListener('change', function (e) {
var dateien = Array.prototype.slice.call(e.target.files || []);
e.target.value = '';
var weiter = Promise.resolve();
dateien.forEach(function (d, i) {
weiter = weiter.then(function () {
melden((dateien.length > 1 ? (i + 1) + '/' + dateien.length + ' · ' : '') + d.name + ' wird hochgeladen …');
return rufen('/soundboard/neu?name=' + encodeURIComponent(d.name), null, d).then(function (r) {
melden(r.text, r.ok ? 'ok' : 'fehler');
if (r.sounds) zeigeSounds(r);
// Der erste Sound geht gleich ins Bearbeiten: ein Emoji macht aus
// einer Liste von Dateinamen erst ein Pult.
if (r.ok && r.sound && dateien.length === 1) oeffneBlatt(r.sound.id);
});
});
});
});
// ------------------------------------------------------------ Blatt
var blatt = $('blatt');
var offenId = null;
var probe = null;
(function baueBlatt() {
var schnell = $('b-schnell');
SCHNELL.forEach(function (em) {
var b = el('button', '', em);
b.type = 'button';
b.addEventListener('click', function () { $('b-emoji').value = em; tippen(); });
schnell.appendChild(b);
});
var farben = $('b-farben');
FARBEN.forEach(function (f) {
var b = el('button', 'farbe');
b.type = 'button';
b.dataset.farbe = f;
b.setAttribute('role', 'radio');
b.setAttribute('aria-label', f);
b.addEventListener('click', function () { waehleFarbe(f); tippen(); });
farben.appendChild(b);
});
})();
function waehleFarbe(f) {
var alle = $('b-farben').children;
for (var i = 0; i < alle.length; i++) alle[i].setAttribute('aria-checked', String(alle[i].dataset.farbe === f));
blatt.dataset.farbe = f;
}
function oeffneBlatt(id) {
var s = zustand.sounds.find(function (x) { return x.id === id; });
if (!s) return;
offenId = id;
$('b-name').value = s.name;
$('b-emoji').value = s.emoji || '';
$('b-laut').value = Math.round((s.laut ?? 0.8) * 100);
$('b-laut-zahl').textContent = $('b-laut').value + ' %';
waehleFarbe(s.farbe);
$('b-loeschen').textContent = 'Löschen';
$('b-loeschen').dataset.sicher = '';
if (typeof blatt.showModal === 'function') blatt.showModal(); else blatt.setAttribute('open', '');
}
function schliesseBlatt() {
if (probe) { probe.pause(); probe = null; }
offenId = null;
if (blatt.open) blatt.close();
}
$('b-laut').addEventListener('input', function () {
$('b-laut-zahl').textContent = $('b-laut').value + ' %';
if (probe) probe.volume = Number($('b-laut').value) / 100;
});
$('b-hoeren').addEventListener('click', function () {
if (!offenId) return;
if (probe) { probe.pause(); probe = null; $('b-hoeren').textContent = 'Am Handy anhören'; return; }
probe = new Audio('/soundboard/datei/' + encodeURIComponent(offenId));
probe.volume = Number($('b-laut').value) / 100;
$('b-hoeren').textContent = 'Anhören stoppen';
probe.onended = function () { probe = null; $('b-hoeren').textContent = 'Am Handy anhören'; };
probe.play().catch(function () { melden('Abspielen am Handy ging nicht.', 'fehler'); });
});
function verschieben(um) {
if (!offenId) return;
tippen();
rufen('/soundboard/verschieben', { id: offenId, um: String(um) }).then(function (r) {
if (r.sounds) zeigeSounds(r);
});
}
$('b-vor').addEventListener('click', function () { verschieben(-1); });
$('b-zurueck').addEventListener('click', function () { verschieben(1); });
$('b-loeschen').addEventListener('click', function () {
var b = $('b-loeschen');
// Zweimal tippen statt confirm(): ein Dialog des Browsers sieht am Handy
// aus wie eine Fehlermeldung, und er haelt die Seite an.
if (!b.dataset.sicher) { b.dataset.sicher = '1'; b.textContent = 'Wirklich löschen?'; tippen(); return; }
var id = offenId;
schliesseBlatt();
rufen('/soundboard/loeschen', { id: id }).then(function (r) {
melden(r.text, r.ok ? 'ok' : 'fehler');
if (r.sounds) zeigeSounds(r);
});
});
$('b-abbrechen').addEventListener('click', schliesseBlatt);
blatt.addEventListener('close', function () { if (probe) { probe.pause(); probe = null; } });
blatt.addEventListener('click', function (e) { if (e.target === blatt) schliesseBlatt(); });
$('blatt-form').addEventListener('submit', function (e) {
e.preventDefault();
var id = offenId;
if (!id) return;
var felder = {
id: id,
name: $('b-name').value,
emoji: $('b-emoji').value,
farbe: blatt.dataset.farbe || 'lila',
laut: String(Number($('b-laut').value) / 100),
};
schliesseBlatt();
rufen('/soundboard/aendern', felder).then(function (r) {
if (!r.ok) melden(r.text, 'fehler');
if (r.sounds) zeigeSounds(r);
});
});
// ------------------------------------------------------------ OBS
function zeigeObs(o) {
zustand.obs = o;
var fehlt = !o.pc || !o.obs;
$('obs-fehlt').hidden = !fehlt;
$('obs-da').hidden = fehlt;
if (!o.pc) {
$('obs-fehlt-titel').textContent = 'Stream-PC nicht verbunden';
$('obs-fehlt-text').textContent = 'Auf dem Stream-PC muss abdulive ingest laufen und einmal mit ' +
'„abdulive fern:verbinden …“ eingerichtet sein. Der Befehl steht auf der Übersicht unter „Stream-PC verbinden“.';
} else if (!o.obs) {
$('obs-fehlt-titel').textContent = 'OBS ist nicht erreichbar';
$('obs-fehlt-text').textContent = 'Der Stream-PC ist da, aber OBS nicht. Läuft OBS, und ist ' +
'obs-websocket an (Werkzeuge → WebSocket-Servereinstellungen)?';
}
if (fehlt) return;
var szenen = $('szenen');
szenen.textContent = '';
o.szenen.forEach(function (name) {
var b = el('button', 'szene' + (name === o.aktuell ? ' aktiv' : ''));
b.type = 'button';
b.dataset.szene = name;
b.appendChild(el('span', 'szene-name', name));
b.appendChild(el('span', 'marke-an', 'Im Bild'));
szenen.appendChild(b);
});
if (!o.szenen.length) szenen.appendChild(el('p', 'klein', 'OBS meldet keine Szenen.'));
$('clip').disabled = !o.puffer;
$('clip-hinweis').textContent = o.puffer
? 'Sichert die letzten Sekunden aus dem Wiedergabepuffer, wie !clip im Chat – ohne Abklingzeit.'
: 'Der Wiedergabepuffer läuft nicht. In OBS unten rechts „Wiedergabepuffer starten“.';
var ton = $('ton');
ton.textContent = '';
o.ton.forEach(function (t) {
var b = el('button', 'ton-zeile' + (t.stumm ? ' stumm' : ''));
b.type = 'button';
b.dataset.quelle = t.name;
b.dataset.stumm = t.stumm ? '1' : '0';
b.innerHTML = t.stumm
? '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M11 5 6 9H3v6h3l5 4zM16 9l5 6M21 9l-5 6"/></svg>'
: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M11 5 6 9H3v6h3l5 4zM15.5 8.5a5 5 0 0 1 0 7M18.5 5.5a9 9 0 0 1 0 13"/></svg>';
b.appendChild(el('span', 'ton-name', t.name));
b.appendChild(el('span', 'ton-stand', t.stumm ? 'stumm' : 'an'));
ton.appendChild(b);
});
if (!o.ton.length) ton.appendChild(el('p', 'klein', 'Keine Toneingänge gefunden.'));
}
function obsRuf(knopf, felder) {
tippen();
return mitWarten(knopf, rufen('/pult/obs', felder)).then(function (r) {
melden(r.text, r.ok ? 'ok' : 'fehler');
return r;
});
}
$('szenen').addEventListener('click', function (e) {
var b = e.target.closest('.szene');
if (!b || b.classList.contains('aktiv')) return;
obsRuf(b, { was: 'szene', szene: b.dataset.szene }).then(function (r) {
// Sofort umfaerben, nicht erst auf den naechsten Stand warten. Der
// Stand vom PC kommt hinterher und rueckt es gerade, falls noetig.
if (r.ok) zeigeObs(Object.assign({}, zustand.obs, { aktuell: b.dataset.szene }));
});
});
$('clip').addEventListener('click', function () { obsRuf($('clip'), { was: 'clip' }); });
$('ton').addEventListener('click', function (e) {
var b = e.target.closest('.ton-zeile');
if (!b) return;
obsRuf(b, { was: 'stumm', quelle: b.dataset.quelle, stumm: b.dataset.stumm === '1' ? '0' : '1' });
});
// ------------------------------------------------------------ Liegestuetze
function zeigeLz(st) {
if (!st) return;
$('lz-karte').hidden = false;
if (typeof st.offen === 'number') $('lz-offen').textContent = st.offen.toLocaleString('de-DE');
if (typeof st.gemacht === 'number') $('lz-gemacht').textContent = st.gemacht.toLocaleString('de-DE');
}
document.querySelector('.lz-knoepfe').addEventListener('click', function (e) {
var b = e.target.closest('button[data-um]');
if (!b) return;
tippen();
mitWarten(b, rufen('/liegestuetze', { was: 'aendern', wert: b.dataset.um })).then(function (r) {
zeigeLz(r);
if (!r.ok) melden(r.text, 'fehler');
});
});
// ------------------------------------------------------------ Vorlesen
function zeigeWartend(liste) {
var box = $('wartend');
box.textContent = '';
$('tts-anzahl').hidden = !liste.length;
$('tts-anzahl').textContent = String(liste.length);
$('nav-tts').hidden = !liste.length;
$('alle-ablehnen').hidden = liste.length < 2;
if (!liste.length) { box.appendChild(el('div', 'leer-zeile', 'Nichts wartet auf Freigabe.')); return; }
liste.forEach(function (w) {
var n = el('div', 'nachricht');
n.appendChild(el('div', 'wer', w.name));
n.appendChild(el('div', 'was', w.text));
var reihe = el('div', 'reihe');
var ja = el('button', 'knopf primaer', 'Vorlesen');
ja.type = 'button'; ja.dataset.was = 'freigeben'; ja.dataset.id = w.id;
var nein = el('button', 'knopf', 'Ablehnen');
nein.type = 'button'; nein.dataset.was = 'ablehnen'; nein.dataset.id = w.id;
reihe.appendChild(ja); reihe.appendChild(nein);
n.appendChild(reihe);
box.appendChild(n);
});
}
function ladeWartend() {
if (!zustand.ttsAn) return;
holen('/tts/wartend').then(function (b) { zeigeWartend(b.wartend || []); }, function () { /* still */ });
}
function ttsRuf(was, id) {
tippen();
return rufen('/tts', { was: was, id: id || '' }).then(function (r) {
melden(r.text, r.ok ? 'ok' : 'fehler');
if (r.wartend) zeigeWartend(r.wartend);
});
}
$('wartend').addEventListener('click', function (e) {
var b = e.target.closest('button[data-was]');
if (b) ttsRuf(b.dataset.was, b.dataset.id);
});
$('alle-ablehnen').addEventListener('click', function () { ttsRuf('alle-ablehnen'); });
// Der Panikschalter. Ohne Vorlesen haelt er wenigstens die Sounds an.
$('still').addEventListener('click', function () {
tippen();
if (zustand.ttsAn) { ttsRuf('still'); return; }
rufen('/soundboard/stopp').then(function (r) { melden(r.ok ? 'Alles still.' : r.text, r.ok ? 'ok' : 'fehler'); });
});
// ------------------------------------------------------------ Test
function baueTest(knoepfe, demoLaeuft) {
var gruppen = $('test-gruppen');
gruppen.textContent = '';
var namen = [];
knoepfe.forEach(function (k) { if (namen.indexOf(k.gruppe) === -1) namen.push(k.gruppe); });
namen.forEach(function (g) {
var box = el('div', 'gruppe');
box.appendChild(el('h3', 'abschnitt', g));
var reihe = el('div', 'knoepfe');
knoepfe.filter(function (k) { return k.gruppe === g; }).forEach(function (k) {
var b = el('button', 'knopf', k.label);
b.type = 'button';
b.dataset.art = k.art;
if (k.nurOffline) b.dataset.offline = '1';
reihe.appendChild(b);
});
box.appendChild(reihe);
gruppen.appendChild(box);
});
setzeDemo(demoLaeuft);
}
function setzeDemo(laeuft) {
var d = $('demo');
d.dataset.art = laeuft ? 'demo-aus' : 'demo-an';
d.textContent = laeuft ? 'Vorführung stoppen' : 'Vorführung starten';
d.disabled = !laeuft && zustand.live;
}
function testRuf(knopf) {
tippen();
mitWarten(knopf, rufen('/test', { art: knopf.dataset.art })).then(function (r) {
melden(r.text, r.ok ? 'ok' : 'fehler');
if (typeof r.demo === 'boolean') setzeDemo(r.demo);
});
}
$('test-gruppen').addEventListener('click', function (e) {
var b = e.target.closest('button[data-art]');
if (b && !b.disabled) testRuf(b);
});
$('demo').addEventListener('click', function () { testRuf($('demo')); });
// ------------------------------------------------------------ Strom
var quelle = null;
var zuletzt = Date.now();
function verbinde() {
if (quelle) { try { quelle.close(); } catch (e) { /* zu */ } }
quelle = new EventSource('/events?quelle=pult');
zuletzt = Date.now();
quelle.onopen = function () { setzeNetz(true); };
quelle.onerror = function () {
setzeNetz(false);
// EventSource sagt nicht, ob es an der Anmeldung liegt. Eine kurze
// Frage klaert das, sonst stuende "keine Verbindung" fuer immer da.
holen('/soundboard/liste').then(function () { /* nur Netz */ }, function () { /* abgemeldet() hat schon */ });
};
quelle.onmessage = function (e) {
zuletzt = Date.now();
setzeNetz(true);
var ev;
try { ev = JSON.parse(e.data); } catch (err) { return; }
switch (ev.kind) {
case 'state':
zeigeStream(ev.stream);
if (ev.obs) zeigeObs(ev.obs);
if (ev.liegestuetze && !$('lz-karte').hidden) zeigeLz(ev.liegestuetze);
break;
case 'stream': zeigeStream(ev); break;
case 'obs': zeigeObs(ev); break;
case 'liegestuetze': if (!$('lz-karte').hidden) zeigeLz(ev); break;
case 'sound': knall(ev.id); break;
case 'sounds-geaendert': ladeSounds(); break;
case 'tts': ladeWartend(); break;
}
};
}
function setzeNetz(an) {
$('netz').classList.toggle('an', an);
var banner = $('banner');
banner.hidden = an;
if (!an) banner.textContent = 'Keine Verbindung zum Hub – verbinde neu …';
}
// Wachhund wie in client.js: der Hub schickt alle 15 s einen Herzschlag.
// Am Handy reisst die Leitung beim Wegwischen ab, ohne dass es jemand merkt.
setInterval(function () {
if (Date.now() - zuletzt > 45000) verbinde();
}, 10000);
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'visible' && Date.now() - zuletzt > 20000) verbinde();
});
// ------------------------------------------------------------ Start
holen('/pult/knoepfe').then(function (b) {
baueTest(b.knoepfe || [], !!b.demo);
zustand.ttsAn = !!b.tts;
$('tts-karte').hidden = !b.tts;
if (b.liegestuetze) zeigeLz(b.liegestuetze);
ladeWartend();
}, function () { /* abgemeldet oder weg - zeigt sich oben */ });
ladeSounds();
verbinde();
wachHolen();
// Die Liste der Wartenden kommt nicht ueber den Strom (dort steht nur, was
// vorgelesen wird). Also nachsehen, solange das Pult offen ist.
setInterval(function () {
if (document.visibilityState === 'visible') ladeWartend();
}, 4000);
// Ob die Sound-Quelle in OBS offen ist, aendert sich ohne Ereignis.
setInterval(function () {
if (document.visibilityState === 'visible') ladeSounds();
}, 20000);
// Ein Reiter in der Adresse (Verknuepfung "Szenen" am App-Symbol) geht vor
// dem zuletzt benutzten.
var gemerkt = location.hash.slice(1) || null;
if (!gemerkt) { try { gemerkt = localStorage.getItem('pult-reiter'); } catch (e) { /* privat */ } }
if (gemerkt && $('tab-' + gemerkt)) zeigeReiter(gemerkt);
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(function (e) {
console.warn('[pult] Service Worker nicht registriert', e);
});
}
})();
+109
View File
@@ -0,0 +1,109 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>abdulive - Soundboard</title>
<link rel="stylesheet" href="/theme.css">
<style>
/* Eine reine Tonquelle. Im Stream ist nichts zu sehen - ein Sound, der
* zusaetzlich seinen Namen ins Bild schreibt, nimmt ihm die Pointe.
*
* Mit ?probe=1 steht unten, was gerade klingt. Damit laesst sich im Browser
* pruefen, ob ein Druck am Handy hier ankommt, auch ohne Lautsprecher. */
#liste {
position: fixed;
left: 12px; right: 12px; bottom: 12px;
display: none;
flex-direction: column;
gap: 6px;
font: 600 16px/1.2 var(--font-zahl);
}
body.probe #liste { display: flex; }
.laeuft {
padding: 8px 12px;
border-radius: 10px;
background: var(--tinte-klar);
border-left: 4px solid var(--lila);
}
.laeuft.fehler { border-left-color: var(--rot); }
</style>
</head>
<body>
<div id="liste"></div>
<script src="/client.js"></script>
<script>
(function () {
'use strict';
var liste = document.getElementById('liste');
var schluessel = Overlay.param('key', '');
/* Lautstaerke der ganzen Quelle. Besser im OBS-Mixer regeln; das hier ist
* fuer den Fall, dass alle Sounds gemeinsam zu laut aufgenommen sind. */
var gesamt = Math.max(0, Math.min(1, Overlay.param('laut', 1)));
/* Mehr gleichzeitig klingt nicht mehr nach Soundboard, sondern nach Unfall.
* Der aelteste Sound macht dem neuen Platz. */
var maxGleichzeitig = Math.max(1, Overlay.param('max', 4));
var laufend = [];
function zeile(name, fehler) {
var d = document.createElement('div');
d.className = 'laeuft' + (fehler ? ' fehler' : '');
d.textContent = name;
liste.appendChild(d);
return d;
}
function aufhoeren(eintrag) {
var i = laufend.indexOf(eintrag);
if (i !== -1) laufend.splice(i, 1);
try { eintrag.ton.pause(); } catch (e) { /* egal */ }
eintrag.ton.removeAttribute('src');
if (eintrag.zeile.parentNode) eintrag.zeile.parentNode.removeChild(eintrag.zeile);
}
function alleStopp() {
laufend.slice().forEach(aufhoeren);
}
Overlay.on('sound', function (ev) {
while (laufend.length >= maxGleichzeitig) aufhoeren(laufend[0]);
var ton = new Audio('/soundboard/datei/' + encodeURIComponent(ev.id) +
(schluessel ? '?key=' + encodeURIComponent(schluessel) : ''));
var laut = typeof ev.laut === 'number' ? ev.laut : 0.8;
ton.volume = Math.max(0, Math.min(1, laut * gesamt));
var eintrag = { ton: ton, zeile: zeile(ev.name || 'Sound') };
laufend.push(eintrag);
ton.onended = function () { aufhoeren(eintrag); };
ton.onerror = function () {
console.error('[sounds] nicht abspielbar', ev.name, ton.error && ton.error.code);
eintrag.zeile.classList.add('fehler');
setTimeout(function () { aufhoeren(eintrag); }, 3000);
};
// Notbremse: ein Sound, der nie "zu Ende" meldet, belegte sonst fuer
// immer einen Platz.
setTimeout(function () { aufhoeren(eintrag); }, 5 * 60 * 1000);
var p = ton.play();
// Ein normaler Browser spielt erst nach einem Klick in die Seite. OBS
// nicht - wer hier im Browser testet, sieht es an der roten Zeile.
if (p && p.catch) p.catch(function (e) {
console.error('[sounds] Wiedergabe verweigert', e && e.name);
eintrag.zeile.textContent = (ev.name || 'Sound') + ' (erst in die Seite klicken)';
eintrag.zeile.classList.add('fehler');
setTimeout(function () { aufhoeren(eintrag); }, 3000);
});
});
Overlay.on('sound-stopp', alleStopp);
// Der Panikschalter gilt auch hier.
Overlay.on('still', alleStopp);
// Nach dem Neuverbinden nichts nachholen: ein Sound zur falschen Zeit ist
// schlimmer als keiner.
Overlay.on('state', function () { });
})();
</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>
+47
View File
@@ -0,0 +1,47 @@
/* Service Worker fuer das Pult.
*
* Er macht genau zwei Dinge: er ist da, damit sich das Pult als App
* installieren laesst, und er haelt die Huelle des Pults vor, damit es ohne
* Netz wenigstens aufgeht und "keine Verbindung" sagen kann statt einer
* leeren Fehlerseite des Browsers.
*
* Immer zuerst das Netz: nach dem Ausrollen soll sofort die neue Fassung
* laufen, nicht die aus dem Speicher. Alles, was nicht auf der Liste steht -
* Ereignisstrom, Knoepfe, Sounds, die Browser-Sources fuer OBS -, fasst er
* nicht an. Ein Pult, das einen Szenenwechsel aus dem Speicher beantwortet,
* waere schlimmer als gar keins. */
var SPEICHER = 'pult-v1';
var HUELLE = ['/pult', '/pult.css', '/pult.js', '/manifest.webmanifest',
'/icons/pult-192.png', '/icons/apple-touch-icon.png'];
self.addEventListener('install', function () { self.skipWaiting(); });
self.addEventListener('activate', function (e) {
e.waitUntil(caches.keys().then(function (namen) {
return Promise.all(namen.filter(function (n) { return n !== SPEICHER; })
.map(function (n) { return caches.delete(n); }));
}).then(function () { return self.clients.claim(); }));
});
self.addEventListener('fetch', function (e) {
var url = new URL(e.request.url);
if (e.request.method !== 'GET' || url.origin !== self.location.origin) return;
if (HUELLE.indexOf(url.pathname) === -1) return;
e.respondWith(fetch(e.request).then(function (res) {
// Nur Gelungenes merken. Die Anmeldeseite (401) gehoert nicht in den
// Speicher, sonst steht sie beim naechsten Funkloch statt des Pults da.
if (res.ok) {
var kopie = res.clone();
caches.open(SPEICHER).then(function (c) { c.put(url.pathname, kopie); });
}
return res;
}, function () {
return caches.match(url.pathname).then(function (r) {
return r || new Response('Keine Verbindung zum Hub.', {
status: 503, headers: { 'content-type': 'text/plain; charset=utf-8' },
});
});
}));
});
+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. */
+184
View File
@@ -0,0 +1,184 @@
<!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 fuer den Fall, dass eine Nachricht ohne Ton bleibt. 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">
Kein Ton: der Hub konnte nicht sprechen, und hier gibt es keine deutsche
Stimme. Die Nachricht wird nur angezeigt.
</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 = Math.max(0, Math.min(1, Overlay.param('laut', 1)));
var schluessel = Overlay.param('key', '');
var fehltEl = document.getElementById('fehlt');
var warteschlange = [];
var laeuft = false;
/* Was gerade klingt, damit der Panikschalter es abbrechen kann. */
var aktuell = null;
/* Gesprochen wird im Hub (Piper), hier wird nur abgespielt. Das hat zwei
* Gruende: die eingebaute Sprachausgabe von OBS' Browser sah keine deutsche
* Stimme und las amerikanisch, und ein <audio> laeuft sicher ueber OBS'
* Tonspur statt am Mixer vorbei.
*
* Die Browser-Sprachausgabe bleibt nur als Notnagel, wenn der Hub keinen
* Ton liefern konnte - und auch dann nur mit einer deutschen Stimme. Eine
* englische Stimme, die deutschen Text radebrecht, ist schlimmer als Stille. */
function deutscheStimme() {
if (typeof window.speechSynthesis === 'undefined' ||
typeof window.SpeechSynthesisUtterance === 'undefined') return null;
var alle = window.speechSynthesis.getVoices() || [];
var kurz = sprache.slice(0, 2).toLowerCase();
for (var i = 0; i < alle.length; i++) {
if (alle[i].lang && alle[i].lang.toLowerCase().indexOf(kurz) === 0) return alle[i];
}
return null;
}
function zeige(name, text) {
werEl.textContent = name;
wasEl.textContent = text;
kasten.classList.add('an');
}
function verstecke() { kasten.classList.remove('an'); fehltEl.style.display = 'none'; }
/* Geschaetzte Sprechdauer, als Notbremse und fuer die stumme Anzeige. */
function dauerMs(text) { return 3000 + text.length * 90; }
function naechste() {
var n = warteschlange.shift();
aktuell = null;
if (!n) { laeuft = false; verstecke(); return; }
laeuft = true;
zeige(n.name, n.text);
var weiter = false;
var gehWeiter = function () {
if (weiter) return;
weiter = true;
setTimeout(naechste, 400);
};
// Notbremse: haengt die Wiedergabe, steht die Warteschlange sonst fuer
// den Rest des Streams. Grosszuegig bemessen.
setTimeout(gehWeiter, 8000 + dauerMs(n.text) * 2);
if (n.audio) {
var ton = new Audio('/tts/audio/' + encodeURIComponent(n.id) +
(schluessel ? '?key=' + encodeURIComponent(schluessel) : ''));
ton.volume = lautstaerke;
ton.onended = gehWeiter;
ton.onerror = function () {
console.error('[tts] Ton nicht abspielbar', ton.error && ton.error.code);
gehWeiter();
};
aktuell = { stopp: function () { try { ton.pause(); } catch (e) { /* egal */ } } };
var p = ton.play();
// Ein normaler Browser blockiert Ton ohne vorherigen Klick. OBS nicht -
// wer die Seite zum Testen im Browser offen hat, sieht es hier.
if (p && p.catch) p.catch(function (e) {
console.error('[tts] Wiedergabe verweigert', e && e.name);
gehWeiter();
});
return;
}
var stimme = deutscheStimme();
if (!stimme) {
console.error('[tts] Kein Ton vom Hub und keine deutsche Stimme - nur Anzeige.');
fehltEl.style.display = 'block';
setTimeout(gehWeiter, dauerMs(n.text));
return;
}
var rede = new SpeechSynthesisUtterance(n.text);
rede.lang = stimme.lang;
rede.voice = stimme;
rede.rate = tempo;
rede.volume = lautstaerke;
rede.onend = gehWeiter;
rede.onerror = function (e) {
console.error('[tts] Sprachausgabe fehlgeschlagen', e && e.error);
gehWeiter();
};
aktuell = { stopp: function () { try { window.speechSynthesis.cancel(); } catch (e) { /* egal */ } } };
window.speechSynthesis.speak(rede);
}
Overlay.on('tts', function (ev) {
warteschlange.push({ id: ev.id, name: ev.name, text: ev.text, audio: !!ev.audio });
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 (aktuell) aktuell.stopp();
aktuell = null;
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>
+71 -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,62 @@ 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 | liegestuetze | nichts
rewards:
enabled: false # aus, bis hier bewusst Belohnungen stehen
belohnungen: []
# Beispiel:
# belohnungen:
# - titel: Konfetti
# aktion: konfetti
# - titel: Nachricht vorlesen
# aktion: tts
# maxZeichen: 150
# - titel: 5 Liegestütze
# aktion: liegestuetze
# anzahl: 5 # so viele kommen je Einloesung dazu
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
# Gesprochen wird im Hub mit Piper, nicht im Browser - dort haengt die
# Stimme am Rechner, der streamt. Name einer Datei unter models/piper/
# (ohne .onnx) oder ein absoluter Pfad. Einrichten: scripts/install-piper.sh
stimme: de_DE-thorsten-medium
# Nur bei Stimmen mit mehreren Sprechern, z. B. de_DE-mls-medium (0-235).
# Die klang im Stream allerdings verwaschen.
# sprecher: 0
# 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:
+2
View File
@@ -22,6 +22,8 @@ und Chat sowie Zuschauerzahlen vollständig in der DB stehen.
- [ ] Community-Modul: Begrüßungslogik, Cooldowns, `!notiz` - [ ] Community-Modul: Begrüßungslogik, Cooldowns, `!notiz`
- [ ] Overlay-Seite `/overlay/community` (Chatter-Hinweis für Abdu) - [ ] Overlay-Seite `/overlay/community` (Chatter-Hinweis für Abdu)
- [ ] `apps/broadcaster`: Discord + Bluesky, Story-Bild-Generator - [ ] `apps/broadcaster`: Discord + Bluesky, Story-Bild-Generator
(Discord-Seite ist fertig: Server, Kanal und Webhook liegen bereit,
siehe [`projects/discord-community.md`](projects/discord-community.md))
- [ ] Draft-Modus, Kill-Switch, Doppelpost-Schutz - [ ] Draft-Modus, Kill-Switch, Doppelpost-Schutz
**Fertig, wenn:** Erstchatter werden erkannt und begrüßt, Stream-Start wird **Fertig, wenn:** Erstchatter werden erkannt und begrüßt, Stream-Start wird
+30 -18
View File
@@ -15,24 +15,13 @@ Mehr nicht. Kein Docker, kein WSL2, kein Compiler, kein Git.
## Installieren ## Installieren
Das Paket herunterladen, entpacken, darin `install.ps1` ausfuehren:
```powershell ```powershell
Expand-Archive .\abdulive-0.5.0-win-x64.zip -DestinationPath .\abdulive irm https://git.zeasy.dev/ZeasyAlex/abdulive/raw/branch/main/scripts/install.ps1 | iex
.\abdulive\install.ps1
``` ```
Liegt das Paket an einer Adresse **ohne** Anmeldung, holt der Installer es Alternativ das Paket von der Releases-Seite laden, entpacken und darin
selbst: `install.ps1` ausfuehren - dasselbe Skript, es merkt selbst, dass das
Paket schon danebenliegt.
```powershell
.\install.ps1 -Quelle https://.../abdulive
```
> Ein `irm … | iex`-Einzeiler funktioniert mit `git.zeasy.dev` nicht. Die
> Instanz verlangt fuer alles eine Anmeldung und antwortet mit einer Umleitung
> auf `/user/login`; `iex` fuehrt dann die Anmeldeseite als PowerShell aus.
> Wo die Pakete liegen sollen, ist noch zu entscheiden.
Der Installer laeuft **ohne Administratorrechte**: Der Installer laeuft **ohne Administratorrechte**:
@@ -80,15 +69,20 @@ im Einrichtungs-Assistenten einfügen (siehe unten). Begründung:
## Erste Einrichtung ## Erste Einrichtung
Der Installer erledigt das schon: Sprachmodell laden, Selbsttest, Daemon
starten, warten bis die Oberflaeche antwortet, Browser oeffnen.
Auf der Einrichtungsseite dann Client-ID und Secret der beiden Twitch-Apps
eintragen und je einmal auf „Mit Twitch verbinden" klicken.
Von Hand geht es auch:
```powershell ```powershell
abdulive check-env # ffmpeg, whisper, Schriften, Verzeichnisse, Layout abdulive check-env # ffmpeg, whisper, Schriften, Verzeichnisse, Layout
abdulive fetch-model # Sprachmodell, ca. 465 MB abdulive fetch-model # Sprachmodell, ca. 465 MB
Start-ScheduledTask -TaskName abdulive Start-ScheduledTask -TaskName abdulive
``` ```
Dann **http://localhost:4792/setup** oeffnen: Client-ID und Secret der beiden
Twitch-Apps eintragen und je einmal auf „Mit Twitch verbinden" klicken.
## Layout einmessen ## Layout einmessen
```powershell ```powershell
@@ -133,6 +127,24 @@ Stop-ScheduledTask -TaskName abdulive; Start-ScheduledTask -TaskName abdulive
Die Aufgabe `abdulive-update` macht die Pruefung taeglich um 05:00 von selbst. Die Aufgabe `abdulive-update` macht die Pruefung taeglich um 05:00 von selbst.
## Wieder entfernen
```powershell
& "$env:LOCALAPPDATA\Programs\abdulive\uninstall.ps1"
```
Nimmt die beiden Aufgaben aus dem Aufgabenplaner, beendet den laufenden
Daemon und loescht den Programmordner. **Die Daten bleiben liegen** — in der
Datenbank stehen Chatverlaeufe, und die sind nach einer versehentlichen
Deinstallation nicht wiederherstellbar. Wer neu installiert, findet seine
Einrichtung wieder.
Wirklich alles weg, auch Datenbank, Clips und Sprachmodell:
```powershell
& "$env:LOCALAPPDATA\Programs\abdulive\uninstall.ps1" -AuchDaten
```
## Backup ## Backup
```powershell ```powershell
@@ -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.
+15
View File
@@ -0,0 +1,15 @@
# Baut das Handbuch nach handbuch.pdf.
#
# Braucht MiKTeX oder TeX Live mit xelatex. Zweimal, damit das
# Inhaltsverzeichnis stimmt.
$ErrorActionPreference = 'Stop'
Set-Location $PSScriptRoot
foreach ($lauf in 1..2) {
xelatex -interaction=nonstopmode handbuch.tex | Out-Null
if ($LASTEXITCODE -ne 0) { throw "xelatex ist in Durchlauf $lauf gescheitert. Siehe handbuch.log" }
}
Remove-Item handbuch.aux, handbuch.log, handbuch.out, handbuch.toc -ErrorAction SilentlyContinue
Write-Host "handbuch.pdf gebaut ($([math]::Round((Get-Item handbuch.pdf).Length/1KB)) KB)"
Binary file not shown.
+449
View File
@@ -0,0 +1,449 @@
\documentclass[11pt,a4paper]{article}
\usepackage[ngerman]{babel}
\usepackage{fontspec}
\usepackage[a4paper,margin=2.4cm,headsep=14pt]{geometry}
\usepackage{xcolor}
\usepackage{titlesec}
\usepackage{enumitem}
\usepackage{booktabs}
\usepackage{tabularx}
\usepackage{fancyhdr}
\usepackage{fancyvrb}
\usepackage{mdframed}
\usepackage[hidelinks]{hyperref}
\setmainfont{Segoe UI}
\setmonofont{Consolas}[Scale=0.92]
\definecolor{lila}{HTML}{6B3FA0}
\definecolor{grau}{HTML}{555555}
\definecolor{hellgrau}{HTML}{F2F2F4}
\definecolor{rahmen}{HTML}{D8D8DE}
\titleformat{\section}{\Large\bfseries\color{lila}}{\thesection}{0.7em}{}
\titleformat{\subsection}{\large\bfseries}{\thesubsection}{0.6em}{}
\titlespacing*{\section}{0pt}{20pt}{8pt}
\titlespacing*{\subsection}{0pt}{14pt}{5pt}
\pagestyle{fancy}
\fancyhf{}
\fancyhead[L]{\small\color{grau}abdulive-toolkit}
\fancyhead[R]{\small\color{grau}\leftmark}
\fancyfoot[C]{\small\color{grau}\thepage}
\renewcommand{\headrulewidth}{0.4pt}
\renewcommand{\headrule}{\color{rahmen}\hrule height 0.4pt}
\renewcommand{\sectionmark}[1]{\markboth{#1}{}}
% Keine Einrueckung, dafuer Abstand zwischen den Absaetzen. Sonst wirkt jeder
% Absatz nach einer Liste oder Tabelle wie ein neuer Einzug.
\setlength{\parindent}{0pt}
\setlength{\parskip}{7pt}
\renewcommand{\arraystretch}{1.15}
\setlist[itemize]{leftmargin=1.3em,itemsep=2pt,topsep=4pt,parsep=0pt}
\setlist[enumerate]{leftmargin=1.6em,itemsep=3pt,topsep=4pt,parsep=0pt}
% Ohne commandchars: Backslashes in Windows-Pfaden bleiben woertlich. Mit
% commandchars wuerde aus \textbackslash ein Backslash plus Leerzeichen,
% und ein kopierter Pfad waere kaputt.
\DefineVerbatimEnvironment{code}{Verbatim}
{fontsize=\small,xleftmargin=8pt,frame=leftline,framerule=1.6pt,
rulecolor=\color{rahmen},framesep=8pt}
% Nur fuer Bloecke, die eine Einfaerbung brauchen.
\DefineVerbatimEnvironment{codef}{Verbatim}
{fontsize=\small,xleftmargin=8pt,frame=leftline,framerule=1.6pt,
rulecolor=\color{rahmen},framesep=8pt,commandchars=\\\{\}}
\newmdenv[backgroundcolor=hellgrau,linecolor=rahmen,linewidth=0.6pt,
innertopmargin=8pt,innerbottommargin=8pt,innerleftmargin=10pt,
innerrightmargin=10pt,skipabove=10pt,skipbelow=10pt,roundcorner=2pt]{kasten}
\newcommand{\pfad}[1]{\texttt{\small #1}}
\begin{document}
\thispagestyle{empty}
\vspace*{2.5cm}
{\Huge\bfseries\color{lila} abdulive-toolkit}
\vspace{6pt}
{\Large Handbuch}
\vspace{28pt}
{\large Aus einem Zuruf im Chat wird ein fertiges Hochformat-Video.}
\vspace{40pt}
\begin{tabularx}{\textwidth}{@{}lX@{}}
\toprule
Version & 0.5.4 \\
Stand & 9. September 2026 \\
Gilt für & Windows 10 und 11, 64 Bit \\
Quelltext & \url{https://git.zeasy.dev/ZeasyAlex/abdulive} \\
\bottomrule
\end{tabularx}
\vfill
\noindent\small\color{grau}
Das Toolkit speichert Chatverläufe. Die Datenbank enthält damit
personenbezogene Daten und gehört weder ins Repository noch in eine
Cloud ohne Absprache.
\newpage
\tableofcontents
\newpage
\section{Was das Werkzeug tut}
Jemand tippt \texttt{!clip} in den Chat. Etwa eine Minute später liegt ein
fertiges 9:16-Video mit Untertiteln auf der Platte, bereit für TikTok, Reels
oder Shorts.
Dazwischen passiert Folgendes:
\begin{enumerate}
\item Das Toolkit liest den Chat mit und erkennt den Befehl.
\item Es weist OBS an, den Wiedergabepuffer zu speichern. OBS hat die
letzten 90 Sekunden ohnehin im Speicher, der Moment ist also schon
vorbei und trotzdem noch da.
\item OBS meldet den Dateipfad zurück. Das Toolkit wartet, bis die Datei
fertig geschrieben ist.
\item Aus den letzten 45 Sekunden wird ein Hochformat gebaut: Facecam oben,
Gameplay unten, beides ohne Verzerrung zugeschnitten.
\item Die Mikrofonspur wird transkribiert und als Untertitel ins Bild
gebrannt.
\end{enumerate}
\begin{kasten}
\textbf{Während des Streams wird nicht gerendert.} Das Encodieren nimmt
Rechenzeit, die der Stream braucht. Die Warteschlange arbeitet erst nach
Streamende. Wer das anders will, setzt \texttt{renderWhileLive: true}.
\end{kasten}
\subsection{Was dabei herauskommt}
Je Clip entsteht ein Ordner mit:
\begin{tabularx}{\textwidth}{@{}lX@{}}
\toprule
\pfad{final.mp4} & das fertige Video mit eingebrannten Untertiteln \\
\pfad{vertical.mp4} & dasselbe ohne Untertitel \\
\pfad{thumb.jpg} & Vorschaubild \\
\pfad{subs.srt} & Untertitel als Textdatei, praktisch für die Beschreibung \\
\pfad{transcript.txt} & reiner Text des Gesagten \\
\bottomrule
\end{tabularx}
\subsection{Was es noch nicht tut}
Es lädt nichts hoch. Es entscheidet nicht, welcher Clip gut ist. Die
Sichtungsseite, auf der man Clips durchgeht und aussortiert, ist noch nicht
gebaut.
\section{Installation}
Vorausgesetzt wird Windows 10 oder 11 in 64 Bit und OBS Studio 30 oder neuer.
Sonst nichts. Node, ffmpeg, whisper.cpp und die Schriften sind im Paket
enthalten.
In PowerShell:
\begin{code}
irm https://git.zeasy.dev/ZeasyAlex/abdulive/raw/branch/main/scripts/install.ps1 | iex
\end{code}
Das Skript lädt das Paket, prüft die Prüfsumme, installiert, holt das
Sprachmodell (etwa 465 MB), startet den Dienst und öffnet die
Einrichtungsseite im Browser.
Es braucht keine Administratorrechte. Beim ersten Start warnt Windows
SmartScreen vor einem unbekannten Herausgeber, weil das Paket nicht signiert
ist. Einmal auf \emph{Weitere Informationen}, dann \emph{Trotzdem ausführen}.
\subsection{Wohin es installiert}
\begin{tabularx}{\textwidth}{@{}lX@{}}
\toprule
Programm & \pfad{\%LOCALAPPDATA\%\textbackslash Programs\textbackslash abdulive} \\
Daten & \pfad{\%LOCALAPPDATA\%\textbackslash abdulive} \\
Autostart & Aufgabe \texttt{abdulive}, bei jeder Anmeldung \\
Updates & Aufgabe \texttt{abdulive-update}, täglich 5:00 Uhr \\
\bottomrule
\end{tabularx}
Programm und Daten liegen getrennt. Ein Update tauscht den Programmordner
komplett aus, die Datenbank bleibt davon unberührt.
\section{Einrichtung}
Die Einrichtungsseite liegt unter \url{http://localhost:4792/setup} und ist
nur vom eigenen Rechner erreichbar. Sie zeigt Zugangsdaten im Klartext, also
nicht während des Streams öffnen.
\subsection{Schritt 1: Zwei Twitch-Anwendungen anlegen}
Unter \url{https://dev.twitch.tv/console/apps} zwei Anwendungen erstellen. Bei
beiden als OAuth-Redirect-URL exakt eintragen:
\begin{code}
http://localhost:4792/callback
\end{code}
Die Anwendungen hängen nicht am Kanal. Sie können auch unter einem anderen
Konto angelegt werden.
\subsection{Schritt 2: Broadcaster-Konto verbinden}
Client-ID und Secret der ersten Anwendung auf der Einrichtungsseite eintragen,
speichern, dann auf \emph{Mit Twitch verbinden} klicken.
\begin{kasten}
\textbf{Das kann nur der Streamer selbst.} Twitch bindet die Berechtigungen an
das Konto, das sich anmeldet. Es gibt keine Rolle, mit der jemand anderes das
übernehmen kann. Moderator und Redakteur helfen hier nicht.
\end{kasten}
Damit bekommt das Toolkit Zugriff auf Clips, Titel und Kategorie, Abonnenten,
Follower, Raids, Kanalpunkte und Bits.
\subsection{Schritt 3: Bot-Konto verbinden}
Dasselbe mit der zweiten Anwendung, angemeldet als Bot-Konto. Das ist ein
eigenes, kostenloses Twitch-Konto, damit Antworten im Chat als Bot erkennbar
sind.
Ohne dieses Konto liest das Toolkit den Chat trotzdem mit. Es kann nur nicht
antworten.
\begin{kasten}
\textbf{Das Bot-Konto sollte Moderator im Kanal sein.} Der Streamer tippt
dafür einmal \texttt{/mod botname} in seinen Chat. Ohne Moderatorrechte gilt
ein Limit von 20 Nachrichten je 30 Sekunden statt 100, und im
Follower-only-Modus kommt der Bot gar nicht durch.
\end{kasten}
\subsection{Schritt 4: OBS-Passwort}
In OBS unter \emph{Werkzeuge} $\rightarrow$ \emph{WebSocket-Servereinstellungen}
den Server aktivieren, Port 4455. Das dort angezeigte Passwort auf der
Einrichtungsseite eintragen.
\subsection{Fertig?}
Unten auf der Seite steht \texttt{bereit} oder \texttt{unvollständig}.
Solange das Broadcaster-Konto fehlt, läuft das Toolkit im Lesemodus: Chat
wird mitgeschrieben, Zuschauerzahlen und Clips bleiben aus.
\section{OBS einrichten}
Ohne diese vier Punkte entstehen keine Clips oder sie sitzen schief.
\subsection{Wiedergabepuffer}
\emph{Einstellungen} $\rightarrow$ \emph{Ausgabe} $\rightarrow$
\emph{Wiedergabepuffer} aktivieren, 90 Sekunden.
Unter \emph{Einstellungen} $\rightarrow$ \emph{Hotkeys} eine Taste auf
\emph{Wiedergabepuffer speichern} legen. Das ist der Clip-Knopf für den
Streamer selbst. Ein eigener Windows-Hotkey wird bewusst nicht gebaut: OBS
meldet den gespeicherten Puffer ohnehin über WebSocket, das ist derselbe
Auslöser.
\subsection{Getrennte Tonspuren}
\emph{Einstellungen} $\rightarrow$ \emph{Ausgabe} $\rightarrow$
\emph{Aufnahme} $\rightarrow$ \emph{Audiospuren}, und im Audiomixer je Quelle
die Zuordnung prüfen:
\begin{tabularx}{\textwidth}{@{}lX@{}}
\toprule
Spur 1 & Mischung, kommt ins fertige Video \\
Spur 2 & Mikrofon, daraus entstehen die Untertitel \\
Spur 3 & Spielton \\
\bottomrule
\end{tabularx}
Das ist bei lauten Spielen der Unterschied zwischen brauchbarem und
unbrauchbarem Transkript.
\subsection{Bildaufteilung}
Der Zuschnitt nimmt feste Bereiche aus dem fertigen Streambild. Verrutscht die
Kamera um 50 Pixel, sitzt in jedem Short der Kopf schief. Deshalb liegt die
Position fest:
\begin{tabularx}{\textwidth}{@{}lll@{}}
\toprule
\textbf{Element} & \textbf{Position} & \textbf{Größe} \\
\midrule
Facecam & x 0, y 0 & 480 $\times$ 270 \\
Gameplay-Band & x 515 bis 1405 & 890 $\times$ 1080 \\
Chat, Alerts, Overlays & ab x 1405 & 515 px Spalte \\
\bottomrule
\end{tabularx}
Zwischen Kamera und Gameplay-Band liegen 35 Pixel Luft. Ein leicht
verrutschter Kamerarahmen schiebt so nicht sofort einen Streifen Gesicht ins
Spielbild.
Zum Ausrichten liegt eine Vorlage bei. Als Bildquelle in OBS laden, ganz nach
oben ziehen, Deckkraft über einen Farbkorrektur-Filter auf etwa 60 Prozent,
eigene Quellen einpassen, Vorlage wieder ausblenden.
Steht die Kamera in OBS als 4:3, vorher in den Kameraeinstellungen auf 16:9
umstellen. Ein 4:3-Bild in einen 16:9-Rahmen zu ziehen staucht das Gesicht.
\subsection{Fremder Text im Bild}
Was im Streambild steht, ist eingebrannt und lässt sich nachträglich nicht
entfernen. Untertitel des Spiels und Live-Caption-Overlays landen also im
Short, zusätzlich zu den eigenen Untertiteln.
\begin{itemize}
\item \textbf{Spieluntertitel:} abschalten. Sie laufen über die volle
Bildbreite, werden vom Zuschnitt angeschnitten und bringen im Short
nichts.
\item \textbf{Live-Captions:} verschieben statt abschalten. Für Zuschauer
ohne Ton sind sie wertvoll. Sie müssen nur aus dem Gameplay-Band
heraus, etwa nach unten links oder in die rechte Spalte.
\end{itemize}
Welche OBS-Quelle den Text überhaupt malt, sagt \texttt{abdulive sources}.
\section{Der laufende Betrieb}
\subsection{Clips auslösen}
\begin{tabularx}{\textwidth}{@{}lX@{}}
\toprule
\texttt{!clip} im Chat & jeder Zuschauer \\
OBS-Hotkey & der Streamer \\
Chat-Spitze & erkennt auffällige Reaktionen, standardmäßig aus \\
\bottomrule
\end{tabularx}
Abklingzeiten sind nicht optional. Ohne sie ist \texttt{!clip} nach zwei
Streams ein Spielzeug: 60 Sekunden global, 300 Sekunden je Zuschauer. Wer zu
früh dran ist, bekommt eine Antwort im Chat.
Die Spitzen-Erkennung läuft von Anfang an mit und schreibt jeden Kandidaten
samt Punktzahl mit, löst aber nichts aus. Sobald ein paar Streams
protokolliert sind, lässt sie sich an echten Daten kalibrieren und
scharfschalten.
\subsection{Befehle}
\begin{tabularx}{\textwidth}{@{}lX@{}}
\toprule
\texttt{abdulive check-env} & Selbsttest: Programme, Schriften, Modell, Verzeichnisse, OBS \\
\texttt{abdulive sources} & listet die OBS-Quellen und sagt, welche im 9:16-Ausschnitt landen \\
\texttt{abdulive calibrate} & prüft den Bildzuschnitt an einer echten Aufnahme \\
\texttt{abdulive render <datei>} & rendert eine Rohdatei nach, etwa nach einer Layoutänderung \\
\texttt{abdulive fetch-model} & lädt ein Sprachmodell \\
\texttt{abdulive update} & prüft auf eine neue Fassung \\
\bottomrule
\end{tabularx}
\subsection{Läuft es?}
\begin{codef}
Start-ScheduledTask -TaskName abdulive \textcolor{grau}{# starten}
Stop-ScheduledTask -TaskName abdulive \textcolor{grau}{# stoppen}
\end{codef}
Status und Zahlen liefert \url{http://127.0.0.1:4792/health}. Die Protokolle
liegen unter \pfad{\%LOCALAPPDATA\%\textbackslash abdulive\textbackslash logs}.
\begin{kasten}
Bei einer Adresse im Browser immer \texttt{127.0.0.1} statt
\texttt{localhost} verwenden, wenn etwas hängt. Der Dienst lauscht nur auf
IPv4, und Windows probiert bei \texttt{localhost} zuerst IPv6. Das kostet
zwei Sekunden.
\end{kasten}
\section{Updates}
Die Aufgabe \texttt{abdulive-update} sieht täglich um 5:00 Uhr nach. Findet
sie eine neue Fassung, lädt sie das Paket, prüft die Prüfsumme und legt es
bereit.
\textbf{Eingespielt wird beim nächsten Start.} Mitten im Stream wird nichts
ausgetauscht. Schlägt der Tausch fehl, läuft die alte Fassung weiter.
Sofort übernehmen:
\begin{code}
Stop-ScheduledTask -TaskName abdulive; Start-ScheduledTask -TaskName abdulive
\end{code}
\section{Sicherung}
\begin{code}
& "$env:LOCALAPPDATA\Programs\abdulive\backup.ps1"
\end{code}
Schreibt eine in sich stimmige Kopie der Datenbank und hält 30 Generationen.
Die Datenbank wird im laufenden Betrieb beschrieben, ein einfaches Kopieren
ergäbe eine unbrauchbare Sicherung.
\section{Deinstallation}
\begin{code}
& "$env:LOCALAPPDATA\Programs\abdulive\uninstall.ps1"
\end{code}
Nimmt die beiden Aufgaben, beendet den Dienst und löscht den Programmordner.
Die Daten bleiben stehen, weil in der Datenbank Chatverläufe liegen. Wer neu
installiert, findet seine Einrichtung wieder.
Wirklich alles weg, auch Datenbank, Clips und Sprachmodell:
\begin{code}
& "$env:LOCALAPPDATA\Programs\abdulive\uninstall.ps1" -AuchDaten
\end{code}
\section{Wenn etwas nicht geht}
\begin{tabularx}{\textwidth}{@{}lX@{}}
\toprule
\textbf{Symptom} & \textbf{Ursache und Abhilfe} \\
\midrule
Keine Clips & OBS-WebSocket aus oder Passwort fehlt. \texttt{check-env}
zeigt es unter \emph{OBS}. \\
Clips ohne Untertitel & Sprachmodell fehlt. \texttt{abdulive fetch-model}. \\
Untertitel voller Unsinn & Transkribiert wird die falsche Tonspur. Mikrofon
gehört auf Spur 2. \\
Kopf sitzt schief & OBS-Layout weicht von der Vorgabe ab.
\texttt{abdulive calibrate}. \\
Fremder Text im Short & Eine OBS-Quelle liegt im Gameplay-Band.
\texttt{abdulive sources}. \\
Chat wird nur gelesen & Broadcaster-Konto nicht verbunden. \\
Oberfläche antwortet nicht & Protokolle unter
\pfad{\%LOCALAPPDATA\%\textbackslash abdulive\textbackslash logs} ansehen. \\
\bottomrule
\end{tabularx}
\section{Wo was liegt}
\begin{tabularx}{\textwidth}{@{}lX@{}}
\toprule
\pfad{config\textbackslash config.yaml} & Einstellungen. Keine Geheimnisse. \\
\pfad{config\textbackslash layout.yaml} & Bildaufteilung für den Zuschnitt \\
\pfad{db\textbackslash} & Datenbank und verschlüsselte Zugangsdaten \\
\pfad{clips\textbackslash} & fertige Videos \\
\pfad{work\textbackslash} & Rohdateien der Verarbeitung \\
\pfad{models\textbackslash} & Sprachmodelle \\
\pfad{logs\textbackslash} & Protokolle \\
\bottomrule
\end{tabularx}
Alles unterhalb von \pfad{\%LOCALAPPDATA\%\textbackslash abdulive}.
\begin{kasten}
\textbf{Der Arbeitsordner wird noch nicht aufgeräumt.} Rohdateien sammeln
sich unbegrenzt an, mehrere hundert MB je Clip. Bis eine Aufbewahrungsregel
gebaut ist, gelegentlich selbst nachsehen.
\end{kasten}
\end{document}
+512 -69
View File
@@ -1,126 +1,569 @@
# Übergabe # Übergabe
Stand **2026-09-09**. Wer hier weitermacht, sollte diese Seite zuerst lesen und Stand **2026-10-18**, letztes gebautes Paket 0.5.4. Wer hier weitermacht,
danach [`07-mvp-plan.md`](07-mvp-plan.md). liest zuerst diese Seite, dann [`07-mvp-plan.md`](07-mvp-plan.md). Für Abdu
selbst gibt es das [Handbuch](handbuch/handbuch.pdf).
## Wo das Projekt steht ## Wo das Projekt steht
Vier von sieben Scheiben des MVP „Clip-Knopf" sind gebaut. Die Kette vom Vier von sieben Scheiben des MVP „Clip-Knopf" sind gebaut. Die Kette vom
Auslöser bis zum fertigen Short steht **im Code vollständig** — geprüft ist sie Auslöser bis zum fertigen Short steht im Code vollständig. Geprüft ist sie
überall dort, wo es ohne Abdus Stream-PC ging. überall dort, wo es ohne Abdus Stream-PC ging.
| Scheibe | Stand | Nachweis | | Scheibe | Stand | Nachweis |
|---|---|---| |---|---|---|
| S0 Toolchain + Auslieferung | fertig | `check-env` nativ ohne harte Fehler, Paket gebaut und installiert | | S0 Toolchain + Auslieferung | fertig | Paket gebaut und installiert, `check-env` ohne harte Fehler, Updater einmal komplett durchgelaufen |
| S1 Einrichtung, Auth, DB, Chat | fertig | Daemon läuft, Chat verbunden, Secrets verschlüsselt | | S1 Einrichtung, Auth, DB, Chat | fertig | Daemon läuft, Chat verbunden, Secrets verschlüsselt |
| S2 OBS + `!clip` | Code fertig, **nicht abgenommen** | braucht laufendes OBS | | S2 OBS + `!clip` | Code fertig, **nicht abgenommen** | braucht laufendes OBS |
| S3 9:16-Render | fertig | Durchlauf mit künstlicher Aufnahme | | S3 9:16-Render | fertig | Durchlauf mit künstlicher Aufnahme |
| S4 Untertitel | fertig | Durchlauf mit echtem deutschen Clip | | S4 Untertitel | fertig | Durchlauf mit echtem deutschen Clip, Ränder nachgemessen |
| S5 Onboarding + Sichtung | offen | — | | S5 Onboarding + Sichtung | offen | |
| S6 Erstchatter-Begrüßung | offen | — | | S6 Erstchatter-Begrüßung | offen | |
79 Tests, alle grün. Vollständige Liste dessen, was noch fehlt: 288 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". **Abdu hat am 2026-09-23 sein
Konto verbunden** (`abdulive_`, alle 9 Scopes). Damit laufen Helix mit
Nutzer-Token und EventSub. Der Chat liest weiter anonym mit, weil das
Bot-Konto noch nicht verbunden ist.
**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.** Bis zum 2026-09-23 fehlte das
Broadcaster-Token; das ist jetzt da. Der nächste Stream ist der erste echte
Durchlauf, im Entwurfsmodus.
## Das Overlay steht
Browser-Sources unter `apps/overlay-web/public`, ausgeliefert vom
Overlay-Server: Einblendungen, Chat, Kamerarahmen, Statusleiste, Effekte,
Pausenbild, Vorlesen, Liegestütze. Kein Build, kein Framework - OBS lädt die
Dateien direkt.
**Nächste Sitzung: das Overlay wird neu gemacht.** Gestaltung von Grund auf,
nicht nur der Kamerarahmen. Was bleiben sollte, weil es Fehler verhindert, die
man erst im Stream bemerkt: `client.js` (Neuverbinden, Wachhund,
Schnappschuss, `Overlay.esc`), der durchsichtige Hintergrund und die Kontur
aus `theme.css`, `?probe=1` zum Ansehen im Browser. Die Ereignisse auf dem Bus
(`apps/overlay-server/src/bus.ts`) sind die Schnittstelle, an der sich die
neuen Seiten festhalten können.
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. EventSub
läuft inzwischen, abonniert ist aber nur `channel_points_custom_reward_redemption.add`.
Follows wären ein zweites Abonnement (`channel.follow`, Version 2, braucht
`moderator:read:followers`, der Scope ist schon da).
**Kamerarahmen, Entwurf** (`/kamera`, 520×380 auf 0,0): ein Rahmen rechts und
unten um die Facecam, darunter eine Platte mit Kanal, Kategorie, Zuschauern
und Laufzeit. Soll die Statusleiste ersetzen, die als unschön empfunden wird.
Über der Kamera liegt bewusst nichts, weil genau dieser Bereich für die
Shorts ausgeschnitten wird. Noch nicht ausgerollt, Rückmeldung zur Richtung
steht aus. Ansehen: `node apps/cli/main.ts overlay --demo`, dann
`http://127.0.0.1:4791/kamera?probe=1`.
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".
## Das Pult: Handy als Stream Deck
Seit 2026-10-26 gebaut, **noch nicht ausgerollt und nicht an echtem OBS
geprüft**. `/pult` auf dem Hub ist eine installierbare Web-App (PWA) mit
Soundboard, Szenenwechsel, Clip-Knopf, Stummschaltern, Liegestützen,
Vorlese-Freigabe und den Testknöpfen. Beschreibung in
[`apps/overlay-web/README.md`](../apps/overlay-web/README.md#das-pult-pult).
- **Sounds lädt Abdu selbst hoch**, am Handy über „+ Sound“. Sie liegen auf
dem Hub unter `<db>/sounds/`, gespielt wird über die neue Browser-Source
`/sounds` (muss in OBS eingetragen werden, mit Ton).
- **Szenen** brauchen die neue Leitung Stream-PC → Hub: einmal
`abdulive fern:verbinden <adresse> <schlüssel>` auf dem Stream-PC (Befehl
steht auf der Übersicht), dann läuft sie in `abdulive ingest` mit. Das
heißt auch: **ein neues Paket für den Stream-PC** ist nötig, nicht nur
ein Ausrollen des Hubs.
- Geprüft: 311 Tests, Typprüfung, ein Durchlauf im Browser gegen einen
nachgebauten Stream-PC mit falschem OBS (Szenenwechsel, Stummschalten,
abgelehnte Szene, Hochladen, Abspielen in der Sound-Quelle). Ungeprüft:
echtes OBS, iPhone/Android-Installation, der Weg durch Nginx Proxy Manager
für die lange offene `/fern/befehle`-Leitung und Uploads bis 8 MB.
## Kanalpunkte: „Nachricht vorlesen" läuft
Seit 2026-09-23 scharf. Der Weg: EventSub meldet die Einlösung, der Hub
prüft (`apps/hub/src/rewards.ts`), Piper spricht, `/tts` spielt ab.
| | |
|---|---|
| Belohnung | „Nachricht vorlesen", 1000 Punkte, Texteingabe Pflicht |
| Freigabe | keine, wird direkt vorgelesen |
| Schutz | Wortfilter (29 Einträge), 200 Zeichen, 3 je Person und Stunde, „Sofort still" auf der Übersichtsseite |
| Stimme | Piper, `de_DE-thorsten-medium` (mls klang verwaschen) |
**Die Stimme entsteht im Hub, nicht im Browser.** Zuerst lief es über
`speechSynthesis` in der Browser-Quelle. Das hat in OBS gesprochen, aber
amerikanisch: OBS' Browser sah keine deutsche Stimme und die Seite nahm die
erste beliebige. Piper liegt unter `/opt/piper` im LXC (2 Kerne: 200 Zeichen
in 0,7 s), das Stimmenmodell unter `models/piper/`. Beides überlebt jedes
Ausrollen; eingerichtet mit `scripts/install-piper.sh`.
**Die erste Stimme war `de_DE-mls-medium`, Sprecher 0**, und klang im Stream
„wie jemand mit Schlaganfall". Die Phoneme stimmten (`piper --debug`), es lag
am Modell: Hörbuch-Aufnahmen von 236 Laien, Sprecher 0 ist ein beliebiger.
Seit 2026-09-24 läuft `de_DE-thorsten-medium`, ein Sprecher, darum kein
`sprecher` mehr in der Konfiguration. Das mls-Modell liegt noch daneben.
**Die Belohnung muss über die API angelegt sein** (`abdulive rewards:anlegen`).
Nur die App, die eine Belohnung angelegt hat, darf deren Einlösungen
abschließen oder erstatten. Im Dashboard sieht man davon nichts. Titel,
Kosten und Abklingzeit darf man dort ändern; löschen und neu anlegen nicht,
dann ist die ID weg, die in der Hub-Konfiguration steht.
**Der Wortfilter normalisiert vorher**: Leetspeak, Punkte zwischen den
Buchstaben, gezogene Buchstaben, Akzente. Einträge ab 5 Buchstaben werden im
zusammengezogenen Text gesucht, kürzere nur am Wortanfang, sonst stünde
„nazi" in „kann azubi". Die Liste steht nur in der Hub-Konfiguration, nicht
im Repo.
**Der Filter hat eine Stunde lang fast alles abgelehnt.** `www` in der Liste
wurde zusammengezogen zu `w` und traf damit jedes Wort, das mit w anfängt
(„wie", „weg"). Die Punkte gingen zurück, im Log stand nur `Wortfilter`.
Behoben am 2026-09-24: was nach dem Zusammenziehen unter 3 Buchstaben hat,
trifft nur noch als ganzes Wort. **Noch offen:** lange Einträge treffen über
Wortgrenzen hinweg, „Spaß tut gut" fliegt wegen `spast` raus, „eine Gerade"
wegen `neger`. Wer eine Ablehnung nachvollziehen will: der Text steht im
Ereignis `points.redeemed` in der Datenbank.
**Der YAML-Leser konnte keine Listen aus Maps.** `- titel: X` zerfiel in
Zeichenketten, jede Belohnung war ohne Meldung unwirksam - auch das Beispiel
in `config.example.yaml`. Behoben und getestet; die bestehenden Dateien
werden nachweislich gleich gelesen wie vorher.
## Kanalpunkte: Liegestütze
Seit 2026-10-04 scharf. Die Belohnung „5 Liegestütze" gab es auf Twitch schon
(von Hand angelegt, 61 Einlösungen bis dahin). Sie steht jetzt in der
Hub-Konfiguration mit `aktion: liegestuetze`, `anzahl: 5`, zugeordnet über den
Titel. Wird sie auf Twitch umbenannt, zählt der Hub nicht mehr mit.
| | |
|---|---|
| Zähler | `apps/hub/src/liegestuetze.ts`, Tabelle `zaehler` (Migration 003), übersteht Neustarts |
| Offen | Einlösung +5. Auf der Übersicht −1/−5/−10, Setzen, auf 0 |
| Gemacht | Was mit −1/−5/−10 abgezogen wird. Setzen zählt nicht, das ist Korrektur. Eigenes Feld zum Korrigieren |
| Neuer Stream | Gemacht auf 0, offen bleibt. Nicht beim Neustart des Hubs mitten im Stream, nicht bei einer Lücke bis 5 Minuten |
| Overlay | `/liegestuetze`, 420×200. `?verstecken=1`, `?gemacht=0`, `?titel=...` |
**Ungeprüft im Stream.** Getestet mit Probeserver und Browser. Weder eine
echte Einlösung noch das Nullen beim Streamstart ist seit dem Ausrollen
passiert. Der Zähler startete bei 0 offen, die 61 alten Einlösungen sind nicht
übernommen. In OBS ist die Quelle noch nicht eingetragen.
**Die Belohnung ist nicht über die API angelegt.** Der Hub kann ihre
Einlösungen deshalb nicht als erledigt melden und schreibt bei jeder eine
Warnung ins Log. Für das Zählen egal. Eine ältere Belohnung „10 Liegestütze"
(12 Einlösungen bis 2026-09-24) steht nicht in der Konfiguration.
**Nebenbei behoben: die Vorlese-Freigabe auf der Übersicht war tot.**
`POST /tts` landete bei der Overlay-Seite `/tts` statt bei der Freigabe, auf
dem Hub ohne Schlüssel sogar mit 403. „Vorlesen", „Ablehnen" und „Sofort
still" haben damit nie gewirkt. Overlay-Seiten werden jetzt nur bei GET
ausgeliefert. Gegen eine echte Warteschlange nicht nachgeprüft.
## Der Discord-Server steht
Dazugekommen seit der letzten Übergabe, und kein Teil des MVP: der
Community-Server ist eingerichtet, und zwar als Code statt von Hand. Der
Bauplan liegt in [`packages/discord/src/plan.ts`](../packages/discord/src/plan.ts),
aufgetragen wird er mit `abdulive discord:setup`. Wiederholbar: gefunden wird
über Namen und frühere Namen, danach wird angeglichen. Der zweite Lauf ändert
nichts, gelöscht wird nie.
Das ist Vorarbeit für drei Werkzeuge. Die Webhooks für
[Go-Live](projects/go-live-broadcaster.md), [Recap](projects/stream-recap.md)
und [Clips](projects/clip-shorts.md) liegen fertig im verschlüsselten Speicher
unter `goLiveWebhook`, `recapWebhook` und `clipsWebhook`. Wer diese Werkzeuge
baut, muss sich um Discord nicht mehr kümmern, sondern nur noch posten.
Zehn Kanäle, vier Rollen, zwei AutoMod-Regeln. Seit 2026-10-17 dabei:
`🙋│begrüßung` für Discords Beitrittsmeldungen (liefen vorher in
`#allgemein`), und die Foren `🎮│spielvorschläge` und
`👀│reaction-einsendungen`. Beide hatte Abdu als Textkanäle angelegt, sie sind
jetzt Foren mit Tags, die nur Mods setzen, und 👍 als Standard-Reaktion.
Discord wandelt keinen Textkanal in ein Forum um: die beiden leeren
Textkanäle wurden einmalig über die API gelöscht, das Setup-Skript selbst
löscht weiterhin nichts.
Im Spielvorschläge-Forum stehen neun Beiträge, einer je Spiel aus Abdus
Liste, vom Bot angelegt (Titel vorher nachgeschlagen, „involkyr" heißt
Invokyr). In `📢│ankündigungen` hat der Bot das Forum angekündigt, ohne Ping.
Der Bot darf dort trotz Schreibsperre für `@everyone` posten.
**Der Hub ist nicht neu ausgerollt.** Das Setup lief aus einer Kopie im
Container, der laufende Prozess hat noch den Stand davor. Für den Server ist
das egal, für den Knopf auch. Beim nächsten Ausrollen kommt es mit. Der Entwurf und seine
Begründung stehen in [`projects/discord-community.md`](projects/discord-community.md);
die Kurzfassung ist, dass bei 5 bis 30 Zuschauern fünf sichtbare Kanäle besser
sind als zwanzig leere.
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 mit
im Prozess und meldet sich als `discordConnected` auf der Statusseite. Seit dem
Umzug in den Hub läuft er durch, auch wenn Abdus Rechner aus ist.
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
**Es gibt keine Abhängigkeiten und keinen Build.** Node 24 bringt SQLite, **Keine Abhängigkeiten, kein Build.** Node 24 bringt SQLite, WebSocket,
WebSocket, `fetch` und die Ausführung von TypeScript mit. `node apps/cli/main.ts` `fetch` und die Ausführung von TypeScript mit. `node apps/cli/main.ts` läuft
läuft direkt. Deshalb gilt: nur *löschbare* TypeScript-Syntax — keine `enum`, direkt. Deshalb gilt: nur *löschbare* TypeScript-Syntax, also keine `enum`,
keine Parameter-Properties im Konstruktor, keine `namespace`. `erasableSyntaxOnly` keine Parameter-Properties im Konstruktor, keine `namespace`.
in der `tsconfig.json` erzwingt das und hat schon einen echten Fehler gefangen. `erasableSyntaxOnly` in der `tsconfig.json` erzwingt das und hat schon einen
echten Fehler gefangen.
**Alles läuft unter einem Dach**, in einem Prozess. Schwere **Alles läuft in einem Prozess** - aber es gibt jetzt zwei davon, Hub und
Arbeit (ffmpeg, whisper) läuft als Kindprozess und darf abstürzen. Der Ingest Ingest (siehe oben). Schwere Arbeit (ffmpeg, whisper) läuft als Kindprozess und
darf nie sterben — was er nicht 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 liegen **Zugangsdaten kommen aus der Weboberfläche**, nicht aus einer Datei. Sie
verschlüsselt neben der Datenbank, aber bewusst nicht *in* ihr — die Datenbank liegen verschlüsselt neben der Datenbank, aber bewusst nicht *in* ihr. Die
wird gesichert und weitergereicht, die Geheimnisse nicht. Datenbank wird gesichert und weitergereicht, die Geheimnisse nicht.
**Rohdateien gehören in den Arbeitsordner**, nicht neben den OBS-Puffer. Das **Das Repo ist seit 2026-09-09 öffentlich.** Damit funktioniert der Einzeiler
war unter Docker ein Zeitproblem (Encodieren über die WSL2-Brücke kostete ein zur Installation, und der Updater braucht keinen Token. Es heißt aber auch:
Vielfaches) und ist nativ nur noch Ordnung — der Wiedergabepuffer-Ordner nichts Vertrauliches mehr hineinschreiben, auch nicht in die Doku.
gehört OBS.
Die Begründungen stehen in [`decisions/`](decisions/), sieben ADRs. Wer eine **Es gibt zwei Remotes**, beide mit demselben Inhalt: `origin`
davon umwerfen will, sollte sie vorher lesen; jede hat einen Grund, der beim (`ZeasyAlex/abdulive`) und `discord` (`ZeasyAlex/abdulive_-discord`). Wer
Bauen aufgetaucht ist. ADR-0004 (Docker) ist durch ADR-0007 ersetzt — die pusht, pusht auf beide, sonst laufen sie auseinander.
Analyse darin bleibt trotzdem lesenswert.
**`deploy-hub.sh` schiebt den Arbeitsstand, nicht den Commit.** Liegt
Unfertiges herum, landet es im LXC. Sauber ausrollen aus einem eigenen
Checkout:
```bash
git worktree add --detach ../abdulive-deploy HEAD
../abdulive-deploy/scripts/deploy-hub.sh
git worktree remove ../abdulive-deploy
```
Die Begründungen stehen in [`decisions/`](decisions/), acht ADRs. Wer eine
davon umwerfen will, liest sie vorher. ADR-0004 (Docker) ist durch ADR-0007
ersetzt; die Analyse darin bleibt lesenswert, weil zwei der vier
beschriebenen Fallen mit dem Container weggefallen sind statt gelöst worden
zu sein.
## Loslegen ## Loslegen
**Auf Abdus Rechner** läuft eine native Windows-Anwendung — kein Docker, kein Auf Abdus Rechner, eine native Windows-Anwendung:
WSL2, nichts zu übersetzen ([ADR-0007](decisions/ADR-0007-native-windows.md)):
```powershell ```powershell
Expand-Archive .\abdulive-0.5.0-win-x64.zip -DestinationPath .\abdulive irm https://git.zeasy.dev/ZeasyAlex/abdulive/raw/branch/main/scripts/install.ps1 | iex
.\abdulive\install.ps1
abdulive fetch-model
abdulive check-env
``` ```
Das Repo ist seit 2026-09-09 oeffentlich, die Pakete haengen an der Der Installer lädt das Paket, prüft die Prüfsumme, holt das Sprachmodell,
Veroeffentlichung `latest`. Einen `irm … | iex`-Einzeiler gibt es trotzdem läuft den Selbsttest, startet den Dienst und öffnet die Einrichtungsseite.
nicht — Rohdateien liefert Gitea als `text/plain`, und ein Fehlschlag saehe Ohne Administratorrechte. Ausführlich in [`05-setup.md`](05-setup.md).
aus wie ein PowerShell-Syntaxfehler statt wie ein Fehlschlag.
Ausführlich in [`05-setup.md`](05-setup.md). Zum Entwickeln reicht ein Checkout und Node 24 oder neuer:
**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 scripts/build-package.ts --version 0.5.0 # baut dist/*.zip node apps/cli/main.ts overlay --demo # Overlay ohne Stream ansehen
node scripts/build-package.ts --version 0.5.5 # baut dist/
``` ```
Typprüfung (das Einzige, wofür npm gebraucht wird): Den Hub ausrollen (braucht SSH auf den Proxmox-Host):
```bash
scripts/deploy-hub.sh
```
Typprüfung, das Einzige wofür npm gebraucht wird:
```powershell ```powershell
npm i typescript@5.9 @types/node npm i typescript@5.9 @types/node
npx tsc --noEmit npx tsc --noEmit
``` ```
## Ausliefern
`build-package.ts` lädt Node, ffmpeg und whisper.cpp bei ihren Herausgebern,
packt alles nach `dist/` und schreibt `abdulive.json`. Dieses Manifest fragt
`abdulive update` auf Abdus Rechner ab.
Die Veröffentlichung `latest` trägt beide Dateien und wird bei jedem Bau
ersetzt, damit die Adresse gleich bleibt.
`.gitea/workflows/release.yml` macht das beim Tag automatisch. **Die Pipeline
ist noch nie gelaufen**, bisher wurde von Hand veröffentlicht. Der Runner
(`ubuntu-latest`) steht.
## Windows-Fallen, die schon zugeschlagen haben
Alle vier haben Zeit gekostet, und keine sieht im Fehlerfall nach ihrer
Ursache aus. Wer an den PowerShell-Skripten arbeitet, sollte sie kennen.
**stderr beendet das Skript.** Windows PowerShell 5.1 verpackt jede
stderr-Zeile eines nativen Programms in einen `ErrorRecord`. Zusammen mit
`$ErrorActionPreference = 'Stop'` bricht das sofort alles ab. Der Daemon
schreibt im Normalbetrieb Warnungen auf stderr, der Starter hat ihn also bei
seiner ersten Logzeile umgebracht: Aufgabe meldete Code 1, das Protokoll war
leer. Deshalb läuft der Start über `Start-Process` mit getrennten
Umleitungen, und `install.ps1` ruft native Programme über eine Hilfsfunktion
auf, die `ErrorActionPreference` kurz zurückstellt.
**`& $programm; return $LASTEXITCODE` gibt auch die Ausgabe zurück.** Die
Funktion liefert dann ein Array aus allen Ausgabezeilen plus Exit-Code, und
jeder Vergleich damit ist falsch, ohne dass es auffällt. Ein erfolgreiches
`fetch-model` galt so als fehlgeschlagen. Ausgabe mit `| Out-Host`
durchreichen.
**`localhost` kostet zwei Sekunden.** Der Dienst lauscht nur auf IPv4,
Windows probiert bei `localhost` zuerst `::1`. Gemessen: 2066 ms bis zum
Rückfall gegenüber 15 ms bei `127.0.0.1`. Eine Bereitschaftsprüfung mit zwei
Sekunden Zeitlimit läuft dadurch immer ab, obwohl der Dienst längst läuft.
**Windows-Pfade im ffmpeg-Filtergraphen brauchen drei Schritte.** An echtem
ffmpeg 8.1 durchprobiert:
| Form | Ergebnis |
|---|---|
| `ass=filename=C:/x/s.ass` | `No option name near '/x/s.ass'` |
| `ass=filename=C\:/x/s.ass` | ebenso |
| `ass=filename='C:/x/s.ass'` | ebenso |
| `ass=filename='C\:/x/s.ass'` | läuft |
Vorwärtsschrägstriche, escapter Doppelpunkt **und** Anführungszeichen. Je
zwei davon reichen nicht. `filterPath()` in `packages/media/src/ffmpeg.ts`
macht das, mit Tests.
## Untertitel: was gemessen ist
**Die Schrift muss mitgeliefert werden.** Im Container kam DejaVu Sans aus
dem Betriebssystem, Windows hat sie nicht. Fehlt `fontsdir`, setzt libass
wortlos eine Ersatzschrift ein. Gemessen: dieselbe Zeile wird 720 statt 826
Pixel breit, ohne jede Fehlermeldung. Die Dateien liegen unter
`assets/fonts/`.
**Die Zeilenlänge ist eingemessen, nicht geschätzt.** Vorher stand dort fest
`maxChars: 30`. Das sind bei Schriftgröße 76 rund 1085 px, nutzbar sind 940.
Zusammen mit `WrapStyle: 2`, bei dem libass gar nicht umbricht, lief der Text
links und rechts aus dem Bild. Jetzt wird die Grenze aus Breite und
Schriftgröße abgeleitet, Versalien zählen breiter, und einzelne zu lange
Wörter bekommen eine kleinere Schrift, weil libass innerhalb eines Wortes
nicht umbricht. Deutsche Komposita sind regelmäßig breiter als das Bild.
Wer die Schrift wechselt, muss `CHAR_WIDTH_RATIO` in `subtitles.ts` neu
einmessen. Sonst bricht die Zeile wieder an der falschen Stelle.
## Discord: was gemessen ist
Vier Dinge, die nicht in der Dokumentation standen, in der man zuerst sucht.
Alle am echten Server nachgeprüft.
**Kanalnamen lassen sich nur zweimal in zehn Minuten ändern.** Danach verlangt
Discord eine Pause, gemessen wurden 529 Sekunden. Ein Einrichtungslauf, der
brav auf ein 429 wartet, steht dann neun Minuten. Der Client wartet deshalb
höchstens eine Minute, meldet den Kanal als übersprungen und macht weiter.
**Eine Rolle kann nur Rollen unterhalb der eigenen verschieben** - auch mit
Administrator. Discord hängt die Rolle eines frisch eingeladenen Bots ganz
unten ein, also gibt es dort keinen Platz. Der erste Lauf quittierte das mit
`Missing Permissions`, was nach einem fehlenden Recht aussieht und keines ist.
`rolePositions()` rechnet den Platz jetzt aus und sagt, was zu tun ist.
**Discords Onboarding braucht einen größeren Server.**
```
HTTP 400 Cannot enable onboarding, requirements are not met (350000)
sichtbar für @everyone: 5 davon beschreibbar: 3
Discord verlangt: 7 davon beschreibbar: 5
```
Zwei Kanäle anzulegen, damit eine Schwelle passt, hätte den Server leerer
gemacht statt besser. Daher der Knopf.
**Kanalnamen werden normalisiert, aber nicht so weit wie erwartet.**
Textkanäle werden kleingeschrieben und Leerzeichen zu Bindestrichen; Emoji,
Umlaute und das Trennzeichen `│` (U+2502) bleiben unverändert stehen.
`normalizeName()` bildet das ab. Ohne diesen Abgleich geht bei jedem Lauf
dieselbe Umbenennung erneut raus - und läuft nach zwei Läufen in das Limit von
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.**~~ Erledigt am 2026-09-23. Der Rest
(dev.twitch.tv/console/apps, Redirect-URI `http://localhost:4792/callback`) dieses Punkts bleibt als Nachschlag stehen, falls neu verbunden werden
kann auch jemand anders anlegen — sie hängen nicht am Kanal. Was **nur Abdu** muss.
kann: auf der Einrichtungsseite eingeloggt als er selbst auf „Mit Twitch
verbinden" klicken. Dafür muss die Anwendung auf seinem Rechner schon Die beiden Twitch-Apps (dev.twitch.tv/console/apps) kann auch jemand anders
laufen; 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). Die 3. **Layout umstellen** nach [`obs-layout.md`](obs-layout.md), danach
Ausrichtungsvorlage in OBS über die Szene legen, Quellen einpassen, wieder `abdulive calibrate` zur Kontrolle.
ausblenden. Danach `calibrate` zur Kontrolle.
Ohne Punkt 1 liest das Toolkit den Chat anonym mit und kann nichts senden. Das Bot-Konto ist noch nicht verbunden: bis dahin liest das Toolkit den
Chat anonym mit und kann nichts senden.
Ohne Punkt 2 gibt es keine Clips. Ohne Punkt 3 sitzt der Ausschnitt schief. Ohne Punkt 2 gibt es keine Clips. Ohne Punkt 3 sitzt der Ausschnitt schief.
Dazu eine Kleinigkeit mit Wirkung: das Bot-Konto sollte Moderator sein
(`/mod <botname>`). Ohne das gilt ein Limit von 20 Chat-Nachrichten je 30
Sekunden statt 100, und im Follower-only-Modus kommt der Bot gar nicht durch.
Und drei für Discord, die nicht über die API gehen:
1. **Sich selbst die Rolle `Abdu` geben.** Das Skript legt Rollen an, aber es
hängt sie niemandem um.
2. **Einladungslink** ohne Ablaufdatum erzeugen und ins Twitch-Panel, in die
Kanalinfo und in einen Chat-Befehl eintragen. Ein Server, den im Stream
niemand erwähnt, bleibt leer. Das ist der eine Handgriff, an dem hängt, ob
der Server überhaupt jemanden bekommt.
3. **Tags in den Foren pflegen.** „Wird gespielt", „Gespielt", „Eher nicht"
bzw. „Kommt dran", „Angeschaut" setzen nur Abdu und Mods. Ein Vorschlag,
der sichtbar umgesetzt wird, bringt den nächsten.
## Wo ich weitermachen würde ## Wo ich weitermachen würde
**S5.** Nicht wegen des Codes, sondern weil das Werkzeug ohne Einrichtungs- **S2 an einem laufenden OBS abnehmen.** Das ist die einzige ungeprüfte Stelle
Assistenten und Sichtungsseite für Abdu nicht bedienbar ist. Die Kette in der ganzen Kette, und alles Weitere hängt daran. Seit es `abdulive sources`
funktioniert, aber sie hat noch keine Oberfläche, die jemand ohne Erklärung gibt, ist auch die Frage beantwortbar, welche OBS-Quelle fremden Text ins Bild
benutzt. Abnahmekriterium steht im Plan: *Ein Fremder kommt von „nichts malt. Das war vorher nicht zu erraten.
eingerichtet" bis zum ersten eigenen Clip, ohne die Dokumentation zu lesen.*
Davor, wenn Gelegenheit ist: **S2 an einem laufenden OBS abnehmen**. Das ist **Den Go-Live-Ping scharf schalten.** Er steht auf `draft`. Einmal
die einzige ungeprüfte Stelle in der ganzen Kette, und alles Weitere hängt `abdulive golive:test --echt` laufen lassen, prüfen ob das Handy klingelt, dann
daran. `mode: live`. Das ist der kleinste verbleibende Handgriff mit der größten
Wirkung.
**Das Overlay neu machen** (nächste Sitzung, siehe oben). Der Kamerarahmen
als Entwurf geht darin auf, die Statusleiste soll ersetzt werden. Danach die
Liegestütze-Quelle in OBS eintragen und im Stream einmal beobachten.
**`apps/recap` bauen.** Angefangen, aber nicht committet: `apps/recap/src/recap.ts`
und Ergänzungen in `packages/db/src/repos.ts` liegen nur im Arbeitsstand
(die Typprüfung meckert dort noch über `url: undefined`). Der Webhook liegt
bereit, der Hub hat die Daten. Seit 2026-10-04 wird aus einem sauberen
Checkout ausgerollt, der halbe Recap liegt also nicht mehr im LXC.
**Danach S5.** Nicht wegen des Codes, sondern weil das Werkzeug ohne
Sichtungsseite für Abdu nicht bedienbar ist. Die Kette funktioniert, aber sie
hat keine Oberfläche, auf der man Clips durchgeht und aussortiert.
Abnahmekriterium steht im Plan: *Ein Fremder kommt von „nichts eingerichtet"
bis zum ersten eigenen Clip, ohne die Dokumentation zu lesen.* Installer und
Handbuch decken davon inzwischen ein gutes Stück ab.
## Womit ich nicht zufrieden bin ## Womit ich nicht zufrieden bin
Die Untertitel sind brauchbar, nicht gut. `ggml-small` verhört sich hörbar **Das Aufräumen im Arbeitsordner fehlt komplett.** Rohdateien sammeln sich
(„Danke" wurde „Dank es"), die Schrift ist unauffällig, und das fortlaufende unbegrenzt an, mehrere hundert MB je Clip. Das ist die Lücke, die als Erstes
Einfärben ist nicht der Stil, der auf Shorts trägt. Sechs konkrete weh tun wird.
Verbesserungen stehen in [`offene-punkte.md`](offene-punkte.md).
Und: Das Aufräumen im Arbeitsordner fehlt komplett. Rohdateien sammeln sich **Die Untertitel sind brauchbar, nicht gut.** `ggml-small` verhört sich
unbegrenzt an. Das ist die Lücke, die als Erstes weh tun wird. hörbar („Danke" wurde „Dank es"), DejaVu Sans ist unauffällig, und das
fortlaufende Einfärben ist nicht der Stil, der auf Shorts trägt. Sechs
konkrete Verbesserungen stehen in [`offene-punkte.md`](offene-punkte.md).
Der Updater ist einmal komplett durchgelaufen: 0.4.0 erkannt, 119 MB von **Die CI ist geschrieben, aber ungetestet.** Beim ersten Tag zusehen.
`git.zeasy.dev` geladen, Prüfsumme geprüft, bereitgelegt, von `launcher.ps1`
getauscht, `pending` aufgeräumt. Was noch nie lief, ist die CI-Pipeline. **Das Paket ist nicht signiert.** SmartScreen warnt bei jeder Installation.
Solange nur Abdu installiert, reicht ein Klick. Sobald jemand Drittes es
bekommt, nicht mehr.
**Der Discord-Knopf hängt am Daemon** - das war der Punkt aus der letzten
Übergabe und ist mit dem Hub erledigt. Dafür gibt es jetzt einen neuen: der
Bot darf nur an **einer** Stelle laufen. Steht auf dem Stream-PC ein
Discord-Bot-Token im Speicher und läuft dort `ingest`, antworten zwei Prozesse
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 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
und ein Klickweg gegen einen nachgebauten Server; am echten Server ist die
Verbindung nachgewiesen und der Rollenwechsel einmal in beide Richtungen
durchgespielt. Was bei gleichzeitigen Klicks oder nach Tagen Laufzeit
passiert, ist offen.
**Es gibt keine Einstellung für den OBS-Wiedergabepuffer-Ordner.** Im
Container kam der Pfad über `OBS_REPLAY_DIR`, nativ fehlt der Weg. Für die
Clip-Kette ist das egal, weil OBS den Pfad über WebSocket meldet. Aber
`calibrate` findet so keine Aufnahmen, und `check-env` warnt zu Recht.
+16
View File
@@ -82,6 +82,22 @@ Der Durchlauf am echten Clip war brauchbar, aber nicht gut. Was auffiel:
heraus ging das nicht. `clipper.ffmpeg.encoder` steht weiter auf heraus ging das nicht. `clipper.ffmpeg.encoder` steht weiter auf
`libx264`. Auf Abdus Rechner messen, ob `h264_qsv` etwas bringt. `libx264`. Auf Abdus Rechner messen, ob `h264_qsv` etwas bringt.
## Discord
- [ ] **Den Bot unter Last sehen.** Gleichzeitige Klicks auf den Rollen-Knopf
sind nie vorgekommen, ebenso wenig eine Laufzeit über Tage. Die
Gateway-Verbindung kann fortsetzen und wiederverbinden, aber beides ist
nur gegen einen nachgebauten Server geprüft.
- [ ] **Onboarding einschalten, sobald der Server groß genug ist.** Discord
verlangt sieben Vorgabekanäle mit fünf beschreibbaren, wir haben fünf und
drei. Die Frage liegt fertig auf dem Server, nur ausgeschaltet. Dann
könnte der Knopf entfallen und mit ihm die Gateway-Verbindung.
- [ ] **Twitch-Konto unter Integrationen verbinden**, damit Subs automatisch
eine Rolle bekommen.
- [ ] **Stammgast automatisch vergeben.** Bisher von Hand. Die Daten dafür
liegen längst in der Datenbank, es fehlt die Brücke zwischen
Twitch-Konto und Discord-Konto.
## Nach dem MVP ## Nach dem MVP
- [ ] **Spitzen-Erkennung scharf schalten.** Sie läuft mit und schreibt jeden - [ ] **Spitzen-Erkennung scharf schalten.** Sie läuft mit und schreibt jeden
+1
View File
@@ -15,3 +15,4 @@ Entscheidungen, Fallstricke, Abnahmekriterien.
| 7 | [Chat steuert Dinge](chat-control.md) | `apps/overlay-server`, `apps/overlay-web` | | 7 | [Chat steuert Dinge](chat-control.md) | `apps/overlay-server`, `apps/overlay-web` |
| 8 | [Punktesystem und Shop](loyalty-shop.md) | `apps/loyalty` | | 8 | [Punktesystem und Shop](loyalty-shop.md) | `apps/loyalty` |
| 9 | [Stream-Recap](stream-recap.md) | `apps/recap` | | 9 | [Stream-Recap](stream-recap.md) | `apps/recap` |
| — | [Discord-Community](discord-community.md) | `packages/discord` |
+302
View File
@@ -0,0 +1,302 @@
# Discord-Community
**Wirkrichtung:** Bindung. Twitch ist der Ort, an dem Leute zuschauen. Discord
ist der Ort, an dem sie zwischen den Streams noch da sind. Ohne den zweiten Ort
faengt jeder Stream bei null an.
Der Server ist kein Tool aus der Neunerliste, aber drei davon posten dorthin:
[Go-Live-Broadcaster](go-live-broadcaster.md), [Stream-Recap](stream-recap.md)
und [Clip-to-Shorts](clip-shorts.md). Deshalb steht die Struktur hier im Repo
und nicht nur in einer Oberflaeche.
## Leitgedanke: klein anfangen
Der Kanal hat 5 bis 30 gleichzeitige Zuschauer. Daraus werden realistisch 20
bis 100 Discord-Mitglieder, von denen eine Handvoll schreibt.
Ein Server mit zwanzig Kanaelen sieht bei dieser Groesse nicht nach einer
grossen Community aus, sondern nach einer verlassenen. Zwanzig leere Kanaele
sagen jedem Neuankoemmling: hier ist nichts los. Fuenf Kanaele, in denen etwas
steht, sagen das Gegenteil.
Also: **fuenf sichtbare Kanaele.** Wachstum kommt spaeter, und nur dann, wenn
ein bestehender Kanal wirklich zu voll wird. Wann das so weit ist, steht weiter
unten unter [Wann ein Kanal dazukommt](#wann-ein-kanal-dazukommt).
## Die Struktur
Der Bauplan liegt in [`packages/discord/src/plan.ts`](../../packages/discord/src/plan.ts)
und ist die massgebliche Fassung. Was hier steht, ist die Begruendung dazu.
### Kanaele
| Kategorie | Kanal | Wer schreibt | Wofuer |
|---|---|---|---|
| 📌 Info | `👋│willkommen` | niemand | Die drei Regeln, einmal gepostet |
| 📌 Info | `🙋│begrüßung` | alle | Discords Beitrittsmeldungen |
| 📌 Info | `📢│ankündigungen` | nur Webhooks | Stream-Start und Recap; Diskussion in Threads |
| 🎮 Community | `💬│allgemein` | alle | Der Hauptkanal. Im Zweifel hier |
| 🎮 Community | `🎬│clips` | alle + Clip-Knopf | Gute Momente |
| 🎮 Community | `🎮│spielvorschläge` | alle (Forum) | Ein Beitrag je Spiel, abgestimmt per 👍 |
| 🎮 Community | `👀│reaction-einsendungen` | alle (Forum) | Ein Beitrag je Clip, auf den Abdu reagieren soll |
| 🎮 Community | `🔊 Zusammen zocken` | — | Sprachkanal |
| 🔒 Intern | `🔨│mod-chat` | Mods | Fuer alle anderen unsichtbar |
| 🔒 Intern | `🤖│technik-log` | Toolkit | Probelaeufe, Fehler, AutoMod-Treffer |
Zu den Namen: Discord schreibt Textkanaele klein und macht aus Leerzeichen
Bindestriche. Emoji, Umlaute und das Trennzeichen `│` (U+2502) bleiben stehen -
am echten Server nachgemessen, nicht angenommen. `normalizeName()` bildet das
ab, damit nicht bei jedem Lauf dieselbe Umbenennung erneut rausgeht.
Und zur Schreibweise: im Quelltext bleibt es bei `ae/oe/ue` wie im uebrigen
Repo, aber alles, was ein Mitglied auf Discord liest, steht in richtigem
Deutsch. „Sei anstaendig" liest sich wie ein Tippfehler, und der Text im
Server ist der erste Eindruck, den jemand davon bekommt.
`📢│ankündigungen` ist schreibgeschuetzt, erlaubt aber Reaktionen und Threads.
So bleibt die Kanalansicht eine saubere Liste von Streams, und die Diskussion
ueber das Recap haengt trotzdem direkt daran.
`🙋│begrüßung` gibt es, weil Discord die Beitrittsmeldungen sonst in den
ersten Kanal des Servers schreibt, und das war `💬│allgemein`. Dort gingen die
Meldungen im Gespräch unter und das Gespräch in den Meldungen. Der Kanal ist
beschreibbar, sonst tut der Winke-Knopf unter der Meldung nichts. Welcher
Kanal es ist, steht in `SYSTEM_CHANNEL`.
Die beiden Foren hat Abdu als Textkanäle angelegt, seit 2026-10-17 stehen sie
als Foren im Bauplan. In einem Textkanal schlägt nach drei Wochen jemand
dasselbe Spiel noch einmal vor, weil der erste Vorschlag weggescrollt ist. Im
Forum gibt es einen Beitrag je Spiel, darunter wird per 👍 abgestimmt. Die
Tags für den Stand („Wird gespielt", „Gespielt", „Eher nicht" bzw. „Kommt
dran", „Angeschaut") setzen nur Mods. Sonst markiert jeder sein eigenes Spiel
als „wird gespielt". Discord wandelt einen Textkanal nicht in ein Forum um:
die leeren Textkanäle wurden einmalig von Hand gelöscht, das Skript selbst
löscht weiterhin nichts. Foren gehen auch ohne Community-Server, am echten
Server nachgeprüft.
Der Sprachkanal ist bewusst dabei, obwohl er anfangs leer sein wird. Ein
Sprachkanal, den zwei Leute nutzen, ist der Anfang; einer, den es nicht gibt,
wird nie einer.
### Rollen
Die Reihenfolge ist zugleich die Rangfolge auf dem Server.
| Rolle | Sichtbar | Wofuer |
|---|---|---|
| `Abdu` | ja | Der Kanal selbst. Administrator |
| `Mod` | ja | Loeschen, stummschalten, rauswerfen |
| `Stammgast` | ja | Auszeichnung fuer Leute, die regelmaessig da sind |
| `Live-Ping` | nein | Wird bei Stream-Start angepingt, selbst vergeben |
**`Abdu`** traegt `Administrator`. Das Recht schliesst alle anderen ein und
setzt jede Kanal-Ausnahme ausser Kraft, auch kuenftige. Es gehoert deshalb an
genau ein Konto und an kein zweites. Ein Test im Repo haelt fest, dass keine
andere Rolle es bekommt.
**`Live-Ping` ist die wichtigste der vier.** Ein Rundruf an alle bei jedem
Stream ist der schnellste Weg, dass die Leute den Server stummschalten - und
ein stummgeschalteter Server ist ein toter Server. Angepingt wird nur, wer sich
die Rolle selbst geholt hat. Das sind weniger Leute, aber es sind die, die
kommen.
### Der Knopf fuer Live-Ping
`Live-Ping` nuetzt nur, wenn man sie sich selbst geben kann. Discord bietet
dafuer **Onboarding** an, aber das laesst sich hier nicht einschalten:
```
HTTP 400 Cannot enable onboarding, requirements are not met (350000)
sichtbar fuer @everyone: 5 davon beschreibbar: 3
Discord verlangt: 7 davon beschreibbar: 5
```
Zwei Kanaele anzulegen, damit eine Schwelle passt, macht den Server leerer
statt besser - genau das, wogegen der ganze Entwurf gebaut ist. Also
stattdessen ein **Knopf unter dem Willkommenstext**. Ein Druck holt die Rolle,
der naechste gibt sie wieder ab, und die Antwort sieht nur, wer geklickt hat.
Der Knopf braucht einen laufenden Bot: Discord liefert Klicks entweder ans
Gateway oder an eine oeffentlich erreichbare HTTPS-Adresse, und der Stream-PC
hat keine. Die Gateway-Verbindung haengt deshalb im Ingest-Daemon mit drin
([`gateway.ts`](../../packages/discord/src/gateway.ts)) und meldet sich als
`discordConnected` auf der Statusseite. Zum Ausprobieren einzeln:
```powershell
abdulive discord:bot
```
Die Verbindung fordert **keine** Absichten an (`intents: 0`). Interaktionen
kommen trotzdem an, aber Nachrichteninhalte und Mitgliederlisten sieht der Bot
nicht - was man nicht anfordert, kann man auch nicht versehentlich mitlesen.
Welche Knoepfe es gibt, steht in `SELF_ROLES` in `plan.ts`. Die `custom_id`
steht dauerhaft in der Nachricht und darf sich nie aendern, sonst tun alte
Knoepfe nichts mehr.
**`Stammgast`** hat fast keine zusaetzlichen Rechte, und das ist Absicht. Der
Wert liegt darin, dass der Name farbig ist und in der Mitgliederliste oben
steht. Bei dieser Kanalgroesse ist sichtbare Zugehoerigkeit der wirksamste
Hebel, den es gibt, und er kostet nichts.
**`Mod`** bekommt kein `Server verwalten`, kein `Rollen verwalten` und kein
`Kanaele verwalten`. Moderation heisst aufraeumen, nicht umbauen. Wer umbauen
soll, bekommt das Recht einzeln.
### Was @everyone darf
Gesetzt wird eine vollstaendige Liste statt Discords Vorgabe - die aendert sich
mit der Zeit, und dann steht irgendwann ein Recht drin, das niemand vergeben
hat. Ausdruecklich **nicht** enthalten: `@everyone` erwaehnen, TTS-Nachrichten,
private Threads, saemtliche Verwaltungsrechte.
Ausdruecklich **enthalten**: Einladungen erstellen. Ein Server, in den
Mitglieder niemanden einladen koennen, waechst nur so schnell, wie Abdu selbst
Links verteilt.
### Serverweit
- **Sicherheitsstufe „mittel"** - das Konto muss aelter als fuenf Minuten sein.
Kostet echte Leute nichts und haelt Wegwerf-Konten draussen. Ein oeffentlich
von Twitch aus verlinkter Server bekommt die.
- **Benachrichtigungen nur bei Erwaehnung.** Sonst klingelt jede Nachricht bei
jedem. Siehe oben, stummgeschalteter Server.
- **Zwei AutoMod-Regeln**: Erwaehnungs-Spam (mehr als fuenf auf einmal) und
Discords eigene Spam-Erkennung. Beide blocken die Nachricht und melden sie in
`🤖│technik-log`. Kein Wortfilter - der kostet bei dieser Groesse mehr Aerger
durch Fehlalarme, als er an Spam verhindert.
## Einrichten
### 1. Von Hand: Server und Bot
Beides geht nur ueber die Oberflaeche, ein Skript darf das nicht.
1. **Server anlegen.** In Discord links unten auf `+`, „Eigenen erstellen",
Name und Bild setzen.
2. **Entwicklermodus einschalten** (Einstellungen > Erweitert), dann Rechtsklick
auf den Server > „Server-ID kopieren".
3. **App anlegen** unter <https://discord.com/developers/applications>:
„New Application", dann links „Bot". Dort „Reset Token", Token kopieren.
Er wird genau einmal angezeigt.
4. **Bot einladen.** Client-ID aus „General Information" einsetzen:
```
https://discord.com/oauth2/authorize?client_id=DEINE_CLIENT_ID&permissions=805375024&scope=bot
```
Die Zahl sind genau die sieben Rechte, die das Skript braucht: Kanaele,
Rollen, Webhooks und Server verwalten, lesen, schreiben, Verlauf lesen.
Kein Administrator.
### 2. Der Befehl
```powershell
abdulive discord:setup --server <SERVER-ID> --vorschau
```
Die Vorschau liest den Server aus und zeigt Zeile fuer Zeile, was passieren
wuerde. Sie schreibt nichts. Sieht das gut aus, dasselbe ohne `--vorschau`:
```powershell
abdulive discord:setup --server <SERVER-ID>
```
Der Bot-Token wird beim ersten Lauf abgefragt (die Eingabe wird nicht
angezeigt) und danach zusammen mit der Server-ID verschluesselt abgelegt -
siehe [ADR-0005](../decisions/ADR-0005-zugangsdaten.md). Ab dem zweiten Lauf
genuegt `abdulive discord:setup`.
Die drei Webhook-URLs landet das Skript direkt im selben Speicher, unter
`goLiveWebhook`, `recapWebhook` und `clipsWebhook`. Sie werden nie ausgegeben
und nie geloggt - Broadcaster, Recap und Clipper lesen sie von dort.
### 3. Von Hand: der Rest
Drei Dinge kann die API nicht oder nicht sinnvoll:
1. **Dir selbst `Abdu` geben** und die Bot-Rolle in den Servereinstellungen
ueber `Abdu` ziehen. Eine Rolle kann nur Rollen unterhalb der eigenen
verschieben - auch mit Administrator. Discord haengt die Bot-Rolle beim
Einladen ganz unten ein, dort ist also kein Platz fuer vier Rollen darunter.
Bis das erledigt ist, meldet das Skript die Rangfolge als uebersprungen und
nennt den Grund.
2. **Einladungslink** erzeugen, ohne Ablaufdatum, und ins Twitch-Panel, in die
Kanalinfo und in einen Chat-Befehl eintragen. Ein Server, den im Stream
niemand erwaehnt, bleibt leer.
3. **Twitch-Konto verbinden** (Servereinstellungen > Integrationen). Danach
bekommen Subs automatisch eine Rolle.
Onboarding steht bewusst nicht in der Liste - warum, steht oben unter
[Der Knopf fuer Live-Ping](#der-knopf-fuer-live-ping).
## Was das Skript nicht tut
**Es loescht nichts.** Keine Kanaele, keine Rollen, keine Nachrichten. Ein
Einrichtungsskript, das aufraeumt, raeumt irgendwann das Falsche weg. Was von
Hand dazugekommen ist, bleibt stehen - auch eigene Rechte-Ausnahmen in einem
Kanal, den das Skript anfasst.
**Es ueberschreibt den Willkommenstext nicht.** Gepostet wird nur, wenn
`#willkommen` leer ist. Danach gehoert der Text der Hand, die ihn zuletzt
angefasst hat.
**Es laesst von Discord verwaltete Rollen in Ruhe.** Rollen, die zu einer App
oder zur Twitch-Anbindung gehoeren, meldet es als uebersprungen.
Wiederholtes Ausfuehren ist der Normalfall: gefunden wird ueber den Namen,
danach wird angeglichen statt neu angelegt. Der zweite Lauf aendert nichts.
## Wann ein Kanal dazukommt
Nicht auf Verdacht. Jeweils erst, wenn die Bedingung wirklich eintritt:
| Neuer Kanal | Erst wenn |
|---|---|
| `#vorschlaege` | In `#allgemein` gehen Vorschlaege zwischen anderem unter |
| `#fotos` oder `#offtopic` | `#allgemein` besteht ueberwiegend aus einem Thema, das nicht der Stream ist |
| Spiel-eigener Kanal | Zu einem Spiel gibt es ueber Wochen taeglich Gespraech |
| Zweiter Sprachkanal | Der erste ist regelmaessig belegt, wenn jemand dazu will |
| Forum statt `#clips` | Mehr als etwa zehn Clips pro Woche |
Umgekehrt gilt genauso: ein Kanal, in dem vier Wochen nichts steht, kommt weg.
## Fallstricke
- **Die Bot-Rolle steht anfangs unten.** Discord haengt sie beim Einladen
unterhalb bestehender Rollen ein. Dann schlaegt das Setzen der Rangfolge
fehl. Das Skript bricht deswegen nicht ab, sondern meldet es - nachziehen und
noch einmal laufen lassen.
- **Von Hand angelegte Webhooks geben ihren Token nicht heraus.** Wer einen
Webhook in der Oberflaeche neu anlegt, bekommt eine URL, die das Skript nicht
auslesen kann. Es meldet ihn als uebersprungen; loeschen und noch einmal
laufen lassen loest es.
- **Kanalnamen lassen sich nur zweimal in zehn Minuten aendern.** Discord
verlangt danach bis zu neun Minuten Pause. Der Lauf wartet hoechstens eine
Minute, meldet den Kanal als uebersprungen und macht weiter - spaeter noch
einmal ausfuehren holt es nach.
- **Umbenennen statt verdoppeln.** Aendert sich ein Name im Bauplan, gehoert
der alte in `aliases`. Sonst findet das Skript den Kanal nicht wieder und
legt einen zweiten daneben an. Ein Test besteht darauf, dass jeder Eintrag
einen frueheren Namen hinterlegt hat.
- **Ankuendigungskanaele im Discord-Sinn** (die, denen andere Server folgen
koennen) brauchen einen Community-Server, und der braucht Regelkanal,
Update-Kanal und einige Einstellungen. Fuer diese Groesse bringt das nichts,
deshalb ist `#ankuendigungen` ein normaler Textkanal mit Schreibschutz.
- **Ein Ping ist keine Reichweite.** Wer `Live-Ping` nicht anbietet und
stattdessen `@everyone` benutzt, hat beim dritten Mal einen stummen Server.
## Abnahmekriterien
- `abdulive discord:setup --vorschau` laeuft auf einem frischen Server durch und
meldet keinen uebersprungenen Punkt.
- Nach dem echten Lauf existieren drei Kategorien, sieben Kanaele, vier Rollen
und drei Webhooks; `👋│willkommen` enthaelt genau eine Nachricht.
- Ein zweiter Lauf meldet ausschliesslich „steht" und legt nichts doppelt an.
- Ein Konto ohne Rolle sieht `Intern` nicht und kann in `#ankuendigungen` nicht
schreiben, aber einen Thread eroeffnen.
- Die Webhook-URLs stehen im verschluesselten Speicher und tauchen in keiner
Ausgabe und keinem Log auf.
- Der Knopf unter dem Willkommenstext vergibt `Live-Ping` und nimmt sie beim
zweiten Druck wieder weg; die Antwort sieht nur, wer geklickt hat.
+1
View File
@@ -10,6 +10,7 @@
"imports": { "imports": {
"#shared/*": "./packages/shared/src/*", "#shared/*": "./packages/shared/src/*",
"#core/*": "./packages/core/src/*", "#core/*": "./packages/core/src/*",
"#discord/*": "./packages/discord/src/*",
"#db/*": "./packages/db/src/*", "#db/*": "./packages/db/src/*",
"#twitch/*": "./packages/twitch/src/*", "#twitch/*": "./packages/twitch/src/*",
"#obs/*": "./packages/obs/src/*", "#obs/*": "./packages/obs/src/*",
+76 -1
View File
@@ -45,6 +45,41 @@ 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' | 'liegestuetze' | 'nichts';
freigabe?: boolean; maxZeichen?: number; anzahl?: number;
}>;
tts: {
freigabe: boolean; maxZeichen: number;
blockliste: string[]; proPersonProStunde: number;
/** Piper-Stimme: Name im Modellordner (models/piper/<name>.onnx) oder absoluter Pfad. */
stimme: string;
/** Sprecher-Nummer bei Stimmen mit mehreren Sprechern. Leer: der erste. */
sprecher?: 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 +116,41 @@ 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' | 'liegestuetze' | 'nichts';
freigabe?: boolean; maxZeichen?: number; anzahl?: 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,
stimme: 'de_DE-thorsten-medium',
},
},
overlay: { port: 4791, enabled: true },
publicUrl: '',
ingestServer: { port: 4790 }, ingestServer: { port: 4790 },
dashboard: { port: 4792 }, dashboard: { port: 4792 },
}; };
@@ -105,7 +175,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
@@ -53,3 +53,37 @@ test('ein Byte Order Mark verstellt nicht den ersten Schluessel', () => {
assert.deepEqual(y, { channel: { login: 'abdulive_' } }); assert.deepEqual(y, { channel: { login: 'abdulive_' } });
assert.ok(!Object.keys(y).some((k) => k.charCodeAt(0) === 0xfeff)); assert.ok(!Object.keys(y).some((k) => k.charCodeAt(0) === 0xfeff));
}); });
test('Listen aus Maps, wie in rewards.belohnungen', () => {
// Frueher zerfiel das in Zeichenketten wie "id: abc", und die Belohnung
// war ohne jede Meldung unwirksam.
const y = parseYaml([
'rewards:',
' enabled: true',
' belohnungen:',
' - id: abc-123',
' titel: Nachricht vorlesen',
' aktion: tts',
' - titel: Konfetti',
' aktion: konfetti',
' tts:',
' blockliste:',
' - sieg heil',
' - http',
' maxZeichen: 200',
].join('\n'));
assert.deepEqual(y, {
rewards: {
enabled: true,
belohnungen: [
{ id: 'abc-123', titel: 'Nachricht vorlesen', aktion: 'tts' },
{ titel: 'Konfetti', aktion: 'konfetti' },
],
tts: { blockliste: ['sieg heil', 'http'], maxZeichen: 200 },
},
});
});
test('Listeneintrag mit Doppelpunkt ohne Leerzeichen bleibt Text', () => {
assert.deepEqual(parseYaml('a:\n - http://x.de\n'), { a: ['http://x.de'] });
});
+16 -3
View File
@@ -1,8 +1,8 @@
// Minimaler YAML-Leser fuer unsere eigenen Konfigurationsdateien. // Minimaler YAML-Leser fuer unsere eigenen Konfigurationsdateien.
// //
// Deckt genau das ab, was config/*.yaml benutzt: verschachtelte Maps ueber // Deckt genau das ab, was config/*.yaml benutzt: verschachtelte Maps ueber
// Einrueckung, Skalare, Inline-Maps `{a: 1, b: 2}`, Listen aus Skalaren und // Einrueckung, Skalare, Inline-Maps `{a: 1, b: 2}`, Listen aus Skalaren oder
// Blockskalare (`>` / `|`). Bewusst kein vollstaendiges YAML - das Projekt // Maps (`- titel: X`) und Blockskalare (`>` / `|`). Bewusst kein vollstaendiges YAML - das Projekt
// kommt ohne npm-Abhaengigkeiten aus, und der Sprachumfang, den wir selbst // kommt ohne npm-Abhaengigkeiten aus, und der Sprachumfang, den wir selbst
// schreiben, ist ueberschaubar. // schreiben, ist ueberschaubar.
@@ -73,7 +73,20 @@ export function parseYaml(text: string): YamlMap {
if (body.startsWith('- ')) { if (body.startsWith('- ')) {
const key = frame.listKey; const key = frame.listKey;
const list = key ? frame.target[key] : undefined; const list = key ? frame.target[key] : undefined;
if (Array.isArray(list)) list.push(scalar(body.slice(2))); const item = body.slice(2);
// `- titel: X` beginnt eine Map als Listeneintrag. Die Zeile wird mit
// der Einrueckung ihres Inhalts neu gelesen, die folgenden, tiefer
// eingerueckten Zeilen landen in derselben Map. Ohne das zerfiel
// `rewards.belohnungen` in einzelne Zeichenketten, und jede Belohnung
// war ohne Fehlermeldung unwirksam.
if (Array.isArray(list) && /^[^\s'"{[#-][^:]*:(\s|$)/.test(item)) {
const map: YamlMap = {};
list.push(map);
stack.push({ indent, target: map });
lines[i] = ' '.repeat(indent + 2) + item;
continue;
}
if (Array.isArray(list)) list.push(scalar(item));
i++; i++;
continue; continue;
} }
+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);
+8
View File
@@ -0,0 +1,8 @@
-- Zaehler, die einen Neustart ueberleben muessen. Erster Fall: offene
-- Liegestuetze aus Kanalpunkten. Wer 2000 Punkte ausgegeben hat, will nicht
-- erleben, dass ein Update des Hubs seine Liegestuetze verschluckt.
CREATE TABLE zaehler (
name TEXT PRIMARY KEY,
wert INTEGER NOT NULL,
geaendert TEXT NOT NULL
);
+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[];
+36
View File
@@ -0,0 +1,36 @@
# @abdulive/discord
Discord-Anbindung: ein kleiner REST-Client und das Einrichten des
Community-Servers.
| Datei | Inhalt |
|---|---|
| `src/permissions.ts` | Rechtebits als `BigInt` (das Feld ist 64 Bit breit) |
| `src/api.ts` | REST-Client: Warteschlange, Rate Limits, lesbare Fehler |
| `src/plan.ts` | **Der Bauplan**: Rollen, Kanaele, Rechte, Servereinstellungen |
| `src/apply.ts` | Traegt den Bauplan auf einen bestehenden Server auf |
| `src/gateway.ts` | WebSocket-Verbindung fuer Klicks auf Knoepfe |
| `src/selfroles.ts` | Der Knopf, der `Live-Ping` gibt und wieder nimmt |
| `src/bot.ts` | Beides zusammen; laeuft im Ingest-Daemon mit |
Einrichten geschieht einmalig ueber `apply.ts`, der Betrieb laeuft ueber
`bot.ts`. Beide lesen denselben Bauplan.
Aufruf ueber die CLI:
```powershell
abdulive discord:setup --server <ID> --vorschau # zeigt nur, was passieren wuerde
abdulive discord:setup --server <ID> # wendet es an
```
Und der laufende Teil, der die Knoepfe bedient:
```powershell
abdulive discord:bot # zum Ausprobieren; sonst im Ingest-Daemon
```
Wiederholbar: gefunden wird ueber den Namen, danach wird angeglichen statt neu
angelegt. Geloescht wird nie.
Begruendung der Struktur, die Schritte, die von Hand gehoeren, und die
Fallstricke: [docs/projects/discord-community.md](../../docs/projects/discord-community.md)
+186
View File
@@ -0,0 +1,186 @@
// Discord-REST-Client. Nur das, was fuers Einrichten und fuers Posten
// gebraucht wird - kein Gateway, keine Bibliothek.
//
// Discord drosselt pro Route mit eigenen Eimern. Wir schicken die Anfragen
// nacheinander und werten `X-RateLimit-Remaining` aus: sinkt es auf 0, warten
// wir `X-RateLimit-Reset-After` ab, bevor die naechste Anfrage rausgeht. Das
// ist bei ~25 Anfragen pro Lauf reichlich und spart uns eine Eimerverwaltung.
import { logger } from '#core/logger.ts';
import { retry, sleep } from '#core/retry.ts';
const log = logger('discord');
const BASE = 'https://discord.com/api/v10';
/** Laenger warten wir bei einem 429 nicht - dann wird der Schritt gemeldet. */
const MAX_WAIT_MS = 60_000;
export type RestError = Error & { status: number; code: number };
function restError(status: number, code: number, message: string): RestError {
const err = new Error(message) as RestError;
err.status = status;
err.code = code;
return err;
}
/**
* Fehlertexte von Discord sind verschachtelt:
* `{ code: 50013, message: "Missing Permissions", errors: { name: { _errors: [...] } } }`
* Wir ziehen die tiefen Feldfehler raus, sonst steht da nur "Invalid Form Body".
*/
function describe(body: unknown): { code: number; message: string } {
if (!body || typeof body !== 'object') return { code: 0, message: '' };
const b = body as { code?: number; message?: string; errors?: unknown };
const parts: string[] = [];
const walk = (node: unknown, path: string): void => {
if (!node || typeof node !== 'object') return;
const n = node as { _errors?: Array<{ message?: string }> };
if (Array.isArray(n._errors)) {
for (const e of n._errors) parts.push(`${path}: ${e.message ?? ''}`.trim());
return;
}
for (const [k, v] of Object.entries(node)) walk(v, path ? `${path}.${k}` : k);
};
walk(b.errors, '');
const detail = parts.length ? ` (${parts.join('; ')})` : '';
return { code: b.code ?? 0, message: `${b.message ?? 'Unbekannter Fehler'}${detail}` };
}
export class Discord {
private token: string;
private queue: Promise<unknown> = Promise.resolve();
/** Zeitpunkt, ab dem wieder gesendet werden darf (ms seit Epoche). */
private notBefore = 0;
constructor(botToken: string) {
if (!botToken) throw new Error('Kein Discord-Bot-Token angegeben.');
this.token = botToken;
}
/** Reiht die Anfrage ein - es laeuft immer nur eine gleichzeitig. */
private run<T>(fn: () => Promise<T>): Promise<T> {
const next = this.queue.then(fn, fn);
// Fehler duerfen die Kette nicht vergiften, der Aufrufer bekommt sie.
this.queue = next.catch(() => undefined);
return next;
}
async request<T>(method: string, path: string, body?: unknown, reason?: string): Promise<T> {
return this.run(() => retry(async () => {
const wait = this.notBefore - Date.now();
if (wait > 0) await sleep(wait);
const res = await fetch(`${BASE}${path}`, {
method,
headers: {
Authorization: `Bot ${this.token}`,
'User-Agent': 'abdulive-toolkit (https://git.zeasy.dev/ZeasyAlex/abdulive, 0.1.0)',
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
// Landet im Audit-Log des Servers - so ist spaeter nachvollziehbar,
// woher eine Rolle oder ein Kanal kam.
...(reason ? { 'X-Audit-Log-Reason': encodeURIComponent(reason) } : {}),
},
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
});
const remaining = Number(res.headers.get('x-ratelimit-remaining') ?? '1');
const resetAfter = Number(res.headers.get('x-ratelimit-reset-after') ?? '0');
if (remaining <= 0 && resetAfter > 0) this.notBefore = Date.now() + resetAfter * 1000 + 50;
if (res.status === 429) {
const info = await res.json().catch(() => ({})) as { retry_after?: number; global?: boolean };
const retryAfter = Math.max(1, (info.retry_after ?? 1)) * 1000 + 100;
// Umbenennen eines Kanals ist auf zwei je zehn Minuten begrenzt. Dort
// verlangt Discord schon mal neun Minuten Pause - so lange darf ein
// Einrichtungslauf nicht stehen. Lieber melden und weitermachen.
if (retryAfter > MAX_WAIT_MS) {
const err = restError(429, 0,
`${method} ${path}: Discord verlangt ${Math.round(retryAfter / 1000)} s Pause ` +
'(bei Kanalnamen sind nur zwei Aenderungen je zehn Minuten erlaubt) - spaeter erneut ausfuehren');
throw Object.assign(err, { final: true });
}
this.notBefore = Date.now() + retryAfter;
log.warn(`Rate Limit${info.global ? ' (global)' : ''} auf ${method} ${path}, warte ${Math.round(retryAfter)} ms`);
throw new Error('Rate Limit erreicht');
}
if (res.status === 204) return undefined as T;
const text = await res.text();
const parsed: unknown = text ? JSON.parse(text) : null;
if (!res.ok) {
const { code, message } = describe(parsed);
// 401/403/404 sind Einrichtungsfehler und werden durch Wiederholen
// nicht besser - sofort durchreichen.
const err = restError(res.status, code, `${method} ${path}: HTTP ${res.status} ${message}`);
if (res.status === 401 || res.status === 403 || res.status === 404) throw Object.assign(err, { final: true });
throw err;
}
return parsed as T;
}, {
attempts: 4,
// `retry` bietet keinen Abbruch, aber `onRetry` laeuft innerhalb des
// catch-Blocks: wirft es, verlaesst der Fehler die Schleife sofort.
// So werden Einrichtungsfehler nicht viermal wiederholt.
onRetry: (err, n, ms) => {
if ((err as { final?: boolean }).final) throw err;
log.warn(`Wiederhole ${method} ${path} (${n}) in ${ms} ms`, { err: String(err) });
},
}));
}
get<T>(path: string): Promise<T> { return this.request<T>('GET', path); }
post<T>(path: string, body: unknown, reason?: string): Promise<T> { return this.request<T>('POST', path, body, reason); }
patch<T>(path: string, body: unknown, reason?: string): Promise<T> { return this.request<T>('PATCH', path, body, reason); }
put<T>(path: string, body?: unknown, reason?: string): Promise<T> { return this.request<T>('PUT', path, body, reason); }
del<T>(path: string, reason?: string): Promise<T> { return this.request<T>('DELETE', path, undefined, reason); }
/** Fuer die Gateway-Verbindung; verlaesst diese Klasse sonst nie. */
get botToken(): string { return this.token; }
}
/**
* Was apply.ts vom Client braucht. Als Typ statt als Klasse, damit die Tests
* einen nachgebauten Server unterschieben koennen, ohne ins Netz zu gehen.
*/
export type Rest = {
get<T>(path: string): Promise<T>;
post<T>(path: string, body: unknown, reason?: string): Promise<T>;
patch<T>(path: string, body: unknown, reason?: string): Promise<T>;
};
/** Zusaetzlich das, was der Bot im Betrieb braucht: Rollen setzen und loesen. */
export type RestFull = Rest & {
put<T>(path: string, body?: unknown, reason?: string): Promise<T>;
del<T>(path: string, reason?: string): Promise<T>;
};
// ---------------------------------------------------------------- Typen
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 ApiTag = { id?: string; name: string; emoji_id?: string | null; emoji_name: string | null; moderated: boolean };
export type ApiChannel = {
id: string; name: string; type: number; position: number; parent_id: string | null;
topic?: string | null; permission_overwrites?: ApiOverwrite[];
// Nur Foren.
available_tags?: ApiTag[];
default_reaction_emoji?: { emoji_id: string | null; emoji_name: string | null } | null;
};
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;
system_channel_id?: string | null;
};
export type ApiWebhook = { id: string; name: string; channel_id: string; url?: string; token?: string };
export type ApiUser = { id: string; username: string; bot?: boolean };
/** Kanaltypen, die hier vorkommen. */
export const CH = { TEXT: 0, VOICE: 2, CATEGORY: 4, FORUM: 15 } as const;
+544
View File
@@ -0,0 +1,544 @@
// Der ganze Durchlauf gegen einen nachgebauten Server. Kein Netz, kein Token.
//
// Die Frage, die hier beantwortet wird, ist nicht "kompiliert es", sondern:
// Was passiert beim zweiten Lauf? Ein Einrichtungsskript, das beim zweiten Mal
// alles doppelt anlegt, richtet mehr Schaden an, als es Arbeit spart.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { CH, type ApiChannel, type ApiGuild, type ApiOverwrite, type ApiRole, type ApiWebhook, type Rest } from './api.ts';
import { applyPlan, mergeTags, type Step } from './apply.ts';
import { CATEGORIES, CHANNELS, EVERYONE, ROLES } from './plan.ts';
import { str } from './permissions.ts';
const GUILD = '100';
/** Ein Discord-Server im Arbeitsspeicher. Kennt nur die Routen, die wir nutzen. */
class FakeDiscord implements Rest {
guild: ApiGuild;
channels: ApiChannel[] = [];
webhooks: ApiWebhook[] = [];
automod: Array<{ id: string; name: string; actions?: unknown[] }> = [];
messages = new Map<string, Array<{ id: string; content: string; author: { id: string }; components?: unknown[] }>>();
calls: string[] = [];
private next = 1000;
/** Platz der Bot-Rolle. Discord haengt sie beim Einladen auf 1. */
botRolePosition: number;
readonly botUserId = '5000';
readonly botRoleId = '5001';
constructor(opts: { botRolePosition?: number } = {}) {
this.botRolePosition = opts.botRolePosition ?? 8;
this.guild = {
id: GUILD, name: 'abdulive', owner_id: '1', features: [],
roles: [
{
id: GUILD, name: '@everyone', permissions: '0', position: 0,
color: 0, hoist: false, mentionable: false, managed: false,
},
{
id: this.botRoleId, name: 'abdulive_ Bot', permissions: '8',
position: this.botRolePosition, color: 0, hoist: false,
mentionable: false, managed: true,
},
],
};
}
private id(): string { return String(this.next++); }
async get<T>(path: string): Promise<T> {
this.calls.push(`GET ${path}`);
if (path === '/users/@me') return { id: this.botUserId, username: 'abdulive_ Bot', bot: true } as T;
if (path === `/guilds/${GUILD}/members/${this.botUserId}`) return { roles: [this.botRoleId] } as T;
if (path === `/guilds/${GUILD}/roles`) return structuredClone(this.guild.roles) as T;
if (path === `/guilds/${GUILD}`) return structuredClone(this.guild) as T;
if (path === `/guilds/${GUILD}/channels`) return structuredClone(this.channels) as T;
if (path === `/guilds/${GUILD}/auto-moderation/rules`) return structuredClone(this.automod) as T;
const wh = /^\/channels\/(\d+)\/webhooks$/.exec(path);
if (wh) return this.webhooks.filter((w) => w.channel_id === wh[1]) as T;
const msg = /^\/channels\/(\d+)\/messages\?limit=(\d+)$/.exec(path);
if (msg) return structuredClone(this.messages.get(msg[1]!) ?? []).slice(0, Number(msg[2])) as T;
throw new Error(`GET ${path} nicht nachgebaut`);
}
async post<T>(path: string, body: unknown): Promise<T> {
this.calls.push(`POST ${path}`);
const b = body as Record<string, never>;
if (path === `/guilds/${GUILD}/roles`) {
const role = { id: this.id(), position: 1, managed: false, ...b } as unknown as ApiRole;
this.guild.roles.push(role);
return structuredClone(role) as T;
}
if (path === `/guilds/${GUILD}/channels`) {
const ch = {
id: this.id(), position: this.channels.length, parent_id: null,
permission_overwrites: [], ...b,
} as unknown as ApiChannel;
this.channels.push(ch);
return structuredClone(ch) as T;
}
if (path === `/guilds/${GUILD}/auto-moderation/rules`) {
const src = body as Record<string, unknown>;
const rule = { id: this.id(), name: String(src['name']), actions: src['actions'] as unknown[] };
this.automod.push(rule);
return structuredClone(rule) as T;
}
const wh = /^\/channels\/(\d+)\/webhooks$/.exec(path);
if (wh) {
const made: ApiWebhook = { id: this.id(), name: String(b['name']), channel_id: wh[1]!, token: 'tok-' + this.id() };
this.webhooks.push(made);
return structuredClone(made) as T;
}
const msg = /^\/channels\/(\d+)\/messages$/.exec(path);
if (msg) {
const made = {
id: this.id(),
content: String((b as Record<string, unknown>)['content'] ?? ''),
author: { id: this.botUserId },
components: ((b as Record<string, unknown>)['components'] ?? []) as unknown[],
};
const list = this.messages.get(msg[1]!) ?? [];
list.push(made);
this.messages.set(msg[1]!, list);
return structuredClone(made) as T;
}
throw new Error(`POST ${path} nicht nachgebaut`);
}
async patch<T>(path: string, body: unknown): Promise<T> {
this.calls.push(`PATCH ${path}`);
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
for (const e of body as Array<{ id: string; position: number }>) {
const r = this.guild.roles.find((x) => x.id === e.id);
if (!r) continue;
if (e.position >= this.botRolePosition) throw new Error('Missing Permissions');
r.position = e.position;
}
return structuredClone(this.guild.roles) as T;
}
const role = /^\/guilds\/\d+\/roles\/(\d+)$/.exec(path);
if (role) {
const r = this.guild.roles.find((x) => x.id === role[1]);
if (r) Object.assign(r, body);
return structuredClone(r) as T;
}
const rule = /^\/guilds\/\d+\/auto-moderation\/rules\/(\d+)$/.exec(path);
if (rule) {
const r = this.automod.find((x) => x.id === rule[1]);
if (r) Object.assign(r, body);
return structuredClone(r) as T;
}
const msg = /^\/channels\/(\d+)\/messages\/(\d+)$/.exec(path);
if (msg) {
const m = (this.messages.get(msg[1]!) ?? []).find((x) => x.id === msg[2]);
if (m) {
const src = body as Record<string, unknown>;
m.content = String(src['content'] ?? '');
m.components = (src['components'] ?? []) as unknown[];
}
return structuredClone(m) as T;
}
const ch = /^\/channels\/(\d+)$/.exec(path);
if (ch) {
const c = this.channels.find((x) => x.id === ch[1]);
if (c) Object.assign(c, body);
return structuredClone(c) as T;
}
throw new Error(`PATCH ${path} nicht nachgebaut`);
}
}
const run = (api: Rest, dryRun = false) =>
applyPlan(api, GUILD, { dryRun, saveSecrets: false });
/** Der Kanal aus dem Bauplan, so wie er im nachgebauten Server gelandet ist. */
const live = (api: FakeDiscord, key: string): ApiChannel | undefined => {
const plan = CHANNELS.find((c) => c.key === key);
assert.ok(plan, `key ${key} gibt es im Bauplan nicht`);
return api.channels.find((c) => c.name === plan.name && c.type === plan.type);
};
const katName = (key: string): string => {
const k = CATEGORIES.find((c) => c.key === key);
assert.ok(k);
return k.name;
};
const counts = (steps: Step[]): Record<string, number> => {
const out: Record<string, number> = {};
for (const s of steps) out[s.change] = (out[s.change] ?? 0) + 1;
return out;
};
// ---------------------------------------------------------------- Erstlauf
test('der erste Lauf legt Rollen, Kategorien, Kanaele und Webhooks an', async () => {
const api = new FakeDiscord();
const res = await run(api);
assert.equal(res.guild, 'abdulive');
for (const r of ROLES) {
assert.ok(api.guild.roles.find((x) => x.name === r.name), `Rolle ${r.name} fehlt`);
}
assert.equal(api.channels.filter((c) => c.type === CH.CATEGORY).length, 3);
for (const c of CHANNELS) {
assert.ok(api.channels.find((x) => x.name === c.name), `Kanal ${c.name} fehlt`);
}
assert.equal(res.webhooks, 4);
assert.equal(api.automod.length, 2);
assert.equal(counts(res.steps)['uebersprungen'] ?? 0, 0);
});
test('@everyone bekommt die Grundrechte aus dem Bauplan', async () => {
const api = new FakeDiscord();
await run(api);
const everyone = api.guild.roles.find((r) => r.id === GUILD);
assert.equal(everyone?.permissions, str(EVERYONE));
});
test('Kanaele haengen unter der richtigen Kategorie', async () => {
const api = new FakeDiscord();
await run(api);
const kat = (key: string) =>
api.channels.find((c) => c.name === katName(key) && c.type === CH.CATEGORY)?.id;
for (const plan of CHANNELS) {
const c = live(api, plan.key);
assert.equal(c?.parent_id, kat(plan.category!), `${plan.name} haengt falsch`);
}
});
test('der interne Bereich ist fuer @everyone gesperrt und fuer Mod offen', async () => {
const api = new FakeDiscord();
await run(api);
const intern = api.channels.find((c) => c.name === katName('intern'));
const mod = api.guild.roles.find((r) => r.name === 'Mod');
const ow = intern?.permission_overwrites ?? [];
const jeder = ow.find((o) => o.id === GUILD);
assert.ok(jeder && (BigInt(jeder.deny) & 1024n) === 1024n, 'VIEW_CHANNEL muss fuer @everyone gesperrt sein');
const fuerMod = ow.find((o) => o.id === mod?.id);
assert.ok(fuerMod && (BigInt(fuerMod.allow) & 1024n) === 1024n, 'Mod muss hineinsehen koennen');
});
test('die Begruessung wird gepostet und die Kanalnamen sind aufgeloest', async () => {
const api = new FakeDiscord();
await run(api);
const willkommen = live(api, 'willkommen');
const posts = api.messages.get(willkommen!.id) ?? [];
assert.equal(posts.length, 1);
assert.match(posts[0]!.content, /<#\d+> - alles andere/, 'die Kanalnamen muessen aufgeloest sein');
});
// ---------------------------------------------------------------- Zweitlauf
test('der zweite Lauf aendert nichts', async () => {
const api = new FakeDiscord();
await run(api);
const nachErstlauf = {
rollen: api.guild.roles.length,
kanaele: api.channels.length,
webhooks: api.webhooks.length,
automod: api.automod.length,
};
api.calls = [];
const res = await run(api);
assert.deepEqual({
rollen: api.guild.roles.length,
kanaele: api.channels.length,
webhooks: api.webhooks.length,
automod: api.automod.length,
}, nachErstlauf, 'beim zweiten Lauf wurde etwas doppelt angelegt');
// Kein POST ausser den Lesevorgaengen - nichts Neues, nichts Geaendertes.
const schreibend = api.calls.filter((c) => c.startsWith('POST') || (c.startsWith('PATCH') && !c.endsWith('/roles')));
assert.deepEqual(schreibend.filter((c) => c !== `PATCH /guilds/${GUILD}`), [],
`unerwartete Schreibzugriffe: ${schreibend.join(', ')}`);
assert.equal(counts(res.steps)['erstellt'] ?? 0, 0);
assert.equal(res.webhooks, 4, 'die bestehenden Webhook-URLs muessen wiedergefunden werden');
});
test('die Begruessung wird nicht ein zweites Mal gepostet', async () => {
const api = new FakeDiscord();
await run(api);
await run(api);
const willkommen = live(api, 'willkommen');
assert.equal((api.messages.get(willkommen!.id) ?? []).length, 1);
});
// ---------------------------------------------------------------- Vorschau
test('die Vorschau schreibt nichts', async () => {
const api = new FakeDiscord();
const res = await run(api, true);
assert.equal(api.guild.roles.length, 2, 'nur @everyone und die Bot-Rolle, die es schon gab');
assert.equal(api.channels.length, 0);
assert.equal(api.webhooks.length, 0);
assert.equal(api.automod.length, 0);
assert.deepEqual(api.calls.filter((c) => !c.startsWith('GET')), []);
assert.ok((counts(res.steps)['erstellt'] ?? 0) > 10, 'die Vorschau muss zeigen, was entstehen wuerde');
});
// ---------------------------------------------------------------- Bestand
test('von Hand angelegte Kanaele und Rechte bleiben stehen', async () => {
const api = new FakeDiscord();
api.channels.push({
id: '900', name: 'memes', type: CH.TEXT, position: 0, parent_id: null,
permission_overwrites: [{ id: '777', type: 1, allow: '2048', deny: '0' }],
});
api.channels.push({
id: '901', name: 'allgemein', type: CH.TEXT, position: 1, parent_id: null,
permission_overwrites: [{ id: '777', type: 1, allow: '0', deny: '2048' }],
});
await run(api);
assert.ok(api.channels.find((c) => c.name === 'memes'), 'ein fremder Kanal darf nicht verschwinden');
const allgemein = api.channels.find((c) => c.id === '901');
assert.equal(live(api, 'allgemein')?.id, '901', 'der bestehende Kanal muss umbenannt werden');
assert.equal(api.channels.filter((c) => c.name.includes('allgemein')).length, 1, 'nicht doppelt anlegen');
const fremd = (allgemein?.permission_overwrites ?? []).find((o: ApiOverwrite) => o.id === '777');
assert.deepEqual(fremd, { id: '777', type: 1, allow: '0', deny: '2048' },
'eine von Hand gesetzte Ausnahme darf nicht wegfallen');
});
test('ein von Hand angelegter Webhook wird gemeldet, nicht ueberschrieben', async () => {
const api = new FakeDiscord();
await run(api); // legt alles an
// Jemand loescht den Go-Live-Webhook und baut ihn in der Oberflaeche nach -
// dann fehlt uns der Token, und das muss auffallen statt still zu scheitern.
const i = api.webhooks.findIndex((w) => w.name === 'Go-Live');
delete api.webhooks[i]!.token;
const res = await run(api);
const step = res.steps.find((s) => s.kind === 'Webhook' && s.name.startsWith('Go-Live'));
assert.equal(step?.change, 'uebersprungen');
assert.match(step?.note ?? '', /nicht auslesbar/);
assert.equal(res.webhooks, 3, 'die drei uebrigen muessen trotzdem durchkommen');
});
test('eine von Discord verwaltete Rolle wird in Ruhe gelassen', async () => {
const api = new FakeDiscord();
// Die Twitch-Anbindung legt eigene Rollen an. Die gehoeren Discord.
api.guild.roles.push({
id: '800', name: 'Stammgast', permissions: '0', position: 2,
color: 0, hoist: false, mentionable: false, managed: true,
});
const res = await run(api);
const step = res.steps.find((s) => s.kind === 'Rolle' && s.name === 'Stammgast');
assert.equal(step?.change, 'uebersprungen');
assert.equal(api.guild.roles.find((r) => r.id === '800')?.permissions, '0', 'unveraendert geblieben');
});
test('scheitert die Rangfolge, laeuft der Rest trotzdem durch', async () => {
const api = new FakeDiscord();
const echt = api.patch.bind(api);
api.patch = async <T,>(path: string, body: unknown): Promise<T> => {
if (path === `/guilds/${GUILD}/roles`) throw new Error('Missing Permissions');
return echt<T>(path, body);
};
const res = await run(api);
const step = res.steps.find((s) => s.name === 'Rangfolge');
assert.equal(step?.change, 'uebersprungen');
assert.equal(api.channels.length, 3 + CHANNELS.length, 'die Kanaele muessen trotzdem entstehen');
});
// ---------------------------------------------------------------- Rangfolge
test('die Rollen landen unter der Bot-Rolle, in der Reihenfolge des Bauplans', async () => {
const api = new FakeDiscord({ botRolePosition: 8 });
await run(api);
const pos = (name: string) => api.guild.roles.find((r) => r.name === name)?.position;
assert.deepEqual(ROLES.map((r) => pos(r.name)), [7, 6, 5, 4]);
for (const r of ROLES) {
assert.ok(pos(r.name)! < api.botRolePosition, `${r.name} steht ueber der Bot-Rolle`);
}
});
test('Bot-Rolle ganz unten: Rangfolge wird gemeldet statt zu scheitern', async () => {
// Der reale Fall direkt nach dem Einladen. Discord haengt die Bot-Rolle auf
// Platz 1, darunter ist kein Platz - frueher gab das ein Missing Permissions.
const api = new FakeDiscord({ botRolePosition: 1 });
const res = await run(api);
const step = res.steps.find((s) => s.name === 'Rangfolge');
assert.equal(step?.change, 'uebersprungen');
assert.match(step?.note ?? '', /nach oben ziehen/);
// Alles andere muss trotzdem stehen.
assert.equal(api.channels.length, 3 + CHANNELS.length);
assert.equal(res.webhooks, 4);
});
// ---------------------------------------------------------------- Umbenennen
test('ein Kanal unter altem Namen wird umbenannt, nicht verdoppelt', async () => {
const api = new FakeDiscord();
// So sah der Server nach dem ersten Lauf ohne Emoji aus.
const alt = new Map<string, string>();
CHANNELS.forEach((plan, i) => {
const id = String(900 + i); // der Fake erwartet Ziffern-IDs
alt.set(plan.key, id);
api.channels.push({
id, name: plan.aliases![0]!, type: plan.type,
position: 0, parent_id: null, permission_overwrites: [],
});
});
const res = await run(api);
for (const plan of CHANNELS) {
assert.equal(live(api, plan.key)?.id, alt.get(plan.key), `${plan.key} wurde neu angelegt statt umbenannt`);
}
assert.equal(api.channels.filter((c) => c.type !== CH.CATEGORY).length, CHANNELS.length,
'es darf kein Kanal dazugekommen sein');
assert.ok(res.steps.some((s) => s.name.includes('->')), 'die Umbenennung muss im Bericht stehen');
});
test('haengt Discord beim Umbenennen im Rate Limit, laeuft der Rest weiter', async () => {
const api = new FakeDiscord();
api.channels.push({
id: '901', name: 'allgemein', type: CH.TEXT, position: 0, parent_id: null,
permission_overwrites: [],
});
const echt = api.patch.bind(api);
api.patch = async <T,>(path: string, body: unknown): Promise<T> => {
if (path === '/channels/901') throw new Error('Discord verlangt 530 s Pause');
return echt<T>(path, body);
};
const res = await run(api);
const step = res.steps.find((s) => s.name.includes('allgemein'));
assert.equal(step?.change, 'uebersprungen');
assert.match(step?.note ?? '', /530 s/);
// Die uebrigen Kanaele und die Webhooks muessen trotzdem entstehen.
assert.equal(res.webhooks, 4);
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 () => {
const api = new FakeDiscord();
await run(api);
const regel = api.automod[0]!;
(regel.actions as Array<{ type: number; metadata: { custom_message: string } }>)[0]!
.metadata.custom_message = 'alter Text';
const res = await run(api);
const step = res.steps.find((s) => s.kind === 'AutoMod' && s.name === regel.name);
assert.equal(step?.change, 'angepasst');
assert.match(step?.note ?? '', /actions/);
assert.equal(api.automod.length, 2, 'die Regel darf nicht neu angelegt werden');
});
test('der Meldekanal einer bestehenden Regel bleibt, wie er gesetzt wurde', async () => {
const api = new FakeDiscord();
await run(api);
const regel = api.automod[0]!;
const actions = regel.actions as Array<{ type: number; metadata: Record<string, string> }>;
actions[0]!.metadata['custom_message'] = 'alter Text';
actions[1]!.metadata['channel_id'] = '424242'; // jemand hat umgestellt
await run(api);
const danach = (api.automod[0]!.actions as typeof actions).find((a) => a.type === 2);
assert.equal(danach?.metadata['channel_id'], '424242', 'die eigene Wahl darf nicht ueberschrieben werden');
});
test('der Willkommenstext bekommt die Knopfleiste', async () => {
const api = new FakeDiscord();
await run(api);
const post = api.messages.get(live(api, 'willkommen')!.id)![0]!;
const reihen = post.components as Array<{ type: number; components: Array<{ custom_id: string }> }>;
assert.equal(reihen.length, 1);
assert.equal(reihen[0]!.components[0]!.custom_id, 'rolle:live-ping');
});
test('eine Nachricht ohne Knopfleiste bekommt sie nachgereicht', async () => {
// So sieht der Willkommenstext aus, der vor den Knoepfen gepostet wurde.
const api = new FakeDiscord();
await run(api);
const kanal = live(api, 'willkommen')!.id;
api.messages.get(kanal)![0]!.components = [];
const res = await run(api);
const step = res.steps.find((s) => s.name === 'Willkommenstext');
assert.equal(step?.change, 'angepasst');
assert.equal((api.messages.get(kanal)![0]!.components as unknown[]).length, 1);
assert.equal(api.messages.get(kanal)!.length, 1, 'nicht neu posten');
});
// ---------------------------------------------------------------- Foren
test('ein Forum bekommt Tags und Standard-Reaktion aus dem Bauplan', async () => {
const api = new FakeDiscord();
await run(api);
const plan = CHANNELS.find((c) => c.key === 'spielvorschlaege')!;
const forum = live(api, 'spielvorschlaege')!;
assert.equal(forum.type, CH.FORUM);
assert.deepEqual(forum.available_tags?.map((t) => t.name), plan.tags?.map((t) => t.name));
assert.equal(forum.default_reaction_emoji?.emoji_name, plan.reaction);
});
test('ein Textkanal gleichen Namens wird nicht fuer das Forum gehalten', async () => {
// Discord kann einen Textkanal nicht in ein Forum umwandeln. Der alte steht
// also daneben, bis ihn jemand loescht - angepasst werden darf er nicht.
const api = new FakeDiscord();
api.channels.push({ id: '1', name: '🎮│spielvorschläge', type: CH.TEXT, position: 0, parent_id: null });
await run(api);
assert.equal(api.channels.filter((c) => c.name === '🎮│spielvorschläge').length, 2);
assert.equal(api.channels.find((c) => c.id === '1')?.available_tags, undefined);
});
test('von Hand angelegte Tags bleiben mit ihrer ID stehen', async () => {
const fremd = { id: '77', name: 'Horror', emoji_name: '👻', moderated: false };
const eigen = { id: '78', name: 'gespielt', emoji_name: '✔️', moderated: false };
const out = mergeTags([fremd, eigen], [
{ name: 'Gespielt', emoji: '✅', moderated: true },
{ name: 'Eher nicht', emoji: '❌', moderated: true },
]);
assert.ok(out);
assert.deepEqual(out.find((t) => t.name === 'Horror'), fremd);
const g = out.find((t) => t.name === 'Gespielt')!;
assert.equal(g.id, '78', 'ohne ID verlieren die Beitraege ihre Markierung');
assert.equal(g.emoji_name, '✅');
assert.ok(out.find((t) => t.name === 'Eher nicht'));
assert.equal(mergeTags(out, [{ name: 'Gespielt', emoji: '✅', moderated: true }]), null);
});
+582
View File
@@ -0,0 +1,582 @@
// Traegt den Bauplan aus plan.ts auf einen bestehenden Server auf.
//
// Wiederholbar: gefunden wird ueber Name und frueheren Namen, danach wird
// angepasst statt neu angelegt. Zweimal laufen lassen aendert nichts. Was die
// Hand am Server zusaetzlich eingerichtet hat - eigene Kanaele, eigene
// Rechte-Ausnahmen - bleibt stehen; wir setzen nur, was im Plan steht.
//
// Was hier NICHT passiert: loeschen. Ein Einrichtungsskript, das Kanaele
// wegraeumt, ist ein Einrichtungsskript, das irgendwann den falschen wegraeumt.
import { logger } from '#core/logger.ts';
import { updateSecrets } from '#twitch/secrets.ts';
import {
CH, type ApiChannel, type ApiGuild, type ApiOverwrite, type ApiRole, type ApiTag, type ApiUser,
type ApiWebhook, type Rest,
} from './api.ts';
import { str } from './permissions.ts';
import { selfRoleComponents } from './selfroles.ts';
import {
AUTOMOD, AUTOMOD_BLOCK, CATEGORIES, CHANNELS, EVERYONE, GUILD_SETTINGS, ROLES, SYSTEM_CHANNEL, WELCOME,
type CategoryPlan, type ChannelPlan, type OverwritePlan, type TagPlan, type WebhookSlot,
} from './plan.ts';
const log = logger('discord:setup');
export type Change = 'erstellt' | 'angepasst' | 'unveraendert' | 'uebersprungen';
export type Step = { kind: string; name: string; change: Change; note?: string };
export type ApplyResult = {
guild: string;
steps: Step[];
/** Nur die Anzahl - die URLs selbst werden nie zurueckgegeben oder geloggt. */
webhooks: number;
};
export type ApplyOptions = {
/** Nur zeigen, was passieren wuerde. Schreibt nichts. */
dryRun?: boolean;
/** Webhook-URLs in den verschluesselten Speicher schreiben. */
saveSecrets?: boolean;
};
// ---------------------------------------------------------------- Helfer
const eq = (a: string, b: string): boolean => a.toLowerCase() === b.toLowerCase();
const byName = <T extends { name: string }>(list: T[], name: string): T | undefined =>
list.find((x) => eq(x.name, name));
/** Findet einen Kanal ueber den geplanten Namen oder einen frueheren Namen. */
export function findChannel(
existing: ApiChannel[], plan: { name: string; aliases?: string[]; type: number },
): ApiChannel | undefined {
for (const name of [plan.name, ...(plan.aliases ?? [])]) {
const hit = existing.find((c) => c.type === plan.type && eq(c.name, name));
if (hit) return hit;
}
return undefined;
}
/**
* Setzt die geplanten Rechte-Ausnahmen, laesst fremde stehen.
* Gibt null zurueck, wenn sich nichts aendert - dann sparen wir das PATCH.
*/
export function mergeOverwrites(
existing: ApiOverwrite[],
planned: OverwritePlan[],
roleId: (name: string) => string | undefined,
): ApiOverwrite[] | null {
const out = existing.map((o) => ({ ...o }));
let changed = false;
for (const p of planned) {
const id = roleId(p.role);
if (!id) continue; // Rolle gibt es (noch) nicht
const allow = str(p.allow ?? 0n);
const deny = str(p.deny ?? 0n);
const found = out.find((o) => o.id === id && o.type === 0);
if (!found) {
out.push({ id, type: 0, allow, deny });
changed = true;
} else if (found.allow !== allow || found.deny !== deny) {
found.allow = allow;
found.deny = deny;
changed = true;
}
}
return changed ? out : null;
}
/**
* Discord normalisiert Kanalnamen: Textkanaele werden kleingeschrieben und
* Leerzeichen werden zu Bindestrichen. Emoji, Umlaute und das Trennzeichen
* U+2502 bleiben stehen. Wer das nicht mitrechnet, schickt bei jedem Lauf
* dieselbe Umbenennung erneut - und laeuft in das Limit von zwei
* Umbenennungen je Kanal und zehn Minuten.
*/
export function normalizeName(name: string, type: number): string {
if (type !== CH.TEXT) return name;
return name.toLowerCase().replace(/\s+/g, '-');
}
// ---------------------------------------------------------------- Rollen
/**
* Wohin die Rollen in der Rangfolge duerfen.
*
* Eine Rolle kann nur Rollen unterhalb der eigenen bearbeiten - auch mit
* Administrator. Die Rolle, die der Bot mitbringt, haengt Discord beim
* Einladen ganz unten ein, dort ist also erst einmal kein Platz. Statt blind
* 3, 2, 1 zu verlangen und ein "Missing Permissions" zu kassieren, rechnen wir
* aus, was geht, und sagen sonst, was zu tun ist.
*/
export function rolePositions(
count: number, botTop: number,
): { positions: number[] } | { problem: string } {
// Frei sind die Plaetze 1 bis botTop-1; Platz 0 gehoert @everyone.
const room = botTop - 1;
if (room < count) {
return {
problem:
`Bot-Rolle steht auf Platz ${botTop}, darunter ist Platz fuer ${Math.max(0, room)} ` +
`von ${count} Rollen. In den Servereinstellungen unter "Rollen" die Bot-Rolle ` +
'nach oben ziehen und den Befehl erneut ausfuehren.',
};
}
return { positions: Array.from({ length: count }, (_, i) => botTop - 1 - i) };
}
async function applyRoles(
api: Rest, guild: ApiGuild, botUserId: string, steps: Step[], dry: boolean,
): Promise<Map<string, string>> {
const ids = new Map<string, string>();
const roles = [...guild.roles];
// @everyone traegt in Discord die ID des Servers.
ids.set('@everyone', guild.id);
const everyone = roles.find((r) => r.id === guild.id);
if (everyone && everyone.permissions !== str(EVERYONE)) {
if (!dry) await api.patch(`/guilds/${guild.id}/roles/${guild.id}`, { permissions: str(EVERYONE) }, 'Grundrechte aus dem Bauplan');
steps.push({ kind: 'Rolle', name: '@everyone', change: 'angepasst' });
} else {
steps.push({ kind: 'Rolle', name: '@everyone', change: 'unveraendert' });
}
for (const plan of ROLES) {
const found = byName(roles, plan.name);
const want = {
name: plan.name,
permissions: str(plan.permissions),
color: plan.color,
hoist: plan.hoist,
mentionable: plan.mentionable,
};
if (!found) {
if (dry) {
steps.push({ kind: 'Rolle', name: plan.name, change: 'erstellt', note: 'Vorschau' });
continue;
}
const made = await api.post<ApiRole>(`/guilds/${guild.id}/roles`, want, `Rolle aus dem Bauplan: ${plan.purpose}`);
ids.set(plan.name, made.id);
steps.push({ kind: 'Rolle', name: plan.name, change: 'erstellt' });
continue;
}
ids.set(plan.name, found.id);
const differs = found.permissions !== want.permissions || found.color !== want.color ||
found.hoist !== want.hoist || found.mentionable !== want.mentionable;
if (!differs) {
steps.push({ kind: 'Rolle', name: plan.name, change: 'unveraendert' });
continue;
}
if (found.managed) {
// Rollen, die zu einer App oder zur Twitch-Anbindung gehoeren, gehoeren
// Discord. Anfassen geht nicht und waere auch falsch.
steps.push({ kind: 'Rolle', name: plan.name, change: 'uebersprungen', note: 'von Discord verwaltet' });
continue;
}
if (!dry) await api.patch(`/guilds/${guild.id}/roles/${found.id}`, want, 'Rolle aus dem Bauplan angeglichen');
steps.push({ kind: 'Rolle', name: plan.name, change: 'angepasst' });
}
await applyRoleOrder(api, guild, botUserId, ids, steps, dry);
return ids;
}
async function applyRoleOrder(
api: Rest, guild: ApiGuild, botUserId: string, ids: Map<string, string>,
steps: Step[], dry: boolean,
): Promise<void> {
const wanted = ROLES.map((r) => ids.get(r.name)).filter((id): id is string => Boolean(id));
if (dry || wanted.length === 0) return;
let botTop: number;
let istPosition = new Map<string, number>();
try {
const member = await api.get<{ roles: string[] }>(`/guilds/${guild.id}/members/${botUserId}`);
const fresh = await api.get<ApiRole[]>(`/guilds/${guild.id}/roles`);
istPosition = new Map(fresh.map((r) => [r.id, r.position]));
botTop = fresh
.filter((r) => member.roles.includes(r.id))
.reduce((max, r) => Math.max(max, r.position), 0);
} catch (err) {
steps.push({ kind: 'Rollen', name: 'Rangfolge', change: 'uebersprungen', note: (err as Error).message });
return;
}
const fit = rolePositions(wanted.length, botTop);
if ('problem' in fit) {
steps.push({ kind: 'Rollen', name: 'Rangfolge', change: 'uebersprungen', note: fit.problem });
log.warn(fit.problem);
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 {
await api.patch(`/guilds/${guild.id}/roles`,
wanted.map((id, i) => ({ id, position: fit.positions[i] })), 'Rangfolge aus dem Bauplan');
steps.push({ kind: 'Rollen', name: 'Rangfolge', change: 'angepasst' });
} catch (err) {
steps.push({ kind: 'Rollen', name: 'Rangfolge', change: 'uebersprungen', note: (err as Error).message });
log.warn(`Rangfolge nicht gesetzt: ${(err as Error).message}`);
}
}
// ---------------------------------------------------------------- Kanaele
function kindOf(type: number): string {
if (type === CH.CATEGORY) return 'Kategorie';
if (type === CH.VOICE) return 'Sprachkanal';
return 'Kanal';
}
/** Kategorie und Kanal unterscheiden sich fuers Auftragen nur im Typ. */
type AnyPlan = CategoryPlan & { type: number; topic?: string; tags?: TagPlan[]; reaction?: string };
/**
* Tags eines Forums: die aus dem Plan nachziehen, fremde stehen lassen.
* Discord ersetzt beim PATCH die ganze Liste, deshalb gehen die vorhandenen
* mit ihrer ID wieder mit - ohne ID waere es ein neuer Tag, und Beitraege
* verloeren ihre Markierung. Gibt null zurueck, wenn alles passt.
*/
export function mergeTags(existing: ApiTag[], plan: TagPlan[]): ApiTag[] | null {
let changed = false;
const out = existing.map((t) => ({ ...t }));
for (const p of plan) {
const want = { name: p.name, emoji_name: p.emoji, moderated: p.moderated ?? false };
const hit = out.find((t) => eq(t.name, p.name));
if (!hit) {
out.push(want);
changed = true;
} else if (hit.name !== want.name || hit.emoji_name !== want.emoji_name || hit.moderated !== want.moderated) {
Object.assign(hit, want, { emoji_id: null });
changed = true;
}
}
return changed ? out : null;
}
async function applyChannel(
api: Rest, guild: ApiGuild, existing: ApiChannel[], steps: Step[], dry: boolean,
plan: AnyPlan,
parentId: string | null,
roleId: (name: string) => string | undefined,
): Promise<ApiChannel | null> {
const kind = kindOf(plan.type);
const found = findChannel(existing, plan);
if (!found) {
if (dry) {
steps.push({ kind, name: plan.name, change: 'erstellt', note: 'Vorschau' });
return null;
}
const overwrites: ApiOverwrite[] = (plan.overwrites ?? []).flatMap((p) => {
const id = roleId(p.role);
return id ? [{ id, type: 0 as const, allow: str(p.allow ?? 0n), deny: str(p.deny ?? 0n) }] : [];
});
const made = await api.post<ApiChannel>(`/guilds/${guild.id}/channels`, {
name: plan.name,
type: plan.type,
...(parentId ? { parent_id: parentId } : {}),
...(plan.topic ? { topic: plan.topic } : {}),
...(overwrites.length ? { permission_overwrites: overwrites } : {}),
...(plan.tags?.length ? { available_tags: mergeTags([], plan.tags) } : {}),
...(plan.reaction ? { default_reaction_emoji: { emoji_id: null, emoji_name: plan.reaction } } : {}),
}, 'Kanal aus dem Bauplan');
steps.push({ kind, name: plan.name, change: 'erstellt' });
return made;
}
const patch: Record<string, unknown> = {};
const rename = !eq(found.name, normalizeName(plan.name, plan.type));
if (rename) patch['name'] = plan.name;
if (plan.topic && (found.topic ?? '') !== plan.topic) patch['topic'] = plan.topic;
if (parentId && found.parent_id !== parentId) patch['parent_id'] = parentId;
const merged = mergeOverwrites(found.permission_overwrites ?? [], plan.overwrites ?? [], roleId);
if (merged) patch['permission_overwrites'] = merged;
const tags = plan.tags?.length ? mergeTags(found.available_tags ?? [], plan.tags) : null;
if (tags) patch['available_tags'] = tags;
if (plan.reaction && found.default_reaction_emoji?.emoji_name !== plan.reaction) {
patch['default_reaction_emoji'] = { emoji_id: null, emoji_name: plan.reaction };
}
if (Object.keys(patch).length === 0) {
steps.push({ kind, name: plan.name, change: 'unveraendert' });
return found;
}
const label = rename ? `${found.name} -> ${plan.name}` : plan.name;
if (dry) {
steps.push({ kind, name: label, change: 'angepasst', note: `Vorschau: ${Object.keys(patch).join(', ')}` });
return found;
}
try {
await api.patch(`/channels/${found.id}`, patch, 'Kanal aus dem Bauplan angeglichen');
} catch (err) {
// Umbenennungen sind bei Discord auf zwei je Kanal und zehn Minuten
// begrenzt. Das darf den Rest des Laufs nicht aufhalten.
steps.push({ kind, name: label, change: 'uebersprungen', note: (err as Error).message });
log.warn(`${label}: ${(err as Error).message}`);
return found;
}
steps.push({ kind, name: label, change: 'angepasst', note: Object.keys(patch).join(', ') });
return found;
}
// ---------------------------------------------------------------- Webhooks
async function applyWebhooks(
api: Rest, channel: ApiChannel, plan: ChannelPlan, steps: Step[], dry: boolean,
urls: Map<WebhookSlot, string>,
): Promise<void> {
if (!plan.webhooks?.length) return;
const existing = await api.get<ApiWebhook[]>(`/channels/${channel.id}/webhooks`);
for (const w of plan.webhooks) {
const found = byName(existing, w.name);
if (found?.token) {
urls.set(w.slot, `https://discord.com/api/webhooks/${found.id}/${found.token}`);
steps.push({ kind: 'Webhook', name: `${w.name} in ${plan.name}`, change: 'unveraendert' });
continue;
}
if (found && !found.token) {
// Von Hand angelegte Webhooks geben ihren Token nicht an uns heraus.
steps.push({
kind: 'Webhook', name: `${w.name} in ${plan.name}`, change: 'uebersprungen',
note: 'von Hand angelegt, URL nicht auslesbar - umbenennen oder loeschen',
});
continue;
}
if (dry) {
steps.push({ kind: 'Webhook', name: `${w.name} in ${plan.name}`, change: 'erstellt', note: 'Vorschau' });
continue;
}
const made = await api.post<ApiWebhook>(`/channels/${channel.id}/webhooks`, { name: w.name }, 'Webhook aus dem Bauplan');
if (made.token) urls.set(w.slot, `https://discord.com/api/webhooks/${made.id}/${made.token}`);
steps.push({ kind: 'Webhook', name: `${w.name} in ${plan.name}`, change: 'erstellt' });
}
}
// ---------------------------------------------------------------- AutoMod
/**
* Die Aktionen einer Regel: Nachricht blocken, und wenn es den Log-Kanal gibt,
* dort melden. Ist die Regel schon da, behalten wir ihren bestehenden
* Meldekanal - den kann jemand bewusst umgestellt haben.
*/
function actionsFor(
rule: { name: string },
logChannelId: string | null,
vorhanden: { actions?: Array<{ type: number; metadata?: { channel_id?: string } }> } | undefined,
): Array<Record<string, unknown>> {
const alterKanal = vorhanden?.actions?.find((a) => a.type === 2)?.metadata?.channel_id;
const ziel = alterKanal ?? logChannelId;
return [
{ type: 1, metadata: { custom_message: AUTOMOD_BLOCK } },
...(ziel ? [{ type: 2, metadata: { channel_id: ziel } }] : []),
];
}
async function applyAutomod(
api: Rest, guild: ApiGuild, logChannelId: string | null, steps: Step[], dry: boolean,
): Promise<void> {
type Action = { type: number; metadata?: { custom_message?: string; channel_id?: string } };
type Rule = { id: string; name: string; actions?: Action[] };
let existing: Rule[];
try {
existing = await api.get<Rule[]>(`/guilds/${guild.id}/auto-moderation/rules`);
} catch (err) {
steps.push({ kind: 'AutoMod', name: 'alle Regeln', change: 'uebersprungen', note: (err as Error).message });
return;
}
for (const rule of AUTOMOD) {
// Wie bei Kanaelen: erst unter dem geplanten Namen suchen, dann unter dem
// frueheren. Sonst steht nach einer Umbenennung dieselbe Regel zweimal da.
const found = [rule.name, ...(rule.aliases ?? [])]
.map((n) => byName(existing, n)).find((r) => r !== undefined);
if (found) {
const patch: Record<string, unknown> = {};
if (!eq(found.name, rule.name)) patch['name'] = rule.name;
// Der Blocktext steht im Bauplan, also gilt der Bauplan. Ohne diesen
// Abgleich behaelt eine einmal angelegte Regel ihren alten Text fuer immer.
const blockText = found.actions?.find((a) => a.type === 1)?.metadata?.custom_message;
if (blockText !== AUTOMOD_BLOCK) patch['actions'] = actionsFor(rule, logChannelId, found);
if (Object.keys(patch).length === 0) {
steps.push({ kind: 'AutoMod', name: rule.name, change: 'unveraendert' });
continue;
}
const label = patch['name'] ? `${found.name} -> ${rule.name}` : rule.name;
const was = Object.keys(patch).join(', ');
if (dry) {
steps.push({ kind: 'AutoMod', name: label, change: 'angepasst', note: `Vorschau: ${was}` });
continue;
}
await api.patch(`/guilds/${guild.id}/auto-moderation/rules/${found.id}`,
patch, 'AutoMod-Regel aus dem Bauplan angeglichen');
steps.push({ kind: 'AutoMod', name: label, change: 'angepasst', note: was });
continue;
}
if (dry) {
steps.push({ kind: 'AutoMod', name: rule.name, change: 'erstellt', note: 'Vorschau' });
continue;
}
await api.post(`/guilds/${guild.id}/auto-moderation/rules`, {
name: rule.name,
event_type: 1, // beim Senden pruefen
trigger_type: rule.trigger_type,
trigger_metadata: rule.trigger_metadata,
enabled: true,
actions: actionsFor(rule, logChannelId, undefined),
}, 'AutoMod-Regel aus dem Bauplan');
steps.push({ kind: 'AutoMod', name: rule.name, change: 'erstellt' });
}
}
// ---------------------------------------------------------------- Willkommen
/**
* Der Begruessungstext.
*
* Nachrichten von Menschen fasst das Skript nie an. Die eigene schon: bei
* Discord kann nur der Verfasser eine Nachricht bearbeiten, eine vom Bot
* gepostete kann also niemand sonst veraendert haben. So bleibt der Bauplan
* die massgebliche Fassung, auch wenn sich der Text spaeter noch aendert.
*/
async function applyWelcome(
api: Rest, channel: ApiChannel, botUserId: string, mentions: Map<string, string>,
steps: Step[], dry: boolean,
): Promise<void> {
const body = WELCOME.replace(/\{(\w[\w-]*)\}/g, (all, key: string) => {
const id = mentions.get(key);
return id ? `<#${id}>` : all;
});
const components = selfRoleComponents();
const payload = {
content: body,
components,
allowed_mentions: { parse: [] }, // der Begruessungstext pingt niemanden
};
type Msg = { id: string; content: string; author?: { id: string }; components?: unknown[] };
const seen = await api.get<Msg[]>(`/channels/${channel.id}/messages?limit=50`);
const own = seen.find((m) => m.author?.id === botUserId);
if (own) {
// Auch die Knopfleiste gehoert zum Soll-Zustand: fehlt sie, weil die
// Nachricht aus einer Zeit vor den Knoepfen stammt, wird sie nachgereicht.
const knoepfe = (own.components ?? []).length;
if (own.content === body && knoepfe === components.length) {
steps.push({ kind: 'Nachricht', name: 'Willkommenstext', change: 'unveraendert' });
return;
}
if (dry) {
steps.push({ kind: 'Nachricht', name: 'Willkommenstext', change: 'angepasst', note: 'Vorschau: Text' });
return;
}
await api.patch(`/channels/${channel.id}/messages/${own.id}`, payload);
steps.push({ kind: 'Nachricht', name: 'Willkommenstext', change: 'angepasst' });
return;
}
if (seen.length > 0) {
steps.push({
kind: 'Nachricht', name: 'Willkommenstext', change: 'uebersprungen',
note: 'im Kanal steht schon etwas, das nicht vom Bot ist',
});
return;
}
if (dry) {
steps.push({ kind: 'Nachricht', name: 'Willkommenstext', change: 'erstellt', note: 'Vorschau' });
return;
}
await api.post(`/channels/${channel.id}/messages`, payload);
steps.push({ kind: 'Nachricht', name: 'Willkommenstext', change: 'erstellt' });
}
// ---------------------------------------------------------------- Hauptlauf
export async function applyPlan(
api: Rest, guildId: string, opts: ApplyOptions = {},
): Promise<ApplyResult> {
const dry = opts.dryRun ?? false;
const steps: Step[] = [];
const guild = await api.get<ApiGuild>(`/guilds/${guildId}`);
const me = await api.get<ApiUser>('/users/@me');
log.info(`Server "${guild.name}"${dry ? ' (Vorschau, es wird nichts geaendert)' : ''}`);
// 1. Servereinstellungen. Vergleichen statt blind schreiben: sonst meldet
// jeder Lauf eine Aenderung, auch wenn es keine gab, und die Vorschau sagt
// 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
const roleIds = await applyRoles(api, guild, me.id, steps, dry);
const roleId = (name: string): string | undefined => roleIds.get(name);
// 3. Kategorien, dann Kanaele
const channels = await api.get<ApiChannel[]>(`/guilds/${guild.id}/channels`);
const categoryIds = new Map<string, string>();
for (const cat of CATEGORIES) {
const made = await applyChannel(api, guild, channels, steps, dry, { ...cat, type: CH.CATEGORY }, null, roleId);
if (made) categoryIds.set(cat.key, made.id);
}
const urls = new Map<WebhookSlot, string>();
const made = new Map<string, ApiChannel>();
for (const plan of CHANNELS) {
const parent = plan.category ? categoryIds.get(plan.category) ?? null : null;
const channel = await applyChannel(api, guild, channels, steps, dry, plan, parent, roleId);
if (!channel) continue;
made.set(plan.key, channel);
await applyWebhooks(api, channel, plan, steps, dry, urls);
}
// 4. Beitrittsmeldungen, AutoMod und Begruessung - alles braucht Kanaele,
// die es geben muss.
const system = made.get(SYSTEM_CHANNEL);
if (system && guild.system_channel_id !== system.id) {
if (!dry) await api.patch(`/guilds/${guild.id}`, { system_channel_id: system.id }, 'Beitrittsmeldungen aus dem Bauplan');
steps.push({ kind: 'Server', name: `Beitrittsmeldungen nach ${system.name}`, change: dry ? 'erstellt' : 'angepasst' });
}
await applyAutomod(api, guild, made.get('technik-log')?.id ?? null, steps, dry);
const welcome = made.get('willkommen');
if (welcome) {
const mentions = new Map([...made].map(([key, c]) => [key, c.id]));
await applyWelcome(api, welcome, me.id, mentions, steps, dry);
}
// 5. Webhook-URLs wegschliessen. Sie werden nie ausgegeben - siehe ADR-0005.
if (!dry && opts.saveSecrets !== false && urls.size > 0) {
updateSecrets((s) => {
s.discord = { ...s.discord, guildId: guild.id };
for (const [slot, url] of urls) s.discord[slot] = url;
});
log.info(`${urls.size} Webhook-URLs im verschluesselten Speicher abgelegt`);
}
return { guild: guild.name, steps, webhooks: urls.size };
}
+44
View File
@@ -0,0 +1,44 @@
// Der laufende Teil der Discord-Anbindung: haelt die Gateway-Verbindung und
// beantwortet Klicks auf die Rollen-Knoepfe.
//
// Laeuft im Ingest-Daemon mit. Ohne hinterlegtes Bot-Token passiert nichts -
// das ist kein Fehler, sondern der Zustand vor der Einrichtung.
import { logger } from '#core/logger.ts';
import { readSecrets } from '#twitch/secrets.ts';
import { Discord } from './api.ts';
import { createGateway } from './gateway.ts';
import { createRoleButtons } from './selfroles.ts';
const log = logger('discord:bot');
export type Bot = {
run: (signal: AbortSignal) => Promise<void>;
isConnected: () => boolean;
};
/**
* Gibt null zurueck, wenn Token oder Server-ID fehlen. Der Aufrufer soll dann
* einfach ohne Discord weiterlaufen.
*/
export function createBot(): Bot | null {
const cfg = readSecrets().discord;
if (!cfg?.botToken || !cfg.guildId) {
log.info('Kein Discord-Bot hinterlegt - Rollen-Knoepfe bleiben aus. ' +
'Einrichten mit: abdulive discord:setup');
return null;
}
const api = new Discord(cfg.botToken);
const buttons = createRoleButtons(api, cfg.guildId);
const gateway = createGateway(
api.botToken,
(path) => api.get<{ url: string }>(path),
{
onInteraction: (i) => buttons.handle(i),
onReady: ({ user }) => log.info(`Rollen-Knoepfe sind scharf (${user.username})`),
},
);
return { run: (signal) => gateway.run(signal), isConnected: () => gateway.isConnected() };
}
+281
View File
@@ -0,0 +1,281 @@
// Discord-Gateway ueber WebSocket. Nur so viel, wie ein Knopf braucht:
// verbinden, am Leben halten, Interaktionen durchreichen.
//
// Warum ueberhaupt eine dauerhafte Verbindung: Discord liefert Klicks auf
// einen Knopf entweder ans Gateway oder an eine oeffentlich erreichbare
// HTTPS-Adresse. Der Stream-PC hat keine, also Gateway. Die Verbindung haengt
// im Ingest-Daemon mit drin und kostet dort praktisch nichts.
//
// Absichten (Intents) stehen auf 0: Interaktionen kommen immer an, ohne
// Berechtigung fuer Nachrichteninhalte oder Mitgliederlisten. Was wir nicht
// anfordern, koennen wir auch nicht versehentlich mitlesen.
import { logger } from '#core/logger.ts';
import { forever } from '#core/retry.ts';
const log = logger('discord:gateway');
// Betriebsarten des Gateways
const OP = {
DISPATCH: 0,
HEARTBEAT: 1,
IDENTIFY: 2,
RESUME: 6,
RECONNECT: 7,
INVALID_SESSION: 9,
HELLO: 10,
HEARTBEAT_ACK: 11,
} as const;
/**
* Schliessgruende, bei denen ein neuer Versuch nichts bringt: falsches Token,
* falsche Absichten, falsche Fassung. Dagegen hilft nur eine Aenderung an der
* App, und bis dahin waere jeder Wiederverbindungsversuch nur Laerm.
*/
const FATAL_CLOSE = new Set([4004, 4010, 4011, 4012, 4013, 4014]);
export type Payload = { op: number; d?: unknown; s?: number | null; t?: string | null };
/** Nur die Felder einer Interaktion, die hier gebraucht werden. */
export type Interaction = {
id: string;
token: string;
type: number;
application_id: string;
guild_id?: string;
channel_id?: string;
data?: { custom_id?: string; component_type?: number };
member?: { user: { id: string; username: string }; roles: string[] };
user?: { id: string; username: string };
};
export type GatewayHandlers = {
onReady?: (info: { user: { id: string; username: string }; sessionId: string }) => void;
onInteraction?: (interaction: Interaction) => Promise<void> | void;
onClosed?: (code: number, reason: string) => void;
};
export type GatewayClient = {
/** Haelt die Verbindung; kehrt erst bei `signal.abort` oder einem harten Fehler zurueck. */
run: (signal: AbortSignal) => Promise<void>;
isConnected: () => boolean;
};
/** Was ueber eine Trennung hinweg erhalten bleibt, damit fortgesetzt statt neu begonnen wird. */
type Session = { id: string; resumeUrl: string; seq: number | null };
export function createGateway(
token: string,
restUrl: (path: string) => Promise<{ url: string }>,
handlers: GatewayHandlers,
): GatewayClient {
let session: Session | null = null;
let connected = false;
return {
isConnected: () => connected,
run: async (outer) => {
// Bei einem harten Fehler brechen wir die Schleife selbst ab, statt
// Discord alle paar Sekunden erneut mit demselben Token zu behelligen.
const own = new AbortController();
const signal = AbortSignal.any([outer, own.signal]);
await forever(
async (sig) => {
try {
await connectOnce(token, restUrl, handlers, sig, {
get: () => session,
set: (s) => { session = s; },
setConnected: (v) => { connected = v; },
giveUp: () => own.abort(),
});
} finally {
connected = false;
}
},
{
signal,
baseMs: 2000,
maxMs: 120_000,
onWait: (ms, n) => {
if (own.signal.aborted) return;
log.warn(`Gateway getrennt, neuer Versuch in ${Math.round(ms / 1000)} s (${n}.)`);
},
},
);
},
};
function connectOnce(
botToken: string,
getGateway: (path: string) => Promise<{ url: string }>,
h: GatewayHandlers,
signal: AbortSignal,
store: {
get: () => Session | null;
set: (s: Session | null) => void;
setConnected: (v: boolean) => void;
/** Bricht die Wiederverbindungsschleife ab - nur bei harten Fehlern. */
giveUp: () => void;
},
): Promise<void> {
return new Promise<void>((_resolve, reject) => {
let ws: WebSocket | null = null;
let beat: ReturnType<typeof setInterval> | null = null;
let firstBeat: ReturnType<typeof setTimeout> | null = null;
let acked = true;
let seq: number | null = store.get()?.seq ?? null;
let done = false;
const finish = (why: string): void => {
if (done) return;
done = true;
if (beat) clearInterval(beat);
if (firstBeat) clearTimeout(firstBeat);
store.setConnected(false);
try { ws?.close(); } catch { /* schon zu */ }
reject(new Error(why));
};
signal.addEventListener('abort', () => finish('abgebrochen'), { once: true });
const send = (payload: Payload): void => {
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(payload));
};
const heartbeat = (): void => {
if (!acked) {
// Discord hat den letzten Schlag nicht bestaetigt. Die Verbindung
// sieht offen aus, ist es aber nicht mehr - abreissen und neu.
log.warn('Kein Herzschlag bestaetigt, Verbindung gilt als tot');
finish('Herzschlag ohne Antwort');
return;
}
acked = false;
send({ op: OP.HEARTBEAT, d: seq });
};
void (async () => {
let url: string;
const resume = store.get();
try {
url = resume?.resumeUrl ?? (await getGateway('/gateway/bot')).url;
} catch (err) {
finish(`Gateway-Adresse nicht abrufbar: ${(err as Error).message}`);
return;
}
if (done) return;
ws = new WebSocket(`${url}?v=10&encoding=json`);
ws.addEventListener('close', (ev) => {
const code = (ev as CloseEvent).code;
const reason = (ev as CloseEvent).reason || '';
h.onClosed?.(code, reason);
if (FATAL_CLOSE.has(code)) {
// Nicht fortsetzbar und nicht behebbar: Token oder Absichten.
log.error(`Gateway hat endgueltig geschlossen (${code} ${reason}). ` +
'Bot-Token pruefen und den Dienst neu starten.');
store.set(null);
store.giveUp();
} else if (code === 4007 || code === 4009 || code === 1000 || code === 1001) {
// Sitzung ist hin, aber ein neuer Anlauf lohnt.
store.set(null);
}
finish(`geschlossen (${code})`);
});
ws.addEventListener('error', () => finish('Verbindungsfehler'));
ws.addEventListener('message', (ev) => {
let msg: Payload;
try {
msg = JSON.parse(String((ev as MessageEvent).data)) as Payload;
} catch {
return;
}
if (typeof msg.s === 'number') seq = msg.s;
switch (msg.op) {
case OP.HELLO: {
const interval = (msg.d as { heartbeat_interval: number }).heartbeat_interval;
// Der erste Schlag kommt versetzt, damit nicht alle Verbindungen
// der Welt im selben Moment klopfen - so schreibt es Discord vor.
firstBeat = setTimeout(() => {
heartbeat();
beat = setInterval(heartbeat, interval);
}, Math.floor(interval * Math.random()));
const s = store.get();
if (s) {
send({ op: OP.RESUME, d: { token: botToken, session_id: s.id, seq: s.seq } });
} else {
send({
op: OP.IDENTIFY,
d: {
token: botToken,
intents: 0, // nur Interaktionen, nichts mitlesen
properties: { os: process.platform, browser: 'abdulive-toolkit', device: 'abdulive-toolkit' },
},
});
}
return;
}
case OP.HEARTBEAT:
// Discord darf jederzeit einen Schlag anfordern.
send({ op: OP.HEARTBEAT, d: seq });
return;
case OP.HEARTBEAT_ACK:
acked = true;
return;
case OP.RECONNECT:
log.info('Gateway bittet um Wiederverbindung');
finish('Wiederverbindung angefordert');
return;
case OP.INVALID_SESSION:
// d = true heisst: die Sitzung laesst sich noch fortsetzen.
if (msg.d !== true) store.set(null);
finish('Sitzung ungueltig');
return;
case OP.DISPATCH:
onDispatch(msg);
return;
default:
return;
}
});
function onDispatch(msg: Payload): void {
if (msg.t === 'READY') {
const d = msg.d as {
session_id: string; resume_gateway_url: string;
user: { id: string; username: string };
};
store.set({ id: d.session_id, resumeUrl: d.resume_gateway_url, seq });
store.setConnected(true);
log.info(`Gateway verbunden als ${d.user.username}`);
h.onReady?.({ user: d.user, sessionId: d.session_id });
return;
}
if (msg.t === 'RESUMED') {
store.setConnected(true);
log.info('Gateway-Sitzung fortgesetzt');
return;
}
if (msg.t === 'INTERACTION_CREATE') {
const s = store.get();
if (s) store.set({ ...s, seq });
void Promise.resolve(h.onInteraction?.(msg.d as Interaction))
.catch((err: unknown) => log.error(`Interaktion nicht verarbeitet: ${String(err)}`));
}
}
})();
});
}
}
+75
View File
@@ -0,0 +1,75 @@
// Discord-Rechtebits. Die Werte sind ein 64-Bit-Feld und werden in der API
// als Dezimalstring uebertragen - deshalb BigInt und nicht number.
//
// Kein `enum`: Node fuehrt die .ts-Dateien direkt aus und entfernt nur Typen,
// ein enum waere zur Laufzeit weg (ADR-0001, `erasableSyntaxOnly`).
export const P = {
CREATE_INSTANT_INVITE: 1n << 0n,
KICK_MEMBERS: 1n << 1n,
BAN_MEMBERS: 1n << 2n,
ADMINISTRATOR: 1n << 3n,
MANAGE_CHANNELS: 1n << 4n,
MANAGE_GUILD: 1n << 5n,
ADD_REACTIONS: 1n << 6n,
VIEW_AUDIT_LOG: 1n << 7n,
PRIORITY_SPEAKER: 1n << 8n,
STREAM: 1n << 9n,
VIEW_CHANNEL: 1n << 10n,
SEND_MESSAGES: 1n << 11n,
SEND_TTS_MESSAGES: 1n << 12n,
MANAGE_MESSAGES: 1n << 13n,
EMBED_LINKS: 1n << 14n,
ATTACH_FILES: 1n << 15n,
READ_MESSAGE_HISTORY: 1n << 16n,
MENTION_EVERYONE: 1n << 17n,
USE_EXTERNAL_EMOJIS: 1n << 18n,
VIEW_GUILD_INSIGHTS: 1n << 19n,
CONNECT: 1n << 20n,
SPEAK: 1n << 21n,
MUTE_MEMBERS: 1n << 22n,
DEAFEN_MEMBERS: 1n << 23n,
MOVE_MEMBERS: 1n << 24n,
USE_VAD: 1n << 25n,
CHANGE_NICKNAME: 1n << 26n,
MANAGE_NICKNAMES: 1n << 27n,
MANAGE_ROLES: 1n << 28n,
MANAGE_WEBHOOKS: 1n << 29n,
MANAGE_GUILD_EXPRESSIONS: 1n << 30n,
USE_APPLICATION_COMMANDS: 1n << 31n,
REQUEST_TO_SPEAK: 1n << 32n,
MANAGE_EVENTS: 1n << 33n,
MANAGE_THREADS: 1n << 34n,
CREATE_PUBLIC_THREADS: 1n << 35n,
CREATE_PRIVATE_THREADS: 1n << 36n,
USE_EXTERNAL_STICKERS: 1n << 37n,
SEND_MESSAGES_IN_THREADS: 1n << 38n,
USE_EMBEDDED_ACTIVITIES: 1n << 39n,
MODERATE_MEMBERS: 1n << 40n,
USE_SOUNDBOARD: 1n << 42n,
CREATE_EVENTS: 1n << 44n,
USE_EXTERNAL_SOUNDS: 1n << 45n,
SEND_VOICE_MESSAGES: 1n << 46n,
SEND_POLLS: 1n << 49n,
} as const;
export type PermName = keyof typeof P;
/** Vereinigt mehrere Rechte zu einem Bitfeld. */
export function bits(...names: PermName[]): bigint {
let out = 0n;
for (const n of names) out |= P[n];
return out;
}
/** Wie die API es erwartet: Dezimalstring. */
export const str = (v: bigint): string => v.toString();
/** Namen eines Bitfelds - nur fuer die Vorschau und Fehlermeldungen. */
export function names(value: bigint): PermName[] {
const out: PermName[] = [];
for (const [name, bit] of Object.entries(P) as Array<[PermName, bigint]>) {
if ((value & bit) === bit) out.push(name);
}
return out;
}
+359
View File
@@ -0,0 +1,359 @@
// Der Bauplan wird gegen einen echten Server gefahren, auf dem danach Leute
// sitzen. Ein vertipptes Recht faellt dort nicht auf - es faellt auf, wenn
// jemand es ausnutzt. Deshalb pruefen diese Tests vor allem das, was nicht
// drinstehen darf.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { P, bits, names, str } from './permissions.ts';
import { AUTOMOD, AUTOMOD_BLOCK, CATEGORIES, CHANNELS, EVERYONE, ROLES, SYSTEM_CHANNEL, WELCOME } from './plan.ts';
import { findChannel, mergeOverwrites, normalizeName, rolePositions } from './apply.ts';
import { CH } from './api.ts';
const role = (name: string) => {
const r = ROLES.find((x) => x.name === name);
assert.ok(r, `Rolle ${name} fehlt im Bauplan`);
return r;
};
// ---------------------------------------------------------------- Bits
test('Rechtebits entsprechen den Werten der Discord-API', () => {
assert.equal(P.VIEW_CHANNEL, 1024n);
assert.equal(P.SEND_MESSAGES, 2048n);
assert.equal(P.ADMINISTRATOR, 8n);
assert.equal(P.MENTION_EVERYONE, 131072n);
// Jenseits von 2^31 - hier wuerde eine number-Rechnung still falsch werden.
assert.equal(P.CREATE_PUBLIC_THREADS, 34359738368n);
assert.equal(P.MODERATE_MEMBERS, 1099511627776n);
assert.equal(P.SEND_POLLS, 562949953421312n);
});
test('bits vereinigt, str liefert den Dezimalstring der API', () => {
assert.equal(bits('VIEW_CHANNEL', 'SEND_MESSAGES'), 3072n);
assert.equal(str(bits('VIEW_CHANNEL', 'SEND_MESSAGES')), '3072');
assert.equal(bits(), 0n);
// Doppelt genannt bleibt einfach gesetzt.
assert.equal(bits('VIEW_CHANNEL', 'VIEW_CHANNEL'), 1024n);
});
test('names liest ein Bitfeld wieder auf', () => {
assert.deepEqual(names(bits('KICK_MEMBERS', 'BAN_MEMBERS')), ['KICK_MEMBERS', 'BAN_MEMBERS']);
});
// ---------------------------------------------------------------- Rollen
test('@everyone bekommt kein Recht, mit dem sich der Server sprengen laesst', () => {
for (const verboten of [
'ADMINISTRATOR', 'MENTION_EVERYONE', 'MANAGE_GUILD', 'MANAGE_ROLES', 'MANAGE_CHANNELS',
'MANAGE_MESSAGES', 'MANAGE_WEBHOOKS', 'KICK_MEMBERS', 'BAN_MEMBERS', 'SEND_TTS_MESSAGES',
] as const) {
assert.equal(EVERYONE & P[verboten], 0n, `@everyone darf nicht ${verboten}`);
}
});
test('@everyone kann das Noetige: lesen, schreiben, einladen', () => {
for (const noetig of ['VIEW_CHANNEL', 'SEND_MESSAGES', 'READ_MESSAGE_HISTORY', 'CREATE_INSTANT_INVITE'] as const) {
assert.equal(EVERYONE & P[noetig], P[noetig], `@everyone braucht ${noetig}`);
}
});
test('Mod darf moderieren, aber nicht umbauen', () => {
const mod = role('Mod').permissions;
for (const darf of ['KICK_MEMBERS', 'BAN_MEMBERS', 'MODERATE_MEMBERS', 'MANAGE_MESSAGES'] as const) {
assert.equal(mod & P[darf], P[darf], `Mod braucht ${darf}`);
}
for (const nicht of ['ADMINISTRATOR', 'MANAGE_GUILD', 'MANAGE_ROLES', 'MANAGE_CHANNELS', 'MANAGE_WEBHOOKS'] as const) {
assert.equal(mod & P[nicht], 0n, `Mod darf nicht ${nicht}`);
}
});
test('Administrator traegt genau eine Rolle, und das ist Abdu', () => {
const mitAdmin = ROLES.filter((r) => (r.permissions & P.ADMINISTRATOR) === P.ADMINISTRATOR);
assert.deepEqual(mitAdmin.map((r) => r.name), ['Abdu']);
});
test('Abdu steht ganz oben in der Rangfolge', () => {
// Die Reihenfolge in ROLES ist die Rangfolge auf dem Server.
assert.equal(ROLES[0]?.name, 'Abdu');
});
test('Stammgast ist Auszeichnung, keine Macht', () => {
const s = role('Stammgast');
assert.equal(s.hoist, true, 'muss in der Mitgliederliste sichtbar sein - das ist der ganze Zweck');
// Ueber @everyone hinaus nur Kosmetik und Sprachkanal-Vorrang.
assert.equal(s.permissions & ~(EVERYONE | P.PRIORITY_SPEAKER), 0n);
});
test('Live-Ping ist erwaehnbar, sonst nichts', () => {
const l = role('Live-Ping');
assert.equal(l.mentionable, true, 'sonst kann die Go-Live-Meldung sie nicht anpingen');
assert.equal(l.permissions & ~EVERYONE, 0n);
});
// ---------------------------------------------------------------- Kanaele
test('Kanalnamen sind eindeutig und ueberleben Discords Normalisierung', () => {
const seen = new Set<string>();
for (const c of CHANNELS) {
// Discord schreibt Textkanaele klein. Steht im Plan ein Grossbuchstabe,
// weicht der gespeicherte Name ab und wir benennen bei jedem Lauf erneut um.
if (c.type === CH.TEXT) {
assert.equal(c.name, normalizeName(c.name, c.type),
`${c.name}: Discord wuerde den Namen veraendern`);
}
assert.equal(seen.has(c.name), false, `${c.name} kommt doppelt vor`);
seen.add(c.name);
}
});
test('jeder Eintrag hat einen eindeutigen key', () => {
const keys = [...CATEGORIES.map((c) => c.key), ...CHANNELS.map((c) => c.key)];
assert.equal(new Set(keys).size, keys.length, 'keys muessen eindeutig sein');
});
test('der alte Name steht als alias drin, sonst entsteht ein zweiter Kanal', () => {
// Beim Umstellen auf Emoji-Namen war genau das die Gefahr.
for (const c of CHANNELS) {
assert.ok(c.aliases?.length, `${c.key} hat keinen frueheren Namen hinterlegt`);
}
});
test('jeder Kanal haengt in einer Kategorie, die es gibt', () => {
for (const c of CHANNELS) {
assert.ok(c.category, `${c.name} hat keine Kategorie`);
assert.ok(CATEGORIES.some((k) => k.key === c.category),
`${c.name}: Kategorie ${c.category} ist unbekannt`);
}
});
test('Rechte-Ausnahmen verweisen nur auf Rollen aus dem Bauplan', () => {
const bekannt = new Set(['@everyone', ...ROLES.map((r) => r.name)]);
const alle = [...CHANNELS.flatMap((c) => c.overwrites ?? []), ...CATEGORIES.flatMap((c) => c.overwrites ?? [])];
for (const o of alle) assert.equal(bekannt.has(o.role), true, `unbekannte Rolle: ${o.role}`);
});
test('die interne Kategorie ist fuer Mitglieder unsichtbar, fuer Mods nicht', () => {
const ow = CATEGORIES.find((c) => c.key === 'intern')?.overwrites;
assert.ok(ow);
const jeder = ow.find((o) => o.role === '@everyone');
const mod = ow.find((o) => o.role === 'Mod');
assert.equal(jeder?.deny! & P.VIEW_CHANNEL, P.VIEW_CHANNEL);
assert.equal(mod?.allow! & P.VIEW_CHANNEL, P.VIEW_CHANNEL);
});
test('alle internen Kanaele liegen wirklich in der internen Kategorie', () => {
for (const key of ['mod-chat', 'technik-log']) {
const c = CHANNELS.find((x) => x.key === key);
assert.equal(c?.category, 'intern', `${key} waere sonst oeffentlich`);
}
});
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));
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', () => {
// Go-Live und Recap muessen obenauf bleiben; in #clips darf jeder posten.
const ank = CHANNELS.find((c) => c.key === 'ankuendigungen');
const deny = ank?.overwrites?.find((o) => o.role === '@everyone')?.deny ?? 0n;
assert.equal(deny & P.SEND_MESSAGES, P.SEND_MESSAGES);
});
test('der Begruessungstext verweist nur auf Kanaele, die angelegt werden', () => {
const keys = new Set(CHANNELS.map((c) => c.key));
for (const [, key] of WELCOME.matchAll(/\{(\w[\w-]*)\}/g)) {
assert.equal(keys.has(key!), true, `${key} aus dem Willkommenstext gibt es nicht`);
}
});
test('Beitritte landen in einem eigenen, beschreibbaren Kanal', () => {
// Ohne Angabe nimmt Discord den ersten Kanal, und das war #allgemein.
const c = CHANNELS.find((x) => x.key === SYSTEM_CHANNEL);
assert.ok(c, `${SYSTEM_CHANNEL} gibt es nicht`);
assert.notEqual(c.key, 'allgemein');
// Der Winke-Knopf unter der Meldung schreibt im Namen dessen, der klickt.
const deny = c.overwrites?.find((o) => o.role === '@everyone')?.deny ?? 0n;
assert.equal(deny & P.SEND_MESSAGES, 0n);
});
// ---------------------------------------------------------------- Merge
const ID = (name: string): string | undefined =>
({ '@everyone': '1', Mod: '2', Stammgast: '3' })[name];
test('fremde Rechte-Ausnahmen bleiben stehen', () => {
// "99" hat jemand von Hand gesetzt. Das Skript darf das nicht wegraeumen.
const vorher = [{ id: '99', type: 0 as const, allow: '8', deny: '0' }];
const out = mergeOverwrites(vorher, [{ role: 'Mod', allow: 1024n }], ID);
assert.ok(out);
assert.equal(out.length, 2);
assert.deepEqual(out.find((o) => o.id === '99'), vorher[0]);
assert.deepEqual(out.find((o) => o.id === '2'), { id: '2', type: 0, allow: '1024', deny: '0' });
});
test('nichts zu tun heisst null - dann faellt das PATCH weg', () => {
const vorher = [{ id: '1', type: 0 as const, allow: '0', deny: '2048' }];
assert.equal(mergeOverwrites(vorher, [{ role: '@everyone', deny: 2048n }], ID), null);
});
test('abweichende Rechte werden ueberschrieben, nicht ergaenzt', () => {
const vorher = [{ id: '1', type: 0 as const, allow: '64', deny: '0' }];
const out = mergeOverwrites(vorher, [{ role: '@everyone', deny: 2048n }], ID);
assert.deepEqual(out, [{ id: '1', type: 0, allow: '0', deny: '2048' }]);
});
test('eine Ausnahme fuer ein Mitglied wird nicht mit einer Rolle verwechselt', () => {
// type 1 ist ein einzelnes Mitglied. Gleiche ID, anderer Typ - darf nicht
// ueberschrieben werden.
const vorher = [{ id: '2', type: 1 as const, allow: '64', deny: '0' }];
const out = mergeOverwrites(vorher, [{ role: 'Mod', allow: 1024n }], ID);
assert.equal(out?.length, 2);
assert.deepEqual(out?.[0], vorher[0]);
});
test('Rollen, die es noch nicht gibt, werden uebergangen statt zu raten', () => {
assert.equal(mergeOverwrites([], [{ role: 'Gibtsnicht', allow: 1024n }], ID), null);
});
test('die Vorlage wird nicht verbogen', () => {
const vorher = [{ id: '1', type: 0 as const, allow: '0', deny: '0' }];
const kopie = structuredClone(vorher);
mergeOverwrites(vorher, [{ role: '@everyone', deny: 2048n }], ID);
assert.deepEqual(vorher, kopie, 'mergeOverwrites darf die Eingabe nicht veraendern');
});
// ---------------------------------------------------------------- Namen
test('normalizeName bildet ab, was Discord mit dem Namen macht', () => {
// Am echten Server nachgemessen: kleinschreiben, Leerzeichen zu Bindestrich,
// Emoji, Umlaute und U+2502 bleiben unveraendert.
assert.equal(normalizeName('Aü Test Kanal', CH.TEXT), 'aü-test-kanal');
assert.equal(normalizeName('📢│ankündigungen', CH.TEXT), '📢│ankündigungen');
// Sprachkanaele und Kategorien behalten Grossbuchstaben und Leerzeichen.
assert.equal(normalizeName('🔊 Zusammen zocken', CH.VOICE), '🔊 Zusammen zocken');
assert.equal(normalizeName('📌 Info', CH.CATEGORY), '📌 Info');
});
test('findChannel erkennt den Kanal am frueheren Namen', () => {
const vorhanden = [
{ id: '1', name: 'allgemein', type: CH.TEXT, position: 0, parent_id: null },
{ id: '2', name: 'sonstwas', type: CH.TEXT, position: 1, parent_id: null },
];
const plan = { name: '💬│allgemein', aliases: ['allgemein'], type: CH.TEXT };
assert.equal(findChannel(vorhanden, plan)?.id, '1');
});
test('findChannel bevorzugt den neuen Namen vor dem alten', () => {
const vorhanden = [
{ id: '1', name: 'allgemein', type: CH.TEXT, position: 0, parent_id: null },
{ id: '2', name: '💬│allgemein', type: CH.TEXT, position: 1, parent_id: null },
];
const plan = { name: '💬│allgemein', aliases: ['allgemein'], type: CH.TEXT };
assert.equal(findChannel(vorhanden, plan)?.id, '2');
});
test('findChannel verwechselt Sprach- und Textkanal nicht', () => {
const vorhanden = [{ id: '1', name: 'clips', type: CH.VOICE, position: 0, parent_id: null }];
assert.equal(findChannel(vorhanden, { name: '🎬│clips', aliases: ['clips'], type: CH.TEXT }), undefined);
});
// ---------------------------------------------------------------- Rangfolge
test('rolePositions setzt die Rollen unter die Bot-Rolle', () => {
// Bot auf Platz 5, drei Rollen -> 4, 3, 2. Platz 1 bleibt frei, 0 ist @everyone.
assert.deepEqual(rolePositions(3, 5), { positions: [4, 3, 2] });
});
test('rolePositions nutzt den Platz genau aus', () => {
assert.deepEqual(rolePositions(3, 4), { positions: [3, 2, 1] });
});
test('steht die Bot-Rolle zu weit unten, sagt rolePositions was zu tun ist', () => {
// Der Normalfall direkt nach dem Einladen: Bot auf Platz 1, kein Platz darunter.
const out = rolePositions(3, 1);
assert.ok('problem' in out);
assert.match(out.problem, /Platz fuer 0 von 3/);
assert.match(out.problem, /nach oben ziehen/);
});
test('rolePositions verlangt nie einen Platz ueber der Bot-Rolle', () => {
// Genau das hat Discord mit "Missing Permissions" quittiert.
for (let botTop = 1; botTop <= 8; botTop++) {
const out = rolePositions(3, botTop);
if ('problem' in out) continue;
for (const p of out.positions) {
assert.ok(p < botTop, `Platz ${p} liegt nicht unter der Bot-Rolle (${botTop})`);
assert.ok(p > 0, `Platz ${p} wuerde @everyone verdraengen`);
}
}
});
// ---------------------------------------------------------------- Deutsch
test('was Mitglieder lesen, steht in richtigem Deutsch', () => {
// Im Quelltext ist ae/oe/ue Konvention. In allem, was auf Discord sichtbar
// ist, waere es ein Tippfehler - "Sei anstaendig" liest sich wie einer.
const sichtbar = [
WELCOME,
...CHANNELS.map((c) => c.name),
...CHANNELS.map((c) => c.topic ?? ''),
...CATEGORIES.map((c) => c.name),
...ROLES.map((r) => r.name),
...AUTOMOD.map((r) => r.name),
AUTOMOD_BLOCK,
].join('\n');
// Wortstaemme, die im Deutschen praktisch immer einen Umlaut tragen.
for (const falsch of ['ae', 'oe', 'ue']) {
const treffer = sichtbar.match(new RegExp('\\w*' + falsch + '\\w*', 'g')) ?? [];
// "Neuer", "Feuer" und Aehnliches sind echte Vorkommen - nur die bekannten
// Umschreibungen aus diesem Projekt duerfen nicht auftauchen.
for (const wort of treffer) {
assert.equal(
/gehoert|anstaendig|Probelaeufe|Erwaehnung|aendert|laeuft|zusaetzlich|Aenderung/i.test(wort),
false,
`"${wort}" gehoert in richtiges Deutsch geschrieben`,
);
}
}
});
test('Abdu traegt Administrator, sonst niemand', () => {
for (const r of ROLES) {
const admin = (r.permissions & P.ADMINISTRATOR) === P.ADMINISTRATOR;
assert.equal(admin, r.name === 'Abdu', `${r.name}: Administrator=${admin}`);
}
});
test('die AutoMod-Regel kennt ihren frueheren Namen', () => {
const regel = AUTOMOD.find((r) => r.name === 'Erwähnungs-Spam');
assert.ok(regel?.aliases?.includes('Erwaehnungs-Spam'),
'sonst steht die Regel nach der Umbenennung zweimal da');
});
test('Tags und Standard-Reaktion stehen nur an Foren und passen in Discords Grenzen', () => {
for (const c of CHANNELS) {
if (c.type !== CH.FORUM) {
assert.equal(c.tags, undefined, `${c.name}: Tags gibt es nur in Foren`);
assert.equal(c.reaction, undefined, `${c.name}: Standard-Reaktion gibt es nur in Foren`);
continue;
}
assert.ok((c.tags?.length ?? 0) <= 20, `${c.name}: hoechstens 20 Tags`);
for (const t of c.tags ?? []) assert.ok(t.name.length <= 20, `Tag ${t.name} ist laenger als 20 Zeichen`);
assert.ok((c.topic?.length ?? 0) <= 4096, `${c.name}: Richtlinien zu lang`);
}
});
+420
View File
@@ -0,0 +1,420 @@
// Der Bauplan des Community-Servers. Deklarativ, damit man ihn lesen und
// im Pull Request diskutieren kann, statt ihn in einer Oberflaeche zu suchen.
//
// Leitgedanke: der Kanal hat 5 bis 30 gleichzeitige Zuschauer. Ein Server mit
// zwanzig Kanaelen wirkt bei dieser Groesse leer, und ein leerer Kanal haelt
// niemanden. Fuenf sichtbare Kanaele, in denen etwas passiert, sind besser.
// Wann welcher Kanal dazukommt, steht in docs/projects/discord-community.md.
//
// Jeder Eintrag hat einen `key`, der sich nie aendert, und einen `name`, der
// sich aendern darf. Gefunden wird ueber `name` und `aliases`, verwiesen wird
// ueber `key` - so wird aus einer Umbenennung eine Umbenennung und kein
// zweiter Kanal daneben.
import { bits } from './permissions.ts';
import { CH } from './api.ts';
// Zur Schreibweise: Kommentare und Bezeichner bleiben bei ae/oe/ue wie im
// restlichen Repo. Alles, was ein Mitglied auf Discord zu sehen bekommt -
// Kanalnamen, Themen, Rollennamen, der Willkommenstext, AutoMod-Meldungen -
// steht in richtigem Deutsch. "Sei anstaendig" liest sich wie ein Tippfehler,
// und der erste Eindruck eines Servers ist der Text darin.
/** Schluessel, unter denen eine Webhook-URL im Zugangsdaten-Speicher landet. */
export type WebhookSlot = 'goLiveWebhook' | 'recapWebhook' | 'clipsWebhook' | 'technikWebhook';
export type RolePlan = {
name: string;
color: number;
/** Eigene Gruppe in der Mitgliederliste. */
hoist: boolean;
mentionable: boolean;
permissions: bigint;
/** Wofuer es die Rolle gibt - landet in der Vorschau und in der Doku. */
purpose: string;
};
export type OverwritePlan = {
/** Rollenname, oder '@everyone' fuer die Grundrolle. */
role: string;
allow?: bigint;
deny?: bigint;
};
export type CategoryPlan = {
key: string;
name: string;
/** Frueher benutzte Namen. Wird einer gefunden, wird umbenannt. */
aliases?: string[];
overwrites?: OverwritePlan[];
};
/** Ein Stichwort, mit dem Forenbeitraege markiert werden. */
export type TagPlan = {
/** Hoechstens 20 Zeichen, sonst lehnt Discord ab. */
name: string;
emoji: string;
/** Setzen duerfen nur Mods (MANAGE_THREADS), nicht der Verfasser. */
moderated?: boolean;
};
export type ChannelPlan = {
key: string;
name: string;
aliases?: string[];
type: number;
/** Bei Foren sind das die Richtlinien, die ueber jedem neuen Beitrag stehen. */
topic?: string;
/** `key` einer Kategorie aus CATEGORIES. */
category?: string;
overwrites?: OverwritePlan[];
webhooks?: Array<{ name: string; slot: WebhookSlot }>;
/** Nur Foren. Von Hand angelegte Tags bleiben daneben stehen. */
tags?: TagPlan[];
/** Nur Foren: die Reaktion, die unter jedem Beitrag schon angeboten wird. */
reaction?: string;
};
// ---------------------------------------------------------------- Rechte
/**
* Was ein normales Mitglied ueberall darf. Bewusst als vollstaendige Liste
* gesetzt statt auf Discords Vorgabe zu vertrauen - die aendert sich, und
* "jeder darf alle anpingen" ist der Unterschied zwischen einem Server, den
* man anlaesst, und einem, den man stummschaltet.
*/
export const EVERYONE = bits(
'CREATE_INSTANT_INVITE', // Mitglieder duerfen einladen. Wachstum vor Kontrolle.
'VIEW_CHANNEL', 'READ_MESSAGE_HISTORY',
'SEND_MESSAGES', 'SEND_MESSAGES_IN_THREADS', 'CREATE_PUBLIC_THREADS',
'ADD_REACTIONS', 'EMBED_LINKS', 'ATTACH_FILES',
'USE_EXTERNAL_EMOJIS', 'USE_EXTERNAL_STICKERS', 'USE_APPLICATION_COMMANDS',
'SEND_POLLS', 'CHANGE_NICKNAME',
'CONNECT', 'SPEAK', 'STREAM', 'USE_VAD', 'USE_SOUNDBOARD',
);
// Nicht enthalten und damit verboten: MENTION_EVERYONE, SEND_TTS_MESSAGES,
// CREATE_PRIVATE_THREADS und saemtliche Verwaltungsrechte.
const MOD = EVERYONE | bits(
'KICK_MEMBERS', 'BAN_MEMBERS', 'MODERATE_MEMBERS',
'MANAGE_MESSAGES', 'MANAGE_THREADS', 'MANAGE_NICKNAMES',
'MUTE_MEMBERS', 'DEAFEN_MEMBERS', 'MOVE_MEMBERS',
'VIEW_AUDIT_LOG', 'MENTION_EVERYONE',
);
// Kein MANAGE_GUILD, kein MANAGE_ROLES, kein MANAGE_CHANNELS: Moderation
// heisst aufraeumen, nicht umbauen. Wer umbauen soll, bekommt es einzeln.
// Abdu bekommt ADMINISTRATOR. Das Recht schliesst alle anderen ein und setzt
// jede Kanal-Ausnahme ausser Kraft - auch kuenftige. Wer diese Rolle traegt,
// kann den Server komplett umbauen und loeschen, deshalb gehoert sie an genau
// ein Konto und an kein zweites.
const ABDU = bits('ADMINISTRATOR');
export const ROLES: RolePlan[] = [
{
name: 'Abdu',
color: 0x9146ff,
hoist: true,
mentionable: true,
permissions: ABDU,
purpose:
'Der Kanal selbst. Steht ganz oben in der Mitgliederliste und traegt ' +
'Administrator, darf also alles. Nur fuer Abdus eigenes Konto.',
},
{
name: 'Mod',
color: 0x5865f2,
hoist: true,
mentionable: true,
permissions: MOD,
purpose: 'Raeumt auf: loeschen, stummschalten, im Zweifel rauswerfen.',
},
{
name: 'Stammgast',
color: 0x4fd1c5,
hoist: true,
mentionable: false,
permissions: EVERYONE | bits('PRIORITY_SPEAKER'),
purpose:
'Auszeichnung fuer Leute, die regelmaessig da sind. Bei dieser Kanalgroesse ' +
'ist sichtbare Zugehoerigkeit der wirksamste Hebel - die Rolle faerbt den ' +
'Namen und sortiert ihn in der Mitgliederliste nach oben.',
},
{
name: 'Live-Ping',
color: 0x99aab5,
hoist: false,
mentionable: true,
permissions: EVERYONE,
purpose:
'Wird bei Stream-Start angepingt - selbst vergeben, nicht automatisch. ' +
'Ein Rundruf an alle bei jedem Stream ist der schnellste Weg, dass der ' +
'Server stummgeschaltet wird.',
},
];
// ---------------------------------------------------------------- Kanaele
//
// Zu den Namen: Discord schreibt Textkanaele klein und macht aus Leerzeichen
// Bindestriche. Emoji, das Trennzeichen U+2502 und Umlaute bleiben unveraendert
// stehen - nachgemessen am echten Server, nicht angenommen.
export const CATEGORIES: CategoryPlan[] = [
{ key: 'info', name: '📌 Info', aliases: ['Info', 'Informationen'] },
{ key: 'community', name: '🎮 Community', aliases: ['Community', 'Textkanäle'] },
{
key: 'intern',
name: '🔒 Intern',
aliases: ['Intern'],
// Fuer normale Mitglieder gibt es diese Kategorie nicht.
overwrites: [
{ role: '@everyone', deny: bits('VIEW_CHANNEL') },
{ role: 'Mod', allow: bits('VIEW_CHANNEL') },
],
},
];
/** Nur lesen: schreiben darf hier niemand ausser Mods und Webhooks. */
const READONLY: OverwritePlan[] = [
{ role: '@everyone', deny: bits('SEND_MESSAGES', 'CREATE_PUBLIC_THREADS') },
];
/** Lesen, aber diskutieren nur in Threads - haelt die Ankuendigungen sauber. */
const ANNOUNCE: OverwritePlan[] = [
{
role: '@everyone',
deny: bits('SEND_MESSAGES'),
allow: bits('ADD_REACTIONS', 'CREATE_PUBLIC_THREADS', 'SEND_MESSAGES_IN_THREADS'),
},
];
export const CHANNELS: ChannelPlan[] = [
{
key: 'willkommen',
name: '👋│willkommen',
aliases: ['willkommen', 'willkommen-und-richtlinien'],
type: CH.TEXT,
category: 'info',
topic: 'Was das hier ist und die drei Regeln.',
overwrites: READONLY,
},
{
// Hier landen Discords Beitrittsmeldungen (SYSTEM_CHANNEL). Beschreibbar,
// damit der Winke-Knopf unter der Meldung funktioniert - der antwortet im
// Namen dessen, der klickt.
key: 'begruessung',
name: '🙋│begrüßung',
aliases: ['begrüßung', 'begruessung'],
type: CH.TEXT,
category: 'info',
topic: 'Wer neu ist, taucht hier auf. Sag Hallo.',
},
{
key: 'ankuendigungen',
name: '📢│ankündigungen',
aliases: ['ankuendigungen', 'ankündigungen'],
type: CH.TEXT,
category: 'info',
topic: 'Stream-Start und die Zusammenfassung danach. Reden gerne in den Threads.',
overwrites: ANNOUNCE,
webhooks: [
{ name: 'Go-Live', slot: 'goLiveWebhook' },
{ name: 'Stream-Recap', slot: 'recapWebhook' },
],
},
{
key: 'allgemein',
name: '💬│allgemein',
aliases: ['allgemein'],
type: CH.TEXT,
category: 'community',
topic: 'Der Hauptkanal. Im Zweifel hier.',
},
{
key: 'clips',
name: '🎬│clips',
aliases: ['clips'],
type: CH.TEXT,
category: 'community',
topic: 'Gute Momente aus dem Stream. Der Clip-Knopf postet hier hin.',
webhooks: [{ name: 'Clips', slot: 'clipsWebhook' }],
},
{
// Von Abdu als Textkanal angelegt, seit 2026-10-17 ein Forum: ein Beitrag
// je Spiel, abgestimmt wird per Daumen. Im Textkanal schlaegt nach drei
// Wochen jemand dasselbe Spiel noch einmal vor, weil der erste Vorschlag
// weggescrollt ist. Der erste Satz der Richtlinien ist Abdus eigener.
// Die Stand-Tags setzen nur Mods, sonst markiert jeder sein Spiel als
// "wird gespielt".
key: 'spielvorschlaege',
name: '🎮│spielvorschläge',
aliases: ['spielvorschläge', 'spielvorschlaege'],
type: CH.FORUM,
category: 'community',
topic: 'Ihr wollt sehen wie ich Spiele zocke, die euch gefallen? Schreibt gerne eure Vorschläge rein!\n\n' +
'Ein Beitrag pro Spiel. Gibt es das Spiel schon, Daumen hoch drücken statt noch einmal posten - ' +
'so sehe ich, was ihr am meisten sehen wollt.',
reaction: '👍',
tags: [
{ name: 'Wird gespielt', emoji: '🎮', moderated: true },
{ name: 'Gespielt', emoji: '✅', moderated: true },
{ name: 'Eher nicht', emoji: '❌', moderated: true },
],
},
{
// Ebenfalls von Abdu angelegt und ebenfalls ein Forum: ein Beitrag je
// Clip, damit man sieht, was schon dran war.
key: 'reaction',
name: '👀│reaction-einsendungen',
aliases: ['reaction-einsendungen'],
type: CH.FORUM,
category: 'community',
topic: 'Ihr habt Clips oder Videos worauf ich reagieren sollte? Schickt hier die Clips rein!\n\n' +
'Ein Beitrag pro Clip, Link in den Beitrag.',
reaction: '👍',
tags: [
{ name: 'Kommt dran', emoji: '🔜', moderated: true },
{ name: 'Angeschaut', emoji: '✅', moderated: true },
],
},
{
key: 'voice',
name: '🔊 Zusammen zocken',
aliases: ['zusammen-zocken', 'Zusammen zocken'],
type: CH.VOICE,
category: 'community',
},
{
key: 'mod-chat',
name: '🔨│mod-chat',
aliases: ['mod-chat'],
type: CH.TEXT,
category: 'intern',
topic: 'Nur Mods.',
},
{
key: 'technik-log',
name: '🤖│technik-log',
aliases: ['technik-log'],
type: CH.TEXT,
category: 'intern',
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' }],
},
];
// ---------------------------------------------------------------- Server
/**
* Servereinstellungen.
* - verification_level 2 verlangt ein Konto, das aelter als fuenf Minuten
* ist. Kostet echte Leute nichts und haelt Wegwerf-Konten draussen, was bei
* einem oeffentlich von Twitch aus verlinkten Server der Normalfall ist.
* - default_message_notifications 1 heisst: nur bei Erwaehnung. Sonst
* klingelt jede Nachricht bei jedem, und der Server wird stummgeschaltet.
*/
export const GUILD_SETTINGS = {
verification_level: 2,
explicit_content_filter: 2,
default_message_notifications: 1,
system_channel_flags: 4, // Einrichtungstipps unterdruecken, Beitritte zeigen
};
/**
* `key` des Kanals, in den Discord "X ist beigetreten" schreibt. Ohne Angabe
* nimmt Discord den ersten Kanal des Servers, und das war #allgemein: dort
* gingen die Beitritte zwischen den Gespraechen unter, und umgekehrt.
* Steht nicht in GUILD_SETTINGS, weil die ID erst nach den Kanaelen feststeht.
*/
export const SYSTEM_CHANNEL = 'begruessung';
/**
* Wird einmalig in #willkommen gepostet, wenn der Kanal noch leer ist.
* {key} wird durch die Erwaehnung des Kanals mit diesem `key` ersetzt.
*/
export const WELCOME = `**Willkommen bei abdulive_**
Das hier ist der Ort zwischen den Streams. Wer mitliest, gehört dazu.
**Drei Regeln**
> **1.** Sei anständig. Keine Beleidigungen, kein Rassismus, kein Sexismus.
> **2.** Kein Spam, keine Werbung, keine fremden Streams im Chat.
> **3.** Wenn ein Mod etwas sagt, ist das erledigt. Diskussion per DM.
**Wo was passiert**
> {allgemein} - alles andere
> {clips} - die guten Momente
> {spielvorschlaege} - was Abdu mal spielen sollte
> {reaction} - Clips, auf die Abdu reagieren soll
> {ankuendigungen} - Stream-Start und die Zusammenfassung danach
**Bescheid wissen, wenn der Stream startet**
Knopf drücken. Angepingt wird nur, wer das auch möchte, und ein zweiter Druck
nimmt die Rolle wieder weg.
Twitch: <https://twitch.tv/abdulive_>`;
// ---------------------------------------------------------- Selbstbedienung
export type SelfRolePlan = {
/** Landet im Knopf und kommt bei jedem Klick zurueck. Nie aendern. */
customId: string;
/** Name der Rolle aus ROLES. */
role: string;
label: string;
emoji: string;
/** Antwort beim Holen und beim Abgeben - sieht nur, wer geklickt hat. */
onText: string;
offText: string;
};
/**
* Rollen, die man sich per Knopf selbst gibt.
*
* Discords Onboarding kann das auch, verlangt aber sieben Vorgabekanaele mit
* fuenf beschreibbaren. Der Server hat fuenf und drei. Zwei Kanaele anzulegen,
* damit eine Schwelle passt, macht den Server leerer statt besser - also ein
* Knopf unter dem Willkommenstext. Siehe docs/projects/discord-community.md.
*/
export const SELF_ROLES: SelfRolePlan[] = [
{
customId: 'rolle:live-ping',
role: 'Live-Ping',
label: 'Sag mir Bescheid, wenn der Stream startet',
emoji: '🔴',
onText: 'Passt. Du wirst in den Ankündigungen angepingt, wenn der Stream startet. ' +
'Nochmal drücken nimmt es wieder weg.',
offText: 'Erledigt, du wirst nicht mehr angepingt.',
},
];
// ---------------------------------------------------------------- AutoMod
/**
* Zwei Regeln, die Discord selbst durchsetzt - ohne Bot, auch nachts um drei.
* Mehr braucht es bei dieser Groesse nicht; Wortfilter kosten mehr Aerger
* durch Fehlalarme, als sie an Spam verhindern.
*/
export type AutomodPlan = {
name: string;
/** Frueher benutzte Namen, damit umbenannt statt doppelt angelegt wird. */
aliases?: string[];
trigger_type: number;
trigger_metadata: Record<string, unknown>;
};
export const AUTOMOD: AutomodPlan[] = [
{
name: 'Erwähnungs-Spam',
aliases: ['Erwaehnungs-Spam'],
trigger_type: 5,
trigger_metadata: { mention_total_limit: 5 },
},
{ name: 'Spam-Erkennung', trigger_type: 3, trigger_metadata: {} },
];
/** Was jemand zu sehen bekommt, dessen Nachricht AutoMod abgefangen hat. */
export const AUTOMOD_BLOCK = 'Das hat die Spam-Erkennung abgefangen. Bei Fragen an die Mods.';
+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(); },
};
}
+182
View File
@@ -0,0 +1,182 @@
// Der Knopf vergibt Rollen an echte Leute. Was hier schiefgeht, merkt man
// daran, dass jemand keinen Ping bekommt oder einen, den er abbestellt hat -
// also spaet und vermittelt. Deshalb hier direkt geprueft.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import type { RestFull } from './api.ts';
import type { Interaction } from './gateway.ts';
import { SELF_ROLES } from './plan.ts';
import { createRoleButtons, selfRoleComponents } from './selfroles.ts';
const GUILD = '100';
const LIVE_PING = '777';
const USER = '42';
type Call = { method: string; path: string; body?: unknown };
/** Rest-Client im Arbeitsspeicher. Merkt sich, was gerufen wurde. */
function fakeRest(opts: { roles?: Array<{ id: string; name: string }>; failRole?: boolean } = {}) {
const calls: Call[] = [];
const roles = opts.roles ?? [
{ id: GUILD, name: '@everyone' },
{ id: LIVE_PING, name: 'Live-Ping' },
];
const api: RestFull = {
async get<T>(path: string): Promise<T> {
calls.push({ method: 'GET', path });
if (path === `/guilds/${GUILD}/roles`) return roles as T;
throw new Error(`GET ${path} nicht nachgebaut`);
},
async post<T>(path: string, body: unknown): Promise<T> {
calls.push({ method: 'POST', path, body });
return undefined as T;
},
async patch<T>(path: string, body: unknown): Promise<T> {
calls.push({ method: 'PATCH', path, body });
return undefined as T;
},
async put<T>(path: string): Promise<T> {
calls.push({ method: 'PUT', path });
if (opts.failRole) throw new Error('Missing Permissions');
return undefined as T;
},
async del<T>(path: string): Promise<T> {
calls.push({ method: 'DELETE', path });
if (opts.failRole) throw new Error('Missing Permissions');
return undefined as T;
},
};
return { api, calls };
}
const klick = (customId: string, roles: string[] = []): Interaction => ({
id: 'i1',
token: 'tok',
type: 3, // Klick auf eine Komponente
application_id: 'app',
guild_id: GUILD,
data: { custom_id: customId, component_type: 2 },
member: { user: { id: USER, username: 'jemand' }, roles },
});
const antwort = (calls: Call[]): string => {
const c = calls.find((x) => x.path.startsWith('/interactions/'));
return String((c?.body as { data?: { content?: string } })?.data?.content ?? '');
};
// ---------------------------------------------------------------- Knopf
test('die Knopfleiste bildet den Bauplan ab', () => {
const rows = selfRoleComponents();
assert.equal(rows.length, 1, 'eine Reihe');
const buttons = rows[0]!['components'] as Array<Record<string, unknown>>;
assert.equal(buttons.length, SELF_ROLES.length);
assert.equal(buttons[0]!['custom_id'], SELF_ROLES[0]!.customId);
assert.equal(buttons[0]!['type'], 2, 'Typ 2 ist ein Knopf');
});
test('die custom_id traegt keine Nutzerdaten', () => {
// Sie steht dauerhaft in der Nachricht und geht an jeden, der sie sieht.
for (const r of SELF_ROLES) {
assert.match(r.customId, /^[a-z:-]+$/, `${r.customId} sollte ein fester Bezeichner sein`);
assert.ok(r.customId.length <= 100, 'Discord erlaubt hoechstens 100 Zeichen');
}
});
// ---------------------------------------------------------------- Vergeben
test('wer die Rolle nicht hat, bekommt sie', async () => {
const { api, calls } = fakeRest();
await createRoleButtons(api, GUILD).handle(klick('rolle:live-ping'));
const put = calls.find((c) => c.method === 'PUT');
assert.equal(put?.path, `/guilds/${GUILD}/members/${USER}/roles/${LIVE_PING}`);
assert.equal(antwort(calls), SELF_ROLES[0]!.onText);
});
test('wer sie hat, wird sie los - derselbe Knopf', async () => {
const { api, calls } = fakeRest();
await createRoleButtons(api, GUILD).handle(klick('rolle:live-ping', [LIVE_PING]));
const del = calls.find((c) => c.method === 'DELETE');
assert.equal(del?.path, `/guilds/${GUILD}/members/${USER}/roles/${LIVE_PING}`);
assert.equal(calls.some((c) => c.method === 'PUT'), false, 'nicht gleichzeitig setzen');
assert.equal(antwort(calls), SELF_ROLES[0]!.offText);
});
test('die Antwort sieht nur, wer geklickt hat', async () => {
const { api, calls } = fakeRest();
await createRoleButtons(api, GUILD).handle(klick('rolle:live-ping'));
const c = calls.find((x) => x.path.startsWith('/interactions/'))?.body as
{ type: number; data: { flags: number } };
assert.equal(c.type, 4, 'Antwort mit Text');
assert.equal(c.data.flags, 64, 'ohne flags 64 liest der ganze Kanal mit');
});
// ---------------------------------------------------------------- Abwehr
test('fremde Knoepfe werden ignoriert', async () => {
const { api, calls } = fakeRest();
await createRoleButtons(api, GUILD).handle(klick('irgendwas-anderes'));
assert.deepEqual(calls, [], 'kein Aufruf, keine Antwort');
});
test('andere Interaktionsarten werden ignoriert', async () => {
const { api, calls } = fakeRest();
const i = { ...klick('rolle:live-ping'), type: 2 }; // Slash-Befehl
await createRoleButtons(api, GUILD).handle(i);
assert.deepEqual(calls, []);
});
test('ein Klick aus der Direktnachricht vergibt keine Rolle', async () => {
const { api, calls } = fakeRest();
const ohneMitglied: Interaction = { ...klick('rolle:live-ping') };
delete ohneMitglied.member;
await createRoleButtons(api, GUILD).handle(ohneMitglied);
assert.equal(calls.some((c) => c.method === 'PUT' || c.method === 'DELETE'), false);
assert.match(antwort(calls), /nur im Server/);
});
test('fehlt die Rolle auf dem Server, wird das gesagt statt still zu scheitern', async () => {
const { api, calls } = fakeRest({ roles: [{ id: GUILD, name: '@everyone' }] });
await createRoleButtons(api, GUILD).handle(klick('rolle:live-ping'));
assert.equal(calls.some((c) => c.method === 'PUT'), false);
assert.match(antwort(calls), /Live-Ping/);
assert.match(antwort(calls), /Mod/);
});
test('auf einen Klick kommt auch dann eine Antwort, wenn Discord nein sagt', async () => {
// Ohne Antwort steht bei der Person nur "Interaktion fehlgeschlagen".
const { api, calls } = fakeRest({ failRole: true });
await createRoleButtons(api, GUILD).handle(klick('rolle:live-ping'));
assert.match(antwort(calls), /nicht geklappt/);
});
// ---------------------------------------------------------------- Zwischenspeicher
test('die Rollenliste wird nur einmal geholt', async () => {
const { api, calls } = fakeRest();
const buttons = createRoleButtons(api, GUILD);
await buttons.handle(klick('rolle:live-ping'));
await buttons.handle(klick('rolle:live-ping', [LIVE_PING]));
const holt = calls.filter((c) => c.path === `/guilds/${GUILD}/roles`);
assert.equal(holt.length, 1, 'die zweite Anfrage darf aus dem Speicher kommen');
});
test('eine nachtraeglich angelegte Rolle wird beim zweiten Blick gefunden', async () => {
const roles = [{ id: GUILD, name: '@everyone' }];
const { api, calls } = fakeRest({ roles });
const buttons = createRoleButtons(api, GUILD);
await buttons.handle(klick('rolle:live-ping')); // fehlt noch
roles.push({ id: LIVE_PING, name: 'Live-Ping' }); // jemand legt sie an
await buttons.handle(klick('rolle:live-ping'));
assert.equal(calls.some((c) => c.method === 'PUT'), true, 'beim zweiten Mal muss es klappen');
});
+101
View File
@@ -0,0 +1,101 @@
// Rollen, die man sich selbst per Knopf gibt.
//
// Discords eigenes Onboarding kann das auch, verlangt dafuer aber sieben
// Vorgabekanaele, von denen fuenf beschreibbar sind. Der Server hat fuenf und
// drei - nachgemessen, nicht geraten (Fehlercode 350000). Zwei Kanaele nur
// anzulegen, damit eine Schwelle passt, macht den Server schlechter. Also ein
// Knopf unter dem Willkommenstext.
import { logger } from '#core/logger.ts';
import type { RestFull } from './api.ts';
import type { Interaction } from './gateway.ts';
import { SELF_ROLES, type SelfRolePlan } from './plan.ts';
import { createRoleResolver } from './roles.ts';
const log = logger('discord:rollen');
/** Interaktionsart: Klick auf eine Komponente. */
const MESSAGE_COMPONENT = 3;
/** Antwortart: sichtbare Antwort, hier zusaetzlich nur fuer den Klickenden. */
const CHANNEL_MESSAGE = 4;
const EPHEMERAL = 64;
/**
* Die Knopfleiste unter dem Willkommenstext.
* Style 2 ist der graue Knopf - er soll einladen, nicht schreien.
*/
export function selfRoleComponents(): Array<Record<string, unknown>> {
if (SELF_ROLES.length === 0) return [];
return [{
type: 1, // Reihe
components: SELF_ROLES.map((r) => ({
type: 2, // Knopf
style: 2,
label: r.label,
custom_id: r.customId,
emoji: { name: r.emoji },
})),
}];
}
export type RoleButtons = {
handle: (interaction: Interaction) => Promise<void>;
};
export function createRoleButtons(api: RestFull, guildId: string): RoleButtons {
// Ohne Bremse: ein Klick kommt von einem Menschen, und wenn eine Rolle
// gerade erst angelegt wurde, soll der naechste Klick sie finden.
const roles = createRoleResolver(api, guildId);
async function reply(interaction: Interaction, content: string): Promise<void> {
await api.post(`/interactions/${interaction.id}/${interaction.token}/callback`, {
type: CHANNEL_MESSAGE,
data: { content, flags: EPHEMERAL },
});
}
return {
async handle(interaction) {
if (interaction.type !== MESSAGE_COMPONENT) return;
const customId = interaction.data?.custom_id;
const plan: SelfRolePlan | undefined = SELF_ROLES.find((r) => r.customId === customId);
if (!plan) return;
const member = interaction.member;
if (!member) {
// Ausserhalb eines Servers gibt es keine Rollen zu vergeben.
await reply(interaction, 'Das geht nur im Server selbst.');
return;
}
try {
const id = await roles.id(plan.role);
if (!id) {
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`);
return;
}
const user = member.user.id;
const path = `/guilds/${guildId}/members/${user}/roles/${id}`;
const hatSie = member.roles.includes(id);
if (hatSie) {
await api.del(path, 'Rolle per Knopf abgegeben');
await reply(interaction, plan.offText);
log.info(`${member.user.username} hat ${plan.role} abgegeben`);
} else {
await api.put(path, undefined, 'Rolle per Knopf geholt');
await reply(interaction, plan.onText);
log.info(`${member.user.username} hat ${plan.role} geholt`);
}
} catch (err) {
log.error(`Rollenwechsel fehlgeschlagen: ${(err as Error).message}`);
// Auf einen Klick gehoert eine Antwort, auch wenn es schiefging -
// sonst steht bei der Person nur "Interaktion fehlgeschlagen".
await reply(interaction, 'Hat nicht geklappt. Versuch es gleich noch einmal.')
.catch(() => undefined);
}
},
};
}
+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}`;
}
+36
View File
@@ -0,0 +1,36 @@
// Piper selbst laeuft hier nicht - das Programm gibt es nur im Hub. Geprueft
// wird, was vorher mit dem fremden Text passiert.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { piperFehlt, sprechen, stimmenPfad, textFuerPiper } from './piper.ts';
test('Zeilenumbrueche werden zu Leerzeichen, sonst liest Piper zwei Durchlaeufe', () => {
assert.equal(textFuerPiper('Hallo\nWelt\r\nwie gehts'), 'Hallo Welt wie gehts');
});
test('Steuerzeichen fliegen raus, Umlaute und Emoji bleiben', () => {
assert.equal(textFuerPiper('Grüße\u0007 aus\u0000 Köln 🎉'), 'Grüße aus Köln 🎉');
});
test('leerer Text wird gar nicht erst gesprochen', async () => {
await assert.rejects(sprechen(' \n ', { model: '/gibt/es/nicht.onnx' }), /kein Text/);
});
test('fehlendes Programm meldet sich als Fehler statt als Stille', async () => {
await assert.rejects(
sprechen('Hallo', { binary: '/gibt/es/nicht/piper', model: '/gibt/es/nicht.onnx' }),
/Piper nicht startbar/,
);
});
test('piperFehlt nennt Programm und Stimme', () => {
const fehlt = piperFehlt({ binary: '/gibt/es/nicht/piper', model: '/gibt/es/nicht.onnx' });
assert.equal(fehlt.length, 2);
});
test('Stimmenname wird zum Pfad im Modellordner, ein absoluter Pfad bleibt', () => {
assert.match(stimmenPfad('de_DE-thorsten-medium'), /piper[\\/]de_DE-thorsten-medium\.onnx$/);
const abs = process.platform === 'win32' ? 'C:\\x\\s.onnx' : '/x/s.onnx';
assert.equal(stimmenPfad(abs), abs);
});
+114
View File
@@ -0,0 +1,114 @@
// Text in Sprache, mit Piper.
//
// Warum nicht mehr `speechSynthesis` im Browser: in OBS' Browser-Quelle
// kam eine amerikanische Stimme heraus, weil der eingebaute Browser keine
// deutsche sah. Welche Stimmen es gibt, haengt am Rechner, der streamt - und
// damit an etwas, das hier niemand in der Hand hat. Piper laeuft im Hub, die
// Stimme liegt als Datei daneben und klingt auf jedem Rechner gleich.
//
// Gemessen im LXC (2 Kerne): 200 Zeichen in 0,7 s, rund 11 s Audio. Das ist
// schnell genug, um es pro Nachricht frisch zu erzeugen.
//
// Der Text geht ueber stdin hinein, nie als Argument - er ist fremder Text
// aus dem Chat, und so kommt er gar nicht erst in die Naehe einer Shell.
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { paths } from '#core/config.ts';
import { findBinary } from '#core/platform.ts';
import { logger } from '#core/logger.ts';
const log = logger('piper');
export type PiperOptions = {
binary?: string;
/** Pfad zur .onnx-Datei. Die .onnx.json muss daneben liegen. */
model: string;
/**
* Nummer des Sprechers bei Modellen mit mehreren (z. B. `mls` mit 236).
* Ohne Angabe nimmt Piper den ersten.
*/
speaker?: number;
timeoutMs?: number;
};
/** Pfad zum Stimmenmodell aus seinem Namen, z. B. `de_DE-thorsten-medium`. */
export function stimmenPfad(name: string): string {
return path.isAbsolute(name) ? name : path.join(paths.models, 'piper', `${name}.onnx`);
}
export const piperBinary = (): string => findBinary('piper', 'PIPER_BIN');
/**
* Text so herrichten, dass Piper ihn als einen Satzblock liest.
*
* Piper liest zeilenweise: jede Zeile waere ein eigener Durchlauf. Steuer-
* zeichen haben in gesprochenem Text nichts verloren.
*/
export function textFuerPiper(text: string): string {
return text
.replace(/[\u0000-\u001f\u007f]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
/** Was fehlt, damit gesprochen werden kann. Leer heisst: alles da. */
export function piperFehlt(opts: PiperOptions): string[] {
const fehlt: string[] = [];
const bin = opts.binary ?? piperBinary();
// Ein blanker Name wird erst beim Start im PATH gesucht; das laesst sich
// hier nicht vorab pruefen, also nur absolute Pfade.
if (path.isAbsolute(bin) && !fs.existsSync(bin)) fehlt.push(`Programm ${bin}`);
if (!fs.existsSync(opts.model)) fehlt.push(`Stimme ${opts.model}`);
else if (!fs.existsSync(`${opts.model}.json`)) fehlt.push(`Stimmen-Einstellungen ${opts.model}.json`);
return fehlt;
}
/** Erzeugt eine WAV-Datei im Speicher. */
export function sprechen(text: string, opts: PiperOptions): Promise<Buffer> {
const eingabe = textFuerPiper(text);
if (!eingabe) return Promise.reject(new Error('kein Text'));
const bin = opts.binary ?? piperBinary();
return new Promise((resolve, reject) => {
// `--output_file -` schreibt die fertige WAV samt Kopf nach stdout; eine
// Zwischendatei braucht es damit nicht.
const args = ['--model', opts.model, '--output_file', '-', '--quiet'];
if (opts.speaker !== undefined) args.push('--speaker', String(opts.speaker));
const child = spawn(bin, args, {
stdio: ['pipe', 'pipe', 'pipe'],
});
const teile: Buffer[] = [];
let fehler = '';
let fertig = false;
const ende = (fn: () => void): void => {
if (fertig) return;
fertig = true;
clearTimeout(timer);
fn();
};
const timer = setTimeout(() => ende(() => {
child.kill('SIGKILL');
reject(new Error('Piper hat das Zeitlimit ueberschritten'));
}), opts.timeoutMs ?? 20_000);
child.stdout.on('data', (d: Buffer) => teile.push(d));
child.stderr.on('data', (d: Buffer) => { fehler = (fehler + d.toString()).slice(-2000); });
child.on('error', (err) => ende(() => reject(new Error(`Piper nicht startbar (${bin}): ${err.message}`))));
child.on('close', (code) => ende(() => {
const wav = Buffer.concat(teile);
if (code !== 0 || wav.length < 44) {
reject(new Error(`Piper beendet mit Code ${code}: ${fehler.trim().split('\n').pop() ?? ''}`));
return;
}
log.debug(`${eingabe.length} Zeichen -> ${Math.round(wav.length / 1024)} KB`);
resolve(wav);
}));
child.stdin.on('error', () => { /* Prozess schon weg, das meldet 'close' */ });
child.stdin.end(eingabe + '\n');
});
}
+10 -3
View File
@@ -11,9 +11,13 @@ import { forever } from '#core/retry.ts';
const log = logger('obs'); const log = logger('obs');
/** Ereignisgruppen als Bitmaske. Wir brauchen General, Scenes und Outputs. */ /**
export const SUBSCRIPTIONS = { general: 1, config: 2, scenes: 4, outputs: 64 } as const; * Ereignisgruppen als Bitmaske. Wir brauchen General, Scenes und Outputs,
const DEFAULT_SUBS = SUBSCRIPTIONS.general | SUBSCRIPTIONS.scenes | SUBSCRIPTIONS.outputs; * dazu Inputs fuer die Stummschalter auf dem Pult.
*/
export const SUBSCRIPTIONS = { general: 1, config: 2, scenes: 4, inputs: 8, outputs: 64 } as const;
const DEFAULT_SUBS = SUBSCRIPTIONS.general | SUBSCRIPTIONS.scenes |
SUBSCRIPTIONS.inputs | SUBSCRIPTIONS.outputs;
/** /**
* Antwort auf die Authentifizierungsaufforderung. * Antwort auf die Authentifizierungsaufforderung.
@@ -31,6 +35,8 @@ export type ObsEventHandlers = {
onSceneChanged?: (scene: string) => void; onSceneChanged?: (scene: string) => void;
onIdentified?: () => void; onIdentified?: () => void;
onClosed?: () => void; onClosed?: () => void;
/** Jedes Ereignis, auch die oben schon einzeln gemeldeten. */
onEvent?: (type: string, data: Record<string, unknown>) => void;
}; };
export type ObsClient = { export type ObsClient = {
@@ -105,6 +111,7 @@ export function createObs(
} else if (type === 'CurrentProgramSceneChanged') { } else if (type === 'CurrentProgramSceneChanged') {
handlers.onSceneChanged?.(String(data['sceneName'] ?? '')); handlers.onSceneChanged?.(String(data['sceneName'] ?? ''));
} }
handlers.onEvent?.(type, data);
break; break;
} }
case 7: { // RequestResponse case 7: { // RequestResponse
+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})`));
});
}
+118 -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,50 @@ 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> { export type HelixReward = {
const app = readSecrets().apps[role]; id: string; title: string; cost: number; prompt: string;
is_enabled: boolean; is_user_input_required: boolean;
};
/** Was beim Anlegen einer Belohnung mitgeht. Namen wie in der Helix-API. */
export type NeueBelohnung = {
title: string;
cost: number;
prompt?: string;
is_user_input_required?: boolean;
is_global_cooldown_enabled?: boolean;
global_cooldown_seconds?: number;
/** Die Einloesung sofort als erledigt markieren. Aus, damit wir erstatten koennen. */
should_redemptions_skip_request_queue?: boolean;
};
/**
* `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 +122,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 +156,70 @@ 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 }) });
},
/**
* Die Kanalpunkt-Belohnungen des Kanals. Mit `nurEigene` nur die, die diese
* App angelegt hat - und nur deren Einloesungen darf sie spaeter aendern,
* also auch erstatten. Eine im Dashboard angelegte Belohnung taugt dafuer
* nicht.
*/
async customRewards(broadcasterId: string, nurEigene = false): Promise<HelixReward[]> {
const r = await call<{ data: HelixReward[] }>('broadcaster',
`/channel_points/custom_rewards?broadcaster_id=${broadcasterId}` +
(nurEigene ? '&only_manageable_rewards=true' : ''));
return r.data;
},
async createCustomReward(broadcasterId: string, reward: NeueBelohnung): Promise<HelixReward> {
const r = await call<{ data: HelixReward[] }>('broadcaster',
`/channel_points/custom_rewards?broadcaster_id=${broadcasterId}`,
{ method: 'POST', body: JSON.stringify(reward) });
const angelegt = r.data[0];
if (!angelegt) throw new Error('Twitch hat keine Belohnung zurueckgegeben');
return angelegt;
},
/** 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}`);
+35 -1
View File
@@ -33,7 +33,41 @@ export type SecretStore = {
apps: Partial<Record<Role, { clientId: string; clientSecret: string }>>; apps: Partial<Record<Role, { clientId: string; clientSecret: string }>>;
tokens: Partial<Record<Role, TokenSet>>; tokens: Partial<Record<Role, TokenSet>>;
obs?: { password: string }; obs?: { password: string };
discord?: { goLiveWebhook?: string; recapWebhook?: string }; discord?: {
/** Bot-Token der Discord-App. Nur fuers Einrichten und spaeter fuer den Bot. */
botToken?: string;
/** Server-ID ("Guild"), damit das Einrichten kein Argument braucht. */
guildId?: string;
goLiveWebhook?: string;
recapWebhook?: 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;
/**
* Mit diesem Schluessel waehlt sich der Stream-PC beim Hub ein und
* nimmt Befehle fuer OBS entgegen. Anders als overlayKey schaltet er
* etwas, steht deshalb nie in einer Adresse.
*/
fernKey?: string;
};
/** Auf dem Stream-PC: wo der Hub steht und womit man sich dort meldet. */
fern?: { url: string; key: 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. */
update?: { token: string }; update?: { token: string };
+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' };
}
Loaded 100 of 108 files, more files were not shown because too many files have changed in this diff. Show more