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>
107 lines
4.2 KiB
TypeScript
107 lines
4.2 KiB
TypeScript
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { parseLine, toChatMessage, parseGifs } from './irc.ts';
|
|
|
|
const LINE =
|
|
'@badge-info=subscriber/8;badges=broadcaster/1,subscriber/6;color=#1E90FF;' +
|
|
'display-name=Nic_OOo;emotes=25:0-4/1902:6-10;first-msg=0;id=abc-123;mod=0;' +
|
|
'returning-chatter=0;subscriber=1;tmi-sent-ts=1757404800000;user-id=98765 ' +
|
|
':nic_ooo!nic_ooo@nic_ooo.tmi.twitch.tv PRIVMSG #abdulive_ :Kappa Keepo mehr Rost als Farbe';
|
|
|
|
test('zerlegt eine PRIVMSG mit Tags', () => {
|
|
const m = parseLine(LINE);
|
|
assert.ok(m);
|
|
assert.equal(m.command, 'PRIVMSG');
|
|
assert.equal(m.params[0], '#abdulive_');
|
|
assert.equal(m.params[1], 'Kappa Keepo mehr Rost als Farbe');
|
|
assert.equal(m.tags['user-id'], '98765');
|
|
assert.equal(m.tags['display-name'], 'Nic_OOo');
|
|
});
|
|
|
|
test('entschluesselt escapte Tag-Werte', () => {
|
|
const m = parseLine('@system-msg=Ein\\sTest\\szur\\sSache;id=x :tmi.twitch.tv USERNOTICE #k');
|
|
assert.equal(m?.tags['system-msg'], 'Ein Test zur Sache');
|
|
});
|
|
|
|
test('uebersetzt in eine ChatMessage', () => {
|
|
const msg = toChatMessage(parseLine(LINE)!);
|
|
assert.ok(msg);
|
|
assert.equal(msg.userId, '98765');
|
|
assert.equal(msg.login, 'nic_ooo');
|
|
assert.equal(msg.displayName, 'Nic_OOo');
|
|
assert.equal(msg.isBroadcaster, true);
|
|
assert.equal(msg.isSub, true);
|
|
assert.equal(msg.isFirst, false);
|
|
assert.equal(msg.isCommand, false);
|
|
assert.equal(msg.ts, new Date(1757404800000).toISOString());
|
|
// Emote-Namen werden aus den Positionsangaben zurueckgelesen
|
|
assert.deepEqual(msg.emotes.map((e) => e.name).sort(), ['Kappa', 'Keepo']);
|
|
});
|
|
|
|
test('erkennt Befehle und Erstchatter', () => {
|
|
const line =
|
|
'@first-msg=1;id=y;tmi-sent-ts=1757404800000;user-id=42;display-name=Neu ' +
|
|
':neu!neu@neu.tmi.twitch.tv PRIVMSG #abdulive_ :!clip';
|
|
const msg = toChatMessage(parseLine(line)!)!;
|
|
assert.equal(msg.isFirst, true);
|
|
assert.equal(msg.isCommand, true);
|
|
});
|
|
|
|
test('ignoriert Nachrichten ohne user-id', () => {
|
|
const line = ':tmi.twitch.tv PRIVMSG #abdulive_ :ohne Tags';
|
|
assert.equal(toChatMessage(parseLine(line)!), null);
|
|
});
|
|
|
|
test('PING wird als solches erkannt', () => {
|
|
const m = parseLine('PING :tmi.twitch.tv');
|
|
assert.equal(m?.command, 'PING');
|
|
assert.equal(m?.params[0], 'tmi.twitch.tv');
|
|
});
|
|
|
|
// ---------------------------------------------------------------- GIFs
|
|
|
|
test('der gifs-Tag wird zerlegt', () => {
|
|
// Echte Zeile aus #abdulive_, mitgeschnitten am 2026-09-17.
|
|
const g = parseGifs(
|
|
'0-22|Pnd7qLFuGCOntZcNKB|https://media0.giphy.com/media/Pnd7qLFuGCOntZcNKB/giphy.gif?cid=095d&ct=g');
|
|
assert.equal(g.length, 1);
|
|
assert.equal(g[0]!.id, 'Pnd7qLFuGCOntZcNKB');
|
|
assert.equal(g[0]!.from, 0);
|
|
assert.equal(g[0]!.to, 22);
|
|
assert.match(g[0]!.url, /^https:\/\/media0\.giphy\.com\//);
|
|
assert.ok(g[0]!.url.includes('&ct=g'), 'die Adresse darf nicht am & abgeschnitten werden');
|
|
});
|
|
|
|
test('der Bereich deckt genau den Platzhalter ab', () => {
|
|
// Wenn das nicht stimmt, bleibt ein Rest der eckigen Klammer im Bild stehen.
|
|
const text = '[Idk GIF by Respective]';
|
|
const g = parseGifs('0-22|abc|https://x/y.gif')[0]!;
|
|
assert.equal([...text].slice(g.from, g.to + 1).join(''), text);
|
|
});
|
|
|
|
test('eine Nachricht ohne GIF hat eine leere Liste', () => {
|
|
const m = parseLine('@id=1;user-id=7;emotes= :a!a@a PRIVMSG #k :moin');
|
|
assert.deepEqual(toChatMessage(m!)!.gifs, []);
|
|
});
|
|
|
|
test('eine Nachricht mit GIF traegt es bis in die ChatMessage', () => {
|
|
const m = parseLine(
|
|
'@id=1;user-id=7;emotes=;gifs=0-4|xy|https://media.giphy.com/a.gif ' +
|
|
':a!a@a PRIVMSG #k :[GIF]');
|
|
assert.equal(toChatMessage(m!)!.gifs[0]!.url, 'https://media.giphy.com/a.gif');
|
|
});
|
|
|
|
test('kein https, kein Bild', () => {
|
|
// Der Tag kommt von Twitch, nicht vom Chatter - trotzdem landet die Adresse
|
|
// als Bild im Stream, und dort gilt kein Vertrauensvorschuss.
|
|
assert.deepEqual(parseGifs('0-4|xy|http://media.giphy.com/a.gif'), []);
|
|
assert.deepEqual(parseGifs('0-4|xy|javascript:alert(1)'), []);
|
|
});
|
|
|
|
test('unvollstaendige oder unsinnige Tags werden verworfen', () => {
|
|
for (const kaputt of ['', '0-4', '0-4|nurid', 'abc|x|https://y/z.gif', '-1-4|x|https://y/z.gif',
|
|
'9-4|x|https://y/z.gif']) {
|
|
assert.deepEqual(parseGifs(kaputt), [], `"${kaputt}" haette verworfen gehoert`);
|
|
}
|
|
});
|