- 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
177 lines
5.0 KiB
TypeScript
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';
|
|
}
|
|
}
|