- 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
89 lines
2.7 KiB
TypeScript
89 lines
2.7 KiB
TypeScript
import { FastifyPluginAsync } from 'fastify';
|
|
import { verifyAccessToken } from '../../plugins/auth.js';
|
|
import { ChatManager } from '../../services/chat-manager.service.js';
|
|
|
|
interface ChatWsMessage {
|
|
type: 'subscribe' | 'unsubscribe' | 'send_message';
|
|
planId?: string;
|
|
destinationId?: string;
|
|
text?: string;
|
|
}
|
|
|
|
export function createChatRoutes(chatManager: ChatManager): FastifyPluginAsync {
|
|
const chatRoutes: FastifyPluginAsync = async (fastify) => {
|
|
fastify.get('/chat/ws', { websocket: true }, async (socket, request) => {
|
|
// Authenticate via query param
|
|
const url = new URL(request.url, `http://${request.headers.host}`);
|
|
const token = url.searchParams.get('token');
|
|
|
|
if (!token) {
|
|
socket.send(JSON.stringify({ type: 'error', error: 'Missing token' }));
|
|
socket.close();
|
|
return;
|
|
}
|
|
|
|
let userId: string;
|
|
try {
|
|
userId = await verifyAccessToken(token);
|
|
} catch {
|
|
socket.send(JSON.stringify({ type: 'error', error: 'Invalid or expired token' }));
|
|
socket.close();
|
|
return;
|
|
}
|
|
|
|
request.log.info({ userId }, 'Chat WebSocket connected');
|
|
|
|
socket.on('message', async (data: Buffer) => {
|
|
try {
|
|
const raw = data.toString();
|
|
request.log.info({ userId, raw }, 'Chat WS message received');
|
|
const msg: ChatWsMessage = JSON.parse(raw);
|
|
|
|
switch (msg.type) {
|
|
case 'subscribe':
|
|
if (msg.planId) {
|
|
await chatManager.startChat(msg.planId, userId, socket);
|
|
}
|
|
break;
|
|
|
|
case 'unsubscribe':
|
|
if (msg.planId) {
|
|
await chatManager.stopChat(msg.planId, userId);
|
|
}
|
|
break;
|
|
|
|
case 'send_message':
|
|
if (msg.planId && msg.destinationId && msg.text) {
|
|
await chatManager.handleSendMessage(
|
|
msg.planId,
|
|
userId,
|
|
msg.destinationId,
|
|
msg.text,
|
|
);
|
|
}
|
|
break;
|
|
|
|
default:
|
|
socket.send(JSON.stringify({ type: 'error', error: `Unknown message type: ${msg.type}` }));
|
|
}
|
|
} catch (err) {
|
|
request.log.error({ err }, 'Chat WebSocket message handling error');
|
|
socket.send(JSON.stringify({ type: 'error', error: 'Internal error' }));
|
|
}
|
|
});
|
|
|
|
socket.on('close', () => {
|
|
request.log.info({ userId }, 'Chat WebSocket disconnected');
|
|
chatManager.stopAllForSocket(socket);
|
|
});
|
|
|
|
socket.on('error', (err: Error) => {
|
|
request.log.error({ err, userId }, 'Chat WebSocket error');
|
|
chatManager.stopAllForSocket(socket);
|
|
});
|
|
});
|
|
};
|
|
|
|
return chatRoutes;
|
|
}
|