@marktiderman/genesis-chat 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/README.md +304 -13
  2. package/dist/{chunk-SAY24E6V.js → chunk-5D5A7I4Q.js} +166 -45
  3. package/dist/chunk-5D5A7I4Q.js.map +1 -0
  4. package/dist/{chunk-JQXZUEEL.js → chunk-7S6HYNWW.js} +382 -8
  5. package/dist/chunk-7S6HYNWW.js.map +1 -0
  6. package/dist/chunk-BJCE5O7V.js +70 -0
  7. package/dist/chunk-BJCE5O7V.js.map +1 -0
  8. package/dist/chunk-PZTOY2LY.js +576 -0
  9. package/dist/chunk-PZTOY2LY.js.map +1 -0
  10. package/dist/chunk-VEPZROQB.js +187 -0
  11. package/dist/chunk-VEPZROQB.js.map +1 -0
  12. package/dist/{client-BtdALFCx.d.ts → client-DMa2j7kz.d.ts} +152 -11
  13. package/dist/index.d.ts +78 -163
  14. package/dist/index.js +3 -2
  15. package/dist/native.d.ts +1910 -1
  16. package/dist/native.js +4900 -0
  17. package/dist/native.js.map +1 -1
  18. package/dist/outbox-B0y3phE0.d.ts +151 -0
  19. package/dist/push.d.ts +206 -0
  20. package/dist/push.js +3 -0
  21. package/dist/push.js.map +1 -0
  22. package/dist/react.d.ts +149 -7
  23. package/dist/react.js +3 -3
  24. package/dist/server.d.ts +510 -21
  25. package/dist/server.js +863 -21
  26. package/dist/server.js.map +1 -1
  27. package/dist/thread-DWXHKPdw.d.ts +57 -0
  28. package/dist/web.d.ts +313 -10
  29. package/dist/web.js +1731 -60
  30. package/dist/web.js.map +1 -1
  31. package/migrations/0002_genesis_chat_coaching.sql +934 -0
  32. package/migrations/tests/0002_genesis_chat_coaching.test.sql +265 -0
  33. package/package.json +74 -6
  34. package/src/__tests__/coaching-core.test.ts +332 -0
  35. package/src/__tests__/coaching-react.test.tsx +248 -0
  36. package/src/__tests__/coaching-web.test.tsx +449 -0
  37. package/src/__tests__/core.test.ts +91 -0
  38. package/src/__tests__/outbox.test.ts +122 -0
  39. package/src/__tests__/realtime.test.ts +70 -0
  40. package/src/__tests__/review-fixes-core.test.ts +193 -0
  41. package/src/__tests__/review-fixes-web.test.tsx +402 -0
  42. package/src/__tests__/server-effects.test.ts +158 -0
  43. package/src/__tests__/server-fakes.ts +90 -0
  44. package/src/__tests__/server-integrations.test.ts +247 -0
  45. package/src/__tests__/server-providers.test.ts +246 -0
  46. package/src/__tests__/server-runner.test.ts +277 -0
  47. package/src/__tests__/server-safety.test.ts +290 -0
  48. package/src/__tests__/web-markdown.test.tsx +88 -0
  49. package/src/__tests__/web.test.tsx +333 -0
  50. package/src/core/availability.ts +223 -0
  51. package/src/core/client.ts +757 -0
  52. package/src/core/extras.ts +106 -0
  53. package/src/core/media.ts +144 -0
  54. package/src/core/outbox.ts +429 -0
  55. package/src/core/thread.ts +171 -0
  56. package/src/core/types.ts +311 -0
  57. package/src/index.ts +13 -0
  58. package/src/native/__tests__/auto-retry.test.tsx +128 -0
  59. package/src/native/__tests__/composer-mic-lock.test.tsx +115 -0
  60. package/src/native/__tests__/composer-state.test.ts +188 -0
  61. package/src/native/__tests__/composer.test.tsx +133 -0
  62. package/src/native/__tests__/emoji.test.tsx +238 -0
  63. package/src/native/__tests__/inbox-screen.test.tsx +48 -0
  64. package/src/native/__tests__/message-bubble.test.tsx +127 -0
  65. package/src/native/__tests__/organise-logic.test.ts +164 -0
  66. package/src/native/__tests__/organise-screens.test.tsx +344 -0
  67. package/src/native/__tests__/photo-viewer.test.tsx +226 -0
  68. package/src/native/__tests__/push-payload.test.ts +66 -0
  69. package/src/native/__tests__/push.test.ts +209 -0
  70. package/src/native/__tests__/recorder-machine.test.ts +96 -0
  71. package/src/native/__tests__/rows-actions.test.ts +76 -0
  72. package/src/native/__tests__/thread-layout.test.ts +41 -0
  73. package/src/native/__tests__/thread-screen.test.tsx +116 -0
  74. package/src/native/__tests__/tus.test.ts +154 -0
  75. package/src/native/__tests__/use-voice-recorder.test.tsx +316 -0
  76. package/src/native/__tests__/video-media.test.tsx +421 -0
  77. package/src/native/__tests__/video-recorder-state.test.ts +87 -0
  78. package/src/native/__tests__/video-upload.test.ts +227 -0
  79. package/src/native/access-error.tsx +31 -0
  80. package/src/native/actions.ts +42 -0
  81. package/src/native/attachment-cards.tsx +116 -0
  82. package/src/native/auto-retry.ts +69 -0
  83. package/src/native/availability.tsx +376 -0
  84. package/src/native/broadcast-sheet.tsx +193 -0
  85. package/src/native/composer-state.ts +302 -0
  86. package/src/native/composer.tsx +453 -0
  87. package/src/native/context.tsx +70 -0
  88. package/src/native/copy.ts +296 -0
  89. package/src/native/emoji-data.ts +21 -0
  90. package/src/native/emoji-picker.tsx +229 -0
  91. package/src/native/emoji.ts +118 -0
  92. package/src/native/icon-button.tsx +40 -0
  93. package/src/native/inbox-screen.tsx +116 -0
  94. package/src/native/index.ts +184 -0
  95. package/src/native/link-preview.tsx +104 -0
  96. package/src/native/media-colors.ts +15 -0
  97. package/src/native/media.tsx +187 -0
  98. package/src/native/message-actions-sheet.tsx +121 -0
  99. package/src/native/message-bubble.tsx +374 -0
  100. package/src/native/moderation.tsx +42 -0
  101. package/src/native/modules.ts +275 -0
  102. package/src/native/organise-copy.ts +195 -0
  103. package/src/native/photo-viewer.tsx +531 -0
  104. package/src/native/push.ts +375 -0
  105. package/src/native/recorder-machine.ts +176 -0
  106. package/src/native/rows.ts +81 -0
  107. package/src/native/schedule-sheet.tsx +314 -0
  108. package/src/native/search-screen.tsx +168 -0
  109. package/src/native/stack-options.ts +36 -0
  110. package/src/native/start-sheet.tsx +87 -0
  111. package/src/native/thread-screen.tsx +620 -0
  112. package/src/native/topics.tsx +179 -0
  113. package/src/native/tus.ts +282 -0
  114. package/src/native/use-composer.ts +71 -0
  115. package/src/native/use-jump.ts +91 -0
  116. package/src/native/use-voice-recorder.ts +234 -0
  117. package/src/native/video-player.tsx +370 -0
  118. package/src/native/video-recorder-state.ts +140 -0
  119. package/src/native/video-recorder.tsx +271 -0
  120. package/src/native/video-upload-tray.tsx +77 -0
  121. package/src/native/video-upload.ts +432 -0
  122. package/src/native/voice-player.tsx +283 -0
  123. package/src/react/index.tsx +810 -0
  124. package/src/server/assistant.ts +235 -0
  125. package/src/server/crypto.ts +86 -0
  126. package/src/server/expo.ts +119 -0
  127. package/src/server/handler.ts +32 -0
  128. package/src/server/index.ts +57 -0
  129. package/src/server/moderation.ts +155 -0
  130. package/src/server/mux.ts +290 -0
  131. package/src/server/notifications.ts +130 -0
  132. package/src/server/runner.ts +468 -0
  133. package/src/server/transcribers.ts +111 -0
  134. package/src/server/types.ts +171 -0
  135. package/src/server/unfurl.ts +480 -0
  136. package/src/server/upload.ts +158 -0
  137. package/src/web/attachments.tsx +379 -0
  138. package/src/web/availability.tsx +299 -0
  139. package/src/web/broadcast.tsx +166 -0
  140. package/src/web/bubble.tsx +563 -0
  141. package/src/web/cards.tsx +136 -0
  142. package/src/web/composer.tsx +571 -0
  143. package/src/web/emoji-picker.tsx +84 -0
  144. package/src/web/error-boundary.tsx +33 -0
  145. package/src/web/format.ts +55 -0
  146. package/src/web/inbox.tsx +195 -0
  147. package/src/web/index.ts +75 -0
  148. package/src/web/markdown.tsx +245 -0
  149. package/src/web/memory-client.ts +706 -0
  150. package/src/web/panel.tsx +135 -0
  151. package/src/web/schedule.tsx +192 -0
  152. package/src/web/search.tsx +129 -0
  153. package/src/web/thread-context.ts +64 -0
  154. package/src/web/thread.tsx +802 -0
  155. package/src/web/topics.tsx +160 -0
  156. package/src/web/use-voice-recorder.ts +264 -0
  157. package/src/web/video.tsx +522 -0
  158. package/src/web/voice-note.tsx +197 -0
  159. package/dist/chunk-27UTR5FW.js +0 -322
  160. package/dist/chunk-27UTR5FW.js.map +0 -1
  161. package/dist/chunk-JQXZUEEL.js.map +0 -1
  162. package/dist/chunk-SAY24E6V.js.map +0 -1
