/** * S5 chat-unification contract (streaming-architecture.md §3.5). * * This module is the FROZEN wire contract between the Tactical Media Broadcast Hub chat hub, * the web Control Room and the Opticast client. Both ends implement these * shapes; changing them is a version bump, not a drive-by edit. Handwritten * JSON Schema lives in `chat-contract.schema.ts` (validated against the types * in `chat-contract.test.ts`) so clients can validate a transcript without a * code-generation step and without a new dependency. */ /** Topic names multiplexed on the single /realtime socket. */ export type LiveChatTopic = "livechat" | "channel"; /** WS frame operations (§3.5 wire). Nest control ops reuse the gateway's. */ export type LiveChatFrameOp = | "auth" | "ready" | "close" | "subscribe" | "ack" | "nack" | "msg" | "resync" | "unsubscribe" | "ping" | "pong"; /** `msg.type` values on the livechat topic. */ export type LiveChatEventType = | "chat.message" | "chat.moderated" | "chat.delivery" | "chat.gap" | "stream.state" | "capability"; /** * Platforms the hub normalizes today (§3.2 table). These are open string * unions, not a provider registry: the hub resolves adapters by connector id * and the wire stays open so a new platform needs no contract change. */ export type LiveChatProvider = (string & {}); /** Message provenance. `system` covers gap markers and hub notices. */ export type LiveChatSource = "live-chat" | "social-reply" | "system"; export interface LiveChatAuthor { /** Provider login/handle, never empty. */ handle: string; displayName?: string; avatarUrl?: string; badges?: string[]; /** Hash of `provider:handle` (§3.5 identity rule); a CSS colour. */ color: string; } /** Normalized chat message on the wire. Dates are ISO strings. */ export interface ChatMessage { /** Hub-assigned per-live monotonic ordering. Authoritative for cursors. */ seq: number; id: string; liveStreamId: string; connectionId: string; provider: string; providerMessageId: string; author: LiveChatAuthor; text: string; sentAt: string; receivedAt: string; isModerated: boolean; replyTo?: string; source: LiveChatSource; } /** Per-target send outcome; one row per (message, target). */ export interface Delivery { connectionId: string; provider: string; status: "queued" | "sent" | "failed" | "unsupported"; error?: string; } export interface LiveChatCapability { connectionId: string; provider: string; read: boolean; reply: boolean; moderate: boolean; state: "ok" | "paused:quota" | "paused:rate" | "degraded"; reason?: string; } export interface LiveSummary { liveStreamId: string; connectionId: string; provider: string; title: string; state: "created" | "waiting" | "live" | "ended"; } /** Chat message envelope pushed on the hub topic (WS `msg` payload). */ export interface ChatMessageFrame { kind: "chat"; liveStreamId: string; channelId: string; /** Same value as the message's `seq`; resume cursors use it. */ seq: number; message: ChatMessage; } export interface ChatModeratedFrame { kind: "chat"; liveStreamId: string; channelId: string; seq: number; action: "delete" | "timeout" | "ban"; targetMessageId: string; targetHandle?: string; } export interface ChatDeliveryFrame { kind: "chat"; liveStreamId: string; channelId: string; seq: number; messageId: string; delivery: Delivery[]; } /** Emitted when a provider gap is accepted (e.g. a Twitch WS drop). */ export interface ChatGapFrame { kind: "chat"; liveStreamId: string; channelId: string; seq: number; provider: string; reason: string; } export type ChatHubFrame = ChatMessageFrame | ChatModeratedFrame | ChatDeliveryFrame | ChatGapFrame; // --------------------------------------------------------------------------- // WS frames (both ends implement; §3.5 wire table) // --------------------------------------------------------------------------- export interface WsAuthFrame { op: "auth"; /** Bearer token (session or device). Never in the URL. */ token: string; } export type WsSubscribeFrame = { op: "subscribe"; id: string; /** Resume cursor; absent means latest only. Zero requests all retained frames. */ lastSeq?: number; } & ({ topic: "livechat"; liveId: string } | { topic: "channel"; channelId: string }); export interface WsAckFrame { op: "ack"; id: string; fromSeq: number; } export interface WsNackFrame { op: "nack"; id: string; reason: string; } export interface WsMsgFrame { op: "msg"; topic: LiveChatTopic; seq: number; type: LiveChatEventType; payload: T; } export interface WsResyncFrame { op: "resync"; topic: LiveChatTopic; } export interface WsUnsubscribeFrame { op: "unsubscribe"; id: string; } export interface WsPingFrame { op: "ping"; } export interface WsPongFrame { op: "pong"; } export interface WsReadyFrame { op: "ready" } export interface WsCloseFrame { op: "close"; reason: string } export type LiveChatWsFrame = | WsReadyFrame | WsCloseFrame | WsAuthFrame | WsSubscribeFrame | WsAckFrame | WsNackFrame | WsMsgFrame | WsResyncFrame | WsUnsubscribeFrame | WsPingFrame | WsPongFrame; // --------------------------------------------------------------------------- // REST request bodies (§3.5 endpoints; workspace from session/device token) // --------------------------------------------------------------------------- export interface SendChatMessageBody { /** 1..500 characters. */ text: string; targets: "all" | string[]; replyTo?: string; } export interface ModerateChatBody { action: "delete" | "timeout" | "ban"; /** 1..1209600, required for timeout. */ durationSeconds?: number; } /** Issuance has an empty body; workspace and user come from the session. */ export type DevicePairingRequest = Record; export interface DevicePairingResponse { pairingCode: string; qrPayload: string; expiresAt: string; } export interface DeviceRedeemBody { pairingCode: string; deviceName: string; } export interface DeviceRedeemResponse { deviceToken: string; workspaceId: string; } /** Device token claims, mirrored from the `devices` row. */ export interface DeviceTokenPayload { deviceId: string; workspaceId: string; userId: string; scopes: string[]; epoch: number; issuedAt: number; expiresAt: number; } export const DEVICE_SCOPES = ["livechat.view", "livechat.send", "livechat.moderate", "stream.view"] as const; export type DeviceScope = (typeof DEVICE_SCOPES)[number]; /** Deterministic author colour: hash of `provider:handle` (§3.5 identity). */ export function authorColor(provider: string, handle: string): string { let hash = 0x811c9dc5; const key = `${provider}:${handle}`; for (let i = 0; i < key.length; i += 1) { hash ^= key.charCodeAt(i); hash = Math.imul(hash, 0x01000193); } const hue = ((hash >>> 0) % 360 + 360) % 360; return `hsl(${hue} 70% 62%)`; } /** Validate a transcript payload against the frozen message shape. */ export function isValidChatMessage(value: unknown): value is ChatMessage { if (typeof value !== "object" || value === null) return false; const m = value as Record; return ( typeof m.seq === "number" && Number.isSafeInteger(m.seq) && m.seq > 0 && typeof m.id === "string" && typeof m.liveStreamId === "string" && typeof m.connectionId === "string" && typeof m.provider === "string" && typeof m.providerMessageId === "string" && typeof m.text === "string" && typeof m.sentAt === "string" && typeof m.receivedAt === "string" && typeof m.isModerated === "boolean" && typeof m.source === "string" && (m.source === "live-chat" || m.source === "social-reply" || m.source === "system") && typeof m.author === "object" && m.author !== null && typeof (m.author as Record).handle === "string" && String((m.author as Record).handle).length > 0 && typeof (m.author as Record).color === "string" && (m.replyTo === undefined || typeof m.replyTo === "string") ); }