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:
172
src/services/twitch-chat.service.ts
Normal file
172
src/services/twitch-chat.service.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user