@marktiderman/genesis-chat 0.1.0 → 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,468 @@
1
+ // @feat:chat
2
+ /**
3
+ * The outbox runner: claims due effects from `genesis_chat.effects`, does
4
+ * each one, and settles it. Settling with an error has `complete_effect` back
5
+ * it off (30 s × 2^attempts); `claim_effects` gives up after the 8th attempt.
6
+ *
7
+ * push push_targets(message) → deliver(notification), once per message
8
+ * transcribe signed URL (10 min) → transcriber → update_attachment
9
+ * storage.remove storage.from(bucket).remove([path]); not found is done
10
+ * video.remove video.remove(provider id), when a video provider is given
11
+ * moderate the message's text and photos (signed URLs) → moderator → moderate_message;
12
+ * nothing to judge is `clean` (it releases the push held for the verdict), and
13
+ * a moderator still failing on the last attempt files `flagged` with the
14
+ * reason "moderation unavailable", so observers see it and the push goes
15
+ * unfurl unfurler(url) → save_link_preview (ok, failed or blocked)
16
+ *
17
+ * `moderate` and `unfurl` are claimed only when their adapter is given. Each run
18
+ * also posts due scheduled messages (due_scheduled, then post_scheduled each, as
19
+ * their sender) and, with `retention: true`, removes messages past their kind's
20
+ * retention (purge_expired); a failure there is reported, never thrown.
21
+ *
22
+ * Kinds it doesn't handle are never claimed, so an app's own runner can work
23
+ * them. Effects run concurrently per kind, up to a cap; one failure never
24
+ * stops the others. Errors are stored as short messages, never payloads.
25
+ */
26
+ import { chatPushPayload } from "../native/push";
27
+ import { CHAT_BUCKET, CHAT_SCHEMA, type ChatSupabase } from "../core/client";
28
+ import type {
29
+ ChatDeliver,
30
+ ChatEffect,
31
+ ChatEffectKind,
32
+ ChatNotification,
33
+ ChatRunReport,
34
+ LinkUnfurler,
35
+ Moderator,
36
+ Transcriber,
37
+ VideoProvider,
38
+ } from "./types";
39
+
40
+ /** `claim_effects` stops claiming an effect after this many attempts. */
41
+ export const CHAT_MAX_ATTEMPTS = 8;
42
+ const BASE_KINDS: readonly ChatEffectKind[] = ["push", "transcribe", "storage.remove", "video.remove"];
43
+ const SIGNED_URL_SECONDS = 600;
44
+ /** purge_expired calls per run, each removing up to `retentionBatch`. */
45
+ const PURGE_ROUNDS = 10;
46
+
47
+ export interface ChatRunnerOptions {
48
+ /** A service-role supabase-js client (the runner reads past row level security). */
49
+ supabase: ChatSupabase;
50
+ /** Sends a message's push: `createExpoDeliver(...)`, or a function that calls genesis-notifications' `notify`. */
51
+ deliver: ChatDeliver;
52
+ /** Speech to text for voice notes and stored videos. Without one, their transcripts are set `off`. */
53
+ transcriber?: Transcriber | undefined;
54
+ /** The language to transcribe in (BCP-47). Default: detected. */
55
+ transcriptLanguage?: string | undefined;
56
+ /** Hosted video (Mux), to remove an asset when its attachment goes. */
57
+ video?: Pick<VideoProvider, "name" | "remove"> | undefined;
58
+ /** Moderation for kinds that ask for it (`set_kind_options(kind, p_moderate => true)`): `openAiModerator(...)`. */
59
+ moderator?: Moderator | undefined;
60
+ /** Link previews: `createLinkUnfurler()`. Without one, `unfurl` effects are left unclaimed. */
61
+ unfurler?: LinkUnfurler | undefined;
62
+ /** Post due scheduled messages on each run (default true). */
63
+ scheduled?: boolean | undefined;
64
+ /** Remove messages past their kind's retention on each run (default false: run it once a day). */
65
+ retention?: boolean | undefined;
66
+ /** Messages removed per purge_expired call (default 1000); up to 10 calls a run. */
67
+ retentionBatch?: number | undefined;
68
+ /** The kinds this runner works (default the four base kinds, plus `moderate` and `unfurl` when their adapters are given). */
69
+ kinds?: readonly ChatEffectKind[] | undefined;
70
+ batch?: {
71
+ /** Effects claimed per run (default 50, at most 500). */
72
+ limit?: number | undefined;
73
+ /** Effects of one kind in flight at once (default 4). */
74
+ concurrency?: number | undefined;
75
+ };
76
+ /** A person's display name, for push titles and group previews. */
77
+ resolveName?: ((userId: string) => Promise<string | null | undefined> | string | null | undefined) | undefined;
78
+ /** A person's photo (an https URL), shown on iOS as the sender of a chat push. */
79
+ resolveAvatar?: ((userId: string) => Promise<string | null | undefined> | string | null | undefined) | undefined;
80
+ /** Kinds that are one-to-one (default `["direct"]`): their push title is the sender's name. */
81
+ directKinds?: readonly string[] | undefined;
82
+ /**
83
+ * Set `collapseKey` (`chat:<conversation id>`) so a conversation's newer push
84
+ * replaces the older one. Default false: on iOS the collapse id becomes the
85
+ * notification's identifier, and replies from one conversation's notifications collide.
86
+ */
87
+ collapse?: boolean | undefined;
88
+ /** Kinds left out of the push badge, matching the app's own badge (`useUnreadTotal({ excludeKinds })`). */
89
+ badgeExcludeKinds?: readonly string[] | undefined;
90
+ /** Where a tapped notification opens (default `/messages/<conversationId>`). */
91
+ linkFor?: ((conversationId: string, messageId: string) => string) | undefined;
92
+ /** Safe log lines (effect ids, kinds, short errors). Default: nothing is logged. */
93
+ log?: ((line: string) => void) | undefined;
94
+ }
95
+
96
+ export interface ChatRunner {
97
+ /** One claim, its work and its settling. Rejects only when claiming fails. */
98
+ run(): Promise<ChatRunReport>;
99
+ /** Works one effect (exported for tests and for apps that claim themselves). Throws to retry. */
100
+ handle(effect: ChatEffect): Promise<void>;
101
+ }
102
+
103
+ interface PushTarget {
104
+ user_id: string;
105
+ conversation_id: string;
106
+ title: string | null;
107
+ sender_id: string | null;
108
+ preview: string | null;
109
+ mentioned: boolean | null;
110
+ unread_total: number | null;
111
+ }
112
+
113
+ interface MessageRow {
114
+ id: string;
115
+ body: string | null;
116
+ deleted_at: string | null;
117
+ }
118
+
119
+ interface AttachmentRow {
120
+ id: string;
121
+ kind: string;
122
+ provider: string;
123
+ path: string | null;
124
+ mime: string | null;
125
+ duration_ms: number | null;
126
+ transcript_status: string | null;
127
+ }
128
+
129
+ type Result<T> = { data: T | null; error: { message?: string; code?: string; statusCode?: string | number } | null };
130
+
131
+ function errorText(fn: string, error: { message?: string; code?: string } | null | undefined): Error {
132
+ // The code where there is one: a database message can quote the row it refused.
133
+ return new Error(`${fn}: ${error?.code ?? error?.message ?? "error"}`);
134
+ }
135
+
136
+ function str(value: unknown): string | null {
137
+ return typeof value === "string" && value.length > 0 ? value : null;
138
+ }
139
+
140
+ /** Runs `work` over `items`, at most `limit` at a time. */
141
+ async function pool<T>(items: readonly T[], limit: number, work: (item: T) => Promise<void>): Promise<void> {
142
+ let next = 0;
143
+ const lanes = Array.from({ length: Math.min(limit, items.length) }, async () => {
144
+ while (next < items.length) await work(items[next++]!);
145
+ });
146
+ await Promise.all(lanes);
147
+ }
148
+
149
+ export function createChatRunner(options: ChatRunnerOptions): ChatRunner {
150
+ const { supabase, deliver } = options;
151
+ const kinds = [
152
+ ...(options.kinds ?? [
153
+ ...BASE_KINDS,
154
+ ...(options.moderator ? (["moderate"] as const) : []),
155
+ ...(options.unfurler ? (["unfurl"] as const) : []),
156
+ ]),
157
+ ];
158
+ const limit = Math.min(Math.max(options.batch?.limit ?? 50, 1), 500);
159
+ const concurrency = Math.max(options.batch?.concurrency ?? 4, 1);
160
+ const directKinds = new Set(options.directKinds ?? ["direct"]);
161
+ const log = options.log ?? (() => {});
162
+ const db = () => supabase.schema(CHAT_SCHEMA);
163
+
164
+ async function rpc<T>(fn: string, args: Record<string, unknown>): Promise<T> {
165
+ const { data, error } = (await db().rpc(fn, args)) as Result<T>;
166
+ if (error) throw errorText(fn, error);
167
+ return data as T;
168
+ }
169
+
170
+ async function nameOf(userId: string | null): Promise<string | null> {
171
+ if (!userId || !options.resolveName) return null;
172
+ try {
173
+ return str(await options.resolveName(userId));
174
+ } catch {
175
+ return null;
176
+ }
177
+ }
178
+
179
+ async function push(effect: ChatEffect): Promise<void> {
180
+ const messageId = str(effect.payload.message_id);
181
+ if (!messageId) return;
182
+ const targets =
183
+ (await rpc<PushTarget[] | null>("push_targets", {
184
+ p_message_id: messageId,
185
+ ...(options.badgeExcludeKinds?.length ? { p_badge_exclude_kinds: options.badgeExcludeKinds } : {}),
186
+ })) ?? [];
187
+ // Deleted, silent, or nobody to tell: done.
188
+ if (targets.length === 0) return;
189
+ const first = targets[0]!;
190
+ const conversationId = first.conversation_id;
191
+ const { data: conversation, error } = (await db()
192
+ .from("conversations")
193
+ .select("kind")
194
+ .eq("id", conversationId)
195
+ .maybeSingle()) as Result<{ kind: string }>;
196
+ if (error) throw errorText("conversations", error);
197
+ const direct = conversation ? directKinds.has(conversation.kind) : false;
198
+ // The message's number, so a tapped notification can open the thread at it.
199
+ const { data: numbered } = (await db().from("messages").select("seq").eq("id", messageId).maybeSingle()) as Result<{ seq: number }>;
200
+ const seq = numbered?.seq ?? null;
201
+ const senderName = await nameOf(first.sender_id);
202
+ const preview = first.preview ?? "New message";
203
+ // The conversation's title; a direct conversation (or an untitled group) shows who wrote.
204
+ const title = str(first.title) ?? senderName ?? "New message";
205
+ const body = !direct && senderName ? `${senderName}: ${preview}` : preview;
206
+ let avatarUrl: string | null = null;
207
+ if (first.sender_id && options.resolveAvatar) {
208
+ try {
209
+ avatarUrl = str(await options.resolveAvatar(first.sender_id));
210
+ } catch {
211
+ avatarUrl = null;
212
+ }
213
+ }
214
+ let payload: ReturnType<typeof chatPushPayload>;
215
+ try {
216
+ payload = chatPushPayload({
217
+ conversationId,
218
+ messageId,
219
+ ...(seq != null ? { seq } : {}),
220
+ sender: first.sender_id && senderName ? { id: first.sender_id, name: senderName, avatarUrl } : undefined,
221
+ groupName: direct ? undefined : (str(first.title) ?? undefined),
222
+ });
223
+ } catch {
224
+ // Not a uuid (a test or an app's own ids): a plain push still goes out.
225
+ payload = { categoryId: "messages", threadId: conversationId, mutableContent: false, data: { url: `/messages/${conversationId}`, conversationId, messageId } };
226
+ }
227
+ const notification: ChatNotification = {
228
+ category: "messages",
229
+ messageId,
230
+ conversationId,
231
+ recipients: targets.map((t) => ({ userId: t.user_id, badge: t.unread_total ?? 0, mentioned: t.mentioned === true })),
232
+ title,
233
+ body,
234
+ senderId: first.sender_id,
235
+ ...(options.collapse ? { collapseKey: `chat:${conversationId}` } : {}),
236
+ threadKey: conversationId,
237
+ idempotencyKey: `push:${messageId}`,
238
+ data: { ...payload.data, url: options.linkFor?.(conversationId, messageId) ?? payload.data.url, seq },
239
+ mutableContent: payload.mutableContent,
240
+ categoryId: payload.categoryId,
241
+ };
242
+ await deliver(notification);
243
+ }
244
+
245
+ async function storageRemove(effect: ChatEffect): Promise<void> {
246
+ const path = str(effect.payload.path);
247
+ if (!path) return;
248
+ const bucket = str(effect.payload.bucket) ?? CHAT_BUCKET;
249
+ const { error } = (await supabase.storage.from(bucket).remove([path])) as Result<unknown>;
250
+ if (!error) return;
251
+ const status = String(error.statusCode ?? "");
252
+ if (status === "404" || /not.?found/i.test(error.message ?? "")) return;
253
+ throw new Error(`storage.remove: ${status || error.message || "error"}`);
254
+ }
255
+
256
+ async function transcribe(effect: ChatEffect): Promise<void> {
257
+ const id = str(effect.payload.attachment_id);
258
+ if (!id) return;
259
+ const { data: row, error } = (await db()
260
+ .from("attachments")
261
+ .select("id, kind, provider, path, mime, duration_ms, transcript_status")
262
+ .eq("id", id)
263
+ .maybeSingle()) as Result<AttachmentRow>;
264
+ if (error) throw errorText("attachments", error);
265
+ // Deleted, or already settled: nothing to do.
266
+ if (!row || (row.transcript_status !== "pending" && row.transcript_status !== null)) return;
267
+ // A hosted video's captions come from its provider's webhook (Mux generated subtitles).
268
+ if (row.provider !== "storage") return;
269
+ if (!options.transcriber || !row.path) {
270
+ await rpc("update_attachment", { p_attachment_id: id, p_patch: { transcript_status: "off" } });
271
+ return;
272
+ }
273
+ const signed = (await supabase.storage.from(CHAT_BUCKET).createSignedUrl(row.path, SIGNED_URL_SECONDS)) as Result<{ signedUrl: string }>;
274
+ if (signed.error || !signed.data?.signedUrl) throw errorText("createSignedUrl", signed.error);
275
+ try {
276
+ const result = await options.transcriber.transcribe({
277
+ url: signed.data.signedUrl,
278
+ mime: row.mime,
279
+ durationMs: row.duration_ms,
280
+ language: options.transcriptLanguage,
281
+ });
282
+ await rpc("update_attachment", { p_attachment_id: id, p_patch: { transcript: result.text.slice(0, 100_000), transcript_status: "ready" } });
283
+ } catch (e) {
284
+ // The last attempt: say so, so the player stops waiting for it.
285
+ if (effect.attempts >= CHAT_MAX_ATTEMPTS) {
286
+ await rpc("update_attachment", { p_attachment_id: id, p_patch: { transcript_status: "failed" } }).catch(() => {});
287
+ }
288
+ throw e;
289
+ }
290
+ }
291
+
292
+ async function videoRemove(effect: ChatEffect): Promise<void> {
293
+ const providerId = str(effect.payload.provider_id);
294
+ if (!providerId || !options.video) return;
295
+ const provider = str(effect.payload.provider);
296
+ if (provider && provider !== options.video.name) {
297
+ throw new Error(`video.remove: no adapter for ${provider} (this runner has ${options.video.name})`);
298
+ }
299
+ await options.video.remove(providerId);
300
+ }
301
+
302
+ async function moderate(effect: ChatEffect): Promise<void> {
303
+ const messageId = str(effect.payload.message_id);
304
+ if (!messageId) return;
305
+ if (!options.moderator) throw new Error("moderate: no moderator given");
306
+ const { data: message, error } = (await db()
307
+ .from("messages")
308
+ .select("id, body, deleted_at")
309
+ .eq("id", messageId)
310
+ .maybeSingle()) as Result<MessageRow>;
311
+ if (error) throw errorText("messages", error);
312
+ // Nothing to judge: say clean, which releases the push held for the verdict (none for a deleted message).
313
+ const clean = () => rpc("moderate_message", { p_message_id: messageId, p_result: "clean", p_reason: null });
314
+ if (!message || message.deleted_at) {
315
+ await clean();
316
+ return;
317
+ }
318
+ const { data: rows, error: attachmentsError } = (await db()
319
+ .from("attachments")
320
+ .select("id, kind, provider, path, mime")
321
+ .eq("message_id", messageId)
322
+ .order("position")) as Result<Pick<AttachmentRow, "id" | "kind" | "provider" | "path" | "mime">[]>;
323
+ if (attachmentsError) throw errorText("attachments", attachmentsError);
324
+ // Every photo (a message holds at most 20): one left out could carry what the verdict misses.
325
+ const photos = (rows ?? []).filter((a) => a.provider === "storage" && a.path && (a.kind === "image" || (a.mime ?? "").startsWith("image/")));
326
+ const imageUrls: string[] = [];
327
+ for (const photo of photos) {
328
+ const signed = (await supabase.storage.from(CHAT_BUCKET).createSignedUrl(photo.path!, SIGNED_URL_SECONDS)) as Result<{ signedUrl: string }>;
329
+ if (signed.error || !signed.data?.signedUrl) throw errorText("createSignedUrl", signed.error);
330
+ imageUrls.push(signed.data.signedUrl);
331
+ }
332
+ const text = message.body ?? "";
333
+ if (!text.trim() && imageUrls.length === 0) {
334
+ await clean();
335
+ return;
336
+ }
337
+ let verdict: Awaited<ReturnType<Moderator["moderate"]>>;
338
+ try {
339
+ verdict = await options.moderator.moderate({ text, imageUrls });
340
+ } catch (e) {
341
+ // The last attempt: never fail open silently. Observers get a report and the push goes.
342
+ if (effect.attempts >= CHAT_MAX_ATTEMPTS) {
343
+ await rpc("moderate_message", { p_message_id: messageId, p_result: "flagged", p_reason: "moderation unavailable" });
344
+ }
345
+ throw e;
346
+ }
347
+ await rpc("moderate_message", { p_message_id: messageId, p_result: verdict.result, p_reason: verdict.reason?.slice(0, 1000) ?? null });
348
+ }
349
+
350
+ async function unfurl(effect: ChatEffect): Promise<void> {
351
+ const url = str(effect.payload.url);
352
+ if (!url) return;
353
+ if (!options.unfurler) throw new Error("unfurl: no unfurler given");
354
+ // The table takes https links only; anything else is recorded nowhere.
355
+ if (!/^https:\/\//i.test(url) || url.length > 2000) return;
356
+ const preview = await options.unfurler.unfurl(url);
357
+ const ok = preview.status === "ok";
358
+ await rpc("save_link_preview", {
359
+ p_url: url,
360
+ p_status: preview.status,
361
+ p_title: ok ? (preview.title ?? null) : null,
362
+ p_description: ok ? (preview.description ?? null) : null,
363
+ p_image_url: ok ? (preview.imageUrl ?? null) : null,
364
+ p_site_name: ok ? (preview.siteName ?? null) : null,
365
+ });
366
+ }
367
+
368
+ async function postScheduled(report: ChatRunReport, errors: string[]): Promise<void> {
369
+ const counts = { due: 0, posted: 0, skipped: 0, failed: 0 };
370
+ report.scheduled = counts;
371
+ let due: string[];
372
+ try {
373
+ due = ((await rpc<unknown[] | null>("due_scheduled", { p_limit: limit })) ?? []).map((row) =>
374
+ typeof row === "string" ? row : String((row as Record<string, unknown>)?.due_scheduled ?? (row as Record<string, unknown>)?.id ?? ""),
375
+ );
376
+ } catch (e) {
377
+ errors.push(e instanceof Error ? e.message : "due_scheduled: error");
378
+ return;
379
+ }
380
+ counts.due = due.length;
381
+ await pool(due, concurrency, async (id) => {
382
+ try {
383
+ // Null: refused (the reason is on the row), to be tried again, or taken by another runner.
384
+ const messageId = await rpc<string | null>("post_scheduled", { p_id: id });
385
+ if (messageId) counts.posted++;
386
+ else counts.skipped++;
387
+ } catch (e) {
388
+ counts.failed++;
389
+ log(`genesis-chat: posting scheduled ${id} failed: ${e instanceof Error ? e.message : "error"}`);
390
+ }
391
+ });
392
+ }
393
+
394
+ async function purge(report: ChatRunReport, errors: string[]): Promise<void> {
395
+ const batch = Math.min(Math.max(options.retentionBatch ?? 1000, 1), 10_000);
396
+ report.purged = 0;
397
+ try {
398
+ for (let round = 0; round < PURGE_ROUNDS; round++) {
399
+ const n = Number((await rpc<number | null>("purge_expired", { p_limit: batch })) ?? 0);
400
+ report.purged += n;
401
+ if (n < batch) break;
402
+ }
403
+ } catch (e) {
404
+ errors.push(e instanceof Error ? e.message : "purge_expired: error");
405
+ }
406
+ }
407
+
408
+ async function handle(effect: ChatEffect): Promise<void> {
409
+ switch (effect.kind) {
410
+ case "push":
411
+ return push(effect);
412
+ case "transcribe":
413
+ return transcribe(effect);
414
+ case "storage.remove":
415
+ return storageRemove(effect);
416
+ case "video.remove":
417
+ return videoRemove(effect);
418
+ case "moderate":
419
+ return moderate(effect);
420
+ case "unfurl":
421
+ return unfurl(effect);
422
+ default:
423
+ throw new Error(`no handler for ${effect.kind}`);
424
+ }
425
+ }
426
+
427
+ async function run(): Promise<ChatRunReport> {
428
+ const claimed = (await rpc<ChatEffect[] | null>("claim_effects", { p_kinds: kinds, p_limit: limit })) ?? [];
429
+ const report: ChatRunReport = { claimed: claimed.length, done: 0, failed: 0 };
430
+ const errors: string[] = [];
431
+ // Due messages go out alongside the outbox; their pushes are queued for the next run.
432
+ const scheduling = options.scheduled === false ? Promise.resolve() : postScheduled(report, errors);
433
+ const byKind = new Map<string, ChatEffect[]>();
434
+ for (const effect of claimed) byKind.set(effect.kind, [...(byKind.get(effect.kind) ?? []), effect]);
435
+ await Promise.all(
436
+ [...byKind.values()].map((effects) =>
437
+ pool(effects, concurrency, async (effect) => {
438
+ let failure: string | null = null;
439
+ try {
440
+ await handle({ ...effect, payload: effect.payload ?? {} });
441
+ } catch (e) {
442
+ failure = (e instanceof Error ? e.message : String(e)).slice(0, 500) || "error";
443
+ }
444
+ try {
445
+ await rpc("complete_effect", { p_id: effect.id, p_error: failure });
446
+ } catch (e) {
447
+ // Unsettled: its five-minute claim runs out and it is claimed again.
448
+ log(`genesis-chat: settling effect ${effect.id} failed: ${e instanceof Error ? e.message : "error"}`);
449
+ failure ??= "unsettled";
450
+ }
451
+ if (failure) {
452
+ report.failed++;
453
+ log(`genesis-chat: ${effect.kind} effect ${effect.id} (attempt ${effect.attempts}) failed: ${failure}`);
454
+ } else report.done++;
455
+ }),
456
+ ),
457
+ );
458
+ await scheduling;
459
+ if (options.retention) await purge(report, errors);
460
+ if (errors.length > 0) {
461
+ report.errors = errors.map((e) => e.slice(0, 200));
462
+ for (const e of report.errors) log(`genesis-chat: ${e}`);
463
+ }
464
+ return report;
465
+ }
466
+
467
+ return { run, handle };
468
+ }
@@ -0,0 +1,111 @@
1
+ // @feat:chat
2
+ /**
3
+ * Speech-to-text providers behind `Transcriber`. A failed call throws with
4
+ * the HTTP status only (an error body can echo the request), so the runner
5
+ * retries it.
6
+ */
7
+ import type { TranscribeInput, TranscribeResult, Transcriber } from "./types";
8
+
9
+ const TIMEOUT_MS = 120_000;
10
+
11
+ async function failed(name: string, response: Response): Promise<never> {
12
+ await response.body?.cancel().catch(() => {});
13
+ throw new Error(`${name} answered HTTP ${response.status}`);
14
+ }
15
+
16
+ export interface DeepgramOptions {
17
+ apiKey: string;
18
+ /** Default `nova-3`. */
19
+ model?: string | undefined;
20
+ fetch?: typeof fetch | undefined;
21
+ }
22
+
23
+ /**
24
+ * Deepgram pre-recorded transcription by URL
25
+ * (https://developers.deepgram.com/reference/speech-to-text/listen-pre-recorded):
26
+ * `POST /v1/listen?model=…&smart_format=true&detect_language=true` with
27
+ * `{ url }` and `Authorization: Token <key>`; Deepgram fetches the file itself.
28
+ */
29
+ export function deepgramTranscriber(options: DeepgramOptions): Transcriber {
30
+ const fetchFn = options.fetch ?? fetch;
31
+ const model = options.model ?? "nova-3";
32
+ return {
33
+ name: "deepgram",
34
+ async transcribe(input: TranscribeInput): Promise<TranscribeResult> {
35
+ const params = new URLSearchParams({ model, smart_format: "true" });
36
+ if (input.language) params.set("language", input.language);
37
+ else params.set("detect_language", "true");
38
+ const response = await fetchFn(`https://api.deepgram.com/v1/listen?${params}`, {
39
+ method: "POST",
40
+ headers: { authorization: `Token ${options.apiKey}`, "content-type": "application/json", accept: "application/json" },
41
+ body: JSON.stringify({ url: input.url }),
42
+ signal: AbortSignal.timeout(TIMEOUT_MS),
43
+ });
44
+ if (!response.ok) return failed("Deepgram", response);
45
+ const json = (await response.json()) as {
46
+ results?: { channels?: { detected_language?: string; alternatives?: { transcript?: string }[] }[] };
47
+ };
48
+ const channel = json.results?.channels?.[0];
49
+ const text = channel?.alternatives?.[0]?.transcript;
50
+ if (typeof text !== "string") throw new Error("Deepgram answered without a transcript");
51
+ return { text: text.trim(), language: channel?.detected_language ?? input.language };
52
+ },
53
+ };
54
+ }
55
+
56
+ export interface OpenAiTranscriberOptions {
57
+ apiKey: string;
58
+ /** Default `gpt-4o-mini-transcribe`. */
59
+ model?: string | undefined;
60
+ fetch?: typeof fetch | undefined;
61
+ }
62
+
63
+ const EXTENSIONS: Record<string, string> = {
64
+ "audio/mp4": "m4a",
65
+ "audio/x-m4a": "m4a",
66
+ "audio/aac": "m4a",
67
+ "audio/mpeg": "mp3",
68
+ "audio/webm": "webm",
69
+ "audio/ogg": "ogg",
70
+ "audio/wav": "wav",
71
+ "video/mp4": "mp4",
72
+ "video/webm": "webm",
73
+ };
74
+
75
+ /** OpenAI's limit for one upload. */
76
+ const OPENAI_MAX_BYTES = 25 * 1024 * 1024;
77
+
78
+ /**
79
+ * OpenAI `POST /v1/audio/transcriptions` (multipart: file, model, language):
80
+ * the file is downloaded from the signed URL first, as OpenAI takes no URL.
81
+ * Files over 25 MB are refused by OpenAI, so they fail here without the upload.
82
+ */
83
+ export function openAiTranscriber(options: OpenAiTranscriberOptions): Transcriber {
84
+ const fetchFn = options.fetch ?? fetch;
85
+ const model = options.model ?? "gpt-4o-mini-transcribe";
86
+ return {
87
+ name: "openai",
88
+ async transcribe(input: TranscribeInput): Promise<TranscribeResult> {
89
+ const file = await fetchFn(input.url, { signal: AbortSignal.timeout(TIMEOUT_MS) });
90
+ if (!file.ok) return failed("Storage", file);
91
+ const blob = await file.blob();
92
+ if (blob.size > OPENAI_MAX_BYTES) throw new Error("OpenAI transcription: file over 25 MB");
93
+ const mime = input.mime ?? (blob.type || "audio/mp4");
94
+ const form = new FormData();
95
+ form.set("file", new Blob([blob], { type: mime }), `audio.${EXTENSIONS[mime] ?? "m4a"}`);
96
+ form.set("model", model);
97
+ form.set("response_format", "json");
98
+ if (input.language) form.set("language", input.language);
99
+ const response = await fetchFn("https://api.openai.com/v1/audio/transcriptions", {
100
+ method: "POST",
101
+ headers: { authorization: `Bearer ${options.apiKey}` },
102
+ body: form,
103
+ signal: AbortSignal.timeout(TIMEOUT_MS),
104
+ });
105
+ if (!response.ok) return failed("OpenAI", response);
106
+ const json = (await response.json()) as { text?: string };
107
+ if (typeof json.text !== "string") throw new Error("OpenAI answered without a transcript");
108
+ return { text: json.text.trim(), language: input.language };
109
+ },
110
+ };
111
+ }