@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.
- package/LICENSE +21 -0
- package/README.md +84 -0
- package/dist/attachment-a_lrhg6w.d.ts +7558 -0
- package/dist/authoring.d.ts +94 -0
- package/dist/authoring.js +389 -0
- package/dist/elysia.d.ts +92 -0
- package/dist/elysia.js +32 -0
- package/dist/express.d.ts +60 -0
- package/dist/express.js +37 -0
- package/dist/hono.d.ts +59 -0
- package/dist/hono.js +27 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4529 -0
- package/dist/stream-BLWs7NJ5.js +1489 -0
- package/package.json +78 -0
|
@@ -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 };
|