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:
151
package-lock.json
generated
151
package-lock.json
generated
@@ -11,6 +11,7 @@
|
|||||||
"@fastify/cookie": "^11.0.1",
|
"@fastify/cookie": "^11.0.1",
|
||||||
"@fastify/cors": "^10.0.1",
|
"@fastify/cors": "^10.0.1",
|
||||||
"@fastify/rate-limit": "^10.2.1",
|
"@fastify/rate-limit": "^10.2.1",
|
||||||
|
"@fastify/websocket": "^11.2.0",
|
||||||
"@prisma/client": "^6.4.1",
|
"@prisma/client": "^6.4.1",
|
||||||
"fastify": "^5.2.1",
|
"fastify": "^5.2.1",
|
||||||
"fastify-plugin": "^5.0.1",
|
"fastify-plugin": "^5.0.1",
|
||||||
@@ -19,6 +20,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.13.4",
|
"@types/node": "^22.13.4",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
"prisma": "^6.4.1",
|
"prisma": "^6.4.1",
|
||||||
"tsx": "^4.19.3",
|
"tsx": "^4.19.3",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
@@ -639,6 +641,27 @@
|
|||||||
"toad-cache": "^3.7.0"
|
"toad-cache": "^3.7.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fastify/websocket": {
|
||||||
|
"version": "11.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-11.2.0.tgz",
|
||||||
|
"integrity": "sha512-3HrDPbAG1CzUCqnslgJxppvzaAZffieOVbLp1DAy1huCSynUWPifSvfdEDUR8HlJLp3sp1A36uOM2tJogADS8w==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fastify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fastify"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"duplexify": "^4.1.3",
|
||||||
|
"fastify-plugin": "^5.0.0",
|
||||||
|
"ws": "^8.16.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@jridgewell/sourcemap-codec": {
|
"node_modules/@jridgewell/sourcemap-codec": {
|
||||||
"version": "1.5.5",
|
"version": "1.5.5",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||||
@@ -1138,6 +1161,16 @@
|
|||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/ws": {
|
||||||
|
"version": "8.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||||
|
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vitest/expect": {
|
"node_modules/@vitest/expect": {
|
||||||
"version": "3.2.4",
|
"version": "3.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
|
||||||
@@ -1527,6 +1560,18 @@
|
|||||||
"url": "https://dotenvx.com"
|
"url": "https://dotenvx.com"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/duplexify": {
|
||||||
|
"version": "4.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz",
|
||||||
|
"integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"end-of-stream": "^1.4.1",
|
||||||
|
"inherits": "^2.0.3",
|
||||||
|
"readable-stream": "^3.1.1",
|
||||||
|
"stream-shift": "^1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/effect": {
|
"node_modules/effect": {
|
||||||
"version": "3.18.4",
|
"version": "3.18.4",
|
||||||
"resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz",
|
"resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz",
|
||||||
@@ -1548,6 +1593,15 @@
|
|||||||
"node": ">=14"
|
"node": ">=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/end-of-stream": {
|
||||||
|
"version": "1.4.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||||
|
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"once": "^1.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/es-module-lexer": {
|
"node_modules/es-module-lexer": {
|
||||||
"version": "1.7.0",
|
"version": "1.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
|
||||||
@@ -1844,6 +1898,12 @@
|
|||||||
"giget": "dist/cli.mjs"
|
"giget": "dist/cli.mjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/inherits": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/ipaddr.js": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz",
|
||||||
@@ -2047,6 +2107,15 @@
|
|||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/once": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"wrappy": "1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pathe": {
|
"node_modules/pathe": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||||
@@ -2245,6 +2314,20 @@
|
|||||||
"destr": "^2.0.3"
|
"destr": "^2.0.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/readable-stream": {
|
||||||
|
"version": "3.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||||
|
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"inherits": "^2.0.3",
|
||||||
|
"string_decoder": "^1.1.1",
|
||||||
|
"util-deprecate": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/readdirp": {
|
"node_modules/readdirp": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||||
@@ -2357,6 +2440,26 @@
|
|||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/safe-buffer": {
|
||||||
|
"version": "5.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
|
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/safe-regex2": {
|
"node_modules/safe-regex2": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.0.0.tgz",
|
||||||
@@ -2468,6 +2571,21 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/stream-shift": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/string_decoder": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "~5.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/strip-literal": {
|
"node_modules/strip-literal": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
|
||||||
@@ -2616,6 +2734,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/util-deprecate": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "7.3.1",
|
"version": "7.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||||
@@ -2810,6 +2934,33 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wrappy": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.19.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||||
|
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"@fastify/cookie": "^11.0.1",
|
"@fastify/cookie": "^11.0.1",
|
||||||
"@fastify/cors": "^10.0.1",
|
"@fastify/cors": "^10.0.1",
|
||||||
"@fastify/rate-limit": "^10.2.1",
|
"@fastify/rate-limit": "^10.2.1",
|
||||||
|
"@fastify/websocket": "^11.2.0",
|
||||||
"@prisma/client": "^6.4.1",
|
"@prisma/client": "^6.4.1",
|
||||||
"fastify": "^5.2.1",
|
"fastify": "^5.2.1",
|
||||||
"fastify-plugin": "^5.0.1",
|
"fastify-plugin": "^5.0.1",
|
||||||
@@ -27,9 +28,10 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.13.4",
|
"@types/node": "^22.13.4",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
"prisma": "^6.4.1",
|
"prisma": "^6.4.1",
|
||||||
"tsx": "^4.19.3",
|
"tsx": "^4.19.3",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
"vitest": "^3.0.5"
|
"vitest": "^3.0.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Fastify from 'fastify';
|
import Fastify from 'fastify';
|
||||||
import cors from '@fastify/cors';
|
import cors from '@fastify/cors';
|
||||||
import rateLimit from '@fastify/rate-limit';
|
import rateLimit from '@fastify/rate-limit';
|
||||||
|
import websocket from '@fastify/websocket';
|
||||||
import prismaPlugin from './plugins/prisma.js';
|
import prismaPlugin from './plugins/prisma.js';
|
||||||
import errorHandlerPlugin from './plugins/error-handler.js';
|
import errorHandlerPlugin from './plugins/error-handler.js';
|
||||||
import authPlugin from './plugins/auth.js';
|
import authPlugin from './plugins/auth.js';
|
||||||
@@ -12,6 +13,8 @@ import youtubeRoutes from './routes/providers/youtube.js';
|
|||||||
import twitchRoutes from './routes/providers/twitch.js';
|
import twitchRoutes from './routes/providers/twitch.js';
|
||||||
import planRoutes from './routes/streams/plans.js';
|
import planRoutes from './routes/streams/plans.js';
|
||||||
import lifecycleRoutes from './routes/streams/lifecycle.js';
|
import lifecycleRoutes from './routes/streams/lifecycle.js';
|
||||||
|
import { createChatRoutes } from './routes/chat/websocket.js';
|
||||||
|
import { ChatManager } from './services/chat-manager.service.js';
|
||||||
import { config } from './config.js';
|
import { config } from './config.js';
|
||||||
|
|
||||||
export async function buildApp() {
|
export async function buildApp() {
|
||||||
@@ -31,6 +34,10 @@ export async function buildApp() {
|
|||||||
await app.register(errorHandlerPlugin);
|
await app.register(errorHandlerPlugin);
|
||||||
await app.register(prismaPlugin);
|
await app.register(prismaPlugin);
|
||||||
await app.register(authPlugin);
|
await app.register(authPlugin);
|
||||||
|
await app.register(websocket);
|
||||||
|
|
||||||
|
// Chat manager (instantiated after prisma is available)
|
||||||
|
const chatManager = new ChatManager(app.prisma, app.log);
|
||||||
|
|
||||||
// Routes
|
// Routes
|
||||||
await app.register(healthRoutes);
|
await app.register(healthRoutes);
|
||||||
@@ -41,6 +48,7 @@ export async function buildApp() {
|
|||||||
await app.register(twitchRoutes);
|
await app.register(twitchRoutes);
|
||||||
await app.register(planRoutes);
|
await app.register(planRoutes);
|
||||||
await app.register(lifecycleRoutes);
|
await app.register(lifecycleRoutes);
|
||||||
|
await app.register(createChatRoutes(chatManager));
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
88
src/routes/chat/websocket.ts
Normal file
88
src/routes/chat/websocket.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
433
src/services/chat-manager.service.ts
Normal file
433
src/services/chat-manager.service.ts
Normal file
@@ -0,0 +1,433 @@
|
|||||||
|
import type { PrismaClient } from '@prisma/client';
|
||||||
|
import type { FastifyBaseLogger } from 'fastify';
|
||||||
|
import type { WebSocket } from 'ws';
|
||||||
|
import { decrypt, encrypt } from './crypto.service.js';
|
||||||
|
import { refreshYouTubeToken } from './youtube.service.js';
|
||||||
|
import { refreshTwitchToken } from './twitch.service.js';
|
||||||
|
import {
|
||||||
|
getYouTubeLiveChatId,
|
||||||
|
pollYouTubeChatMessages,
|
||||||
|
sendYouTubeChatMessage,
|
||||||
|
} from './youtube-chat.service.js';
|
||||||
|
import { TwitchChatClient } from './twitch-chat.service.js';
|
||||||
|
|
||||||
|
interface ChatSession {
|
||||||
|
planId: string;
|
||||||
|
userId: string;
|
||||||
|
socket: WebSocket;
|
||||||
|
youtubePollers: Map<string, { timer: ReturnType<typeof setTimeout>; liveChatId: string; pageToken: string }>;
|
||||||
|
twitchClients: Map<string, TwitchChatClient>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDecryptedToken(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
userId: string,
|
||||||
|
linkedAccountId: string,
|
||||||
|
): Promise<{ account: any; accessToken: string }> {
|
||||||
|
const account = await (prisma as any).linkedAccount.findFirst({
|
||||||
|
where: { id: linkedAccountId, userId },
|
||||||
|
});
|
||||||
|
if (!account) throw new Error(`Linked account ${linkedAccountId} not found`);
|
||||||
|
|
||||||
|
// Lazy refresh if token expires within 60s
|
||||||
|
if (account.tokenExpiresAt < new Date(Date.now() + 60 * 1000)) {
|
||||||
|
const refreshToken = decrypt(account.refreshTokenEnc, account.refreshTokenIv);
|
||||||
|
let newAccess: string;
|
||||||
|
let newRefresh: string | undefined;
|
||||||
|
let expiresIn: number;
|
||||||
|
|
||||||
|
if (account.serviceId === 'YOUTUBE') {
|
||||||
|
const result = await refreshYouTubeToken(refreshToken);
|
||||||
|
newAccess = result.accessToken;
|
||||||
|
expiresIn = result.expiresIn;
|
||||||
|
} else {
|
||||||
|
const result = await refreshTwitchToken(refreshToken);
|
||||||
|
newAccess = result.accessToken;
|
||||||
|
newRefresh = result.refreshToken;
|
||||||
|
expiresIn = result.expiresIn;
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessEnc = encrypt(newAccess);
|
||||||
|
const updateData: any = {
|
||||||
|
accessTokenEnc: accessEnc.ciphertext,
|
||||||
|
accessTokenIv: accessEnc.iv,
|
||||||
|
tokenExpiresAt: new Date(Date.now() + expiresIn * 1000),
|
||||||
|
};
|
||||||
|
if (newRefresh) {
|
||||||
|
const refreshEnc = encrypt(newRefresh);
|
||||||
|
updateData.refreshTokenEnc = refreshEnc.ciphertext;
|
||||||
|
updateData.refreshTokenIv = refreshEnc.iv;
|
||||||
|
}
|
||||||
|
|
||||||
|
await (prisma as any).linkedAccount.update({
|
||||||
|
where: { id: account.id },
|
||||||
|
data: updateData,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { account, accessToken: newAccess };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
account,
|
||||||
|
accessToken: decrypt(account.accessTokenEnc, account.accessTokenIv),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ChatManager {
|
||||||
|
private sessions = new Map<string, ChatSession>(); // key: `${userId}:${planId}`
|
||||||
|
private prisma: PrismaClient;
|
||||||
|
private logger: FastifyBaseLogger;
|
||||||
|
|
||||||
|
constructor(prisma: PrismaClient, logger: FastifyBaseLogger) {
|
||||||
|
this.prisma = prisma;
|
||||||
|
this.logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
async startChat(planId: string, userId: string, socket: WebSocket): Promise<void> {
|
||||||
|
const sessionKey = `${userId}:${planId}`;
|
||||||
|
this.logger.info({ planId, userId, sessionKey }, 'startChat called');
|
||||||
|
|
||||||
|
// Stop existing session for this plan
|
||||||
|
await this.stopChat(planId, userId);
|
||||||
|
|
||||||
|
const plan = await (this.prisma as any).streamPlan.findFirst({
|
||||||
|
where: { id: planId, userId },
|
||||||
|
include: { destinations: true },
|
||||||
|
});
|
||||||
|
if (!plan) {
|
||||||
|
this.logger.warn({ planId, userId }, 'startChat: plan not found');
|
||||||
|
this.sendToSocket(socket, { type: 'chat_status', planId, error: 'Plan not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.logger.info({ planId, destCount: plan.destinations.length, destServices: plan.destinations.map((d: any) => d.serviceId) }, 'startChat: plan loaded');
|
||||||
|
|
||||||
|
const session: ChatSession = {
|
||||||
|
planId,
|
||||||
|
userId,
|
||||||
|
socket,
|
||||||
|
youtubePollers: new Map(),
|
||||||
|
twitchClients: new Map(),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.sessions.set(sessionKey, session);
|
||||||
|
|
||||||
|
for (const dest of plan.destinations) {
|
||||||
|
if (dest.serviceId === 'YOUTUBE' && dest.linkedAccountId) {
|
||||||
|
await this.startYouTubeChat(session, dest);
|
||||||
|
} else if (dest.serviceId === 'TWITCH' && dest.linkedAccountId) {
|
||||||
|
await this.startTwitchChat(session, dest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleSendMessage(
|
||||||
|
planId: string,
|
||||||
|
userId: string,
|
||||||
|
destinationId: string,
|
||||||
|
text: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const sessionKey = `${userId}:${planId}`;
|
||||||
|
const session = this.sessions.get(sessionKey);
|
||||||
|
if (!session) return;
|
||||||
|
|
||||||
|
// Check if it's a YouTube destination
|
||||||
|
const ytPoller = session.youtubePollers.get(destinationId);
|
||||||
|
if (ytPoller) {
|
||||||
|
try {
|
||||||
|
const { accessToken } = await getDecryptedToken(this.prisma, userId, destinationId);
|
||||||
|
await sendYouTubeChatMessage(accessToken, ytPoller.liveChatId, text);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error({ err, destinationId }, 'Failed to send YouTube chat message');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a Twitch destination
|
||||||
|
const twitchClient = session.twitchClients.get(destinationId);
|
||||||
|
if (twitchClient) {
|
||||||
|
twitchClient.sendMessage(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async stopChat(planId: string, userId: string): Promise<void> {
|
||||||
|
const sessionKey = `${userId}:${planId}`;
|
||||||
|
const session = this.sessions.get(sessionKey);
|
||||||
|
if (!session) return;
|
||||||
|
|
||||||
|
// Stop YouTube pollers
|
||||||
|
for (const [, poller] of session.youtubePollers) {
|
||||||
|
clearTimeout(poller.timer);
|
||||||
|
}
|
||||||
|
session.youtubePollers.clear();
|
||||||
|
|
||||||
|
// Disconnect Twitch clients
|
||||||
|
for (const [, client] of session.twitchClients) {
|
||||||
|
client.disconnect();
|
||||||
|
}
|
||||||
|
session.twitchClients.clear();
|
||||||
|
|
||||||
|
this.sessions.delete(sessionKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
stopAllForSocket(socket: WebSocket): void {
|
||||||
|
for (const [key, session] of this.sessions) {
|
||||||
|
if (session.socket === socket) {
|
||||||
|
// Stop YouTube pollers
|
||||||
|
for (const [, poller] of session.youtubePollers) {
|
||||||
|
clearTimeout(poller.timer);
|
||||||
|
}
|
||||||
|
session.youtubePollers.clear();
|
||||||
|
|
||||||
|
// Disconnect Twitch clients
|
||||||
|
for (const [, client] of session.twitchClients) {
|
||||||
|
client.disconnect();
|
||||||
|
}
|
||||||
|
session.twitchClients.clear();
|
||||||
|
|
||||||
|
this.sessions.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async startYouTubeChat(session: ChatSession, dest: any): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { accessToken } = await getDecryptedToken(
|
||||||
|
this.prisma,
|
||||||
|
session.userId,
|
||||||
|
dest.linkedAccountId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Need broadcastId to get liveChatId
|
||||||
|
if (!dest.broadcastId) {
|
||||||
|
this.sendToSocket(session.socket, {
|
||||||
|
type: 'chat_status',
|
||||||
|
planId: session.planId,
|
||||||
|
service: 'YOUTUBE',
|
||||||
|
destinationId: dest.linkedAccountId,
|
||||||
|
connected: false,
|
||||||
|
error: 'No broadcast ID',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry getting liveChatId — YouTube may still be transitioning to live
|
||||||
|
let liveChatId: string | null = null;
|
||||||
|
const MAX_RETRIES = 12; // ~60s total (12 * 5s)
|
||||||
|
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
||||||
|
// Re-check session is still alive
|
||||||
|
if (!this.sessions.has(`${session.userId}:${session.planId}`)) return;
|
||||||
|
|
||||||
|
const { accessToken: freshToken } = await getDecryptedToken(
|
||||||
|
this.prisma,
|
||||||
|
session.userId,
|
||||||
|
dest.linkedAccountId,
|
||||||
|
);
|
||||||
|
liveChatId = await getYouTubeLiveChatId(freshToken, dest.broadcastId);
|
||||||
|
if (liveChatId) break;
|
||||||
|
|
||||||
|
this.logger.info(
|
||||||
|
{ planId: session.planId, broadcastId: dest.broadcastId, attempt: attempt + 1 },
|
||||||
|
'YouTube liveChatId not yet available, retrying...',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (attempt === 0) {
|
||||||
|
this.sendToSocket(session.socket, {
|
||||||
|
type: 'chat_status',
|
||||||
|
planId: session.planId,
|
||||||
|
service: 'YOUTUBE',
|
||||||
|
destinationId: dest.linkedAccountId,
|
||||||
|
connected: false,
|
||||||
|
error: 'Waiting for broadcast to go live...',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!liveChatId) {
|
||||||
|
this.sendToSocket(session.socket, {
|
||||||
|
type: 'chat_status',
|
||||||
|
planId: session.planId,
|
||||||
|
service: 'YOUTUBE',
|
||||||
|
destinationId: dest.linkedAccountId,
|
||||||
|
connected: false,
|
||||||
|
error: 'No active live chat after retries',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sendToSocket(session.socket, {
|
||||||
|
type: 'chat_status',
|
||||||
|
planId: session.planId,
|
||||||
|
service: 'YOUTUBE',
|
||||||
|
destinationId: dest.linkedAccountId,
|
||||||
|
connected: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start polling loop
|
||||||
|
const pollerState = { timer: setTimeout(() => {}, 0), liveChatId, pageToken: '' };
|
||||||
|
session.youtubePollers.set(dest.linkedAccountId, pollerState);
|
||||||
|
|
||||||
|
const poll = async () => {
|
||||||
|
// Verify session is still alive
|
||||||
|
if (!session.youtubePollers.has(dest.linkedAccountId)) {
|
||||||
|
this.logger.info({ planId: session.planId }, 'YouTube poll skipped: poller removed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.logger.info({ planId: session.planId, liveChatId }, 'YouTube poll executing');
|
||||||
|
const { accessToken: token } = await getDecryptedToken(
|
||||||
|
this.prisma,
|
||||||
|
session.userId,
|
||||||
|
dest.linkedAccountId,
|
||||||
|
);
|
||||||
|
const result = await pollYouTubeChatMessages(
|
||||||
|
token,
|
||||||
|
liveChatId,
|
||||||
|
pollerState.pageToken || undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.info({ planId: session.planId, messageCount: result.messages.length, nextInterval: result.pollingIntervalMillis }, 'YouTube poll result');
|
||||||
|
pollerState.pageToken = result.nextPageToken;
|
||||||
|
|
||||||
|
for (const msg of result.messages) {
|
||||||
|
this.sendToSocket(session.socket, {
|
||||||
|
type: 'chat_message',
|
||||||
|
planId: session.planId,
|
||||||
|
service: 'YOUTUBE',
|
||||||
|
destinationId: dest.linkedAccountId,
|
||||||
|
message: {
|
||||||
|
id: msg.id,
|
||||||
|
authorName: msg.authorName,
|
||||||
|
authorImageUrl: msg.authorImageUrl,
|
||||||
|
text: msg.text,
|
||||||
|
timestamp: new Date(msg.publishedAt).getTime(),
|
||||||
|
isModerator: msg.isModerator,
|
||||||
|
isBroadcaster: msg.isChatOwner,
|
||||||
|
color: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule next poll respecting API interval
|
||||||
|
const interval = Math.max(result.pollingIntervalMillis, 5000);
|
||||||
|
pollerState.timer = setTimeout(poll, interval);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error({ err, planId: session.planId }, 'YouTube chat poll error');
|
||||||
|
this.sendToSocket(session.socket, {
|
||||||
|
type: 'chat_status',
|
||||||
|
planId: session.planId,
|
||||||
|
service: 'YOUTUBE',
|
||||||
|
destinationId: dest.linkedAccountId,
|
||||||
|
connected: false,
|
||||||
|
error: 'Poll failed',
|
||||||
|
});
|
||||||
|
// Retry after 10s
|
||||||
|
pollerState.timer = setTimeout(poll, 10_000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// First poll immediately
|
||||||
|
clearTimeout(pollerState.timer);
|
||||||
|
pollerState.timer = setTimeout(poll, 0);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error({ err, planId: session.planId }, 'Failed to start YouTube chat');
|
||||||
|
this.sendToSocket(session.socket, {
|
||||||
|
type: 'chat_status',
|
||||||
|
planId: session.planId,
|
||||||
|
service: 'YOUTUBE',
|
||||||
|
destinationId: dest.linkedAccountId,
|
||||||
|
connected: false,
|
||||||
|
error: 'Failed to initialize',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async startTwitchChat(session: ChatSession, dest: any): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { account, accessToken } = await getDecryptedToken(
|
||||||
|
this.prisma,
|
||||||
|
session.userId,
|
||||||
|
dest.linkedAccountId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const channel = account.displayName;
|
||||||
|
const client = new TwitchChatClient(channel, accessToken, account.displayName);
|
||||||
|
|
||||||
|
client.on('connected', () => {
|
||||||
|
this.sendToSocket(session.socket, {
|
||||||
|
type: 'chat_status',
|
||||||
|
planId: session.planId,
|
||||||
|
service: 'TWITCH',
|
||||||
|
destinationId: dest.linkedAccountId,
|
||||||
|
connected: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on('message', (msg) => {
|
||||||
|
this.sendToSocket(session.socket, {
|
||||||
|
type: 'chat_message',
|
||||||
|
planId: session.planId,
|
||||||
|
service: 'TWITCH',
|
||||||
|
destinationId: dest.linkedAccountId,
|
||||||
|
message: {
|
||||||
|
id: msg.id,
|
||||||
|
authorName: msg.authorName,
|
||||||
|
authorImageUrl: null,
|
||||||
|
text: msg.text,
|
||||||
|
timestamp: msg.timestamp,
|
||||||
|
isModerator: msg.isModerator,
|
||||||
|
isBroadcaster: msg.isBroadcaster,
|
||||||
|
color: msg.color || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on('disconnected', () => {
|
||||||
|
this.sendToSocket(session.socket, {
|
||||||
|
type: 'chat_status',
|
||||||
|
planId: session.planId,
|
||||||
|
service: 'TWITCH',
|
||||||
|
destinationId: dest.linkedAccountId,
|
||||||
|
connected: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on('error', (err: Error) => {
|
||||||
|
this.logger.error({ err, planId: session.planId }, 'Twitch chat error');
|
||||||
|
this.sendToSocket(session.socket, {
|
||||||
|
type: 'chat_status',
|
||||||
|
planId: session.planId,
|
||||||
|
service: 'TWITCH',
|
||||||
|
destinationId: dest.linkedAccountId,
|
||||||
|
connected: false,
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
session.twitchClients.set(dest.linkedAccountId, client);
|
||||||
|
client.connect();
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error({ err, planId: session.planId }, 'Failed to start Twitch chat');
|
||||||
|
this.sendToSocket(session.socket, {
|
||||||
|
type: 'chat_status',
|
||||||
|
planId: session.planId,
|
||||||
|
service: 'TWITCH',
|
||||||
|
destinationId: dest.linkedAccountId,
|
||||||
|
connected: false,
|
||||||
|
error: 'Failed to initialize',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sendToSocket(socket: WebSocket, data: Record<string, unknown>): void {
|
||||||
|
try {
|
||||||
|
if (socket.readyState === 1) { // WebSocket.OPEN
|
||||||
|
socket.send(JSON.stringify(data));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error({ err }, 'Failed to send to WebSocket');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,8 @@ const SCOPES = [
|
|||||||
'channel:manage:broadcast',
|
'channel:manage:broadcast',
|
||||||
'channel:read:stream_key',
|
'channel:read:stream_key',
|
||||||
'user:read:email',
|
'user:read:email',
|
||||||
|
'chat:read',
|
||||||
|
'chat:edit',
|
||||||
].join(' ');
|
].join(' ');
|
||||||
|
|
||||||
export function getTwitchAuthUrl(state: string): string {
|
export function getTwitchAuthUrl(state: string): string {
|
||||||
|
|||||||
108
src/services/youtube-chat.service.ts
Normal file
108
src/services/youtube-chat.service.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
export interface YouTubeChatMessage {
|
||||||
|
id: string;
|
||||||
|
authorName: string;
|
||||||
|
authorImageUrl: string;
|
||||||
|
text: string;
|
||||||
|
publishedAt: string;
|
||||||
|
isModerator: boolean;
|
||||||
|
isChatOwner: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface YouTubeChatPollResult {
|
||||||
|
messages: YouTubeChatMessage[];
|
||||||
|
nextPageToken: string;
|
||||||
|
pollingIntervalMillis: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getYouTubeLiveChatId(
|
||||||
|
accessToken: string,
|
||||||
|
broadcastId: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const res = await fetch(
|
||||||
|
`https://www.googleapis.com/youtube/v3/liveBroadcasts?part=snippet,status&id=${broadcastId}`,
|
||||||
|
{ headers: { Authorization: `Bearer ${accessToken}` } },
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text();
|
||||||
|
console.log(`[YT-Chat] liveBroadcasts API error: ${res.status} ${body}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as any;
|
||||||
|
const items = data.items ?? [];
|
||||||
|
if (items.length === 0) {
|
||||||
|
console.log(`[YT-Chat] No broadcast found for id=${broadcastId}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const broadcast = items[0];
|
||||||
|
const lifeCycleStatus = broadcast.status?.lifeCycleStatus;
|
||||||
|
const liveChatId = broadcast.snippet?.liveChatId ?? null;
|
||||||
|
console.log(`[YT-Chat] broadcast=${broadcastId} lifeCycleStatus=${lifeCycleStatus} liveChatId=${liveChatId}`);
|
||||||
|
return liveChatId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pollYouTubeChatMessages(
|
||||||
|
accessToken: string,
|
||||||
|
liveChatId: string,
|
||||||
|
pageToken?: string,
|
||||||
|
): Promise<YouTubeChatPollResult> {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
liveChatId,
|
||||||
|
part: 'snippet,authorDetails',
|
||||||
|
maxResults: '200',
|
||||||
|
});
|
||||||
|
if (pageToken) params.set('pageToken', pageToken);
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`https://www.googleapis.com/youtube/v3/liveChat/messages?${params}`,
|
||||||
|
{ headers: { Authorization: `Bearer ${accessToken}` } },
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text();
|
||||||
|
throw new Error(`YouTube chat poll failed: ${res.status} ${body}`);
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as any;
|
||||||
|
|
||||||
|
const messages: YouTubeChatMessage[] = (data.items ?? []).map((item: any) => ({
|
||||||
|
id: item.id,
|
||||||
|
authorName: item.authorDetails?.displayName ?? 'Unknown',
|
||||||
|
authorImageUrl: item.authorDetails?.profileImageUrl ?? '',
|
||||||
|
text: item.snippet?.displayMessage ?? '',
|
||||||
|
publishedAt: item.snippet?.publishedAt ?? new Date().toISOString(),
|
||||||
|
isModerator: item.authorDetails?.isChatModerator ?? false,
|
||||||
|
isChatOwner: item.authorDetails?.isChatOwner ?? false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages,
|
||||||
|
nextPageToken: data.nextPageToken ?? '',
|
||||||
|
pollingIntervalMillis: data.pollingIntervalMillis ?? 5000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendYouTubeChatMessage(
|
||||||
|
accessToken: string,
|
||||||
|
liveChatId: string,
|
||||||
|
messageText: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const res = await fetch(
|
||||||
|
'https://www.googleapis.com/youtube/v3/liveChat/messages?part=snippet',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
snippet: {
|
||||||
|
liveChatId,
|
||||||
|
type: 'textMessageEvent',
|
||||||
|
textMessageDetails: { messageText },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text();
|
||||||
|
throw new Error(`YouTube send chat message failed: ${res.status} ${body}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user