T-HUB/packages/core/src/chat-contract.ts
glass beadian aa5e262f3a Tactical Media Broadcast Hub — initial release
Self-hosted multi-platform broadcast newsroom for activist collectives:
publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style
column wall with hostile-content-safe rendering, live control room with Auto
Director, bridged chat governance with polls and quorum, distributed sandboxed
transcode workers, encrypted content-addressed archive, monthly analytics.

MIT licensed. See README.md and docs/.
2026-10-10 11:56:42 +00:00

302 lines
8 KiB
TypeScript

/**
* 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<T = ChatHubFrame> {
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<string, never>;
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<string, unknown>;
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<string, unknown>).handle === "string" &&
String((m.author as Record<string, unknown>).handle).length > 0 &&
typeof (m.author as Record<string, unknown>).color === "string" &&
(m.replyTo === undefined || typeof m.replyTo === "string")
);
}