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/.
302 lines
8 KiB
TypeScript
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")
|
|
);
|
|
}
|