import { EventEmitter } from 'events'; import { WebSocket } from 'ws'; export interface TwitchChatMessage { id: string; authorName: string; text: string; timestamp: number; isModerator: boolean; isBroadcaster: boolean; color: string; badges: string; } export class TwitchChatClient extends EventEmitter { private ws: WebSocket | null = null; private channel: string; private token: string; private nick: string; private connected = false; private pingTimer: ReturnType | null = null; constructor(channel: string, token: string, nick: string) { super(); this.channel = channel.toLowerCase(); this.token = token; this.nick = nick.toLowerCase(); } connect(): void { if (this.ws) return; this.ws = new WebSocket('wss://irc-ws.chat.twitch.tv:443'); this.ws.on('open', () => { console.log(`[Twitch-IRC] WebSocket open for #${this.channel}`); if (!this.ws) return; // Request capabilities this.ws.send('CAP REQ :twitch.tv/tags twitch.tv/commands'); // Authenticate this.ws.send(`PASS oauth:${this.token}`); this.ws.send(`NICK ${this.nick}`); // Join channel this.ws.send(`JOIN #${this.channel}`); }); this.ws.on('message', (data: Buffer) => { const raw = data.toString(); console.log(`[Twitch-IRC] #${this.channel} << ${raw.trim().substring(0, 200)}`); const lines = raw.split('\r\n').filter(Boolean); for (const line of lines) { this.handleLine(line); } }); this.ws.on('close', (code: number, reason: Buffer) => { console.log(`[Twitch-IRC] #${this.channel} closed: ${code} ${reason.toString()}`); this.connected = false; this.cleanup(); this.emit('disconnected'); }); this.ws.on('error', (err: Error) => { console.log(`[Twitch-IRC] #${this.channel} error: ${err.message}`); this.emit('error', err); }); // Keep-alive: send PING every 4 minutes this.pingTimer = setInterval(() => { if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send('PING :tmi.twitch.tv'); } }, 240_000); } sendMessage(text: string): void { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return; this.ws.send(`PRIVMSG #${this.channel} :${text}`); } disconnect(): void { this.cleanup(); if (this.ws) { this.ws.close(); this.ws = null; } } private cleanup(): void { if (this.pingTimer) { clearInterval(this.pingTimer); this.pingTimer = null; } } private handleLine(line: string): void { // Handle PING if (line.startsWith('PING')) { this.ws?.send('PONG :tmi.twitch.tv'); return; } // Handle successful join / connection if (line.includes('366') || line.includes(':End of /NAMES list')) { if (!this.connected) { this.connected = true; this.emit('connected'); } return; } // Handle auth failure if (line.includes('NOTICE') && (line.includes('Login unsuccessful') || line.includes('Login authentication failed'))) { this.emit('error', new Error('Twitch login failed — token may be expired or missing chat scopes')); this.disconnect(); return; } // Parse PRIVMSG with IRCv3 tags if (line.includes('PRIVMSG')) { const msg = this.parsePrivmsg(line); if (msg) { this.emit('message', msg); } } } private parsePrivmsg(line: string): TwitchChatMessage | null { // Format: @tags :user!user@user.tmi.twitch.tv PRIVMSG #channel :message let tags: Record = {}; let rest = line; if (rest.startsWith('@')) { const spaceIdx = rest.indexOf(' '); if (spaceIdx === -1) return null; const tagStr = rest.substring(1, spaceIdx); rest = rest.substring(spaceIdx + 1); for (const part of tagStr.split(';')) { const eq = part.indexOf('='); if (eq !== -1) { tags[part.substring(0, eq)] = part.substring(eq + 1); } } } // Find message text after "PRIVMSG #channel :" const privmsgIdx = rest.indexOf('PRIVMSG'); if (privmsgIdx === -1) return null; const afterPrivmsg = rest.substring(privmsgIdx); const colonIdx = afterPrivmsg.indexOf(' :'); if (colonIdx === -1) return null; const text = afterPrivmsg.substring(colonIdx + 2); const displayName = tags['display-name'] || this.extractNick(rest); const timestamp = tags['tmi-sent-ts'] ? parseInt(tags['tmi-sent-ts'], 10) : Date.now(); const badges = tags['badges'] || ''; return { id: tags['id'] || `twitch-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`, authorName: displayName, text, timestamp, isModerator: badges.includes('moderator') || tags['mod'] === '1', isBroadcaster: badges.includes('broadcaster'), color: tags['color'] || '', badges, }; } private extractNick(line: string): string { // :nick!nick@nick.tmi.twitch.tv PRIVMSG ... const match = line.match(/^:(\w+)!/); return match ? match[1] : 'unknown'; } }