@@ -0,0 +1,155 @@
1
+ // @feat:chat
2
+ /**
3
+ * OpenAI's moderation endpoint behind `Moderator`
4
+ * (https://developers.openai.com/api/docs/guides/moderation):
5
+ *
6
+ * POST https://api.openai.com/v1/moderations
7
+ * { model: "omni-moderation-latest", input: [{ type: "text", text }, { type: "image_url", image_url: { url } }] }
8
+ *
9
+ * Each result has `flagged`, `categories`, `category_scores` (0 to 1) and
10
+ * `category_applied_input_types`. A message's text goes with its first photo;
11
+ * further photos go one request each (the guide doesn't say how many images one
12
+ * request takes), and the highest score per category wins.
13
+ *
14
+ * Mapping: any category at or over its `hide` threshold → `hidden`; else any at
15
+ * or over `flag` (or OpenAI's own `flagged`, unless `trustFlagged: false`) →
16
+ * `flagged`; else `clean`. The reason names the categories and scores, never
17
+ * the text.
18
+ */
19
+ import type { ModerateInput, ModerationVerdict, Moderator } from "./types";
20
+
21
+ export const OPENAI_MODERATIONS_URL = "https://api.openai.com/v1/moderations";
22
+
23
+ /** The categories omni-moderation scores (text and images, or text only where noted in OpenAI's guide). */
24
+ export const OPENAI_MODERATION_CATEGORIES = [
25
+ "sexual",
26
+ "sexual/minors",
27
+ "harassment",
28
+ "harassment/threatening",
29
+ "hate",
30
+ "hate/threatening",
31
+ "illicit",
32
+ "illicit/violent",
33
+ "self-harm",
34
+ "self-harm/intent",
35
+ "self-harm/instructions",
36
+ "violence",
37
+ "violence/graphic",
38
+ ] as const;
39
+
40
+ export interface ModerationThresholds {
41
+ /** At or over: a report for observers (default 0.5). */
42
+ flag?: number | undefined;
43
+ /** At or over: the message is hidden from members and reported (default 0.9). */
44
+ hide?: number | undefined;
45
+ }
46
+
47
+ export interface OpenAiModeratorOptions {
48
+ apiKey: string;
49
+ /** Default `omni-moderation-latest`. */
50
+ model?: string | undefined;
51
+ /** Defaults for every category. */
52
+ thresholds?: ModerationThresholds | undefined;
53
+ /**
54
+ * Per category, over the defaults. Default: `sexual/minors` hides at 0.5 and
55
+ * flags at 0.2; `self-harm/intent` and `self-harm/instructions` flag at 0.3
56
+ * (a coach should see them) and hide at 0.95.
57
+ */
58
+ categories?: Record<string, ModerationThresholds> | undefined;
59
+ /** Count OpenAI's own `flagged` as at least `flagged` (default true). */
60
+ trustFlagged?: boolean | undefined;
61
+ /** Text sent per request (default 10 000 characters, a message's maximum). */
62
+ maxTextChars?: number | undefined;
63
+ fetch?: typeof fetch | undefined;
64
+ timeoutMs?: number | undefined;
65
+ }
66
+
67
+ const DEFAULT_CATEGORIES: Record<string, ModerationThresholds> = {
68
+ "sexual/minors": { flag: 0.2, hide: 0.5 },
69
+ "self-harm/intent": { flag: 0.3, hide: 0.95 },
70
+ "self-harm/instructions": { flag: 0.3, hide: 0.95 },
71
+ };
72
+
73
+ interface OpenAiModerationResult {
74
+ flagged?: boolean;
75
+ category_scores?: Record<string, number>;
76
+ }
77
+
78
+ type Input = { type: "text"; text: string } | { type: "image_url"; image_url: { url: string } };
79
+
80
+ /** Maps category scores to a verdict with the given thresholds (exported for tests and other providers). */
81
+ export function moderationVerdict(
82
+ scores: Record<string, number>,
83
+ options: { thresholds?: ModerationThresholds | undefined; categories?: Record<string, ModerationThresholds> | undefined; flagged?: boolean } = {},
84
+ ): ModerationVerdict {
85
+ const flagAt = options.thresholds?.flag ?? 0.5;
86
+ const hideAt = options.thresholds?.hide ?? 0.9;
87
+ const per = { ...DEFAULT_CATEGORIES, ...(options.categories ?? {}) };
88
+ const hidden: string[] = [];
89
+ const flagged: string[] = [];
90
+ for (const [category, raw] of Object.entries(scores)) {
91
+ const score = Number(raw);
92
+ if (!Number.isFinite(score)) continue;
93
+ const t = per[category];
94
+ const label = `${category} ${score.toFixed(2)}`;
95
+ if (score >= (t?.hide ?? hideAt)) hidden.push(label);
96
+ else if (score >= (t?.flag ?? flagAt)) flagged.push(label);
97
+ }
98
+ if (hidden.length > 0) return { result: "hidden", reason: `Automatic moderation: ${[...hidden, ...flagged].join(", ")}` };
99
+ if (flagged.length > 0) return { result: "flagged", reason: `Automatic moderation: ${flagged.join(", ")}` };
100
+ if (options.flagged) return { result: "flagged", reason: "Automatic moderation: flagged by the provider" };
101
+ return { result: "clean" };
102
+ }
103
+
104
+ export function openAiModerator(options: OpenAiModeratorOptions): Moderator {
105
+ const fetchFn = options.fetch ?? fetch;
106
+ const model = options.model ?? "omni-moderation-latest";
107
+ const maxText = options.maxTextChars ?? 10_000;
108
+
109
+ async function call(input: Input[]): Promise<OpenAiModerationResult[]> {
110
+ const response = await fetchFn(OPENAI_MODERATIONS_URL, {
111
+ method: "POST",
112
+ headers: { authorization: `Bearer ${options.apiKey}`, "content-type": "application/json" },
113
+ body: JSON.stringify({ model, input }),
114
+ signal: AbortSignal.timeout(options.timeoutMs ?? 15_000),
115
+ });
116
+ if (!response.ok) {
117
+ await response.body?.cancel().catch(() => {});
118
+ throw new Error(`OpenAI moderation answered HTTP ${response.status}`);
119
+ }
120
+ const body = (await response.json()) as { results?: OpenAiModerationResult[] };
121
+ if (!Array.isArray(body.results)) throw new Error("OpenAI moderation answered without results");
122
+ return body.results;
123
+ }
124
+
125
+ return {
126
+ name: "openai",
127
+ async moderate({ text, imageUrls }: ModerateInput): Promise<ModerationVerdict> {
128
+ const requests: Input[][] = [];
129
+ const first: Input[] = [];
130
+ const trimmed = text.trim().slice(0, maxText);
131
+ if (trimmed) first.push({ type: "text", text: trimmed });
132
+ const [firstImage, ...rest] = imageUrls;
133
+ if (firstImage) first.push({ type: "image_url", image_url: { url: firstImage } });
134
+ if (first.length > 0) requests.push(first);
135
+ for (const url of rest) requests.push([{ type: "image_url", image_url: { url } }]);
136
+ if (requests.length === 0) return { result: "clean" };
137
+
138
+ const scores: Record<string, number> = {};
139
+ let flagged = false;
140
+ for (const results of await Promise.all(requests.map(call))) {
141
+ for (const result of results) {
142
+ if (result.flagged) flagged = true;
143
+ for (const [category, score] of Object.entries(result.category_scores ?? {})) {
144
+ if (typeof score === "number") scores[category] = Math.max(scores[category] ?? 0, score);
145
+ }
146
+ }
147
+ }
148
+ return moderationVerdict(scores, {
149
+ thresholds: options.thresholds,
150
+ categories: options.categories,
151
+ flagged: flagged && options.trustFlagged !== false,
152
+ });
153
+ },
154
+ };
155
+ }
@@ -0,0 +1,290 @@
1
+ // @feat:chat
2
+ /**
3
+ * Mux Video behind `VideoProvider` (https://www.mux.com/docs):
4
+ *
5
+ * createUpload POST /video/v1/uploads: a direct upload, its asset with English
6
+ * auto-generated captions and the passthrough the upload handler
7
+ * sets (the conversation and uploader, never the client's choice)
8
+ * remove DELETE /video/v1/assets/{id}; 404 counts as removed
9
+ * handleWebhook verifies `mux-signature` (HMAC-SHA256 of `t.body`, 5 minutes'
10
+ * tolerance) and applies:
11
+ * video.asset.ready status ready, provider_id = asset id,
12
+ * playback_id, duration_ms
13
+ * video.asset.errored status failed
14
+ * video.asset.track.ready a generated text track: its plain
15
+ * text becomes the transcript
16
+ * video.asset.track.errored a generated text track: transcript failed
17
+ *
18
+ * The attachment is found only by provider_id: the upload id the client sent
19
+ * with its message (unique per attachment), or the asset id once ready. An
20
+ * asset event is applied only when its passthrough names that attachment's
21
+ * conversation, and its sender when it names one; otherwise it answers 409 and
22
+ * changes nothing. An event for an attachment not there yet answers 404, so Mux
23
+ * retries it.
24
+ */
25
+ import { CHAT_SCHEMA, type ChatSupabase } from "../core/client";
26
+ import { hmacSha256Hex, importRsaPrivateKey, safeEqual, signJwtRs256 } from "./crypto";
27
+ import type { VideoProvider } from "./types";
28
+ import { parseChatUploadPassthrough } from "./upload";
29
+
30
+ const API = "https://api.mux.com/video/v1";
31
+ const TOLERANCE_SECONDS = 300;
32
+
33
+ export interface MuxSigningKey {
34
+ /** The signing key's id (`kid`). */
35
+ signingKeyId: string;
36
+ /** The private key as Mux gives it: base64 of a PEM. A PEM works too. */
37
+ privateKey: string;
38
+ }
39
+
40
+ export interface MuxVideoOptions {
41
+ tokenId: string;
42
+ tokenSecret: string;
43
+ /** The webhook endpoint's signing secret (Settings → Webhooks). */
44
+ webhookSecret: string;
45
+ /** `public` (default) or `signed`; signed playback needs `signing` to build URLs. */
46
+ playbackPolicy?: "public" | "signed" | undefined;
47
+ /** Mux's auto-generated captions, kept as the transcript (default English; false turns them off). */
48
+ captions?: { languageCode?: string; name?: string } | false | undefined;
49
+ /** For signed playback: fetching generated captions as the transcript. */
50
+ signing?: MuxSigningKey | undefined;
51
+ /** Mux `video_quality` (default `basic`). */
52
+ videoQuality?: "basic" | "plus" | "premium" | undefined;
53
+ fetch?: typeof fetch | undefined;
54
+ now?: (() => number) | undefined;
55
+ }
56
+
57
+ /** Verifies a `mux-signature` header (`t=<unix>,v1=<hex>`) over the raw body. */
58
+ export async function verifyMuxSignature(
59
+ header: string | null,
60
+ body: string,
61
+ secret: string,
62
+ options: { now?: number; toleranceSeconds?: number } = {},
63
+ ): Promise<boolean> {
64
+ if (!header || !secret) return false;
65
+ let timestamp: string | null = null;
66
+ const signatures: string[] = [];
67
+ for (const part of header.split(",")) {
68
+ const [k, v] = part.split("=", 2).map((s) => s.trim());
69
+ if (k === "t" && v) timestamp = v;
70
+ else if (k === "v1" && v) signatures.push(v.toLowerCase());
71
+ }
72
+ if (!timestamp || !/^\d{1,12}$/.test(timestamp) || signatures.length === 0) return false;
73
+ const now = Math.floor((options.now ?? Date.now()) / 1000);
74
+ if (Math.abs(now - Number(timestamp)) > (options.toleranceSeconds ?? TOLERANCE_SECONDS)) return false;
75
+ const expected = await hmacSha256Hex(secret, `${timestamp}.${body}`);
76
+ let ok = false;
77
+ for (const signature of signatures) if (await safeEqual(signature, expected)) ok = true;
78
+ return ok;
79
+ }
80
+
81
+ /**
82
+ * A signed playback URL (RS256 JWT with `sub` = playback id, `aud` = v, `kid`
83
+ * in the header), valid for `expiresInSeconds` (default 6 hours).
84
+ */
85
+ export async function muxPlaybackUrl(
86
+ playbackId: string,
87
+ key: MuxSigningKey,
88
+ options: { expiresInSeconds?: number; now?: number; path?: string } = {},
89
+ ): Promise<string> {
90
+ const token = await muxPlaybackToken(playbackId, key, options);
91
+ return `https://stream.mux.com/${playbackId}${options.path ?? ".m3u8"}?token=${token}`;
92
+ }
93
+
94
+ export async function muxPlaybackToken(
95
+ playbackId: string,
96
+ key: MuxSigningKey,
97
+ options: { expiresInSeconds?: number; now?: number; audience?: "v" | "t" | "g" | "s" } = {},
98
+ ): Promise<string> {
99
+ const now = Math.floor((options.now ?? Date.now()) / 1000);
100
+ const privateKey = await importRsaPrivateKey(key.privateKey);
101
+ return signJwtRs256(
102
+ { kid: key.signingKeyId },
103
+ { sub: playbackId, aud: options.audience ?? "v", exp: now + (options.expiresInSeconds ?? 6 * 3600) },
104
+ privateKey,
105
+ );
106
+ }
107
+
108
+ interface MuxEvent {
109
+ type?: string;
110
+ data?: {
111
+ id?: string;
112
+ asset_id?: string;
113
+ upload_id?: string;
114
+ passthrough?: string;
115
+ duration?: number;
116
+ playback_ids?: { id?: string; policy?: string }[];
117
+ type?: string;
118
+ text_source?: string;
119
+ };
120
+ }
121
+
122
+ const json = (status: number, body: unknown) =>
123
+ new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
124
+
125
+ export function muxVideo(options: MuxVideoOptions): VideoProvider & {
126
+ playbackUrl(playbackId: string, opts?: { expiresInSeconds?: number }): Promise<string>;
127
+ } {
128
+ const fetchFn = options.fetch ?? fetch;
129
+ const policy = options.playbackPolicy ?? "public";
130
+ const captions = options.captions === false ? null : { languageCode: options.captions?.languageCode ?? "en", name: options.captions?.name ?? "English (auto)" };
131
+ const auth = `Basic ${btoa(`${options.tokenId}:${options.tokenSecret}`)}`;
132
+
133
+ async function api(method: string, path: string, body?: unknown): Promise<Response> {
134
+ return fetchFn(`${API}${path}`, {
135
+ method,
136
+ headers: { authorization: auth, accept: "application/json", ...(body ? { "content-type": "application/json" } : {}) },
137
+ ...(body ? { body: JSON.stringify(body) } : {}),
138
+ signal: AbortSignal.timeout(15_000),
139
+ });
140
+ }
141
+
142
+ async function update(supabase: ChatSupabase, id: string, patch: Record<string, unknown>): Promise<void> {
143
+ const { error } = await supabase.schema(CHAT_SCHEMA).rpc("update_attachment", { p_attachment_id: id, p_patch: patch });
144
+ if (error) throw new Error(`update_attachment: ${error.code ?? "error"}`);
145
+ }
146
+
147
+ interface FoundAttachment {
148
+ id: string;
149
+ conversation_id: string;
150
+ message_id: string;
151
+ playback_id: string | null;
152
+ transcript_status: string | null;
153
+ }
154
+
155
+ /** The attachment an event is about, by provider_id only (the upload id, or the asset id once ready). */
156
+ async function find(supabase: ChatSupabase, providerIds: (string | undefined)[]): Promise<FoundAttachment | null> {
157
+ for (const providerId of providerIds) {
158
+ if (!providerId) continue;
159
+ const { data, error } = await supabase
160
+ .schema(CHAT_SCHEMA)
161
+ .from("attachments")
162
+ .select("id, conversation_id, message_id, playback_id, transcript_status")
163
+ .eq("provider", "mux")
164
+ .eq("provider_id", providerId)
165
+ .limit(1)
166
+ .maybeSingle();
167
+ if (error) throw new Error(`attachments: ${error.code ?? "error"}`);
168
+ if (data) return data as FoundAttachment;
169
+ }
170
+ return null;
171
+ }
172
+
173
+ /** Does the upload's passthrough (set by the upload handler) name this attachment's conversation and sender? */
174
+ async function boundTo(supabase: ChatSupabase, attachment: FoundAttachment, passthrough: string | undefined): Promise<boolean> {
175
+ const binding = parseChatUploadPassthrough(passthrough);
176
+ if (!binding || binding.conversationId !== String(attachment.conversation_id).toLowerCase()) return false;
177
+ if (!binding.userId) return true;
178
+ const { data, error } = await supabase.schema(CHAT_SCHEMA).from("messages").select("sender_id").eq("id", attachment.message_id).maybeSingle();
179
+ if (error) throw new Error(`messages: ${error.code ?? "error"}`);
180
+ const sender = (data as { sender_id: string | null } | null)?.sender_id;
181
+ return typeof sender === "string" && sender.toLowerCase() === binding.userId;
182
+ }
183
+
184
+ async function transcriptOf(playbackId: string, trackId: string): Promise<string | null> {
185
+ let url = `https://stream.mux.com/${playbackId}/text/${trackId}.txt`;
186
+ if (policy === "signed") {
187
+ if (!options.signing) return null;
188
+ url += `?token=${await muxPlaybackToken(playbackId, options.signing, { expiresInSeconds: 600 })}`;
189
+ }
190
+ const response = await fetchFn(url, { signal: AbortSignal.timeout(15_000) });
191
+ if (!response.ok) {
192
+ await response.body?.cancel().catch(() => {});
193
+ throw new Error(`Mux text track answered HTTP ${response.status}`);
194
+ }
195
+ return (await response.text()).trim();
196
+ }
197
+
198
+ return {
199
+ name: "mux",
200
+
201
+ async createUpload({ corsOrigin, passthrough }) {
202
+ const response = await api("POST", "/uploads", {
203
+ cors_origin: corsOrigin,
204
+ new_asset_settings: {
205
+ playback_policies: [policy],
206
+ video_quality: options.videoQuality ?? "basic",
207
+ ...(passthrough ? { passthrough: passthrough.slice(0, 255) } : {}),
208
+ ...(captions ? { inputs: [{ generated_subtitles: [{ language_code: captions.languageCode, name: captions.name }] }] } : {}),
209
+ },
210
+ });
211
+ if (!response.ok) {
212
+ await response.body?.cancel().catch(() => {});
213
+ throw new Error(`Mux uploads answered HTTP ${response.status}`);
214
+ }
215
+ const body = (await response.json()) as { data?: { id?: string; url?: string } };
216
+ if (!body.data?.id || !body.data.url) throw new Error("Mux uploads answered without an upload");
217
+ return { uploadId: body.data.id, url: body.data.url };
218
+ },
219
+
220
+ async remove(assetId) {
221
+ const response = await api("DELETE", `/assets/${encodeURIComponent(assetId)}`);
222
+ await response.body?.cancel().catch(() => {});
223
+ if (response.ok || response.status === 404) return;
224
+ throw new Error(`Mux delete asset answered HTTP ${response.status}`);
225
+ },
226
+
227
+ playbackUrl(playbackId, opts) {
228
+ if (policy !== "signed") return Promise.resolve(`https://stream.mux.com/${playbackId}.m3u8`);
229
+ if (!options.signing) return Promise.reject(new Error("muxVideo: signed playback needs `signing`"));
230
+ return muxPlaybackUrl(playbackId, options.signing, opts);
231
+ },
232
+
233
+ async handleWebhook(request, { supabase }) {
234
+ if (request.method !== "POST") return json(405, { error: "method not allowed" });
235
+ const body = await request.text();
236
+ const ok = await verifyMuxSignature(request.headers.get("mux-signature"), body, options.webhookSecret, {
237
+ ...(options.now ? { now: options.now() } : {}),
238
+ });
239
+ if (!ok) return json(401, { error: "bad signature" });
240
+ let event: MuxEvent;
241
+ try {
242
+ event = JSON.parse(body) as MuxEvent;
243
+ } catch {
244
+ return json(400, { error: "bad json" });
245
+ }
246
+ const data = event.data ?? {};
247
+ switch (event.type) {
248
+ case "video.asset.ready":
249
+ case "video.asset.errored": {
250
+ const attachment = await find(supabase, [data.upload_id, data.id]);
251
+ if (!attachment) return json(404, { error: "attachment not found" });
252
+ // Someone else's upload id on this attachment, or an upload not made for this conversation.
253
+ if (!(await boundTo(supabase, attachment, data.passthrough))) return json(409, { error: "upload not made for this attachment" });
254
+ if (event.type === "video.asset.errored") {
255
+ await update(supabase, attachment.id, { status: "failed", ...(data.id ? { provider_id: data.id } : {}) });
256
+ } else {
257
+ const playback = data.playback_ids?.find((p) => p.policy === policy) ?? data.playback_ids?.[0];
258
+ await update(supabase, attachment.id, {
259
+ status: "ready",
260
+ ...(data.id ? { provider_id: data.id } : {}),
261
+ ...(playback?.id ? { playback_id: playback.id } : {}),
262
+ ...(typeof data.duration === "number" ? { duration_ms: Math.round(data.duration * 1000) } : {}),
263
+ // No captions asked for: nothing will fill the transcript.
264
+ ...(!captions ? { transcript_status: "off" } : {}),
265
+ });
266
+ }
267
+ return json(200, { ok: true });
268
+ }
269
+ case "video.asset.track.ready":
270
+ case "video.asset.track.errored": {
271
+ if (data.type !== "text" || data.text_source !== "generated_vod") return json(200, { ignored: true });
272
+ const attachment = await find(supabase, [data.asset_id]);
273
+ if (!attachment) return json(404, { error: "attachment not found" });
274
+ if (event.type === "video.asset.track.errored") {
275
+ await update(supabase, attachment.id, { transcript_status: "failed" });
276
+ return json(200, { ok: true });
277
+ }
278
+ // The asset's ready event comes first; until it has, Mux retries this one.
279
+ if (!attachment.playback_id || !data.id) return json(404, { error: "no playback id yet" });
280
+ const text = await transcriptOf(attachment.playback_id, data.id);
281
+ if (text === null) return json(200, { ignored: "signed playback without a signing key" });
282
+ await update(supabase, attachment.id, { transcript: text.slice(0, 100_000), transcript_status: "ready" });
283
+ return json(200, { ok: true });
284
+ }
285
+ default:
286
+ return json(200, { ignored: true });
287
+ }
288
+ },
289
+ };
290
+ }
@@ -0,0 +1,130 @@
1
+ // @feat:chat
2
+ /**
3
+ * Chat pushes through genesis-notifications: `notificationsDeliver` hands a
4
+ * message's `ChatNotification` to `notify()` (one call, every recipient with
5
+ * their own badge), and `CHAT_NOTIFICATION_TYPES` / `CHAT_NOTIFICATION_CATEGORIES`
6
+ * go into the app's `defineNotifications` registry. Typed structurally, so
7
+ * genesis-chat doesn't depend on genesis-notifications; its tests check the
8
+ * shapes against the real package.
9
+ *
10
+ * ```ts
11
+ * import { notify } from "@marktiderman/genesis-notifications/server";
12
+ * const deliver = notificationsDeliver({ notify, client: supabase, registry: notifications });
13
+ * ```
14
+ *
15
+ * The preferences, quiet hours, channels and devices are genesis-notifications'.
16
+ */
17
+ import type { ChatDeliver, ChatNotification } from "./types";
18
+
19
+ /** The notification type id chat pushes go out as. */
20
+ export const CHAT_NOTIFICATION_TYPE_ID = "chat.message";
21
+
22
+ /** What `notify()` receives for a chat message (a `NotifyInput<"chat.message">`). */
23
+ export interface ChatNotifyInput {
24
+ type: string;
25
+ recipients: { userId: string; badge?: number }[];
26
+ data: {
27
+ url: string;
28
+ conversationId: string;
29
+ messageId: string;
30
+ seq: number | null;
31
+ title: string;
32
+ body: string;
33
+ senderId: string | null;
34
+ senderName?: string;
35
+ senderAvatar?: string;
36
+ groupName?: string;
37
+ };
38
+
39
+ idempotencyKey: string;
40
+ threadKey: string;
41
+ collapseKey?: string;
42
+ tenantId?: string;
43
+ }
44
+
45
+ export interface NotificationsDeliverOptions<Client, Registry> {
46
+ /** genesis-notifications' `notify` from `@marktiderman/genesis-notifications/server`. */
47
+ notify: (client: Client, input: ChatNotifyInput, options?: { registry?: Registry }) => Promise<unknown>;
48
+ /** The service-role client `notify` calls `genesis_notify` through. */
49
+ client: Client;
50
+ /** Passed to `notify` as `{ registry }`, so an unknown type fails before the database. */
51
+ registry?: Registry | undefined;
52
+ /** Default `chat.message`. `collapseKey` is passed on only when the runner set it (its `collapse` option). */
53
+ type?: string | undefined;
54
+ /** The practice or workspace the conversation belongs to, when the app has one. */
55
+ tenantFor?: ((notification: ChatNotification) => string | null | undefined | Promise<string | null | undefined>) | undefined;
56
+ }
57
+
58
+ /** Maps a chat push onto genesis-notifications' `notify()` (exported for tests and apps that call notify themselves). */
59
+ export function chatNotifyInput(notification: ChatNotification, options: { type?: string | undefined } = {}): ChatNotifyInput {
60
+ return {
61
+ type: options.type ?? CHAT_NOTIFICATION_TYPE_ID,
62
+ recipients: notification.recipients.map((r) => ({ userId: r.userId, badge: Math.max(0, Math.floor(r.badge)) })),
63
+ data: {
64
+ url: notification.data.url,
65
+ conversationId: notification.conversationId,
66
+ messageId: notification.messageId,
67
+ seq: notification.data.seq,
68
+ title: notification.title,
69
+ body: notification.body,
70
+ senderId: notification.senderId,
71
+ ...(notification.data.senderName ? { senderName: notification.data.senderName } : {}),
72
+ ...(notification.data.senderAvatar ? { senderAvatar: notification.data.senderAvatar } : {}),
73
+ ...(notification.data.groupName ? { groupName: notification.data.groupName } : {}),
74
+ },
75
+
76
+ idempotencyKey: notification.idempotencyKey,
77
+ threadKey: notification.threadKey,
78
+ ...(notification.collapseKey ? { collapseKey: notification.collapseKey } : {}),
79
+ };
80
+ }
81
+
82
+ export function notificationsDeliver<Client, Registry>(options: NotificationsDeliverOptions<Client, Registry>): ChatDeliver {
83
+ return async (notification) => {
84
+ const input = chatNotifyInput(notification, options);
85
+ const tenant = options.tenantFor ? await options.tenantFor(notification) : null;
86
+ if (tenant) input.tenantId = tenant;
87
+ return options.registry === undefined
88
+ ? options.notify(options.client, input)
89
+ : options.notify(options.client, input, { registry: options.registry });
90
+ };
91
+ }
92
+
93
+ /** The `messages` category, for `defineNotifications({ categories })`. */
94
+ export const CHAT_NOTIFICATION_CATEGORIES = {
95
+ messages: { label: "Messages", description: "New messages in your conversations.", android: { importance: "high" } },
96
+ } as const;
97
+
98
+ /**
99
+ * The `chat.message` type, for `defineNotifications({ types })`: in the feed and
100
+ * pushed, no email unless the app adds it. Spread it and override what you need
101
+ * (`actions: "message"` with genesis-notifications' `MESSAGE_ACTIONS` for reply
102
+ * and mark-as-read buttons).
103
+ */
104
+ export const CHAT_NOTIFICATION_TYPES = {
105
+ [CHAT_NOTIFICATION_TYPE_ID]: {
106
+ category: "messages",
107
+ channels: { in_app: true, push: true },
108
+ render: (data: Record<string, unknown>) => ({
109
+ title: typeof data.title === "string" && data.title ? data.title : "New message",
110
+ ...(typeof data.body === "string" && data.body ? { body: data.body } : {}),
111
+ ...(typeof data.url === "string" ? { url: data.url } : {}),
112
+ // iOS rich push (genesis-chat/push's chatPushPayload fields): ids always travel (reply,
113
+ // mark read); the sender's name and photo and the group only when previews are shown.
114
+ push: {
115
+ ids: pick(data, ["conversationId", "messageId", "seq", "senderId"]),
116
+ display: pick(data, ["senderName", "senderAvatar", "groupName"]),
117
+ mutableContent: typeof data.senderName === "string" && data.senderName !== "",
118
+ },
119
+ }),
120
+ },
121
+ } as const;
122
+
123
+ function pick(data: Record<string, unknown>, keys: readonly string[]): Record<string, string | number> {
124
+ const out: Record<string, string | number> = {};
125
+ for (const k of keys) {
126
+ const v = data[k];
127
+ if (typeof v === "string" ? v !== "" : typeof v === "number" && Number.isFinite(v)) out[k] = v as string | number;
128
+ }
129
+ return out;
130
+ }