@marktiderman/genesis-chat 0.0.0-stage → 0.1.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.
- package/LICENSE +21 -0
- package/README.md +163 -2
- package/dist/chunk-27UTR5FW.js +322 -0
- package/dist/chunk-27UTR5FW.js.map +1 -0
- package/dist/chunk-JQXZUEEL.js +525 -0
- package/dist/chunk-JQXZUEEL.js.map +1 -0
- package/dist/chunk-SAY24E6V.js +309 -0
- package/dist/chunk-SAY24E6V.js.map +1 -0
- package/dist/client-BtdALFCx.d.ts +349 -0
- package/dist/index.d.ts +185 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/native.d.ts +2 -0
- package/dist/native.js +3 -0
- package/dist/native.js.map +1 -0
- package/dist/react.d.ts +137 -0
- package/dist/react.js +5 -0
- package/dist/react.js.map +1 -0
- package/dist/server.d.ts +311 -0
- package/dist/server.js +562 -0
- package/dist/server.js.map +1 -0
- package/dist/web.d.ts +387 -0
- package/dist/web.js +2405 -0
- package/dist/web.js.map +1 -0
- package/migrations/0001_genesis_chat.sql +1836 -0
- package/migrations/tests/0001_genesis_chat.test.sql +254 -0
- package/package.json +144 -3
package/dist/server.js
ADDED
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
import { CHAT_SCHEMA, CHAT_BUCKET } from './chunk-JQXZUEEL.js';
|
|
2
|
+
|
|
3
|
+
// src/server/runner.ts
|
|
4
|
+
var CHAT_MAX_ATTEMPTS = 8;
|
|
5
|
+
var ALL_KINDS = ["push", "transcribe", "storage.remove", "video.remove"];
|
|
6
|
+
var SIGNED_URL_SECONDS = 600;
|
|
7
|
+
function errorText(fn, error) {
|
|
8
|
+
return new Error(`${fn}: ${error?.code ?? error?.message ?? "error"}`);
|
|
9
|
+
}
|
|
10
|
+
function str(value) {
|
|
11
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
12
|
+
}
|
|
13
|
+
async function pool(items, limit, work) {
|
|
14
|
+
let next = 0;
|
|
15
|
+
const lanes = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
16
|
+
while (next < items.length) await work(items[next++]);
|
|
17
|
+
});
|
|
18
|
+
await Promise.all(lanes);
|
|
19
|
+
}
|
|
20
|
+
function createChatRunner(options) {
|
|
21
|
+
const { supabase, deliver } = options;
|
|
22
|
+
const kinds = [...options.kinds ?? ALL_KINDS];
|
|
23
|
+
const limit = Math.min(Math.max(options.batch?.limit ?? 50, 1), 500);
|
|
24
|
+
const concurrency = Math.max(options.batch?.concurrency ?? 4, 1);
|
|
25
|
+
const directKinds = new Set(options.directKinds ?? ["direct"]);
|
|
26
|
+
const log = options.log ?? (() => {
|
|
27
|
+
});
|
|
28
|
+
const db = () => supabase.schema(CHAT_SCHEMA);
|
|
29
|
+
async function rpc(fn, args) {
|
|
30
|
+
const { data, error } = await db().rpc(fn, args);
|
|
31
|
+
if (error) throw errorText(fn, error);
|
|
32
|
+
return data;
|
|
33
|
+
}
|
|
34
|
+
async function nameOf(userId) {
|
|
35
|
+
if (!userId || !options.resolveName) return null;
|
|
36
|
+
try {
|
|
37
|
+
return str(await options.resolveName(userId));
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
async function push(effect) {
|
|
43
|
+
const messageId = str(effect.payload.message_id);
|
|
44
|
+
if (!messageId) return;
|
|
45
|
+
const targets = await rpc("push_targets", { p_message_id: messageId }) ?? [];
|
|
46
|
+
if (targets.length === 0) return;
|
|
47
|
+
const first = targets[0];
|
|
48
|
+
const conversationId = first.conversation_id;
|
|
49
|
+
const { data: conversation, error } = await db().from("conversations").select("kind").eq("id", conversationId).maybeSingle();
|
|
50
|
+
if (error) throw errorText("conversations", error);
|
|
51
|
+
const direct = conversation ? directKinds.has(conversation.kind) : false;
|
|
52
|
+
const { data: numbered } = await db().from("messages").select("seq").eq("id", messageId).maybeSingle();
|
|
53
|
+
const seq = numbered?.seq ?? null;
|
|
54
|
+
const senderName = await nameOf(first.sender_id);
|
|
55
|
+
const preview = first.preview ?? "New message";
|
|
56
|
+
const title = str(first.title) ?? senderName ?? "New message";
|
|
57
|
+
const body = !direct && senderName ? `${senderName}: ${preview}` : preview;
|
|
58
|
+
const notification = {
|
|
59
|
+
category: "messages",
|
|
60
|
+
messageId,
|
|
61
|
+
conversationId,
|
|
62
|
+
recipients: targets.map((t) => ({ userId: t.user_id, badge: t.unread_total ?? 0, mentioned: t.mentioned === true })),
|
|
63
|
+
title,
|
|
64
|
+
body,
|
|
65
|
+
senderId: first.sender_id,
|
|
66
|
+
collapseKey: `chat:${conversationId}`,
|
|
67
|
+
threadKey: conversationId,
|
|
68
|
+
idempotencyKey: `push:${messageId}`,
|
|
69
|
+
data: { url: options.linkFor?.(conversationId, messageId) ?? `/messages/${conversationId}`, conversationId, messageId, seq }
|
|
70
|
+
};
|
|
71
|
+
await deliver(notification);
|
|
72
|
+
}
|
|
73
|
+
async function storageRemove(effect) {
|
|
74
|
+
const path = str(effect.payload.path);
|
|
75
|
+
if (!path) return;
|
|
76
|
+
const bucket = str(effect.payload.bucket) ?? CHAT_BUCKET;
|
|
77
|
+
const { error } = await supabase.storage.from(bucket).remove([path]);
|
|
78
|
+
if (!error) return;
|
|
79
|
+
const status = String(error.statusCode ?? "");
|
|
80
|
+
if (status === "404" || /not.?found/i.test(error.message ?? "")) return;
|
|
81
|
+
throw new Error(`storage.remove: ${status || error.message || "error"}`);
|
|
82
|
+
}
|
|
83
|
+
async function transcribe(effect) {
|
|
84
|
+
const id = str(effect.payload.attachment_id);
|
|
85
|
+
if (!id) return;
|
|
86
|
+
const { data: row, error } = await db().from("attachments").select("id, kind, provider, path, mime, duration_ms, transcript_status").eq("id", id).maybeSingle();
|
|
87
|
+
if (error) throw errorText("attachments", error);
|
|
88
|
+
if (!row || row.transcript_status !== "pending" && row.transcript_status !== null) return;
|
|
89
|
+
if (row.provider !== "storage") return;
|
|
90
|
+
if (!options.transcriber || !row.path) {
|
|
91
|
+
await rpc("update_attachment", { p_attachment_id: id, p_patch: { transcript_status: "off" } });
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const signed = await supabase.storage.from(CHAT_BUCKET).createSignedUrl(row.path, SIGNED_URL_SECONDS);
|
|
95
|
+
if (signed.error || !signed.data?.signedUrl) throw errorText("createSignedUrl", signed.error);
|
|
96
|
+
try {
|
|
97
|
+
const result = await options.transcriber.transcribe({
|
|
98
|
+
url: signed.data.signedUrl,
|
|
99
|
+
mime: row.mime,
|
|
100
|
+
durationMs: row.duration_ms,
|
|
101
|
+
language: options.transcriptLanguage
|
|
102
|
+
});
|
|
103
|
+
await rpc("update_attachment", { p_attachment_id: id, p_patch: { transcript: result.text.slice(0, 1e5), transcript_status: "ready" } });
|
|
104
|
+
} catch (e) {
|
|
105
|
+
if (effect.attempts >= CHAT_MAX_ATTEMPTS) {
|
|
106
|
+
await rpc("update_attachment", { p_attachment_id: id, p_patch: { transcript_status: "failed" } }).catch(() => {
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
throw e;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
async function videoRemove(effect) {
|
|
113
|
+
const providerId = str(effect.payload.provider_id);
|
|
114
|
+
if (!providerId || !options.video) return;
|
|
115
|
+
const provider = str(effect.payload.provider);
|
|
116
|
+
if (provider && provider !== options.video.name) {
|
|
117
|
+
throw new Error(`video.remove: no adapter for ${provider} (this runner has ${options.video.name})`);
|
|
118
|
+
}
|
|
119
|
+
await options.video.remove(providerId);
|
|
120
|
+
}
|
|
121
|
+
async function handle(effect) {
|
|
122
|
+
switch (effect.kind) {
|
|
123
|
+
case "push":
|
|
124
|
+
return push(effect);
|
|
125
|
+
case "transcribe":
|
|
126
|
+
return transcribe(effect);
|
|
127
|
+
case "storage.remove":
|
|
128
|
+
return storageRemove(effect);
|
|
129
|
+
case "video.remove":
|
|
130
|
+
return videoRemove(effect);
|
|
131
|
+
default:
|
|
132
|
+
throw new Error(`no handler for ${effect.kind}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
async function run() {
|
|
136
|
+
const claimed = await rpc("claim_effects", { p_kinds: kinds, p_limit: limit }) ?? [];
|
|
137
|
+
const report = { claimed: claimed.length, done: 0, failed: 0 };
|
|
138
|
+
const byKind = /* @__PURE__ */ new Map();
|
|
139
|
+
for (const effect of claimed) byKind.set(effect.kind, [...byKind.get(effect.kind) ?? [], effect]);
|
|
140
|
+
await Promise.all(
|
|
141
|
+
[...byKind.values()].map(
|
|
142
|
+
(effects) => pool(effects, concurrency, async (effect) => {
|
|
143
|
+
let failure = null;
|
|
144
|
+
try {
|
|
145
|
+
await handle({ ...effect, payload: effect.payload ?? {} });
|
|
146
|
+
} catch (e) {
|
|
147
|
+
failure = (e instanceof Error ? e.message : String(e)).slice(0, 500) || "error";
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
await rpc("complete_effect", { p_id: effect.id, p_error: failure });
|
|
151
|
+
} catch (e) {
|
|
152
|
+
log(`genesis-chat: settling effect ${effect.id} failed: ${e instanceof Error ? e.message : "error"}`);
|
|
153
|
+
failure ??= "unsettled";
|
|
154
|
+
}
|
|
155
|
+
if (failure) {
|
|
156
|
+
report.failed++;
|
|
157
|
+
log(`genesis-chat: ${effect.kind} effect ${effect.id} (attempt ${effect.attempts}) failed: ${failure}`);
|
|
158
|
+
} else report.done++;
|
|
159
|
+
})
|
|
160
|
+
)
|
|
161
|
+
);
|
|
162
|
+
return report;
|
|
163
|
+
}
|
|
164
|
+
return { run, handle };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// src/server/transcribers.ts
|
|
168
|
+
var TIMEOUT_MS = 12e4;
|
|
169
|
+
async function failed(name, response) {
|
|
170
|
+
await response.body?.cancel().catch(() => {
|
|
171
|
+
});
|
|
172
|
+
throw new Error(`${name} answered HTTP ${response.status}`);
|
|
173
|
+
}
|
|
174
|
+
function deepgramTranscriber(options) {
|
|
175
|
+
const fetchFn = options.fetch ?? fetch;
|
|
176
|
+
const model = options.model ?? "nova-3";
|
|
177
|
+
return {
|
|
178
|
+
name: "deepgram",
|
|
179
|
+
async transcribe(input) {
|
|
180
|
+
const params = new URLSearchParams({ model, smart_format: "true" });
|
|
181
|
+
if (input.language) params.set("language", input.language);
|
|
182
|
+
else params.set("detect_language", "true");
|
|
183
|
+
const response = await fetchFn(`https://api.deepgram.com/v1/listen?${params}`, {
|
|
184
|
+
method: "POST",
|
|
185
|
+
headers: { authorization: `Token ${options.apiKey}`, "content-type": "application/json", accept: "application/json" },
|
|
186
|
+
body: JSON.stringify({ url: input.url }),
|
|
187
|
+
signal: AbortSignal.timeout(TIMEOUT_MS)
|
|
188
|
+
});
|
|
189
|
+
if (!response.ok) return failed("Deepgram", response);
|
|
190
|
+
const json3 = await response.json();
|
|
191
|
+
const channel = json3.results?.channels?.[0];
|
|
192
|
+
const text = channel?.alternatives?.[0]?.transcript;
|
|
193
|
+
if (typeof text !== "string") throw new Error("Deepgram answered without a transcript");
|
|
194
|
+
return { text: text.trim(), language: channel?.detected_language ?? input.language };
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
var EXTENSIONS = {
|
|
199
|
+
"audio/mp4": "m4a",
|
|
200
|
+
"audio/x-m4a": "m4a",
|
|
201
|
+
"audio/aac": "m4a",
|
|
202
|
+
"audio/mpeg": "mp3",
|
|
203
|
+
"audio/webm": "webm",
|
|
204
|
+
"audio/ogg": "ogg",
|
|
205
|
+
"audio/wav": "wav",
|
|
206
|
+
"video/mp4": "mp4",
|
|
207
|
+
"video/webm": "webm"
|
|
208
|
+
};
|
|
209
|
+
var OPENAI_MAX_BYTES = 25 * 1024 * 1024;
|
|
210
|
+
function openAiTranscriber(options) {
|
|
211
|
+
const fetchFn = options.fetch ?? fetch;
|
|
212
|
+
const model = options.model ?? "gpt-4o-mini-transcribe";
|
|
213
|
+
return {
|
|
214
|
+
name: "openai",
|
|
215
|
+
async transcribe(input) {
|
|
216
|
+
const file = await fetchFn(input.url, { signal: AbortSignal.timeout(TIMEOUT_MS) });
|
|
217
|
+
if (!file.ok) return failed("Storage", file);
|
|
218
|
+
const blob = await file.blob();
|
|
219
|
+
if (blob.size > OPENAI_MAX_BYTES) throw new Error("OpenAI transcription: file over 25 MB");
|
|
220
|
+
const mime = input.mime ?? (blob.type || "audio/mp4");
|
|
221
|
+
const form = new FormData();
|
|
222
|
+
form.set("file", new Blob([blob], { type: mime }), `audio.${EXTENSIONS[mime] ?? "m4a"}`);
|
|
223
|
+
form.set("model", model);
|
|
224
|
+
form.set("response_format", "json");
|
|
225
|
+
if (input.language) form.set("language", input.language);
|
|
226
|
+
const response = await fetchFn("https://api.openai.com/v1/audio/transcriptions", {
|
|
227
|
+
method: "POST",
|
|
228
|
+
headers: { authorization: `Bearer ${options.apiKey}` },
|
|
229
|
+
body: form,
|
|
230
|
+
signal: AbortSignal.timeout(TIMEOUT_MS)
|
|
231
|
+
});
|
|
232
|
+
if (!response.ok) return failed("OpenAI", response);
|
|
233
|
+
const json3 = await response.json();
|
|
234
|
+
if (typeof json3.text !== "string") throw new Error("OpenAI answered without a transcript");
|
|
235
|
+
return { text: json3.text.trim(), language: input.language };
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// src/server/crypto.ts
|
|
241
|
+
var encoder = new TextEncoder();
|
|
242
|
+
async function sha256(value) {
|
|
243
|
+
return new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(value)));
|
|
244
|
+
}
|
|
245
|
+
async function safeEqual(a, b) {
|
|
246
|
+
const [x, y] = await Promise.all([sha256(a), sha256(b)]);
|
|
247
|
+
let diff = 0;
|
|
248
|
+
for (let i = 0; i < x.length; i++) diff |= x[i] ^ y[i];
|
|
249
|
+
return diff === 0;
|
|
250
|
+
}
|
|
251
|
+
async function hmacSha256Hex(secret, message) {
|
|
252
|
+
const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
253
|
+
const mac = new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder.encode(message)));
|
|
254
|
+
return Array.from(mac, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
255
|
+
}
|
|
256
|
+
function base64ToBytes(value) {
|
|
257
|
+
const binary = atob(value.replace(/\s+/g, ""));
|
|
258
|
+
const out = new Uint8Array(binary.length);
|
|
259
|
+
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
|
|
260
|
+
return out;
|
|
261
|
+
}
|
|
262
|
+
function base64Url(bytes) {
|
|
263
|
+
const b = typeof bytes === "string" ? encoder.encode(bytes) : bytes;
|
|
264
|
+
let binary = "";
|
|
265
|
+
for (const byte of b) binary += String.fromCharCode(byte);
|
|
266
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
267
|
+
}
|
|
268
|
+
function derLength(n) {
|
|
269
|
+
if (n < 128) return [n];
|
|
270
|
+
const bytes = [];
|
|
271
|
+
for (let v = n; v > 0; v >>= 8) bytes.unshift(v & 255);
|
|
272
|
+
return [128 | bytes.length, ...bytes];
|
|
273
|
+
}
|
|
274
|
+
function der(tag, body) {
|
|
275
|
+
const len = derLength(body.length);
|
|
276
|
+
const out = new Uint8Array(1 + len.length + body.length);
|
|
277
|
+
out[0] = tag;
|
|
278
|
+
out.set(len, 1);
|
|
279
|
+
out.set(body, 1 + len.length);
|
|
280
|
+
return out;
|
|
281
|
+
}
|
|
282
|
+
function pkcs1ToPkcs8(pkcs1) {
|
|
283
|
+
const version = new Uint8Array([2, 1, 0]);
|
|
284
|
+
const algorithm = new Uint8Array([48, 13, 6, 9, 42, 134, 72, 134, 247, 13, 1, 1, 1, 5, 0]);
|
|
285
|
+
const octets = der(4, pkcs1);
|
|
286
|
+
const body = new Uint8Array(version.length + algorithm.length + octets.length);
|
|
287
|
+
body.set(version, 0);
|
|
288
|
+
body.set(algorithm, version.length);
|
|
289
|
+
body.set(octets, version.length + algorithm.length);
|
|
290
|
+
return der(48, body);
|
|
291
|
+
}
|
|
292
|
+
async function importRsaPrivateKey(key) {
|
|
293
|
+
const pem = key.includes("-----BEGIN") ? key : new TextDecoder().decode(base64ToBytes(key));
|
|
294
|
+
const match = /-----BEGIN (RSA )?PRIVATE KEY-----([\s\S]+?)-----END (RSA )?PRIVATE KEY-----/.exec(pem);
|
|
295
|
+
if (!match) throw new Error("importRsaPrivateKey: not a PEM private key");
|
|
296
|
+
const bytes = base64ToBytes(match[2]);
|
|
297
|
+
const pkcs8 = match[1] ? pkcs1ToPkcs8(bytes) : bytes;
|
|
298
|
+
return crypto.subtle.importKey("pkcs8", pkcs8, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, false, ["sign"]);
|
|
299
|
+
}
|
|
300
|
+
async function signJwtRs256(header, claims, key) {
|
|
301
|
+
const input = `${base64Url(JSON.stringify({ alg: "RS256", typ: "JWT", ...header }))}.${base64Url(JSON.stringify(claims))}`;
|
|
302
|
+
const signature = new Uint8Array(await crypto.subtle.sign("RSASSA-PKCS1-v1_5", key, encoder.encode(input)));
|
|
303
|
+
return `${input}.${base64Url(signature)}`;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// src/server/mux.ts
|
|
307
|
+
var API = "https://api.mux.com/video/v1";
|
|
308
|
+
var TOLERANCE_SECONDS = 300;
|
|
309
|
+
var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
310
|
+
async function verifyMuxSignature(header, body, secret, options = {}) {
|
|
311
|
+
if (!header || !secret) return false;
|
|
312
|
+
let timestamp = null;
|
|
313
|
+
const signatures = [];
|
|
314
|
+
for (const part of header.split(",")) {
|
|
315
|
+
const [k, v] = part.split("=", 2).map((s) => s.trim());
|
|
316
|
+
if (k === "t" && v) timestamp = v;
|
|
317
|
+
else if (k === "v1" && v) signatures.push(v.toLowerCase());
|
|
318
|
+
}
|
|
319
|
+
if (!timestamp || !/^\d{1,12}$/.test(timestamp) || signatures.length === 0) return false;
|
|
320
|
+
const now = Math.floor((options.now ?? Date.now()) / 1e3);
|
|
321
|
+
if (Math.abs(now - Number(timestamp)) > (options.toleranceSeconds ?? TOLERANCE_SECONDS)) return false;
|
|
322
|
+
const expected = await hmacSha256Hex(secret, `${timestamp}.${body}`);
|
|
323
|
+
let ok = false;
|
|
324
|
+
for (const signature of signatures) if (await safeEqual(signature, expected)) ok = true;
|
|
325
|
+
return ok;
|
|
326
|
+
}
|
|
327
|
+
async function muxPlaybackUrl(playbackId, key, options = {}) {
|
|
328
|
+
const token = await muxPlaybackToken(playbackId, key, options);
|
|
329
|
+
return `https://stream.mux.com/${playbackId}${options.path ?? ".m3u8"}?token=${token}`;
|
|
330
|
+
}
|
|
331
|
+
async function muxPlaybackToken(playbackId, key, options = {}) {
|
|
332
|
+
const now = Math.floor((options.now ?? Date.now()) / 1e3);
|
|
333
|
+
const privateKey = await importRsaPrivateKey(key.privateKey);
|
|
334
|
+
return signJwtRs256(
|
|
335
|
+
{ kid: key.signingKeyId },
|
|
336
|
+
{ sub: playbackId, aud: options.audience ?? "v", exp: now + (options.expiresInSeconds ?? 6 * 3600) },
|
|
337
|
+
privateKey
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
var json = (status, body) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
|
|
341
|
+
function muxVideo(options) {
|
|
342
|
+
const fetchFn = options.fetch ?? fetch;
|
|
343
|
+
const policy = options.playbackPolicy ?? "public";
|
|
344
|
+
const captions = options.captions === false ? null : { languageCode: options.captions?.languageCode ?? "en", name: options.captions?.name ?? "English (auto)" };
|
|
345
|
+
const auth = `Basic ${btoa(`${options.tokenId}:${options.tokenSecret}`)}`;
|
|
346
|
+
async function api(method, path, body) {
|
|
347
|
+
return fetchFn(`${API}${path}`, {
|
|
348
|
+
method,
|
|
349
|
+
headers: { authorization: auth, accept: "application/json", ...body ? { "content-type": "application/json" } : {} },
|
|
350
|
+
...body ? { body: JSON.stringify(body) } : {},
|
|
351
|
+
signal: AbortSignal.timeout(15e3)
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
async function update(supabase, id, patch) {
|
|
355
|
+
const { error } = await supabase.schema(CHAT_SCHEMA).rpc("update_attachment", { p_attachment_id: id, p_patch: patch });
|
|
356
|
+
if (error) throw new Error(`update_attachment: ${error.code ?? "error"}`);
|
|
357
|
+
}
|
|
358
|
+
async function find(supabase, ids) {
|
|
359
|
+
const table = () => supabase.schema(CHAT_SCHEMA).from("attachments").select("id, playback_id, transcript_status").eq("provider", "mux");
|
|
360
|
+
if (ids.passthrough && UUID.test(ids.passthrough)) {
|
|
361
|
+
const { data, error } = await table().eq("id", ids.passthrough).maybeSingle();
|
|
362
|
+
if (error) throw new Error(`attachments: ${error.code ?? "error"}`);
|
|
363
|
+
if (data) return data;
|
|
364
|
+
}
|
|
365
|
+
for (const providerId of ids.providerIds) {
|
|
366
|
+
if (!providerId) continue;
|
|
367
|
+
const { data, error } = await table().eq("provider_id", providerId).limit(1).maybeSingle();
|
|
368
|
+
if (error) throw new Error(`attachments: ${error.code ?? "error"}`);
|
|
369
|
+
if (data) return data;
|
|
370
|
+
}
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
async function transcriptOf(playbackId, trackId) {
|
|
374
|
+
let url = `https://stream.mux.com/${playbackId}/text/${trackId}.txt`;
|
|
375
|
+
if (policy === "signed") {
|
|
376
|
+
if (!options.signing) return null;
|
|
377
|
+
url += `?token=${await muxPlaybackToken(playbackId, options.signing, { expiresInSeconds: 600 })}`;
|
|
378
|
+
}
|
|
379
|
+
const response = await fetchFn(url, { signal: AbortSignal.timeout(15e3) });
|
|
380
|
+
if (!response.ok) {
|
|
381
|
+
await response.body?.cancel().catch(() => {
|
|
382
|
+
});
|
|
383
|
+
throw new Error(`Mux text track answered HTTP ${response.status}`);
|
|
384
|
+
}
|
|
385
|
+
return (await response.text()).trim();
|
|
386
|
+
}
|
|
387
|
+
return {
|
|
388
|
+
name: "mux",
|
|
389
|
+
async createUpload({ corsOrigin, passthrough }) {
|
|
390
|
+
const response = await api("POST", "/uploads", {
|
|
391
|
+
cors_origin: corsOrigin,
|
|
392
|
+
new_asset_settings: {
|
|
393
|
+
playback_policies: [policy],
|
|
394
|
+
video_quality: options.videoQuality ?? "basic",
|
|
395
|
+
...passthrough ? { passthrough: passthrough.slice(0, 255) } : {},
|
|
396
|
+
...captions ? { inputs: [{ generated_subtitles: [{ language_code: captions.languageCode, name: captions.name }] }] } : {}
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
if (!response.ok) {
|
|
400
|
+
await response.body?.cancel().catch(() => {
|
|
401
|
+
});
|
|
402
|
+
throw new Error(`Mux uploads answered HTTP ${response.status}`);
|
|
403
|
+
}
|
|
404
|
+
const body = await response.json();
|
|
405
|
+
if (!body.data?.id || !body.data.url) throw new Error("Mux uploads answered without an upload");
|
|
406
|
+
return { uploadId: body.data.id, url: body.data.url };
|
|
407
|
+
},
|
|
408
|
+
async remove(assetId) {
|
|
409
|
+
const response = await api("DELETE", `/assets/${encodeURIComponent(assetId)}`);
|
|
410
|
+
await response.body?.cancel().catch(() => {
|
|
411
|
+
});
|
|
412
|
+
if (response.ok || response.status === 404) return;
|
|
413
|
+
throw new Error(`Mux delete asset answered HTTP ${response.status}`);
|
|
414
|
+
},
|
|
415
|
+
playbackUrl(playbackId, opts) {
|
|
416
|
+
if (policy !== "signed") return Promise.resolve(`https://stream.mux.com/${playbackId}.m3u8`);
|
|
417
|
+
if (!options.signing) return Promise.reject(new Error("muxVideo: signed playback needs `signing`"));
|
|
418
|
+
return muxPlaybackUrl(playbackId, options.signing, opts);
|
|
419
|
+
},
|
|
420
|
+
async handleWebhook(request, { supabase }) {
|
|
421
|
+
if (request.method !== "POST") return json(405, { error: "method not allowed" });
|
|
422
|
+
const body = await request.text();
|
|
423
|
+
const ok = await verifyMuxSignature(request.headers.get("mux-signature"), body, options.webhookSecret, {
|
|
424
|
+
...options.now ? { now: options.now() } : {}
|
|
425
|
+
});
|
|
426
|
+
if (!ok) return json(401, { error: "bad signature" });
|
|
427
|
+
let event;
|
|
428
|
+
try {
|
|
429
|
+
event = JSON.parse(body);
|
|
430
|
+
} catch {
|
|
431
|
+
return json(400, { error: "bad json" });
|
|
432
|
+
}
|
|
433
|
+
const data = event.data ?? {};
|
|
434
|
+
switch (event.type) {
|
|
435
|
+
case "video.asset.ready":
|
|
436
|
+
case "video.asset.errored": {
|
|
437
|
+
const attachment = await find(supabase, { passthrough: data.passthrough, providerIds: [data.upload_id, data.id] });
|
|
438
|
+
if (!attachment) return json(404, { error: "attachment not found" });
|
|
439
|
+
if (event.type === "video.asset.errored") {
|
|
440
|
+
await update(supabase, attachment.id, { status: "failed", ...data.id ? { provider_id: data.id } : {} });
|
|
441
|
+
} else {
|
|
442
|
+
const playback = data.playback_ids?.find((p) => p.policy === policy) ?? data.playback_ids?.[0];
|
|
443
|
+
await update(supabase, attachment.id, {
|
|
444
|
+
status: "ready",
|
|
445
|
+
...data.id ? { provider_id: data.id } : {},
|
|
446
|
+
...playback?.id ? { playback_id: playback.id } : {},
|
|
447
|
+
...typeof data.duration === "number" ? { duration_ms: Math.round(data.duration * 1e3) } : {},
|
|
448
|
+
// No captions asked for: nothing will fill the transcript.
|
|
449
|
+
...!captions ? { transcript_status: "off" } : {}
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
return json(200, { ok: true });
|
|
453
|
+
}
|
|
454
|
+
case "video.asset.track.ready":
|
|
455
|
+
case "video.asset.track.errored": {
|
|
456
|
+
if (data.type !== "text" || data.text_source !== "generated_vod") return json(200, { ignored: true });
|
|
457
|
+
const attachment = await find(supabase, { providerIds: [data.asset_id] });
|
|
458
|
+
if (!attachment) return json(404, { error: "attachment not found" });
|
|
459
|
+
if (event.type === "video.asset.track.errored") {
|
|
460
|
+
await update(supabase, attachment.id, { transcript_status: "failed" });
|
|
461
|
+
return json(200, { ok: true });
|
|
462
|
+
}
|
|
463
|
+
if (!attachment.playback_id || !data.id) return json(404, { error: "no playback id yet" });
|
|
464
|
+
const text = await transcriptOf(attachment.playback_id, data.id);
|
|
465
|
+
if (text === null) return json(200, { ignored: "signed playback without a signing key" });
|
|
466
|
+
await update(supabase, attachment.id, { transcript: text.slice(0, 1e5), transcript_status: "ready" });
|
|
467
|
+
return json(200, { ok: true });
|
|
468
|
+
}
|
|
469
|
+
default:
|
|
470
|
+
return json(200, { ignored: true });
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// src/server/expo.ts
|
|
477
|
+
var EXPO_SEND_URL = "https://exp.host/--/api/v2/push/send";
|
|
478
|
+
var EXPO_BATCH = 100;
|
|
479
|
+
function createExpoDeliver(options) {
|
|
480
|
+
const fetchFn = options.fetch ?? fetch;
|
|
481
|
+
return async (notification) => {
|
|
482
|
+
const badges = new Map(notification.recipients.map((r) => [r.userId, r.badge]));
|
|
483
|
+
const devices = await options.tokensFor([...badges.keys()]);
|
|
484
|
+
const messages = devices.map(
|
|
485
|
+
(d) => ({
|
|
486
|
+
userId: d.userId,
|
|
487
|
+
to: d.token,
|
|
488
|
+
title: notification.title,
|
|
489
|
+
body: notification.body,
|
|
490
|
+
data: notification.data,
|
|
491
|
+
sound: options.sound === void 0 ? "default" : options.sound,
|
|
492
|
+
badge: badges.get(d.userId) ?? 0,
|
|
493
|
+
threadId: notification.threadKey,
|
|
494
|
+
...options.collapse ? { collapseId: notification.collapseKey } : {},
|
|
495
|
+
channelId: options.channelId ?? "messages",
|
|
496
|
+
categoryId: options.categoryId ?? "messages"
|
|
497
|
+
})
|
|
498
|
+
);
|
|
499
|
+
const tickets = [];
|
|
500
|
+
let sent = 0;
|
|
501
|
+
let lastError = null;
|
|
502
|
+
for (let i = 0; i < messages.length; i += EXPO_BATCH) {
|
|
503
|
+
const chunk = messages.slice(i, i + EXPO_BATCH);
|
|
504
|
+
let answer = null;
|
|
505
|
+
try {
|
|
506
|
+
const response = await fetchFn(EXPO_SEND_URL, {
|
|
507
|
+
method: "POST",
|
|
508
|
+
headers: {
|
|
509
|
+
accept: "application/json",
|
|
510
|
+
"content-type": "application/json",
|
|
511
|
+
...options.accessToken ? { authorization: `Bearer ${options.accessToken}` } : {}
|
|
512
|
+
},
|
|
513
|
+
body: JSON.stringify(chunk.map(({ userId: _userId, ...m }) => m)),
|
|
514
|
+
signal: AbortSignal.timeout(15e3)
|
|
515
|
+
});
|
|
516
|
+
if (!response.ok) {
|
|
517
|
+
await response.body?.cancel().catch(() => {
|
|
518
|
+
});
|
|
519
|
+
throw new Error(`Expo push answered HTTP ${response.status}`);
|
|
520
|
+
}
|
|
521
|
+
const json3 = await response.json();
|
|
522
|
+
answer = Array.isArray(json3.data) ? json3.data : [];
|
|
523
|
+
sent += chunk.length;
|
|
524
|
+
} catch (e) {
|
|
525
|
+
lastError = e;
|
|
526
|
+
}
|
|
527
|
+
chunk.forEach((m, j) => {
|
|
528
|
+
const t = answer?.[j];
|
|
529
|
+
tickets.push({
|
|
530
|
+
userId: m.userId,
|
|
531
|
+
token: m.to,
|
|
532
|
+
ticket: t && (t.status === "ok" || t.status === "error") ? t : { status: "error", details: { error: answer ? "NoTicket" : "RequestFailed" } }
|
|
533
|
+
});
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
if (messages.length > 0 && sent === 0) throw lastError instanceof Error ? lastError : new Error("Expo push failed");
|
|
537
|
+
return tickets;
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
function deadTokens(tickets) {
|
|
541
|
+
return tickets.filter((t) => t.ticket.status === "error" && t.ticket.details?.error === "DeviceNotRegistered").map((t) => t.token);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// src/server/handler.ts
|
|
545
|
+
var json2 = (status, body) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json", "cache-control": "no-store" } });
|
|
546
|
+
function chatRunnerHandler(options) {
|
|
547
|
+
return async (request) => {
|
|
548
|
+
if (!options.secret || options.secret.length < 32) return json2(500, { error: "runner secret missing or under 32 characters" });
|
|
549
|
+
if (request.method !== "POST" && request.method !== "GET") return json2(405, { error: "method not allowed" });
|
|
550
|
+
const match = /^Bearer\s+(\S+)$/i.exec(request.headers.get("authorization") ?? "");
|
|
551
|
+
if (!match || !await safeEqual(match[1], options.secret)) return json2(401, { error: "unauthorized" });
|
|
552
|
+
try {
|
|
553
|
+
return json2(200, await options.runner.run());
|
|
554
|
+
} catch (e) {
|
|
555
|
+
return json2(500, { error: e instanceof Error ? e.message.slice(0, 200) : "run failed" });
|
|
556
|
+
}
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
export { CHAT_MAX_ATTEMPTS, EXPO_BATCH, EXPO_SEND_URL, chatRunnerHandler, createChatRunner, createExpoDeliver, deadTokens, deepgramTranscriber, muxPlaybackToken, muxPlaybackUrl, muxVideo, openAiTranscriber, verifyMuxSignature };
|
|
561
|
+
//# sourceMappingURL=server.js.map
|
|
562
|
+
//# sourceMappingURL=server.js.map
|