@spectrum-ts/core 5.0.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.
@@ -0,0 +1,1489 @@
1
+ import z from "zod";
2
+ import ogs from "open-graph-scraper";
3
+ import { randomUUID } from "node:crypto";
4
+ import { createReadStream } from "node:fs";
5
+ import { readFile, stat } from "node:fs/promises";
6
+ import { basename } from "node:path";
7
+ import { Readable } from "node:stream";
8
+ import { lookup } from "mime-types";
9
+ import vCard from "vcf";
10
+ import { sanitizeEmail, sanitizeErrorMessage, sanitizePhone } from "@photon-ai/otel";
11
+ import { Marked } from "marked";
12
+ import { Repeater } from "@repeaterjs/repeater";
13
+ //#region src/utils/io.ts
14
+ const readSchema$1 = z.function({
15
+ input: [],
16
+ output: z.promise(z.instanceof(Buffer))
17
+ });
18
+ const streamSchema = z.function({
19
+ input: [],
20
+ output: z.promise(z.instanceof(ReadableStream))
21
+ });
22
+ const bufferToStream = (buf) => new ReadableStream({ start(controller) {
23
+ controller.enqueue(buf);
24
+ controller.close();
25
+ } });
26
+ const DEFAULT_FETCH_TIMEOUT_MS = 1e4;
27
+ /**
28
+ * Fetch URL bytes into memory — never touches the filesystem, so callers
29
+ * remain safe in read-only environments. Returns the response's Content-Type
30
+ * alongside the bytes so callers that want a soft MIME fallback can use it.
31
+ */
32
+ const fetchUrlBytes = async (url, options) => {
33
+ const controller = new AbortController();
34
+ const timer = setTimeout(() => controller.abort(), options?.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS);
35
+ try {
36
+ const res = await fetch(url, {
37
+ signal: controller.signal,
38
+ headers: options?.headers
39
+ });
40
+ if (!res.ok) throw new Error(`URL fetch ${url.toString()} returned ${res.status}`);
41
+ return {
42
+ data: Buffer.from(await res.arrayBuffer()),
43
+ mimeType: res.headers.get("content-type") ?? void 0
44
+ };
45
+ } finally {
46
+ clearTimeout(timer);
47
+ }
48
+ };
49
+ //#endregion
50
+ //#region src/utils/link-metadata.ts
51
+ const DEFAULT_TIMEOUT_MS = 5e3;
52
+ const USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15 spectrum-ts/richlink";
53
+ const normaliseImageUrl = (raw, base) => {
54
+ try {
55
+ return new URL(raw, base).toString();
56
+ } catch {
57
+ return;
58
+ }
59
+ };
60
+ const cleanString = (v) => {
61
+ if (typeof v !== "string") return;
62
+ const trimmed = v.trim();
63
+ return trimmed.length > 0 ? trimmed : void 0;
64
+ };
65
+ const fetchLinkMetadata = async (url) => {
66
+ try {
67
+ const result = await ogs({
68
+ url,
69
+ timeout: DEFAULT_TIMEOUT_MS,
70
+ fetchOptions: { headers: { "User-Agent": USER_AGENT } }
71
+ });
72
+ if (result.error) return {};
73
+ const { ogTitle, ogDescription, ogImage, ogSiteName, twitterTitle, twitterDescription, twitterImage } = result.result;
74
+ const title = cleanString(ogTitle) ?? cleanString(twitterTitle);
75
+ const summary = cleanString(ogDescription) ?? cleanString(twitterDescription);
76
+ const siteName = cleanString(ogSiteName);
77
+ const imageCandidate = ogImage?.[0] ?? twitterImage?.[0];
78
+ const resolved = imageCandidate ? normaliseImageUrl(imageCandidate.url, url) : void 0;
79
+ return {
80
+ title,
81
+ summary,
82
+ siteName,
83
+ image: imageCandidate && resolved ? {
84
+ url: resolved,
85
+ mimeType: "type" in imageCandidate && typeof imageCandidate.type === "string" ? imageCandidate.type : void 0
86
+ } : void 0
87
+ };
88
+ } catch {
89
+ return {};
90
+ }
91
+ };
92
+ const fetchImage = (url) => fetchUrlBytes(new URL(url), {
93
+ timeoutMs: DEFAULT_TIMEOUT_MS,
94
+ headers: { "User-Agent": USER_AGENT }
95
+ });
96
+ //#endregion
97
+ //#region src/content/attachment.ts
98
+ const DEFAULT_ATTACHMENT_NAME = "attachment";
99
+ const attachmentSchema = z.object({
100
+ type: z.literal("attachment"),
101
+ id: z.string().nonempty(),
102
+ name: z.string().nonempty(),
103
+ mimeType: z.string().nonempty(),
104
+ size: z.number().int().nonnegative().optional(),
105
+ read: readSchema$1,
106
+ stream: streamSchema
107
+ });
108
+ const resolveAttachmentName = (input, name) => {
109
+ if (name) return name;
110
+ if (input instanceof URL) return basename(input.pathname) || DEFAULT_ATTACHMENT_NAME;
111
+ if (typeof input === "string") return basename(input);
112
+ return DEFAULT_ATTACHMENT_NAME;
113
+ };
114
+ const resolveAttachmentMimeType = (name, mimeType) => {
115
+ if (mimeType) return mimeType;
116
+ const resolvedMimeType = lookup(name);
117
+ if (!resolvedMimeType) throw new Error(`Unable to resolve MIME type for attachment "${name}". Pass options.mimeType explicitly.`);
118
+ return resolvedMimeType;
119
+ };
120
+ const asAttachment = (input) => {
121
+ let cached;
122
+ const read = () => {
123
+ cached ??= input.read().catch((err) => {
124
+ cached = void 0;
125
+ throw err;
126
+ });
127
+ return cached;
128
+ };
129
+ const stream = input.stream ?? (async () => bufferToStream(await read()));
130
+ return attachmentSchema.parse({
131
+ type: "attachment",
132
+ id: input.id ?? randomUUID(),
133
+ name: input.name,
134
+ mimeType: input.mimeType,
135
+ size: input.size,
136
+ read,
137
+ stream
138
+ });
139
+ };
140
+ function attachment(input, options) {
141
+ return { build: async () => {
142
+ const id = options?.id;
143
+ const name = resolveAttachmentName(input, options?.name);
144
+ const mimeType = resolveAttachmentMimeType(name, options?.mimeType);
145
+ if (input instanceof URL) return asAttachment({
146
+ id,
147
+ name,
148
+ mimeType,
149
+ read: async () => (await fetchUrlBytes(input)).data
150
+ });
151
+ if (typeof input === "string") return asAttachment({
152
+ id,
153
+ name,
154
+ mimeType,
155
+ size: (await stat(input)).size,
156
+ read: () => readFile(input),
157
+ stream: async () => Readable.toWeb(createReadStream(input))
158
+ });
159
+ return asAttachment({
160
+ id,
161
+ name,
162
+ mimeType,
163
+ size: input.byteLength,
164
+ read: async () => input,
165
+ stream: async () => bufferToStream(input)
166
+ });
167
+ } };
168
+ }
169
+ const photoActionSchema = z.discriminatedUnion("kind", [z.object({
170
+ kind: z.literal("set"),
171
+ read: readSchema$1,
172
+ mimeType: z.string().nonempty()
173
+ }), z.object({ kind: z.literal("clear") })]);
174
+ const resolveMimeType = (input, mimeType, contentLabel) => {
175
+ if (mimeType) return mimeType;
176
+ if (input instanceof URL) {
177
+ const resolved = lookup(basename(input.pathname));
178
+ if (resolved) return resolved;
179
+ } else if (typeof input === "string") {
180
+ const resolved = lookup(basename(input));
181
+ if (resolved) return resolved;
182
+ }
183
+ throw new Error(`Unable to resolve MIME type for ${contentLabel}. Pass options.mimeType explicitly.`);
184
+ };
185
+ const cachedRead = (read) => {
186
+ let cached;
187
+ return () => {
188
+ cached ??= read().catch((err) => {
189
+ cached = void 0;
190
+ throw err;
191
+ });
192
+ return cached;
193
+ };
194
+ };
195
+ /**
196
+ * Convert a photo-content input into the discriminated `PhotoAction` shape.
197
+ *
198
+ * - `"clear"` → `{ kind: "clear" }` (reserved sentinel — to send a literal
199
+ * file named `clear`, pass `"./clear"` or load it as a `Buffer`).
200
+ * - `string` path → reads the file lazily via `node:fs/promises.readFile`;
201
+ * MIME type inferred from the filename extension.
202
+ * - `URL` → fetched lazily over the network into memory; never touches the
203
+ * filesystem, so it works in read-only environments. MIME type is inferred
204
+ * from the URL pathname extension at build time (override with
205
+ * `options.mimeType` if the URL has no usable extension).
206
+ * - `Buffer` → in-memory bytes; `options.mimeType` is required.
207
+ *
208
+ * Called at builder-construction time so a missing MIME type fails fast
209
+ * rather than at send time. The returned `read()` is memoized so repeated
210
+ * `build()` / send cycles don't re-read the same file.
211
+ */
212
+ const buildPhotoAction = (input, options, contentLabel) => {
213
+ if (input === "clear") return { kind: "clear" };
214
+ const mimeType = resolveMimeType(input, options?.mimeType, contentLabel);
215
+ let read;
216
+ if (input instanceof URL) read = cachedRead(async () => (await fetchUrlBytes(input)).data);
217
+ else if (typeof input === "string") read = cachedRead(() => readFile(input));
218
+ else {
219
+ const snapshot = Buffer.from(input);
220
+ read = cachedRead(async () => snapshot);
221
+ }
222
+ return {
223
+ kind: "set",
224
+ read,
225
+ mimeType
226
+ };
227
+ };
228
+ //#endregion
229
+ //#region src/utils/vcard.ts
230
+ const asPropertyArray = (prop) => {
231
+ if (!prop) return [];
232
+ return Array.isArray(prop) ? prop : [prop];
233
+ };
234
+ const propString = (prop) => {
235
+ const [first] = asPropertyArray(prop);
236
+ const value = first?.valueOf().trim();
237
+ return value ? value : void 0;
238
+ };
239
+ const paramTypes = (prop) => {
240
+ const { type } = prop;
241
+ if (!type) return [];
242
+ return (Array.isArray(type) ? type : [type]).map((t) => t.toLowerCase());
243
+ };
244
+ const mapPhoneType = (prop) => {
245
+ const types = paramTypes(prop);
246
+ if (types.some((t) => t === "cell" || t === "mobile" || t === "iphone")) return "mobile";
247
+ if (types.includes("home")) return "home";
248
+ if (types.includes("work")) return "work";
249
+ if (types.length > 0) return "other";
250
+ };
251
+ const mapSimpleType = (prop) => {
252
+ const types = paramTypes(prop);
253
+ if (types.includes("home")) return "home";
254
+ if (types.includes("work")) return "work";
255
+ if (types.length > 0) return "other";
256
+ };
257
+ const splitStructured = (value) => value.split(";").map((part) => part.trim());
258
+ const extractName = (card) => {
259
+ const fn = propString(card.data.fn);
260
+ const n = propString(card.data.n);
261
+ if (!(fn || n)) return;
262
+ const result = {};
263
+ if (fn) result.formatted = fn;
264
+ if (n) {
265
+ const [last, first, middle, prefix, suffix] = splitStructured(n);
266
+ if (first) result.first = first;
267
+ if (last) result.last = last;
268
+ if (middle) result.middle = middle;
269
+ if (prefix) result.prefix = prefix;
270
+ if (suffix) result.suffix = suffix;
271
+ }
272
+ return result;
273
+ };
274
+ const extractPhones = (card) => {
275
+ const props = asPropertyArray(card.data.tel);
276
+ if (props.length === 0) return;
277
+ return props.map((p) => {
278
+ const entry = { value: p.valueOf().trim() };
279
+ const type = mapPhoneType(p);
280
+ if (type) entry.type = type;
281
+ return entry;
282
+ });
283
+ };
284
+ const extractEmails = (card) => {
285
+ const props = asPropertyArray(card.data.email);
286
+ if (props.length === 0) return;
287
+ return props.map((p) => {
288
+ const entry = { value: p.valueOf().trim() };
289
+ const type = mapSimpleType(p);
290
+ if (type) entry.type = type;
291
+ return entry;
292
+ });
293
+ };
294
+ const extractAddresses = (card) => {
295
+ const props = asPropertyArray(card.data.adr);
296
+ if (props.length === 0) return;
297
+ return props.map((p) => {
298
+ const [, , street, city, region, postalCode, country] = splitStructured(p.valueOf());
299
+ const entry = {};
300
+ if (street) entry.street = street;
301
+ if (city) entry.city = city;
302
+ if (region) entry.region = region;
303
+ if (postalCode) entry.postalCode = postalCode;
304
+ if (country) entry.country = country;
305
+ const type = mapSimpleType(p);
306
+ if (type) entry.type = type;
307
+ return entry;
308
+ });
309
+ };
310
+ const extractOrg = (card) => {
311
+ const orgStr = propString(card.data.org);
312
+ const title = propString(card.data.title);
313
+ if (!(orgStr || title)) return;
314
+ const result = {};
315
+ if (orgStr) {
316
+ const [name, department] = splitStructured(orgStr);
317
+ if (name) result.name = name;
318
+ if (department) result.department = department;
319
+ }
320
+ if (title) result.title = title;
321
+ return result;
322
+ };
323
+ const extractUrls = (card) => {
324
+ const props = asPropertyArray(card.data.url);
325
+ if (props.length === 0) return;
326
+ return props.map((p) => p.valueOf().trim());
327
+ };
328
+ const photoMimeFromType = (type) => {
329
+ if (!type) return "image/jpeg";
330
+ const lower = type.toLowerCase();
331
+ if (lower.startsWith("image/")) return lower;
332
+ return `image/${lower}`;
333
+ };
334
+ const DATA_URI_PATTERN = /^data:([^;,]+);base64,(.*)$/i;
335
+ const extractPhoto = (card) => {
336
+ const [prop] = asPropertyArray(card.data.photo);
337
+ if (!prop) return;
338
+ const value = prop.valueOf();
339
+ const dataUriMatch = DATA_URI_PATTERN.exec(value);
340
+ if (dataUriMatch) {
341
+ const [, mimeType, base64] = dataUriMatch;
342
+ const buf = Buffer.from(base64 ?? "", "base64");
343
+ return {
344
+ mimeType: mimeType ?? "image/jpeg",
345
+ read: async () => buf
346
+ };
347
+ }
348
+ const type = Array.isArray(prop.type) ? prop.type[0] : prop.type;
349
+ const buf = Buffer.from(value, "base64");
350
+ return {
351
+ mimeType: photoMimeFromType(type),
352
+ read: async () => buf
353
+ };
354
+ };
355
+ const normalizeVCardInput = (vcf) => {
356
+ return (vcf.charCodeAt(0) === 65279 ? vcf.slice(1) : vcf).replace(/\r\n|\r|\n/g, "\r\n");
357
+ };
358
+ const fromVCard = (vcf) => {
359
+ const [card] = vCard.parse(normalizeVCardInput(vcf));
360
+ if (!card) throw new Error("Invalid vCard: no cards parsed");
361
+ const input = { raw: vcf };
362
+ const name = extractName(card);
363
+ if (name) input.name = name;
364
+ const phones = extractPhones(card);
365
+ if (phones) input.phones = phones;
366
+ const emails = extractEmails(card);
367
+ if (emails) input.emails = emails;
368
+ const addresses = extractAddresses(card);
369
+ if (addresses) input.addresses = addresses;
370
+ const org = extractOrg(card);
371
+ if (org) input.org = org;
372
+ const urls = extractUrls(card);
373
+ if (urls) input.urls = urls;
374
+ const birthday = propString(card.data.bday);
375
+ if (birthday) input.birthday = birthday;
376
+ const note = propString(card.data.note);
377
+ if (note) input.note = note;
378
+ const photo = extractPhoto(card);
379
+ if (photo) input.photo = photo;
380
+ return input;
381
+ };
382
+ const formattedNameFor = (name) => {
383
+ if (name?.formatted) return name.formatted;
384
+ const parts = [
385
+ name?.first,
386
+ name?.middle,
387
+ name?.last
388
+ ].filter((p) => Boolean(p));
389
+ if (parts.length > 0) return parts.join(" ");
390
+ return "Unknown";
391
+ };
392
+ const phoneTypeParam = (type) => {
393
+ if (type === "mobile") return "CELL";
394
+ if (type === "home" || type === "work" || type === "other") return type.toUpperCase();
395
+ };
396
+ const simpleTypeParam = (type) => type ? type.toUpperCase() : void 0;
397
+ const photoTypeParam = (mimeType) => {
398
+ return (mimeType.split("/")[1] ?? "jpeg").toUpperCase();
399
+ };
400
+ const writeName = (card, name) => {
401
+ card.set("fn", formattedNameFor(name));
402
+ if (!name) return;
403
+ if (name.first || name.last || name.middle || name.prefix || name.suffix) card.set("n", [
404
+ name.last ?? "",
405
+ name.first ?? "",
406
+ name.middle ?? "",
407
+ name.prefix ?? "",
408
+ name.suffix ?? ""
409
+ ].join(";"));
410
+ };
411
+ const writePhones = (card, phones) => {
412
+ for (const phone of phones ?? []) {
413
+ const type = phoneTypeParam(phone.type);
414
+ card.add("tel", phone.value, type ? { type } : void 0);
415
+ }
416
+ };
417
+ const writeEmails = (card, emails) => {
418
+ for (const email of emails ?? []) {
419
+ const type = simpleTypeParam(email.type);
420
+ card.add("email", email.value, type ? { type } : void 0);
421
+ }
422
+ };
423
+ const writeAddresses = (card, addresses) => {
424
+ for (const addr of addresses ?? []) {
425
+ const value = [
426
+ "",
427
+ "",
428
+ addr.street ?? "",
429
+ addr.city ?? "",
430
+ addr.region ?? "",
431
+ addr.postalCode ?? "",
432
+ addr.country ?? ""
433
+ ].join(";");
434
+ const type = simpleTypeParam(addr.type);
435
+ card.add("adr", value, type ? { type } : void 0);
436
+ }
437
+ };
438
+ const writeOrg = (card, org) => {
439
+ if (!org) return;
440
+ if (org.name || org.department) card.set("org", [org.name ?? "", org.department ?? ""].join(";"));
441
+ if (org.title) card.set("title", org.title);
442
+ };
443
+ const writeUrls = (card, urls) => {
444
+ for (const url of urls ?? []) card.add("url", url);
445
+ };
446
+ const writePhoto = async (card, photo) => {
447
+ if (!photo) return;
448
+ const buf = await photo.read();
449
+ card.set("photo", buf.toString("base64"), {
450
+ encoding: "b",
451
+ type: photoTypeParam(photo.mimeType)
452
+ });
453
+ };
454
+ const toVCard = async (contact) => {
455
+ if (typeof contact.raw === "string" && contact.raw.startsWith("BEGIN:VCARD")) return contact.raw;
456
+ const card = new vCard();
457
+ writeName(card, contact.name);
458
+ writePhones(card, contact.phones);
459
+ writeEmails(card, contact.emails);
460
+ writeAddresses(card, contact.addresses);
461
+ writeOrg(card, contact.org);
462
+ writeUrls(card, contact.urls);
463
+ if (contact.birthday) card.set("bday", contact.birthday);
464
+ if (contact.note) card.set("note", contact.note);
465
+ await writePhoto(card, contact.photo);
466
+ return card.toString();
467
+ };
468
+ //#endregion
469
+ //#region src/content/contact.ts
470
+ const userRefSchema = z.object({
471
+ __platform: z.string(),
472
+ id: z.string()
473
+ });
474
+ const nameSchema = z.object({
475
+ formatted: z.string().optional(),
476
+ first: z.string().optional(),
477
+ last: z.string().optional(),
478
+ middle: z.string().optional(),
479
+ prefix: z.string().optional(),
480
+ suffix: z.string().optional()
481
+ });
482
+ const phoneTypeSchema = z.enum([
483
+ "mobile",
484
+ "home",
485
+ "work",
486
+ "other"
487
+ ]);
488
+ const emailTypeSchema = z.enum([
489
+ "home",
490
+ "work",
491
+ "other"
492
+ ]);
493
+ const addressTypeSchema = z.enum([
494
+ "home",
495
+ "work",
496
+ "other"
497
+ ]);
498
+ const phoneSchema = z.object({
499
+ value: z.string(),
500
+ type: phoneTypeSchema.optional()
501
+ });
502
+ const emailSchema = z.object({
503
+ value: z.string(),
504
+ type: emailTypeSchema.optional()
505
+ });
506
+ const addressSchema = z.object({
507
+ street: z.string().optional(),
508
+ city: z.string().optional(),
509
+ region: z.string().optional(),
510
+ postalCode: z.string().optional(),
511
+ country: z.string().optional(),
512
+ type: addressTypeSchema.optional()
513
+ });
514
+ const orgSchema = z.object({
515
+ name: z.string().optional(),
516
+ title: z.string().optional(),
517
+ department: z.string().optional()
518
+ });
519
+ const photoSchema = z.object({
520
+ mimeType: z.string(),
521
+ read: readSchema$1
522
+ });
523
+ const contactSchema = z.object({
524
+ type: z.literal("contact"),
525
+ user: userRefSchema.optional(),
526
+ name: nameSchema.optional(),
527
+ phones: z.array(phoneSchema).optional(),
528
+ emails: z.array(emailSchema).optional(),
529
+ addresses: z.array(addressSchema).optional(),
530
+ org: orgSchema.optional(),
531
+ urls: z.array(z.string()).optional(),
532
+ birthday: z.string().optional(),
533
+ note: z.string().optional(),
534
+ photo: photoSchema.optional(),
535
+ raw: z.unknown().optional()
536
+ });
537
+ const asContact = (input) => contactSchema.parse({
538
+ type: "contact",
539
+ ...input
540
+ });
541
+ const isUser = (value) => typeof value === "object" && value !== null && "__platform" in value && "id" in value && typeof value.__platform === "string" && typeof value.id === "string";
542
+ function contact(input, details) {
543
+ return { build: async () => {
544
+ if (typeof input === "string") return asContact(fromVCard(input));
545
+ if (input instanceof vCard) return asContact(fromVCard(input.toString()));
546
+ if (isUser(input)) return asContact({
547
+ user: {
548
+ __platform: input.__platform,
549
+ id: input.id
550
+ },
551
+ ...details
552
+ });
553
+ return asContact(input);
554
+ } };
555
+ }
556
+ //#endregion
557
+ //#region src/content/custom.ts
558
+ const customSchema = z.object({
559
+ type: z.literal("custom"),
560
+ raw: z.unknown()
561
+ });
562
+ const asCustom = (raw) => customSchema.parse({
563
+ type: "custom",
564
+ raw
565
+ });
566
+ function custom(raw) {
567
+ return { build: async () => asCustom(raw) };
568
+ }
569
+ //#endregion
570
+ //#region src/content/stream-text.ts
571
+ const streamTextSchema = z.object({
572
+ type: z.literal("streamText"),
573
+ stream: z.custom((v) => typeof v === "function", { message: "streamText.stream must be a function returning AsyncIterable<string>" }),
574
+ format: z.enum(["plain", "markdown"]).optional()
575
+ });
576
+ const asRecord = (value) => typeof value === "object" && value !== null ? value : void 0;
577
+ const SKIP_EVENT_TYPES = new Set([
578
+ "message_start",
579
+ "message_delta",
580
+ "message_stop",
581
+ "content_block_start",
582
+ "content_block_stop",
583
+ "ping"
584
+ ]);
585
+ const fromOpenAIResponses = (obj) => {
586
+ const type = obj.type;
587
+ if (typeof type !== "string" || !type.startsWith("response.")) return;
588
+ if (type === "response.output_text.delta" && typeof obj.delta === "string") return obj.delta;
589
+ return null;
590
+ };
591
+ const fromAnthropicDelta = (obj) => {
592
+ if (obj.type !== "content_block_delta") return;
593
+ const delta = asRecord(obj.delta);
594
+ if (delta?.type === "text_delta" && typeof delta.text === "string") return delta.text;
595
+ return null;
596
+ };
597
+ const fromAiSdkPart = (obj) => {
598
+ if (obj.type !== "text-delta") return;
599
+ if (typeof obj.textDelta === "string") return obj.textDelta;
600
+ return typeof obj.text === "string" ? obj.text : null;
601
+ };
602
+ const fromOpenAIChat = (obj) => {
603
+ if (!Array.isArray(obj.choices)) return;
604
+ const content = asRecord(asRecord(obj.choices[0])?.delta)?.content;
605
+ return typeof content === "string" ? content : null;
606
+ };
607
+ const fromControlEvent = (obj) => typeof obj.type === "string" && SKIP_EVENT_TYPES.has(obj.type) ? null : void 0;
608
+ const OBJECT_EXTRACTORS = [
609
+ fromOpenAIResponses,
610
+ fromAnthropicDelta,
611
+ fromAiSdkPart,
612
+ fromOpenAIChat,
613
+ fromControlEvent
614
+ ];
615
+ /**
616
+ * Auto-detect the text delta in a chunk from a popular LLM SDK. Pass a custom
617
+ * `extract` to `text()` / `markdown()` for any shape this doesn't recognize.
618
+ */
619
+ const defaultExtract = (chunk) => {
620
+ if (typeof chunk === "string") return chunk;
621
+ const record = asRecord(chunk);
622
+ if (!record) throw new Error(`text stream: cannot extract a text delta from a ${typeof chunk} chunk. Pass { extract } to map your stream's chunks to text.`);
623
+ for (const extractor of OBJECT_EXTRACTORS) {
624
+ const result = extractor(record);
625
+ if (result !== void 0) return result;
626
+ }
627
+ throw new Error(`text stream: unrecognized chunk shape (type=${String(record.type)}). Pass an { extract } function to map your provider's chunk to a text delta.`);
628
+ };
629
+ const isReadableStream = (value) => typeof value?.getReader === "function";
630
+ const isAsyncIterable = (value) => typeof value?.[Symbol.asyncIterator] === "function";
631
+ async function* readableToAsync(source) {
632
+ if (isAsyncIterable(source)) {
633
+ yield* source;
634
+ return;
635
+ }
636
+ const reader = source.getReader();
637
+ try {
638
+ while (true) {
639
+ const { done, value } = await reader.read();
640
+ if (done) return;
641
+ yield value;
642
+ }
643
+ } finally {
644
+ reader.releaseLock();
645
+ }
646
+ }
647
+ const resolveChunkIterable = (source) => {
648
+ const textStream = source.textStream;
649
+ if (textStream != null) {
650
+ if (isReadableStream(textStream)) return readableToAsync(textStream);
651
+ if (isAsyncIterable(textStream)) return textStream;
652
+ throw new Error("text stream: `.textStream` must be an AsyncIterable or a ReadableStream.");
653
+ }
654
+ if (isReadableStream(source)) return readableToAsync(source);
655
+ if (isAsyncIterable(source)) return source;
656
+ throw new Error("text stream: source must be an AsyncIterable, a ReadableStream, or an object with a `.textStream` (e.g. the AI SDK streamText() result).");
657
+ };
658
+ /**
659
+ * Thrown when a single-use stream source is consumed a second time. The send
660
+ * pipeline's plain-text fallback matches on this to tell "the provider already
661
+ * consumed the stream" apart from a stream that errored mid-drain.
662
+ */
663
+ var StreamConsumedError = class extends Error {
664
+ constructor() {
665
+ super("text stream: this source has already been consumed — a stream can only be sent once.");
666
+ this.name = "StreamConsumedError";
667
+ }
668
+ };
669
+ const normalize$1 = (source, options) => {
670
+ const extract = options?.extract ? options.extract : defaultExtract;
671
+ let consumed = false;
672
+ return async function* normalized() {
673
+ if (consumed) throw new StreamConsumedError();
674
+ consumed = true;
675
+ for await (const chunk of resolveChunkIterable(source)) {
676
+ const delta = extract(chunk);
677
+ if (delta) yield delta;
678
+ }
679
+ };
680
+ };
681
+ const asStreamText = (input) => streamTextSchema.parse({
682
+ type: "streamText",
683
+ stream: input.stream,
684
+ ...input.format ? { format: input.format } : {}
685
+ });
686
+ /**
687
+ * Consume a `streamText` content's stream to completion and return the full
688
+ * accumulated text. Used by the send pipeline's plain-text fallback for
689
+ * platforms that can't stream. Consumes the single-use stream — the content
690
+ * cannot be sent afterwards.
691
+ */
692
+ const drainStreamText = async (content) => {
693
+ let full = "";
694
+ for await (const delta of content.stream()) full += delta;
695
+ return full;
696
+ };
697
+ /**
698
+ * Shared backing for the stream overloads of `text()` and `markdown()`: the
699
+ * constructor name picks the wire format and labels the eager guard against
700
+ * passing a content builder where a raw stream source belongs.
701
+ */
702
+ const streamTextBuilder = (kind, source, options) => {
703
+ if (typeof source.build === "function") throw new Error(`${kind}(): pass the stream source itself (an AsyncIterable, a ReadableStream, or an SDK result with .textStream), not another content builder.`);
704
+ return { build: async () => asStreamText({
705
+ stream: normalize$1(source, options),
706
+ format: kind === "markdown" ? "markdown" : void 0
707
+ }) };
708
+ };
709
+ //#endregion
710
+ //#region src/content/text.ts
711
+ const textSchema = z.object({
712
+ type: z.literal("text"),
713
+ text: z.string().nonempty()
714
+ });
715
+ const asText = (text) => textSchema.parse({
716
+ type: "text",
717
+ text
718
+ });
719
+ function text(source, options) {
720
+ if (typeof source === "string") return { build: async () => asText(source) };
721
+ return streamTextBuilder("text", source, options);
722
+ }
723
+ //#endregion
724
+ //#region src/content/resolve.ts
725
+ const resolveContents = (items) => Promise.all(items.map((c) => typeof c === "string" ? text(c).build() : c.build()));
726
+ //#endregion
727
+ //#region src/content/group.ts
728
+ const isMessage$2 = (v) => typeof v === "object" && v !== null && "id" in v && "content" in v;
729
+ /**
730
+ * A `group` bundles multiple messages into one logical unit (e.g. an album
731
+ * of images sent together). Each item is a full `Message` — addressable by
732
+ * id, reactable via `.react()`, replyable via `.reply()`.
733
+ *
734
+ * Groups do not nest, and reactions cannot be group members. Enforced by the
735
+ * `group()` builder; platforms may additionally reject unsupported item
736
+ * content types at send time.
737
+ */
738
+ const groupSchema = z.object({
739
+ type: z.literal("group"),
740
+ items: z.array(z.custom(isMessage$2)).min(2)
741
+ });
742
+ const asGroup = (input) => groupSchema.parse({
743
+ type: "group",
744
+ items: input.items
745
+ });
746
+ const stubOutboundMessage = (content) => ({
747
+ id: "",
748
+ content
749
+ });
750
+ function group(...items) {
751
+ return { build: async () => {
752
+ const resolved = await resolveContents(items);
753
+ const members = [];
754
+ for (const item of resolved) {
755
+ if (item.type === "group" || item.type === "reaction") throw new Error(`group() cannot contain "${item.type}" items`);
756
+ members.push(stubOutboundMessage(item));
757
+ }
758
+ return asGroup({ items: members });
759
+ } };
760
+ }
761
+ //#endregion
762
+ //#region src/content/markdown.ts
763
+ /**
764
+ * Styled text written in standard markdown (CommonMark plus GFM tables and
765
+ * strikethrough). Outbound-only by design: inbound messages always surface as
766
+ * `text` content — no provider maps platform formatting back to markdown.
767
+ * Each platform renders the markdown to its native format (Telegram: HTML via
768
+ * `parse_mode`; remote iMessage: styled text via UTF-16 formatting ranges);
769
+ * platforms without native support receive readable plain text via the send
770
+ * pipeline's markdown fallback.
771
+ */
772
+ const markdownSchema = z.object({
773
+ type: z.literal("markdown"),
774
+ markdown: z.string().nonempty()
775
+ });
776
+ const asMarkdown = (markdown) => markdownSchema.parse({
777
+ type: "markdown",
778
+ markdown
779
+ });
780
+ function markdown(source, options) {
781
+ if (typeof source === "string") return { build: async () => asMarkdown(source) };
782
+ return streamTextBuilder("markdown", source, options);
783
+ }
784
+ //#endregion
785
+ //#region src/content/poll.ts
786
+ const pollChoiceSchema = z.object({ title: z.string().nonempty() });
787
+ const pollSchema = z.object({
788
+ type: z.literal("poll"),
789
+ title: z.string().nonempty().max(300),
790
+ options: z.array(pollChoiceSchema).min(2).max(10)
791
+ });
792
+ const pollOptionSchema = z.object({
793
+ type: z.literal("poll_option"),
794
+ option: pollChoiceSchema,
795
+ poll: pollSchema,
796
+ selected: z.boolean(),
797
+ title: z.string().nonempty()
798
+ }).superRefine((value, ctx) => {
799
+ if (value.title !== value.option.title) ctx.addIssue({
800
+ code: "custom",
801
+ message: "poll_option title must match option.title",
802
+ path: ["title"]
803
+ });
804
+ if (!value.poll.options.some((pollOption) => pollOption.title === value.option.title)) ctx.addIssue({
805
+ code: "custom",
806
+ message: "poll_option option must exist in poll.options",
807
+ path: ["option"]
808
+ });
809
+ });
810
+ const asPoll = (input) => pollSchema.parse({
811
+ type: "poll",
812
+ ...input
813
+ });
814
+ const asPollOption = (input) => pollOptionSchema.parse({
815
+ type: "poll_option",
816
+ ...input,
817
+ title: input.option.title
818
+ });
819
+ const option = (title) => ({ title });
820
+ const normalize = (raw) => typeof raw === "string" ? { title: raw } : { title: raw.title };
821
+ const collectOptions = (args) => {
822
+ const [first] = args;
823
+ if (args.length === 1 && Array.isArray(first)) return first;
824
+ return args;
825
+ };
826
+ function poll(title, ...rest) {
827
+ return { build: async () => asPoll({
828
+ title,
829
+ options: collectOptions(rest).map(normalize)
830
+ }) };
831
+ }
832
+ //#endregion
833
+ //#region src/content/reaction.ts
834
+ const isMessage$1 = (v) => typeof v === "object" && v !== null && "id" in v && "content" in v;
835
+ const reactionSchema = z.object({
836
+ type: z.literal("reaction"),
837
+ emoji: z.string().min(1),
838
+ target: z.custom(isMessage$1, { message: "reaction target must be a Message" })
839
+ });
840
+ const asReaction = (input) => reactionSchema.parse({
841
+ type: "reaction",
842
+ ...input
843
+ });
844
+ /**
845
+ * Construct a `reaction` content value targeting the given message.
846
+ *
847
+ * `space.send(reaction(emoji, message))` is the canonical form of
848
+ * `message.react(emoji)`. It resolves to the reaction `Message`
849
+ * (`content.type === "reaction"`) — keep it as the handle to `unsend()`
850
+ * later. Resolves `undefined` only when the platform does not support
851
+ * reactions (warned and skipped).
852
+ *
853
+ * Accepts `Message | undefined` so `space.send` results chain without
854
+ * narrowing (`send` resolves `undefined` when a platform skips unsupported
855
+ * content); an undefined target throws at build time.
856
+ *
857
+ * To react to a message known only by id, resolve it first via
858
+ * `space.getMessage(id)`.
859
+ */
860
+ function reaction(emoji, target) {
861
+ return { build: async () => {
862
+ if (!target) throw new Error("reaction() target is undefined — the targeted message was never sent (space.send resolves undefined when a platform skips unsupported content)");
863
+ if (target.content.type === "reaction") throw new Error("reaction() cannot target \"reaction\" content");
864
+ return asReaction({
865
+ emoji,
866
+ target
867
+ });
868
+ } };
869
+ }
870
+ //#endregion
871
+ //#region src/content/read.ts
872
+ const isMessage = (v) => typeof v === "object" && v !== null && "id" in v && "content" in v;
873
+ /**
874
+ * A `read` marks the conversation as read **up to** `target`, surfacing a
875
+ * read receipt to the sender where the platform supports one.
876
+ *
877
+ * `space.send(read(message))` is the canonical outbound API;
878
+ * `message.read()` and `space.read(message)` are sugar that delegate here.
879
+ * Reads are fire-and-forget — providers handle them inside their `send`
880
+ * action and the resolved value is `undefined`.
881
+ *
882
+ * Granularity is per-platform:
883
+ *
884
+ * - WhatsApp Business: per-message receipt via `markRead(target.id)`, which
885
+ * also marks every earlier message in the conversation as read.
886
+ * - iMessage (remote): chat-level `chats.markRead(chatGuid)` — `target` only
887
+ * identifies the chat, and **every** unread message in it is marked read.
888
+ * Local mode rejects with `UnsupportedError` (warned and skipped).
889
+ * - Telegram / Slack: silently no-op. Neither surfaces read state for bot
890
+ * conversations (Telegram bot chats are effectively auto-read), so the
891
+ * signal is vacuously satisfied — same best-effort contract as `typing`.
892
+ */
893
+ const readSchema = z.object({
894
+ type: z.literal("read"),
895
+ target: z.custom(isMessage, { message: "read target must be a Message" })
896
+ });
897
+ const asRead = (input) => readSchema.parse({
898
+ type: "read",
899
+ ...input
900
+ });
901
+ /**
902
+ * Construct a `read` content value marking the conversation read up to
903
+ * `target`.
904
+ *
905
+ * Only inbound messages (those received from a user) can be marked read;
906
+ * calling this with an outbound target throws at build time so the misuse
907
+ * surfaces before the send pipeline runs. The target is required (not
908
+ * `Message | undefined` like `unsend`): read targets come from the inbound
909
+ * stream, never from a chainable `send()` result.
910
+ */
911
+ function read(target) {
912
+ return { build: async () => {
913
+ if (target.direction !== "inbound") throw new Error(`read() target must be an inbound message (got direction "${target.direction}", message id "${target.id}")`);
914
+ return asRead({ target });
915
+ } };
916
+ }
917
+ //#endregion
918
+ //#region src/content/richlink.ts
919
+ const richlinkCoverSchema = z.object({
920
+ mimeType: z.string().min(1).optional(),
921
+ read: readSchema$1,
922
+ stream: streamSchema
923
+ });
924
+ const optionalStringAccessor = z.function({
925
+ input: [],
926
+ output: z.promise(z.string().min(1).optional())
927
+ });
928
+ const coverAccessor = z.function({
929
+ input: [],
930
+ output: z.promise(richlinkCoverSchema.optional())
931
+ });
932
+ const richlinkSchema = z.object({
933
+ type: z.literal("richlink"),
934
+ url: z.url(),
935
+ title: optionalStringAccessor,
936
+ summary: optionalStringAccessor,
937
+ cover: coverAccessor
938
+ });
939
+ const memoize = (factory) => {
940
+ let cached;
941
+ return () => {
942
+ cached ??= factory();
943
+ return cached;
944
+ };
945
+ };
946
+ const buildCover = (image) => {
947
+ const read = memoize(() => fetchImage(image.url).then((r) => r.data).catch(() => Buffer.alloc(0)));
948
+ return {
949
+ mimeType: image.mimeType,
950
+ read,
951
+ stream: async () => bufferToStream(await read())
952
+ };
953
+ };
954
+ /**
955
+ * Construct a `richlink` content value.
956
+ *
957
+ * Accessors (`title`, `summary`, `cover`) are async and lazy: the first call
958
+ * issues a single network request to the URL; subsequent calls share the
959
+ * cached result. Network / parse failures resolve to `undefined` and are
960
+ * cached — no retries. Callers who only need `title` / `summary` never
961
+ * trigger an image download; calling `cover.read()` triggers one additional
962
+ * request to fetch the image bytes.
963
+ */
964
+ const asRichlink = (input) => {
965
+ const getMetadata = memoize(() => fetchLinkMetadata(input.url));
966
+ const getCover = memoize(async () => {
967
+ const { image } = await getMetadata();
968
+ return image ? buildCover(image) : void 0;
969
+ });
970
+ const title = async () => (await getMetadata()).title;
971
+ const summary = async () => (await getMetadata()).summary;
972
+ return richlinkSchema.parse({
973
+ type: "richlink",
974
+ url: input.url,
975
+ title,
976
+ summary,
977
+ cover: getCover
978
+ });
979
+ };
980
+ function richlink(url) {
981
+ return { build: async () => asRichlink({ url }) };
982
+ }
983
+ //#endregion
984
+ //#region src/content/voice.ts
985
+ const AUDIO_MIME_PATTERN = /^audio\//i;
986
+ const audioMimeSchema = z.string().nonempty().regex(AUDIO_MIME_PATTERN, "voice content requires an audio/* MIME type");
987
+ const voiceSchema = z.object({
988
+ type: z.literal("voice"),
989
+ name: z.string().nonempty().optional(),
990
+ mimeType: audioMimeSchema,
991
+ duration: z.number().nonnegative().optional(),
992
+ size: z.number().int().nonnegative().optional(),
993
+ read: readSchema$1,
994
+ stream: streamSchema
995
+ });
996
+ const resolveVoiceName = (input, name) => {
997
+ if (name) return name;
998
+ if (input instanceof URL) return basename(input.pathname) || void 0;
999
+ if (typeof input === "string") return basename(input);
1000
+ };
1001
+ const resolveVoiceMimeHint = (input, name) => {
1002
+ if (input instanceof URL) return basename(input.pathname) || void 0;
1003
+ if (typeof input === "string") return basename(input);
1004
+ return name;
1005
+ };
1006
+ const resolveVoiceMimeType = (name, mimeType) => {
1007
+ if (mimeType) {
1008
+ if (!AUDIO_MIME_PATTERN.test(mimeType)) throw new Error(`voice content requires an audio/* MIME type, got "${mimeType}".`);
1009
+ return mimeType;
1010
+ }
1011
+ if (name) {
1012
+ const resolved = lookup(name);
1013
+ if (resolved && AUDIO_MIME_PATTERN.test(resolved)) return resolved;
1014
+ if (resolved) throw new Error(`Resolved non-audio MIME type "${resolved}" from name "${name}". Pass options.mimeType explicitly with an audio/* type.`);
1015
+ }
1016
+ throw new Error("Unable to resolve MIME type for voice content. Pass options.mimeType explicitly.");
1017
+ };
1018
+ const asVoice = (input) => {
1019
+ let cached;
1020
+ const read = () => {
1021
+ cached ??= input.read().catch((err) => {
1022
+ cached = void 0;
1023
+ throw err;
1024
+ });
1025
+ return cached;
1026
+ };
1027
+ const stream = input.stream ?? (async () => bufferToStream(await read()));
1028
+ return voiceSchema.parse({
1029
+ type: "voice",
1030
+ name: input.name,
1031
+ mimeType: input.mimeType,
1032
+ duration: input.duration,
1033
+ size: input.size,
1034
+ read,
1035
+ stream
1036
+ });
1037
+ };
1038
+ function voice(input, options) {
1039
+ return { build: async () => {
1040
+ const name = resolveVoiceName(input, options?.name);
1041
+ const mimeType = resolveVoiceMimeType(resolveVoiceMimeHint(input, name), options?.mimeType);
1042
+ if (input instanceof URL) return asVoice({
1043
+ name,
1044
+ mimeType,
1045
+ duration: options?.duration,
1046
+ read: async () => (await fetchUrlBytes(input)).data
1047
+ });
1048
+ if (typeof input === "string") {
1049
+ const stats = await stat(input);
1050
+ if (!stats.isFile()) throw new Error(`voice content path "${input}" is not a regular file.`);
1051
+ return asVoice({
1052
+ name,
1053
+ mimeType,
1054
+ duration: options?.duration,
1055
+ size: stats.size,
1056
+ read: () => readFile(input),
1057
+ stream: async () => Readable.toWeb(createReadStream(input))
1058
+ });
1059
+ }
1060
+ return asVoice({
1061
+ name,
1062
+ mimeType,
1063
+ duration: options?.duration,
1064
+ size: input.byteLength,
1065
+ read: async () => input,
1066
+ stream: async () => bufferToStream(input)
1067
+ });
1068
+ } };
1069
+ }
1070
+ //#endregion
1071
+ //#region src/utils/identifier.ts
1072
+ const PHONE_LIKE = /^\+?[\d\s()\-.]{7,}$/;
1073
+ const EMAIL_LIKE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
1074
+ function classifyIdentifier(s) {
1075
+ if (EMAIL_LIKE.test(s)) return {
1076
+ kind: "email",
1077
+ identifier: sanitizeEmail(s)
1078
+ };
1079
+ if (PHONE_LIKE.test(s) && s.replace(/\D/g, "").length >= 7) return {
1080
+ kind: "phone",
1081
+ identifier: sanitizePhone(s)
1082
+ };
1083
+ return {
1084
+ kind: "unknown",
1085
+ identifier: s
1086
+ };
1087
+ }
1088
+ //#endregion
1089
+ //#region src/utils/markdown.ts
1090
+ const markdownLexer = new Marked();
1091
+ const BULLET = "• ";
1092
+ const HR_LINE = "———";
1093
+ const NESTED_LIST_INDENT = " ";
1094
+ const BLOCK_SEPARATOR = "\n\n";
1095
+ const TABLE_CELL_SEPARATOR = " | ";
1096
+ const DEFAULT_LIST_START = 1;
1097
+ const asMarkedToken = (token) => token;
1098
+ const checkboxPrefix = (item) => {
1099
+ if (!item.task) return "";
1100
+ return item.checked ? "[x] " : "[ ] ";
1101
+ };
1102
+ const listMarker = (list, index) => {
1103
+ if (!list.ordered) return BULLET;
1104
+ return `${(list.start === "" ? DEFAULT_LIST_START : list.start) + index}. `;
1105
+ };
1106
+ const renderLink = (token) => {
1107
+ if (token.text === token.href) return token.href;
1108
+ return `${renderInlineTokens(token.tokens)} (${token.href})`;
1109
+ };
1110
+ const renderImage = (token) => token.text ? `${token.text} (${token.href})` : token.href;
1111
+ const renderInlineToken = (token) => {
1112
+ switch (token.type) {
1113
+ case "strong":
1114
+ case "em":
1115
+ case "del": return renderInlineTokens(token.tokens);
1116
+ case "codespan": return token.text;
1117
+ case "br": return "\n";
1118
+ case "link": return renderLink(token);
1119
+ case "image": return renderImage(token);
1120
+ case "escape": return token.text;
1121
+ case "text": return token.tokens ? renderInlineTokens(token.tokens) : token.text;
1122
+ case "html": return token.text;
1123
+ case "checkbox": return "";
1124
+ default: return "raw" in token ? String(token.raw) : "";
1125
+ }
1126
+ };
1127
+ /**
1128
+ * Render a run of inline markdown tokens (a paragraph's or table cell's
1129
+ * children) to plain text. Package-internal: platform renderers reuse it
1130
+ * where their native format has no inline markup (e.g. Telegram tables).
1131
+ */
1132
+ const renderInlineTokens = (tokens) => {
1133
+ let out = "";
1134
+ for (const token of tokens) out += renderInlineToken(asMarkedToken(token));
1135
+ return out;
1136
+ };
1137
+ const renderBlockquote = (quote) => renderBlockTokens(quote.tokens).split("\n").map((line) => line ? `> ${line}` : ">").join("\n");
1138
+ const renderList = (list) => {
1139
+ const lines = [];
1140
+ for (const [index, item] of list.items.entries()) {
1141
+ const prefix = `${listMarker(list, index)}${checkboxPrefix(item)}`;
1142
+ const blocks = [];
1143
+ for (const token of item.tokens) {
1144
+ const rendered = renderBlockToken(asMarkedToken(token));
1145
+ if (rendered) blocks.push(rendered);
1146
+ }
1147
+ const [first = "", ...rest] = blocks.join("\n").split("\n");
1148
+ lines.push(`${prefix}${first}`);
1149
+ for (const line of rest) lines.push(`${NESTED_LIST_INDENT}${line}`);
1150
+ }
1151
+ return lines.join("\n");
1152
+ };
1153
+ const renderTable = (table) => {
1154
+ const renderRow = (cells) => cells.map((cell) => renderInlineTokens(cell.tokens)).join(TABLE_CELL_SEPARATOR);
1155
+ const lines = [renderRow(table.header)];
1156
+ for (const row of table.rows) lines.push(renderRow(row));
1157
+ return lines.join("\n");
1158
+ };
1159
+ const renderBlockToken = (token) => {
1160
+ switch (token.type) {
1161
+ case "heading":
1162
+ case "paragraph": return renderInlineTokens(token.tokens);
1163
+ case "code": return token.text;
1164
+ case "blockquote": return renderBlockquote(token);
1165
+ case "list": return renderList(token);
1166
+ case "table": return renderTable(token);
1167
+ case "hr": return HR_LINE;
1168
+ case "space":
1169
+ case "def": return "";
1170
+ default: return renderInlineToken(token);
1171
+ }
1172
+ };
1173
+ const renderBlockTokens = (tokens) => {
1174
+ const blocks = [];
1175
+ for (const token of tokens) {
1176
+ const rendered = renderBlockToken(asMarkedToken(token));
1177
+ if (rendered) blocks.push(rendered);
1178
+ }
1179
+ return blocks.join(BLOCK_SEPARATOR);
1180
+ };
1181
+ /**
1182
+ * Render standard markdown (CommonMark + GFM) to readable plain text:
1183
+ * emphasis markers stripped, links as `label (url)`, list bullets kept,
1184
+ * code verbatim. Used by the send pipeline to downgrade `markdown` content
1185
+ * on platforms without native support.
1186
+ */
1187
+ const markdownToPlainText = (markdown) => renderBlockTokens(markdownLexer.lexer(markdown)).trim();
1188
+ //#endregion
1189
+ //#region src/utils/telemetry.ts
1190
+ const targetId = (target) => {
1191
+ const id = target?.id;
1192
+ return typeof id === "string" ? id : void 0;
1193
+ };
1194
+ const targetType = (target) => {
1195
+ const type = target?.content?.type;
1196
+ return typeof type === "string" ? type : void 0;
1197
+ };
1198
+ const replyOrEditAttrs = (content) => {
1199
+ const target = content.target;
1200
+ const innerType = content.content?.type;
1201
+ return {
1202
+ "spectrum.message.content.target.id": targetId(target),
1203
+ "spectrum.message.content.target.type": targetType(target),
1204
+ "spectrum.message.content.inner.type": typeof innerType === "string" ? innerType : void 0
1205
+ };
1206
+ };
1207
+ const unsendAttrs = (content) => {
1208
+ const target = content.target;
1209
+ return {
1210
+ "spectrum.message.content.target.id": targetId(target),
1211
+ "spectrum.message.content.target.type": targetType(target)
1212
+ };
1213
+ };
1214
+ const reactionAttrs = (content) => {
1215
+ const target = content.target;
1216
+ const emoji = content.emoji;
1217
+ return {
1218
+ "spectrum.message.content.target.id": targetId(target),
1219
+ "spectrum.message.content.reaction.emoji": typeof emoji === "string" ? emoji : void 0
1220
+ };
1221
+ };
1222
+ const groupAttrs = (content) => {
1223
+ const items = content.items;
1224
+ if (!Array.isArray(items)) return {};
1225
+ const types = items.map((item) => {
1226
+ const itemType = item?.content?.type;
1227
+ return typeof itemType === "string" ? itemType : void 0;
1228
+ }).filter((t) => t !== void 0);
1229
+ return {
1230
+ "spectrum.message.content.items.count": items.length,
1231
+ "spectrum.message.content.items.types": types.length > 0 ? types.join(",") : void 0
1232
+ };
1233
+ };
1234
+ const typingAttrs = (content) => {
1235
+ const state = content.state;
1236
+ return { "spectrum.message.content.typing.state": typeof state === "string" ? state : void 0 };
1237
+ };
1238
+ const attachmentAttrs = (content) => {
1239
+ const mime = content.mimeType;
1240
+ const size = content.size;
1241
+ return {
1242
+ "spectrum.message.content.attachment.mime": typeof mime === "string" ? mime : void 0,
1243
+ "spectrum.message.content.attachment.size": typeof size === "number" ? size : void 0
1244
+ };
1245
+ };
1246
+ const voiceAttrs = (content) => {
1247
+ const mime = content.mimeType;
1248
+ const duration = content.duration;
1249
+ const size = content.size;
1250
+ return {
1251
+ "spectrum.message.content.voice.mime": typeof mime === "string" ? mime : void 0,
1252
+ "spectrum.message.content.voice.duration": typeof duration === "number" ? duration : void 0,
1253
+ "spectrum.message.content.voice.size": typeof size === "number" ? size : void 0
1254
+ };
1255
+ };
1256
+ const CONTENT_ATTR_HANDLERS = {
1257
+ reply: replyOrEditAttrs,
1258
+ edit: replyOrEditAttrs,
1259
+ unsend: unsendAttrs,
1260
+ reaction: reactionAttrs,
1261
+ group: groupAttrs,
1262
+ typing: typingAttrs,
1263
+ attachment: attachmentAttrs,
1264
+ voice: voiceAttrs
1265
+ };
1266
+ function contentAttrs(content) {
1267
+ const type = content?.type;
1268
+ if (!(content && type)) return { "spectrum.message.content.type": void 0 };
1269
+ const handler = CONTENT_ATTR_HANDLERS[type];
1270
+ return {
1271
+ "spectrum.message.content.type": type,
1272
+ ...handler ? handler(content) : {}
1273
+ };
1274
+ }
1275
+ function senderAttrs(sender) {
1276
+ const id = sender?.id;
1277
+ if (typeof id !== "string" || id.length === 0) return {};
1278
+ const { kind, identifier } = classifyIdentifier(id);
1279
+ return {
1280
+ "spectrum.message.sender.id": identifier,
1281
+ "spectrum.message.sender.kind": kind
1282
+ };
1283
+ }
1284
+ const attrCode = (value) => {
1285
+ if (typeof value === "string" || typeof value === "number") return value;
1286
+ };
1287
+ const causeType = (cause) => {
1288
+ if (cause === void 0) return;
1289
+ return cause instanceof Error ? cause.name : typeof cause;
1290
+ };
1291
+ const causeMessage = (cause) => {
1292
+ if (cause === void 0) return;
1293
+ return sanitizeErrorMessage(cause instanceof Error ? cause.message : String(cause));
1294
+ };
1295
+ /**
1296
+ * Structured attributes for an unknown thrown value: its type, sanitized
1297
+ * message, `code`/`status` when present, and one level of `cause`. Enough to
1298
+ * debug from a log line or span without leaking PII or dumping a raw `Error`
1299
+ * (which stringifies to "[object Object]" inside an attrs object). The stack is
1300
+ * intentionally omitted — pass the `Error` as the logger's 3rd argument (or
1301
+ * wrap with `withSpan`) so it lands on the OTLP record's `exception.*` fields.
1302
+ */
1303
+ function errorAttrs(error, prefix = "spectrum.error") {
1304
+ if (!(error instanceof Error)) return {
1305
+ [`${prefix}.type`]: typeof error,
1306
+ [`${prefix}.message`]: sanitizeErrorMessage(String(error))
1307
+ };
1308
+ const { code, status, cause } = error;
1309
+ return {
1310
+ [`${prefix}.type`]: error.name,
1311
+ [`${prefix}.message`]: sanitizeErrorMessage(error.message),
1312
+ [`${prefix}.code`]: attrCode(code),
1313
+ [`${prefix}.status`]: attrCode(status),
1314
+ [`${prefix}.cause.type`]: causeType(cause),
1315
+ [`${prefix}.cause.message`]: causeMessage(cause)
1316
+ };
1317
+ }
1318
+ //#endregion
1319
+ //#region src/utils/stream.ts
1320
+ /**
1321
+ * Unbounded FIFO queue with `AsyncIterable` consumer. Used by FusorCore to feed
1322
+ * the per-platform message stream — events pushed before a consumer attaches
1323
+ * are buffered, and a pending `next()` is woken when a value arrives.
1324
+ */
1325
+ function createAsyncQueue() {
1326
+ const buffer = [];
1327
+ const resolvers = [];
1328
+ let closed = false;
1329
+ const push = (value) => {
1330
+ if (closed) return;
1331
+ const resolver = resolvers.shift();
1332
+ if (resolver) resolver({
1333
+ value,
1334
+ done: false
1335
+ });
1336
+ else buffer.push(value);
1337
+ };
1338
+ const close = () => {
1339
+ if (closed) return;
1340
+ closed = true;
1341
+ while (resolvers.length > 0) resolvers.shift()?.({
1342
+ value: void 0,
1343
+ done: true
1344
+ });
1345
+ };
1346
+ return {
1347
+ iterable: { [Symbol.asyncIterator]() {
1348
+ return {
1349
+ next() {
1350
+ if (buffer.length > 0) {
1351
+ const value = buffer.shift();
1352
+ return Promise.resolve({
1353
+ value,
1354
+ done: false
1355
+ });
1356
+ }
1357
+ if (closed) return Promise.resolve({
1358
+ value: void 0,
1359
+ done: true
1360
+ });
1361
+ return new Promise((resolve) => {
1362
+ resolvers.push(resolve);
1363
+ });
1364
+ },
1365
+ return() {
1366
+ close();
1367
+ return Promise.resolve({
1368
+ value: void 0,
1369
+ done: true
1370
+ });
1371
+ }
1372
+ };
1373
+ } },
1374
+ push,
1375
+ close
1376
+ };
1377
+ }
1378
+ const ignoreCleanupError = () => void 0;
1379
+ function stream(setup) {
1380
+ const repeater = new Repeater(async (push, stop) => {
1381
+ const emit = async (value) => {
1382
+ try {
1383
+ await push(value);
1384
+ } catch (error) {
1385
+ stop(error);
1386
+ throw error;
1387
+ }
1388
+ };
1389
+ const end = (error) => {
1390
+ stop(error);
1391
+ };
1392
+ const cleanup = await setup(emit, end);
1393
+ try {
1394
+ await stop;
1395
+ } finally {
1396
+ await cleanup?.();
1397
+ }
1398
+ });
1399
+ return Object.assign(repeater, { close: async () => {
1400
+ await repeater.return(void 0).catch(ignoreCleanupError);
1401
+ } });
1402
+ }
1403
+ function mergeStreams(streams) {
1404
+ return stream((emit, end) => {
1405
+ if (streams.length === 0) {
1406
+ end();
1407
+ return;
1408
+ }
1409
+ let openStreams = streams.length;
1410
+ const workers = streams.map(async (source) => {
1411
+ try {
1412
+ for await (const value of source) await emit(value);
1413
+ } catch (error) {
1414
+ end(error);
1415
+ } finally {
1416
+ openStreams -= 1;
1417
+ if (openStreams === 0) end();
1418
+ }
1419
+ });
1420
+ return async () => {
1421
+ await Promise.allSettled(streams.map((source) => source.close()));
1422
+ await Promise.allSettled(workers).catch(ignoreCleanupError);
1423
+ };
1424
+ });
1425
+ }
1426
+ function broadcast(source) {
1427
+ const consumers = /* @__PURE__ */ new Set();
1428
+ let pumping = false;
1429
+ let terminated = false;
1430
+ let terminalError;
1431
+ let pumpPromise;
1432
+ let closed = false;
1433
+ const closeConsumers = (error) => {
1434
+ if (consumers.size === 0) return;
1435
+ const current = Array.from(consumers);
1436
+ consumers.clear();
1437
+ for (const consumer of current) consumer.end(error);
1438
+ };
1439
+ const startPump = () => {
1440
+ if (pumping || terminated) return;
1441
+ pumping = true;
1442
+ pumpPromise = (async () => {
1443
+ try {
1444
+ for await (const value of source) {
1445
+ if (terminated) break;
1446
+ for (const consumer of Array.from(consumers)) consumer.deliveries = consumer.deliveries.then(() => consumer.emit(value).catch(() => {}));
1447
+ }
1448
+ if (terminated) return;
1449
+ terminated = true;
1450
+ await Promise.allSettled(Array.from(consumers, (consumer) => consumer.deliveries));
1451
+ closeConsumers();
1452
+ } catch (error) {
1453
+ terminated = true;
1454
+ terminalError = error;
1455
+ closeConsumers(error);
1456
+ }
1457
+ })();
1458
+ };
1459
+ return {
1460
+ subscribe() {
1461
+ return stream((emit, end) => {
1462
+ if (terminated || closed) {
1463
+ end(terminalError);
1464
+ return;
1465
+ }
1466
+ const consumer = {
1467
+ emit,
1468
+ end,
1469
+ deliveries: Promise.resolve()
1470
+ };
1471
+ consumers.add(consumer);
1472
+ startPump();
1473
+ return () => {
1474
+ consumers.delete(consumer);
1475
+ };
1476
+ });
1477
+ },
1478
+ async close() {
1479
+ if (closed) return;
1480
+ closed = true;
1481
+ terminated = true;
1482
+ closeConsumers();
1483
+ await source.close().catch(ignoreCleanupError);
1484
+ if (pumpPromise) await pumpPromise.catch(ignoreCleanupError);
1485
+ }
1486
+ };
1487
+ }
1488
+ //#endregion
1489
+ export { resolveContents as A, fromVCard as B, poll as C, asGroup as D, markdownSchema as E, drainStreamText as F, attachment as G, buildPhotoAction as H, asCustom as I, fetchLinkMetadata as J, attachmentSchema as K, custom as L, text as M, textSchema as N, group as O, StreamConsumedError as P, asContact as R, option as S, markdown as T, photoActionSchema as U, toVCard as V, asAttachment as W, asReaction as _, contentAttrs as a, asPoll as b, markdownToPlainText as c, asVoice as d, voice as f, read as g, asRead as h, stream as i, asText as j, groupSchema as k, renderInlineTokens as l, richlink as m, createAsyncQueue as n, errorAttrs as o, asRichlink as p, fetchImage as q, mergeStreams as r, senderAttrs as s, broadcast as t, classifyIdentifier as u, reaction as v, asMarkdown as w, asPollOption as x, reactionSchema as y, contact as z };