@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,94 @@
|
|
|
1
|
+
import { $t as groupSchema, Bt as asPollOption, Dn as buildPhotoAction, En as PhotoInput, H as ProviderMessageRecord, K as ManagedStream, Nt as asRead, On as photoActionSchema, St as asText, Tt as asRichlink, Wt as asMarkdown, Zt as asGroup, cn as reactionSchema, gt as asVoice, ln as asCustom, on as asReaction, r as asAttachment, yn as asContact, zt as asPoll } from "./attachment-a_lrhg6w.js";
|
|
2
|
+
import z from "zod";
|
|
3
|
+
import { LogAttrs, LogAttrs as LogAttrs$1, LogLevel, PhotonLogger, createLogger, sanitizeEmail, sanitizeErrorMessage, sanitizePhone, setLogLevel } from "@photon-ai/otel";
|
|
4
|
+
import { Token } from "marked";
|
|
5
|
+
|
|
6
|
+
//#region src/content/effect.d.ts
|
|
7
|
+
declare const messageEffectSchema: z.ZodObject<{
|
|
8
|
+
type: z.ZodLiteral<"effect">;
|
|
9
|
+
content: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
10
|
+
type: z.ZodLiteral<"text">;
|
|
11
|
+
text: z.ZodString;
|
|
12
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
13
|
+
type: z.ZodLiteral<"markdown">;
|
|
14
|
+
markdown: z.ZodString;
|
|
15
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
16
|
+
type: z.ZodLiteral<"attachment">;
|
|
17
|
+
id: z.ZodString;
|
|
18
|
+
name: z.ZodString;
|
|
19
|
+
mimeType: z.ZodString;
|
|
20
|
+
size: z.ZodOptional<z.ZodNumber>;
|
|
21
|
+
read: z.ZodFunction<z.ZodTuple<readonly [], null>, z.ZodPromise<z.ZodCustom<Buffer<ArrayBufferLike>, Buffer<ArrayBufferLike>>>>;
|
|
22
|
+
stream: z.ZodFunction<z.ZodTuple<readonly [], null>, z.ZodPromise<z.ZodCustom<ReadableStream<unknown>, ReadableStream<unknown>>>>;
|
|
23
|
+
}, z.core.$strip>], "type">;
|
|
24
|
+
effect: z.ZodString;
|
|
25
|
+
}, z.core.$strip>;
|
|
26
|
+
//#endregion
|
|
27
|
+
//#region src/utils/audio.d.ts
|
|
28
|
+
declare const ensureM4a: (buffer: Buffer, mimeType: string) => Promise<{
|
|
29
|
+
buffer: Buffer;
|
|
30
|
+
duration?: number;
|
|
31
|
+
}>;
|
|
32
|
+
//#endregion
|
|
33
|
+
//#region src/utils/markdown.d.ts
|
|
34
|
+
/**
|
|
35
|
+
* Render a run of inline markdown tokens (a paragraph's or table cell's
|
|
36
|
+
* children) to plain text. Package-internal: platform renderers reuse it
|
|
37
|
+
* where their native format has no inline markup (e.g. Telegram tables).
|
|
38
|
+
*/
|
|
39
|
+
declare const renderInlineTokens: (tokens: Token[]) => string;
|
|
40
|
+
//#endregion
|
|
41
|
+
//#region src/utils/resumable-stream.d.ts
|
|
42
|
+
interface CloseableAsyncIterable<T> extends AsyncIterable<T> {
|
|
43
|
+
close?: () => Promise<void> | void;
|
|
44
|
+
}
|
|
45
|
+
interface ResumableStreamItem<T> {
|
|
46
|
+
cursor?: string;
|
|
47
|
+
id: string;
|
|
48
|
+
values: readonly T[];
|
|
49
|
+
}
|
|
50
|
+
interface FetchMissedOptions {
|
|
51
|
+
limit: number;
|
|
52
|
+
}
|
|
53
|
+
interface ResumableOrderedStreamOptions<TLive, TMissed, TOutput> {
|
|
54
|
+
bufferLimit?: number;
|
|
55
|
+
catchUpPageSize?: number;
|
|
56
|
+
fetchMissed: (cursor: string, options: FetchMissedOptions) => AsyncIterable<TMissed>;
|
|
57
|
+
initialRetryDelayMs?: number;
|
|
58
|
+
/**
|
|
59
|
+
* Recognizes a `fetchMissed` failure that means the server rejected the
|
|
60
|
+
* resume cursor (e.g. it was pruned). The stream then drops the cursor,
|
|
61
|
+
* accepts the event gap, and resumes live. Only errors raised by the
|
|
62
|
+
* `fetchMissed` iteration itself are classified.
|
|
63
|
+
*/
|
|
64
|
+
isCursorRejectedError?: (error: unknown) => boolean;
|
|
65
|
+
/** Maps a nominal retry delay to the actual sleep; injectable for tests. */
|
|
66
|
+
jitter?: (delayMs: number) => number;
|
|
67
|
+
/** Log provenance, e.g. "imessage.messages:+1555…". */
|
|
68
|
+
label?: string;
|
|
69
|
+
maxRetryDelayMs?: number;
|
|
70
|
+
processLive: (event: TLive) => Promise<ResumableStreamItem<TOutput>>;
|
|
71
|
+
processMissed: (event: TMissed) => Promise<ResumableStreamItem<TOutput>>;
|
|
72
|
+
subscribeLive: (cursor?: string) => CloseableAsyncIterable<TLive>;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Wraps a live event stream with cursor-based catch-up and reconnects forever
|
|
76
|
+
* with capped, jittered exponential backoff — the stream never ends with an
|
|
77
|
+
* error. The only terminal events are `close()` and consumer disconnect. When
|
|
78
|
+
* the server rejects the resume cursor, the cursor is dropped and consumption
|
|
79
|
+
* falls back to live, accepting (and logging) the event gap.
|
|
80
|
+
*/
|
|
81
|
+
declare const resumableOrderedStream: <TLive, TMissed, TOutput>(options: ResumableOrderedStreamOptions<TLive, TMissed, TOutput>) => ManagedStream<TOutput>;
|
|
82
|
+
//#endregion
|
|
83
|
+
//#region src/utils/telemetry.d.ts
|
|
84
|
+
/**
|
|
85
|
+
* Structured attributes for an unknown thrown value: its type, sanitized
|
|
86
|
+
* message, `code`/`status` when present, and one level of `cause`. Enough to
|
|
87
|
+
* debug from a log line or span without leaking PII or dumping a raw `Error`
|
|
88
|
+
* (which stringifies to "[object Object]" inside an attrs object). The stack is
|
|
89
|
+
* intentionally omitted — pass the `Error` as the logger's 3rd argument (or
|
|
90
|
+
* wrap with `withSpan`) so it lands on the OTLP record's `exception.*` fields.
|
|
91
|
+
*/
|
|
92
|
+
declare function errorAttrs(error: unknown, prefix?: string): LogAttrs$1;
|
|
93
|
+
//#endregion
|
|
94
|
+
export { type CloseableAsyncIterable, type LogAttrs, type LogLevel, type PhotoInput, type PhotonLogger, type ProviderMessageRecord, type ResumableStreamItem, asAttachment, asContact, asCustom, asGroup, asMarkdown, asPoll, asPollOption, asReaction, asRead, asRichlink, asText, asVoice, buildPhotoAction, createLogger, ensureM4a, errorAttrs, groupSchema, messageEffectSchema, photoActionSchema, reactionSchema, renderInlineTokens, resumableOrderedStream, sanitizeEmail, sanitizeErrorMessage, sanitizePhone, setLogLevel };
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import { D as asGroup, E as markdownSchema, H as buildPhotoAction, I as asCustom, K as attachmentSchema, N as textSchema, R as asContact, U as photoActionSchema, W as asAttachment, _ as asReaction, b as asPoll, d as asVoice, h as asRead, i as stream, j as asText, k as groupSchema, l as renderInlineTokens, o as errorAttrs, p as asRichlink, w as asMarkdown, x as asPollOption, y as reactionSchema } from "./stream-BLWs7NJ5.js";
|
|
2
|
+
import z from "zod";
|
|
3
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { createLogger, createLogger as createLogger$1, sanitizeEmail, sanitizeErrorMessage, sanitizePhone, setLogLevel } from "@photon-ai/otel";
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
//#region src/content/effect.ts
|
|
9
|
+
const effectInnerSchema = z.discriminatedUnion("type", [
|
|
10
|
+
textSchema,
|
|
11
|
+
markdownSchema,
|
|
12
|
+
attachmentSchema
|
|
13
|
+
]);
|
|
14
|
+
const messageEffectSchema = z.object({
|
|
15
|
+
type: z.literal("effect"),
|
|
16
|
+
content: effectInnerSchema,
|
|
17
|
+
effect: z.string().nonempty()
|
|
18
|
+
});
|
|
19
|
+
//#endregion
|
|
20
|
+
//#region src/utils/audio.ts
|
|
21
|
+
const M4A_BRANDS = new Set([
|
|
22
|
+
"M4A ",
|
|
23
|
+
"M4B ",
|
|
24
|
+
"M4P ",
|
|
25
|
+
"mp42",
|
|
26
|
+
"mp41",
|
|
27
|
+
"isom",
|
|
28
|
+
"iso2"
|
|
29
|
+
]);
|
|
30
|
+
const M4A_MIME_TYPES = new Set([
|
|
31
|
+
"audio/mp4",
|
|
32
|
+
"audio/mp4a-latm",
|
|
33
|
+
"audio/x-m4a",
|
|
34
|
+
"audio/aac",
|
|
35
|
+
"audio/aacp"
|
|
36
|
+
]);
|
|
37
|
+
const FFMPEG_MISSING_MESSAGE = "voice content: input is not m4a/aac and ffmpeg is unavailable. Install `ffmpeg-static` or ensure `ffmpeg` is on PATH.";
|
|
38
|
+
const isM4a = (buffer) => {
|
|
39
|
+
if (buffer.length < 12) return false;
|
|
40
|
+
if (buffer.toString("ascii", 4, 8) !== "ftyp") return false;
|
|
41
|
+
return M4A_BRANDS.has(buffer.toString("ascii", 8, 12));
|
|
42
|
+
};
|
|
43
|
+
const isM4aMimeType = (mimeType) => M4A_MIME_TYPES.has(mimeType.toLowerCase());
|
|
44
|
+
let cachedFfmpegPath;
|
|
45
|
+
const tryStaticBinary = async () => {
|
|
46
|
+
try {
|
|
47
|
+
return (await import("ffmpeg-static")).default ?? void 0;
|
|
48
|
+
} catch {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
const resolveFfmpegPath = async () => {
|
|
53
|
+
if (cachedFfmpegPath) return cachedFfmpegPath;
|
|
54
|
+
cachedFfmpegPath = await tryStaticBinary() ?? "ffmpeg";
|
|
55
|
+
return cachedFfmpegPath;
|
|
56
|
+
};
|
|
57
|
+
const collectStream = (stream) => {
|
|
58
|
+
if (!stream) return Promise.resolve("");
|
|
59
|
+
return new Promise((resolve, reject) => {
|
|
60
|
+
const chunks = [];
|
|
61
|
+
stream.on("data", (chunk) => {
|
|
62
|
+
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
63
|
+
});
|
|
64
|
+
stream.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
65
|
+
stream.on("error", reject);
|
|
66
|
+
});
|
|
67
|
+
};
|
|
68
|
+
const isMissingBinaryError = (err) => err?.code === "ENOENT";
|
|
69
|
+
const runFfmpeg = (ffmpegPath, args) => {
|
|
70
|
+
const proc = spawn(ffmpegPath, args, { stdio: [
|
|
71
|
+
"ignore",
|
|
72
|
+
"ignore",
|
|
73
|
+
"pipe"
|
|
74
|
+
] });
|
|
75
|
+
const stderr = collectStream(proc.stderr);
|
|
76
|
+
const exit = new Promise((resolve, reject) => {
|
|
77
|
+
proc.on("error", (err) => reject(isMissingBinaryError(err) ? /* @__PURE__ */ new Error(FFMPEG_MISSING_MESSAGE) : err));
|
|
78
|
+
proc.on("exit", (code) => resolve(code ?? -1));
|
|
79
|
+
});
|
|
80
|
+
return Promise.all([exit, stderr]).then(([code, text]) => ({
|
|
81
|
+
code,
|
|
82
|
+
stderr: text
|
|
83
|
+
}));
|
|
84
|
+
};
|
|
85
|
+
const DURATION_PATTERN = /Duration:\s*(\d+):(\d{2}):(\d{2})(?:\.(\d{1,3}))?/;
|
|
86
|
+
const parseDuration = (stderr) => {
|
|
87
|
+
const match = stderr.match(DURATION_PATTERN);
|
|
88
|
+
if (!match) return;
|
|
89
|
+
const [, hh, mm, ss, frac] = match;
|
|
90
|
+
const seconds = Number(hh) * 3600 + Number(mm) * 60 + Number(ss) + Number(`0.${frac ?? 0}`);
|
|
91
|
+
return Number.isFinite(seconds) ? seconds : void 0;
|
|
92
|
+
};
|
|
93
|
+
const transcodeToM4a = async (buffer) => {
|
|
94
|
+
const ffmpeg = await resolveFfmpegPath();
|
|
95
|
+
const dir = await mkdtemp(join(tmpdir(), "spectrum-voice-"));
|
|
96
|
+
const inPath = join(dir, "in");
|
|
97
|
+
const outPath = join(dir, "out.m4a");
|
|
98
|
+
try {
|
|
99
|
+
await writeFile(inPath, buffer);
|
|
100
|
+
const { code, stderr } = await runFfmpeg(ffmpeg, [
|
|
101
|
+
"-y",
|
|
102
|
+
"-i",
|
|
103
|
+
inPath,
|
|
104
|
+
"-f",
|
|
105
|
+
"ipod",
|
|
106
|
+
"-c:a",
|
|
107
|
+
"aac",
|
|
108
|
+
outPath
|
|
109
|
+
]);
|
|
110
|
+
if (code !== 0) throw new Error(`ffmpeg conversion failed (exit ${code}): ${stderr}`);
|
|
111
|
+
return {
|
|
112
|
+
buffer: await readFile(outPath),
|
|
113
|
+
duration: parseDuration(stderr)
|
|
114
|
+
};
|
|
115
|
+
} finally {
|
|
116
|
+
await rm(dir, {
|
|
117
|
+
recursive: true,
|
|
118
|
+
force: true
|
|
119
|
+
}).catch(() => {});
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
const ensureM4a = async (buffer, mimeType) => {
|
|
123
|
+
if (isM4aMimeType(mimeType) || isM4a(buffer)) return { buffer };
|
|
124
|
+
return transcodeToM4a(buffer);
|
|
125
|
+
};
|
|
126
|
+
const log = createLogger$1("spectrum.stream");
|
|
127
|
+
var RetryableStreamError = class extends Error {
|
|
128
|
+
constructor(message) {
|
|
129
|
+
super(message);
|
|
130
|
+
this.name = "RetryableStreamError";
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
var LiveBufferOverflowError = class extends RetryableStreamError {
|
|
134
|
+
constructor(limit) {
|
|
135
|
+
super(`Live stream buffer exceeded ${limit} events during catch-up`);
|
|
136
|
+
this.name = "LiveBufferOverflowError";
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
var CursorRejectedError = class extends Error {
|
|
140
|
+
constructor(cause) {
|
|
141
|
+
super("Server rejected resume cursor", { cause });
|
|
142
|
+
this.name = "CursorRejectedError";
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
const closeIterable = async (iterable) => {
|
|
146
|
+
if (!iterable) return;
|
|
147
|
+
await iterable.close?.();
|
|
148
|
+
};
|
|
149
|
+
const ignoreCleanupError = () => void 0;
|
|
150
|
+
const jitterDelay = (delayMs) => delayMs * (.5 + Math.random() * .5);
|
|
151
|
+
async function* throwOnCursorRejection(source, isCursorRejected) {
|
|
152
|
+
try {
|
|
153
|
+
yield* source;
|
|
154
|
+
} catch (error) {
|
|
155
|
+
throw isCursorRejected(error) ? new CursorRejectedError(error) : error;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const numericCursor = (cursor) => {
|
|
159
|
+
if (!cursor) return;
|
|
160
|
+
const value = Number(cursor);
|
|
161
|
+
return Number.isSafeInteger(value) && value >= 0 ? value : void 0;
|
|
162
|
+
};
|
|
163
|
+
const isCursorRegression = (next, current) => {
|
|
164
|
+
const nextValue = numericCursor(next);
|
|
165
|
+
const currentValue = numericCursor(current);
|
|
166
|
+
return nextValue !== void 0 && currentValue !== void 0 && nextValue < currentValue;
|
|
167
|
+
};
|
|
168
|
+
/**
|
|
169
|
+
* Wraps a live event stream with cursor-based catch-up and reconnects forever
|
|
170
|
+
* with capped, jittered exponential backoff — the stream never ends with an
|
|
171
|
+
* error. The only terminal events are `close()` and consumer disconnect. When
|
|
172
|
+
* the server rejects the resume cursor, the cursor is dropped and consumption
|
|
173
|
+
* falls back to live, accepting (and logging) the event gap.
|
|
174
|
+
*/
|
|
175
|
+
const resumableOrderedStream = (options) => stream((emit, end) => {
|
|
176
|
+
const catchUpPageSize = options.catchUpPageSize ?? 100;
|
|
177
|
+
const bufferLimit = options.bufferLimit ?? 1e3;
|
|
178
|
+
const initialRetryDelayMs = options.initialRetryDelayMs ?? 500;
|
|
179
|
+
const maxRetryDelayMs = options.maxRetryDelayMs ?? 3e4;
|
|
180
|
+
const jitter = options.jitter ?? jitterDelay;
|
|
181
|
+
const label = options.label;
|
|
182
|
+
let activeLive;
|
|
183
|
+
let closed = false;
|
|
184
|
+
let failedAttempts = 0;
|
|
185
|
+
let lastCursor;
|
|
186
|
+
let retryDelayMs = initialRetryDelayMs;
|
|
187
|
+
let sleepTimer;
|
|
188
|
+
let wakeSleep;
|
|
189
|
+
const deliveredSinceCursor = /* @__PURE__ */ new Set();
|
|
190
|
+
const streamId = globalThis.crypto?.randomUUID?.() ?? `s_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
|
|
191
|
+
const baseStreamAttrs = (phase) => ({
|
|
192
|
+
"spectrum.stream.id": streamId,
|
|
193
|
+
"spectrum.stream.label": label,
|
|
194
|
+
"spectrum.stream.phase": phase,
|
|
195
|
+
"spectrum.stream.has_cursor": lastCursor !== void 0,
|
|
196
|
+
"spectrum.stream.cursor": lastCursor
|
|
197
|
+
});
|
|
198
|
+
const noteRecovery = () => {
|
|
199
|
+
retryDelayMs = initialRetryDelayMs;
|
|
200
|
+
if (failedAttempts === 0) return;
|
|
201
|
+
log.info("stream recovered", {
|
|
202
|
+
"spectrum.stream.id": streamId,
|
|
203
|
+
"spectrum.stream.label": label,
|
|
204
|
+
"spectrum.stream.attempt": failedAttempts
|
|
205
|
+
});
|
|
206
|
+
failedAttempts = 0;
|
|
207
|
+
};
|
|
208
|
+
const advanceCursor = (cursor, clearDelivered) => {
|
|
209
|
+
if (!cursor || cursor === lastCursor || isCursorRegression(cursor, lastCursor)) return;
|
|
210
|
+
lastCursor = cursor;
|
|
211
|
+
if (clearDelivered) deliveredSinceCursor.clear();
|
|
212
|
+
};
|
|
213
|
+
const deliverItem = async (item, resetRetry, clearOnCursorAdvance) => {
|
|
214
|
+
if (!deliveredSinceCursor.has(item.id)) for (const value of item.values) await emit(value);
|
|
215
|
+
advanceCursor(item.cursor, clearOnCursorAdvance);
|
|
216
|
+
deliveredSinceCursor.add(item.id);
|
|
217
|
+
if (resetRetry) noteRecovery();
|
|
218
|
+
};
|
|
219
|
+
const isCursorRejected = (error) => options.isCursorRejectedError?.(error) === true;
|
|
220
|
+
const sleep = async (delayMs) => {
|
|
221
|
+
if (delayMs <= 0 || closed) return;
|
|
222
|
+
await new Promise((resolve) => {
|
|
223
|
+
wakeSleep = resolve;
|
|
224
|
+
sleepTimer = setTimeout(resolve, jitter(delayMs));
|
|
225
|
+
});
|
|
226
|
+
sleepTimer = void 0;
|
|
227
|
+
wakeSleep = void 0;
|
|
228
|
+
};
|
|
229
|
+
const cancelSleep = () => {
|
|
230
|
+
if (sleepTimer) {
|
|
231
|
+
clearTimeout(sleepTimer);
|
|
232
|
+
sleepTimer = void 0;
|
|
233
|
+
}
|
|
234
|
+
wakeSleep?.();
|
|
235
|
+
wakeSleep = void 0;
|
|
236
|
+
};
|
|
237
|
+
const nextRetryDelay = () => {
|
|
238
|
+
const delay = retryDelayMs;
|
|
239
|
+
retryDelayMs = Math.min(retryDelayMs * 2, maxRetryDelayMs);
|
|
240
|
+
return delay;
|
|
241
|
+
};
|
|
242
|
+
const failureKind = (error) => {
|
|
243
|
+
if (error instanceof CursorRejectedError) return "cursor-rejected";
|
|
244
|
+
if (failedAttempts >= 5) return "persistent";
|
|
245
|
+
return "transient";
|
|
246
|
+
};
|
|
247
|
+
const handleFailure = (error, phase) => {
|
|
248
|
+
failedAttempts += 1;
|
|
249
|
+
const delayMs = nextRetryDelay();
|
|
250
|
+
const attrs = {
|
|
251
|
+
...baseStreamAttrs(phase),
|
|
252
|
+
"spectrum.stream.attempt": failedAttempts,
|
|
253
|
+
"spectrum.stream.delay_ms": delayMs,
|
|
254
|
+
"spectrum.stream.failure_kind": failureKind(error),
|
|
255
|
+
...errorAttrs(error instanceof CursorRejectedError ? error.cause : error)
|
|
256
|
+
};
|
|
257
|
+
if (error instanceof CursorRejectedError) {
|
|
258
|
+
lastCursor = void 0;
|
|
259
|
+
deliveredSinceCursor.clear();
|
|
260
|
+
log.warn("resume cursor rejected; accepting event gap and resuming live", attrs, error);
|
|
261
|
+
return delayMs;
|
|
262
|
+
}
|
|
263
|
+
if (failedAttempts >= 5) {
|
|
264
|
+
log.error("stream persistently failing; still retrying", attrs, error);
|
|
265
|
+
return delayMs;
|
|
266
|
+
}
|
|
267
|
+
log.warn("stream interrupted; reconnecting", attrs, error);
|
|
268
|
+
return delayMs;
|
|
269
|
+
};
|
|
270
|
+
const consumeLive = async () => {
|
|
271
|
+
const live = options.subscribeLive(lastCursor);
|
|
272
|
+
activeLive = live;
|
|
273
|
+
try {
|
|
274
|
+
for await (const event of live) await deliverItem(await options.processLive(event), true, true);
|
|
275
|
+
throw new RetryableStreamError("Live stream ended");
|
|
276
|
+
} finally {
|
|
277
|
+
if (activeLive === live) activeLive = void 0;
|
|
278
|
+
await closeIterable(live);
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
const throwLiveError = (liveError) => {
|
|
282
|
+
if (liveError) throw liveError;
|
|
283
|
+
};
|
|
284
|
+
const bufferLiveEvent = (buffer, event) => {
|
|
285
|
+
if (buffer.length >= bufferLimit) throw new LiveBufferOverflowError(bufferLimit);
|
|
286
|
+
buffer.push(event);
|
|
287
|
+
};
|
|
288
|
+
const startLivePump = (live, isBuffering, liveBuffer) => {
|
|
289
|
+
let liveError;
|
|
290
|
+
return {
|
|
291
|
+
getError: () => liveError,
|
|
292
|
+
pump: (async () => {
|
|
293
|
+
try {
|
|
294
|
+
for await (const event of live) {
|
|
295
|
+
if (isBuffering()) {
|
|
296
|
+
bufferLiveEvent(liveBuffer, event);
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
await deliverItem(await options.processLive(event), true, true);
|
|
300
|
+
}
|
|
301
|
+
throw new RetryableStreamError("Live stream ended");
|
|
302
|
+
} catch (error) {
|
|
303
|
+
liveError = error;
|
|
304
|
+
}
|
|
305
|
+
})()
|
|
306
|
+
};
|
|
307
|
+
};
|
|
308
|
+
const replayMissed = async (cursor, getLiveError) => {
|
|
309
|
+
const missed = throwOnCursorRejection(options.fetchMissed(cursor, { limit: catchUpPageSize }), isCursorRejected);
|
|
310
|
+
for await (const event of missed) {
|
|
311
|
+
throwLiveError(getLiveError());
|
|
312
|
+
await deliverItem(await options.processMissed(event), false, false);
|
|
313
|
+
}
|
|
314
|
+
throwLiveError(getLiveError());
|
|
315
|
+
};
|
|
316
|
+
const flushLiveBuffer = async (liveBuffer, getLiveError, stopBuffering) => {
|
|
317
|
+
let index = 0;
|
|
318
|
+
let lastFlushedId;
|
|
319
|
+
while (index < liveBuffer.length) {
|
|
320
|
+
throwLiveError(getLiveError());
|
|
321
|
+
const event = liveBuffer[index];
|
|
322
|
+
if (event === void 0) throw new RetryableStreamError("Live stream buffer index missing");
|
|
323
|
+
const item = await options.processLive(event);
|
|
324
|
+
await deliverItem(item, true, false);
|
|
325
|
+
lastFlushedId = item.id;
|
|
326
|
+
index += 1;
|
|
327
|
+
}
|
|
328
|
+
liveBuffer.length = 0;
|
|
329
|
+
throwLiveError(getLiveError());
|
|
330
|
+
compactDeliveredIds(lastFlushedId);
|
|
331
|
+
stopBuffering();
|
|
332
|
+
};
|
|
333
|
+
const compactDeliveredIds = (lastId) => {
|
|
334
|
+
if (!lastId) return;
|
|
335
|
+
deliveredSinceCursor.clear();
|
|
336
|
+
deliveredSinceCursor.add(lastId);
|
|
337
|
+
};
|
|
338
|
+
const catchUpThenConsumeLive = async (cursor) => {
|
|
339
|
+
const live = options.subscribeLive(cursor);
|
|
340
|
+
activeLive = live;
|
|
341
|
+
let buffering = true;
|
|
342
|
+
const liveBuffer = [];
|
|
343
|
+
const livePump = startLivePump(live, () => buffering, liveBuffer);
|
|
344
|
+
try {
|
|
345
|
+
await replayMissed(cursor, livePump.getError);
|
|
346
|
+
await flushLiveBuffer(liveBuffer, livePump.getError, () => {
|
|
347
|
+
buffering = false;
|
|
348
|
+
});
|
|
349
|
+
noteRecovery();
|
|
350
|
+
await livePump.pump;
|
|
351
|
+
throwLiveError(livePump.getError());
|
|
352
|
+
} finally {
|
|
353
|
+
buffering = false;
|
|
354
|
+
if (activeLive === live) activeLive = void 0;
|
|
355
|
+
await closeIterable(live);
|
|
356
|
+
await livePump.pump.catch(ignoreCleanupError);
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
const run = async () => {
|
|
360
|
+
while (!closed) {
|
|
361
|
+
const phase = lastCursor ? "catch-up" : "live";
|
|
362
|
+
try {
|
|
363
|
+
if (lastCursor) await catchUpThenConsumeLive(lastCursor);
|
|
364
|
+
else await consumeLive();
|
|
365
|
+
} catch (error) {
|
|
366
|
+
await closeIterable(activeLive).catch(ignoreCleanupError);
|
|
367
|
+
activeLive = void 0;
|
|
368
|
+
if (closed) break;
|
|
369
|
+
await sleep(handleFailure(error, phase));
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
end();
|
|
373
|
+
};
|
|
374
|
+
const pump = run().catch((error) => {
|
|
375
|
+
log.error("resumable stream loop crashed", {
|
|
376
|
+
"spectrum.stream.id": streamId,
|
|
377
|
+
"spectrum.stream.label": label
|
|
378
|
+
}, error);
|
|
379
|
+
if (!closed) end(error);
|
|
380
|
+
});
|
|
381
|
+
return async () => {
|
|
382
|
+
closed = true;
|
|
383
|
+
cancelSleep();
|
|
384
|
+
await closeIterable(activeLive);
|
|
385
|
+
await pump.catch(ignoreCleanupError);
|
|
386
|
+
};
|
|
387
|
+
});
|
|
388
|
+
//#endregion
|
|
389
|
+
export { asAttachment, asContact, asCustom, asGroup, asMarkdown, asPoll, asPollOption, asReaction, asRead, asRichlink, asText, asVoice, buildPhotoAction, createLogger, ensureM4a, errorAttrs, groupSchema, messageEffectSchema, photoActionSchema, reactionSchema, renderInlineTokens, resumableOrderedStream, sanitizeEmail, sanitizeErrorMessage, sanitizePhone, setLogLevel };
|
package/dist/elysia.d.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { T as WebhookHandler, nn as Message, rn as Space } from "./attachment-a_lrhg6w.js";
|
|
2
|
+
import { Elysia } from "elysia";
|
|
3
|
+
|
|
4
|
+
//#region src/elysia.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* The minimal structural surface of a Spectrum instance the plugin needs. Kept
|
|
7
|
+
* structural (rather than importing the generic `SpectrumInstance<Providers>`)
|
|
8
|
+
* so the plugin stays decoupled from provider typing; a real instance is
|
|
9
|
+
* assignable via its Web `Request` webhook overload.
|
|
10
|
+
*/
|
|
11
|
+
interface WebhookReceiver {
|
|
12
|
+
webhook(request: Request, handler: WebhookHandler): Promise<Response>;
|
|
13
|
+
}
|
|
14
|
+
interface SpectrumPluginOptions {
|
|
15
|
+
/** The Spectrum instance returned by `await Spectrum({...})`. */
|
|
16
|
+
app: WebhookReceiver;
|
|
17
|
+
/**
|
|
18
|
+
* Invoked once per inbound message, fire-and-forget after the response — the
|
|
19
|
+
* same `(space, message)` contract as `app.webhook(request, handler)`. Covers
|
|
20
|
+
* both native Spectrum webhooks and fusor webhooks identically.
|
|
21
|
+
*/
|
|
22
|
+
onMessage: WebhookHandler;
|
|
23
|
+
/**
|
|
24
|
+
* Route the webhook is mounted on.
|
|
25
|
+
*
|
|
26
|
+
* @default "/spectrum/webhook"
|
|
27
|
+
*/
|
|
28
|
+
path?: string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Mount a Spectrum webhook endpoint on an Elysia app.
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* ```ts
|
|
35
|
+
* import { Elysia } from "elysia";
|
|
36
|
+
* import { Spectrum } from "spectrum-ts";
|
|
37
|
+
* import { spectrum } from "spectrum-ts/elysia";
|
|
38
|
+
*
|
|
39
|
+
* const app = await Spectrum({ ..., webhookSecret: process.env.SPECTRUM_WEBHOOK_SECRET });
|
|
40
|
+
*
|
|
41
|
+
* new Elysia()
|
|
42
|
+
* .use(spectrum({
|
|
43
|
+
* app,
|
|
44
|
+
* onMessage: async (space, message) => {
|
|
45
|
+
* if (message.content.type === "text") await space.send(`echo: ${message.content.text}`);
|
|
46
|
+
* },
|
|
47
|
+
* }))
|
|
48
|
+
* .listen(3000);
|
|
49
|
+
* ```
|
|
50
|
+
*/
|
|
51
|
+
declare function spectrum(options: SpectrumPluginOptions): Elysia<"", {
|
|
52
|
+
decorator: {};
|
|
53
|
+
store: {};
|
|
54
|
+
derive: {};
|
|
55
|
+
resolve: {};
|
|
56
|
+
}, {
|
|
57
|
+
typebox: {};
|
|
58
|
+
error: {};
|
|
59
|
+
}, {
|
|
60
|
+
schema: {};
|
|
61
|
+
standaloneSchema: {};
|
|
62
|
+
macro: {};
|
|
63
|
+
macroFn: {};
|
|
64
|
+
parser: {};
|
|
65
|
+
response: {};
|
|
66
|
+
}, {
|
|
67
|
+
[x: string]: {
|
|
68
|
+
post: {
|
|
69
|
+
body: unknown;
|
|
70
|
+
params: {};
|
|
71
|
+
query: unknown;
|
|
72
|
+
headers: unknown;
|
|
73
|
+
response: {
|
|
74
|
+
200: Response;
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
}, {
|
|
79
|
+
derive: {};
|
|
80
|
+
resolve: {};
|
|
81
|
+
schema: {};
|
|
82
|
+
standaloneSchema: {};
|
|
83
|
+
response: {};
|
|
84
|
+
}, {
|
|
85
|
+
derive: {};
|
|
86
|
+
resolve: {};
|
|
87
|
+
schema: {};
|
|
88
|
+
standaloneSchema: {};
|
|
89
|
+
response: {};
|
|
90
|
+
}>;
|
|
91
|
+
//#endregion
|
|
92
|
+
export { type Message, type Space, SpectrumPluginOptions, type WebhookHandler, spectrum };
|
package/dist/elysia.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Elysia } from "elysia";
|
|
2
|
+
//#region src/elysia.ts
|
|
3
|
+
/**
|
|
4
|
+
* Mount a Spectrum webhook endpoint on an Elysia app.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* ```ts
|
|
8
|
+
* import { Elysia } from "elysia";
|
|
9
|
+
* import { Spectrum } from "spectrum-ts";
|
|
10
|
+
* import { spectrum } from "spectrum-ts/elysia";
|
|
11
|
+
*
|
|
12
|
+
* const app = await Spectrum({ ..., webhookSecret: process.env.SPECTRUM_WEBHOOK_SECRET });
|
|
13
|
+
*
|
|
14
|
+
* new Elysia()
|
|
15
|
+
* .use(spectrum({
|
|
16
|
+
* app,
|
|
17
|
+
* onMessage: async (space, message) => {
|
|
18
|
+
* if (message.content.type === "text") await space.send(`echo: ${message.content.text}`);
|
|
19
|
+
* },
|
|
20
|
+
* }))
|
|
21
|
+
* .listen(3000);
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
function spectrum(options) {
|
|
25
|
+
const { app, onMessage, path = "/spectrum/webhook" } = options;
|
|
26
|
+
return new Elysia({
|
|
27
|
+
name: "spectrum-webhook",
|
|
28
|
+
seed: path
|
|
29
|
+
}).post(path, ({ request }) => app.webhook(request, onMessage), { parse: "none" });
|
|
30
|
+
}
|
|
31
|
+
//#endregion
|
|
32
|
+
export { spectrum };
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { T as WebhookHandler, nn as Message, rn as Space } from "./attachment-a_lrhg6w.js";
|
|
2
|
+
import { Router } from "express";
|
|
3
|
+
|
|
4
|
+
//#region src/express.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* The minimal structural surface of a Spectrum instance the plugin needs. Kept
|
|
7
|
+
* structural (rather than importing the generic `SpectrumInstance<Providers>`)
|
|
8
|
+
* so the plugin stays decoupled from provider typing; a real instance is
|
|
9
|
+
* assignable via its raw (`{ body, headers }`) webhook overload.
|
|
10
|
+
*/
|
|
11
|
+
interface WebhookReceiver {
|
|
12
|
+
webhook(request: {
|
|
13
|
+
body: Uint8Array | ArrayBuffer;
|
|
14
|
+
headers?: Record<string, string>;
|
|
15
|
+
}, handler: WebhookHandler): Promise<{
|
|
16
|
+
body: Uint8Array;
|
|
17
|
+
headers: Record<string, string>;
|
|
18
|
+
status: number;
|
|
19
|
+
}>;
|
|
20
|
+
}
|
|
21
|
+
interface SpectrumPluginOptions {
|
|
22
|
+
/** The Spectrum instance returned by `await Spectrum({...})`. */
|
|
23
|
+
app: WebhookReceiver;
|
|
24
|
+
/**
|
|
25
|
+
* Invoked once per inbound message, fire-and-forget after the response — the
|
|
26
|
+
* same `(space, message)` contract as `app.webhook(request, handler)`. Covers
|
|
27
|
+
* both native Spectrum webhooks and fusor webhooks identically.
|
|
28
|
+
*/
|
|
29
|
+
onMessage: WebhookHandler;
|
|
30
|
+
/**
|
|
31
|
+
* Route the webhook is mounted on.
|
|
32
|
+
*
|
|
33
|
+
* @default "/spectrum/webhook"
|
|
34
|
+
*/
|
|
35
|
+
path?: string;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Mount a Spectrum webhook endpoint on an Express app.
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* ```ts
|
|
42
|
+
* import express from "express";
|
|
43
|
+
* import { Spectrum } from "spectrum-ts";
|
|
44
|
+
* import { spectrum } from "spectrum-ts/express";
|
|
45
|
+
*
|
|
46
|
+
* const app = await Spectrum({ ..., webhookSecret: process.env.SPECTRUM_WEBHOOK_SECRET });
|
|
47
|
+
*
|
|
48
|
+
* const server = express();
|
|
49
|
+
* server.use(spectrum({ // mount before any global express.json()
|
|
50
|
+
* app,
|
|
51
|
+
* onMessage: async (space, message) => {
|
|
52
|
+
* if (message.content.type === "text") await space.send(`echo: ${message.content.text}`);
|
|
53
|
+
* },
|
|
54
|
+
* }));
|
|
55
|
+
* server.listen(3000);
|
|
56
|
+
* ```
|
|
57
|
+
*/
|
|
58
|
+
declare function spectrum(options: SpectrumPluginOptions): Router;
|
|
59
|
+
//#endregion
|
|
60
|
+
export { type Message, type Space, SpectrumPluginOptions, type WebhookHandler, spectrum };
|