Files
fog/apps/api/src/app/tick-engine/twitch-pubsub.service.ts

73 lines
2.1 KiB
TypeScript
Raw Normal View History

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<MissionStateResponse>
): Promise<void> {
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}`;
}