// Twitch-Chat ueber IRC (WebSocket). Warum IRC und nicht EventSub: die // IRCv3-Tags enthalten first-msg, returning-chatter, msg-id, badges und // Emote-Positionen - genau das, woran Community-Gedaechtnis und // Spike-Erkennung haengen. Siehe ADR-0003. // // Ohne Zugangsdaten wird anonym verbunden (justinfan). Dann kann man mitlesen, // aber nicht schreiben - reicht, um die Datenaufnahme zu pruefen, bevor die // Twitch-Apps eingerichtet sind. import { logger } from '#core/logger.ts'; import { forever } from '#core/retry.ts'; import type { ChatMessage } from '#shared/events.ts'; import { accessToken } from './oauth.ts'; import { readSecrets } from './secrets.ts'; const log = logger('irc'); const URL_WS = 'wss://irc-ws.chat.twitch.tv:443'; export type IrcMessage = { tags: Record; prefix: string; command: string; params: string[] }; /** IRCv3-Zeile zerlegen: `@tags :prefix COMMAND params :trailing` */ export function parseLine(line: string): IrcMessage | null { if (!line) return null; let rest = line; const tags: Record = {}; if (rest.startsWith('@')) { const sp = rest.indexOf(' '); for (const pair of rest.slice(1, sp).split(';')) { const eq = pair.indexOf('='); const k = eq === -1 ? pair : pair.slice(0, eq); const v = eq === -1 ? '' : pair.slice(eq + 1); tags[k] = v .replace(/\\s/g, ' ').replace(/\\n/g, '\n').replace(/\\r/g, '\r') .replace(/\\:/g, ';').replace(/\\\\/g, '\\'); } rest = rest.slice(sp + 1); } let prefix = ''; if (rest.startsWith(':')) { const sp = rest.indexOf(' '); prefix = rest.slice(1, sp); rest = rest.slice(sp + 1); } const trailingAt = rest.indexOf(' :'); const head = trailingAt === -1 ? rest : rest.slice(0, trailingAt); const parts = head.split(' ').filter(Boolean); const command = parts.shift() ?? ''; if (trailingAt !== -1) parts.push(rest.slice(trailingAt + 2)); return { tags, prefix, command, params: parts }; } /** * Der `gifs`-Tag. Twitch hat ihn 2026 mit der GIF-Auswahl im Chat eingefuehrt. * * Aufbau, am echten Chat abgelesen: * * gifs=0-22|Pnd7qLFuGCOntZcNKB|https://media0.giphy.com/media/.../giphy.gif?cid=... * ^^^^ ^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ * Bereich Giphy-ID Adresse des Bildes * * Der Bereich zeigt wie bei `emotes` auf Zeichen im Nachrichtentext - dort * steht ein Platzhalter wie `[Idk GIF by Respective]`, der ersetzt gehoert. * Wer den Tag nicht auswertet, zeigt genau diesen Platzhalter an. * * Ob mehrere GIFs je Nachricht moeglich sind, ist offen: der Trenner koennte * dafuer nicht `/` sein wie bei Emotes, weil eine URL selbst Schraegstriche * enthaelt. Beobachtet wurde bisher genau eines, und danach ist das hier * gebaut - mehr wuerde ignoriert statt falsch dargestellt. */ export function parseGifs(tag: string | undefined): ChatMessage['gifs'] { if (!tag) return []; const teile = tag.split('|'); if (teile.length < 3) return []; // Streng auf Ziffern pruefen und nicht auf `Number` vertrauen: `-1-4` // zerfaellt beim Trennen in ['', '1', '4'], und `Number('')` ist 0. Der // Bereich saehe dann gueltig aus und wuerde den falschen Textteil ersetzen. const bereich = /^(\d+)-(\d+)$/.exec(teile[0] ?? ''); if (!bereich) return []; const from = Number(bereich[1]); const to = Number(bereich[2]); const id = teile[1] ?? ''; // Alles ab dem zweiten Strich ist die Adresse - falls dort je einer auftaucht. const url = teile.slice(2).join('|'); if (to < from) return []; // Nur https. Der Tag kommt von Twitch und nicht vom Chatter, aber er landet // als Bildadresse im Stream - das ist kein Ort fuer Vertrauensvorschuss. if (!url.startsWith('https://')) return []; return [{ id, url, from, to }]; } function parseEmotes(tag: string | undefined, text: string): ChatMessage['emotes'] { if (!tag) return []; const out: ChatMessage['emotes'] = []; const chars = [...text]; for (const part of tag.split('/')) { const [id, ranges] = part.split(':'); if (!id || !ranges) continue; const spans = ranges.split(','); const first = spans[0]?.split('-'); const name = first && first[0] !== undefined && first[1] !== undefined ? chars.slice(Number(first[0]), Number(first[1]) + 1).join('') : id; out.push({ id, name, count: spans.length }); } return out; } export function toChatMessage(m: IrcMessage): ChatMessage | null { if (m.command !== 'PRIVMSG') return null; const text = m.params[1] ?? ''; const login = m.prefix.split('!')[0] ?? ''; const userId = m.tags['user-id']; if (!userId) return null; const badges = m.tags['badges'] ?? ''; return { id: m.tags['id'] ?? `${userId}-${Date.now()}`, ts: new Date(Number(m.tags['tmi-sent-ts'] ?? Date.now())).toISOString(), userId, login, displayName: m.tags['display-name'] || login, text, emotes: parseEmotes(m.tags['emotes'], text), gifs: parseGifs(m.tags['gifs']), isFirst: m.tags['first-msg'] === '1', isReturning: m.tags['returning-chatter'] === '1', isCommand: text.startsWith('!'), isMod: m.tags['mod'] === '1' || badges.includes('broadcaster'), isSub: m.tags['subscriber'] === '1', isBroadcaster: badges.includes('broadcaster'), bits: Number(m.tags['bits'] ?? 0), color: m.tags['color'] || null, }; } /** * Ein USERNOTICE ist alles, was Twitch als Ereignis in den Chat schreibt: * Abos, verschenkte Abos, Raids, Ankuendigungen. * * Warum hier und nicht ueber EventSub: es kommt ohnehin ueber dieselbe * Verbindung, kostet keinen zusaetzlichen Token und keine zweite * Wiederverbindung. Was IRC NICHT liefert, sind Follows - dafuer braucht es * EventSub, und bis dahin bleibt das Overlay bei Follows stumm. */ export type UserNoticeKind = | 'sub' | 'resub' | 'subgift' | 'massgift' | 'raid' | 'announcement' | 'other'; export type UserNotice = { kind: UserNoticeKind; /** Roher msg-id-Wert, falls Twitch etwas Neues schickt. */ rawKind: string; ts: string; login: string; displayName: string; /** Twitchs eigener Satz dazu, schon fertig formuliert. */ systemMessage: string; /** Was die Person selbst dazugeschrieben hat. Oft leer. */ text: string; months?: number; /** Prime, 1000, 2000, 3000 - so wie Twitch es nennt. */ tier?: string; /** Beschenkte Person bei subgift. */ recipient?: string; /** Anzahl: verschenkte Abos oder mitgebrachte Zuschauer beim Raid. */ count?: number; }; const NOTICE_KINDS: Record = { sub: 'sub', resub: 'resub', subgift: 'subgift', submysterygift: 'massgift', raid: 'raid', announcement: 'announcement', }; function num(v: string | undefined): number | undefined { const n = Number(v); return v !== undefined && v !== '' && Number.isFinite(n) ? n : undefined; } export function toUserNotice(m: IrcMessage): UserNotice | null { if (m.command !== 'USERNOTICE') return null; const rawKind = m.tags['msg-id'] ?? ''; const login = m.tags['login'] ?? ''; const months = num(m.tags['msg-param-cumulative-months']) ?? num(m.tags['msg-param-months']); // Beim Raid steht die Zuschauerzahl drin, beim Massengeschenk die Anzahl. const count = num(m.tags['msg-param-viewerCount']) ?? num(m.tags['msg-param-mass-gift-count']); const recipient = m.tags['msg-param-recipient-display-name'] || m.tags['msg-param-recipient-user-name']; const tier = m.tags['msg-param-sub-plan']; return { kind: NOTICE_KINDS[rawKind] ?? 'other', rawKind, ts: new Date(Number(m.tags['tmi-sent-ts'] ?? Date.now())).toISOString(), login, displayName: m.tags['display-name'] || login, systemMessage: m.tags['system-msg'] ?? '', text: m.params[1] ?? '', ...(months !== undefined ? { months } : {}), ...(tier ? { tier } : {}), ...(recipient ? { recipient } : {}), ...(count !== undefined ? { count } : {}), }; } export type IrcHandlers = { onMessage: (msg: ChatMessage) => void; onNotice?: (text: string) => void; onConnected?: (anonymous: boolean) => void; /** Abos, Raids, Ankuendigungen. */ onUserNotice?: (notice: UserNotice) => void; /** * Eine Nachricht oder ein ganzer Chatter wurde geloescht. Das Overlay muss * das mitbekommen, sonst steht eine geloeschte Beleidigung noch eine Minute * lang im Bild - und zwar in der Aufzeichnung. */ onClear?: (what: { userId?: string; messageId?: string }) => void; }; export type IrcClient = { /** Haelt die Verbindung dauerhaft; kehrt erst bei `signal.abort` zurueck. */ run: (signal: AbortSignal) => Promise; /** Sendet in den Chat. Anonym verbundene Clients koennen nicht senden. */ say: (text: string) => boolean; isAnonymous: () => boolean; }; export function createIrc(channel: string, handlers: IrcHandlers): IrcClient { let send: ((text: string) => boolean) | null = null; let anonymous = true; return { run: (signal) => forever( async (sig) => { const conn = await connectOnce(channel, handlers, sig, (fn, anon) => { send = fn; anonymous = anon; }); void conn; }, { signal, onWait: (ms, n) => log.warn(`Chat getrennt, neuer Versuch in ${Math.round(ms / 1000)} s (${n}.)`), } ), say: (text) => (send ? send(text) : false), isAnonymous: () => anonymous, }; } function connectOnce( channel: string, handlers: IrcHandlers, signal: AbortSignal, ready: (say: (text: string) => boolean, anonymous: boolean) => void ): Promise { return new Promise((_resolve, reject) => { const ws = new WebSocket(URL_WS); let anonymous = true; let joined = false; let alive: ReturnType | null = null; const shutdown = (why: string) => { if (alive) clearInterval(alive); try { ws.close(); } catch { /* schon zu */ } reject(new Error(why)); }; signal.addEventListener('abort', () => shutdown('abgebrochen'), { once: true }); ws.addEventListener('open', () => { void (async () => { ws.send('CAP REQ :twitch.tv/tags twitch.tv/commands'); const bot = readSecrets().tokens.bot; if (bot) { try { ws.send(`PASS oauth:${await accessToken('bot')}`); ws.send(`NICK ${bot.login}`); anonymous = false; } catch (err) { log.warn(`Bot-Token nicht nutzbar, lese anonym mit: ${(err as Error).message}`); } } if (anonymous) ws.send(`NICK justinfan${10_000 + Math.floor(Math.random() * 80_000)}`); ws.send(`JOIN #${channel.toLowerCase()}`); })(); }); ws.addEventListener('message', (ev) => { for (const line of String(ev.data).split('\r\n')) { const m = parseLine(line); if (!m) continue; if (m.command === 'PING') { ws.send(`PONG :${m.params[0] ?? 'tmi.twitch.tv'}`); continue; } if (m.command === 'JOIN' && !joined) { joined = true; log.info(`Chat verbunden mit #${channel}${anonymous ? ' (anonym, nur lesend)' : ''}`); ready((text) => { if (anonymous || ws.readyState !== WebSocket.OPEN) return false; ws.send(`PRIVMSG #${channel.toLowerCase()} :${text}`); return true; }, anonymous); handlers.onConnected?.(anonymous); // Twitch schliesst stille Verbindungen; alle 4 Minuten ein Lebenszeichen alive = setInterval(() => { try { ws.send('PING :keepalive'); } catch { /* egal */ } }, 240_000); continue; } if (m.command === 'NOTICE') { handlers.onNotice?.(m.params[1] ?? ''); continue; } if (m.command === 'USERNOTICE') { const notice = toUserNotice(m); if (notice) handlers.onUserNotice?.(notice); continue; } if (m.command === 'CLEARCHAT') { // Ohne target-user-id wurde der ganze Chat geleert. const userId = m.tags['target-user-id']; handlers.onClear?.(userId ? { userId } : {}); continue; } if (m.command === 'CLEARMSG') { const messageId = m.tags['target-msg-id']; if (messageId) handlers.onClear?.({ messageId }); continue; } if (m.command === 'PRIVMSG') { const msg = toChatMessage(m); if (msg) handlers.onMessage(msg); } } }); ws.addEventListener('error', () => shutdown('WebSocket-Fehler')); ws.addEventListener('close', (ev) => shutdown(`Verbindung geschlossen (${ev.code})`)); }); }