import { Injectable, Logger } from '@nestjs/common'; import { createHmac } from 'crypto'; import type { MissionStateResponse } from '@fog-explorer/api-interfaces'; const PUBSUB_URL = 'https://api.twitch.tv/extensions/message'; const TOKEN_TTL_SECONDS = 60; @Injectable() export class TwitchPubSubService { private readonly logger = new Logger(TwitchPubSubService.name); async broadcast( channelId: string | null, state: NonNullable ): Promise { if (!channelId) return; const clientId = process.env['TWITCH_CLIENT_ID']; const secret = process.env['TWITCH_EXTENSION_SECRET']; if (!clientId || !secret) return; const token = buildServerToken(clientId, secret); const payload = JSON.stringify({ content_type: 'application/json', message: JSON.stringify(state), targets: ['broadcast'], }); if (Buffer.byteLength(payload, 'utf8') > 5000) { this.logger.warn({ message: 'pubsub payload too large, skipping', channelId }); return; } try { const res = await fetch(`${PUBSUB_URL}/${channelId}`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Client-Id': clientId, 'Content-Type': 'application/json', }, body: payload, }); if (!res.ok) { this.logger.warn({ message: 'pubsub broadcast failed', channelId, status: res.status, }); } } catch (err) { this.logger.error({ message: 'pubsub broadcast error', channelId, err }); } } } function buildServerToken(clientId: string, secretB64: string): string { const secretBytes = Buffer.from(secretB64, 'base64'); const exp = Math.floor(Date.now() / 1000) + TOKEN_TTL_SECONDS; const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url'); const payload = Buffer.from( JSON.stringify({ exp, user_id: clientId, role: 'external' }) ).toString('base64url'); const sig = createHmac('sha256', secretBytes) .update(`${header}.${payload}`) .digest('base64url'); return `${header}.${payload}.${sig}`; }