YouTube/Twitch live chat backend WebSocket proxy

- YouTube chat polling via liveBroadcasts + liveChat/messages APIs
- Twitch IRC WebSocket client with IRCv3 tag parsing
- ChatManager orchestrator with token refresh, retry logic
- WebSocket endpoint at /chat/ws with JWT auth
- Added chat:read, chat:edit to Twitch OAuth scopes
This commit is contained in:
2026-03-01 22:19:19 +01:00
parent 08cca68086
commit cc8ab2320b
8 changed files with 965 additions and 1 deletions

View File

@@ -0,0 +1,172 @@
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', () => {
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();
const lines = raw.split('\r\n').filter(Boolean);
for (const line of lines) {
this.handleLine(line);
}
});
this.ws.on('close', () => {
this.connected = false;
this.cleanup();
this.emit('disconnected');
});
this.ws.on('error', (err: Error) => {
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 authentication failed')) {
this.emit('error', new Error('missing_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';
}
}