// 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 }; } 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), 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, }; } export type IrcHandlers = { onMessage: (msg: ChatMessage) => void; onNotice?: (text: string) => void; onConnected?: (anonymous: boolean) => 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 === '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})`)); }); }