Files
lck-control-backend/src/services/twitch-chat.service.ts
omigamedev 6931670a1f Resilient prepare, Twitch chat echo, parallel chat startup
- Prepare endpoint wraps each destination in try/catch; partial success
  if at least one destination is ready (e.g., Twitch works when YouTube
  is rate-limited)
- Echo sent Twitch messages back to app WebSocket (IRC doesn't echo
  your own PRIVMSGs)
- Start YouTube and Twitch chat clients in parallel via Promise.allSettled
- Fix Twitch auth failure detection (Login unsuccessful + Login
  authentication failed)
- Add Twitch IRC debug logging
2026-03-02 09:40:15 +01:00

177 lines
5.0 KiB
TypeScript

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<typeof setInterval> | 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<string, string> = {};
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';
}
}