pronto-imessage 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,721 @@
1
+ import { ResilientRpcClient, RpcRequestError, RpcSubmissionUncertainError, } from "./internal/rpc.js";
2
+ import { createHash } from "node:crypto";
3
+ import { databasePath, normalizeConversationFacts, normalizeEvent, qualify, record, } from "./internal/normalize.js";
4
+ import { databaseGeneration } from "./internal/generation.js";
5
+ import { ConversationReferenceExpiredError, ScopedMessagesAccess, } from "./internal/scoped.js";
6
+ import { MemoryCheckpointStore, ProviderStateStore, } from "./internal/state.js";
7
+ function messageDateMs(value) {
8
+ if (value === null)
9
+ return null;
10
+ const parsed = Date.parse(value);
11
+ return Number.isFinite(parsed) ? parsed : null;
12
+ }
13
+ function isMirrorPair(message, original) {
14
+ if (original.conversation.chatId !== message.conversation.chatId ||
15
+ !original.message.fromMe ||
16
+ original.message.text !== message.message.text) {
17
+ return false;
18
+ }
19
+ const rowDistance = message.message.rowId - original.message.rowId;
20
+ if (rowDistance < 1)
21
+ return false;
22
+ const messageTime = messageDateMs(message.message.occurredAt);
23
+ const originalTime = messageDateMs(original.message.occurredAt);
24
+ return (messageTime !== null &&
25
+ originalTime !== null &&
26
+ messageTime <= originalTime &&
27
+ originalTime - messageTime <= 1_000);
28
+ }
29
+ class RecoveryBoundaryError extends Error {
30
+ reason;
31
+ rows;
32
+ constructor(reason, rows) {
33
+ super(reason);
34
+ this.reason = reason;
35
+ this.rows = rows;
36
+ }
37
+ }
38
+ function isGenerationBoundary(error) {
39
+ return error instanceof RecoveryBoundaryError &&
40
+ (error.reason === "database-generation-changed" ||
41
+ error.reason === "database-generation-unavailable");
42
+ }
43
+ class ProntoMessagesClient {
44
+ #rpc;
45
+ #scoped;
46
+ #recentOutgoing = new Map();
47
+ #inFlightDeliveries = new Map();
48
+ #state;
49
+ #limits;
50
+ #diagnostics = {
51
+ attempt: 0,
52
+ catchUpRows: 0,
53
+ restartCount: 0,
54
+ state: "starting",
55
+ };
56
+ #closed = false;
57
+ #databasePath;
58
+ #subscriptionActive = false;
59
+ constructor(input) {
60
+ this.#limits = {
61
+ maxAgeMs: input.recoveryLimits?.maxAgeMs ?? 24 * 60 * 60 * 1_000,
62
+ maxDurationMs: input.recoveryLimits?.maxDurationMs ?? 30_000,
63
+ maxRows: input.recoveryLimits?.maxRows ?? 10_000,
64
+ };
65
+ if (Object.values(this.#limits).some((value) => !Number.isSafeInteger(value) || value <= 0)) {
66
+ throw new Error("messages_recovery_limits_invalid");
67
+ }
68
+ this.#state = input.statePath === undefined
69
+ ? new MemoryCheckpointStore()
70
+ : new ProviderStateStore(input.statePath, {
71
+ ...(input.legacyUnscopedCursor === undefined
72
+ ? {}
73
+ : { legacyUnscopedCursor: input.legacyUnscopedCursor }),
74
+ });
75
+ this.#rpc = ResilientRpcClient.spawn(input.imsgPath);
76
+ this.#scoped = new ScopedMessagesAccess({
77
+ ...(input.attachmentsRoot === undefined ? {} : { attachmentsRoot: input.attachmentsRoot }),
78
+ generation: async () => await this.#refreshGeneration(),
79
+ ...(input.scopeLimits === undefined ? {} : { limits: input.scopeLimits }),
80
+ ...(input.referenceKey === undefined ? {} : { referenceKey: input.referenceKey }),
81
+ rpc: this.#rpc,
82
+ ...(input.scratchRoot === undefined ? {} : { scratchRoot: input.scratchRoot }),
83
+ });
84
+ }
85
+ async qualify() {
86
+ const snapshot = await this.#rpc.request("initialize", { protocol_version: 1 }, 10_000);
87
+ const path = databasePath(snapshot);
88
+ this.#databasePath = path;
89
+ return {
90
+ ...qualify(snapshot),
91
+ databaseGeneration: await databaseGeneration(path),
92
+ };
93
+ }
94
+ diagnostics() {
95
+ const rpc = this.#rpc.diagnostics();
96
+ return {
97
+ ...this.#diagnostics,
98
+ attempt: rpc.attempt,
99
+ restartCount: rpc.restartCount,
100
+ state: this.#closed ? "closed" : rpc.state === "recovering" ? "recovering" : this.#diagnostics.state,
101
+ ...(rpc.nextRetryAt === undefined ? {} : { nextRetryAt: rpc.nextRetryAt }),
102
+ };
103
+ }
104
+ async history(input) {
105
+ return await this.#scoped.history(input);
106
+ }
107
+ async materializeAttachment(input) {
108
+ return await this.#scoped.materializeAttachment(input);
109
+ }
110
+ async subscribe(input) {
111
+ if (this.#subscriptionActive)
112
+ throw new Error("messages_subscription_already_active");
113
+ this.#subscriptionActive = true;
114
+ let subscriptionId = null;
115
+ let databaseGeneration;
116
+ let closed = false;
117
+ let signalClosed;
118
+ const closedSignal = new Promise((resolve) => {
119
+ signalClosed = resolve;
120
+ });
121
+ let queue = Promise.resolve();
122
+ const enqueue = (operation) => {
123
+ queue = queue.then(operation, operation).catch(() => {
124
+ this.#diagnostics = { ...this.#diagnostics, state: "degraded" };
125
+ });
126
+ };
127
+ const pendingNotifications = [];
128
+ const detachProvider = async () => {
129
+ const active = subscriptionId;
130
+ subscriptionId = null;
131
+ if (active !== null) {
132
+ await this.#rpc.request("watch.unsubscribe", { subscription: active }).catch(() => undefined);
133
+ }
134
+ };
135
+ const report = async (outcome) => {
136
+ if (outcome.status === "recovered") {
137
+ const { recoveryReason: _recoveredReason, ...diagnostics } = this.#diagnostics;
138
+ this.#diagnostics = {
139
+ ...diagnostics,
140
+ catchUpRows: this.#diagnostics.catchUpRows + outcome.rows,
141
+ state: "ready",
142
+ };
143
+ }
144
+ else {
145
+ this.#diagnostics = {
146
+ ...this.#diagnostics,
147
+ catchUpRows: this.#diagnostics.catchUpRows + outcome.rows,
148
+ recoveryReason: outcome.reason,
149
+ state: "degraded",
150
+ };
151
+ }
152
+ await Promise.resolve(input.onRecovery?.(outcome)).catch(() => undefined);
153
+ };
154
+ const subscribeProvider = async (useCheckpoint) => {
155
+ if (closed)
156
+ return;
157
+ await detachProvider();
158
+ if (closed)
159
+ return;
160
+ const checkpoint = useCheckpoint
161
+ ? await this.#state.checkpoint(databaseGeneration)
162
+ : undefined;
163
+ const result = record(await this.#rpc.request("watch.subscribe", {
164
+ attachments: true,
165
+ buffer_limit: 256,
166
+ include_reactions: true,
167
+ ...(checkpoint === undefined ? {} : { since_rowid: checkpoint.rowId }),
168
+ }));
169
+ if (typeof result.subscription !== "number" || !Number.isSafeInteger(result.subscription)) {
170
+ throw new Error("imsg returned an invalid watch subscription");
171
+ }
172
+ if (closed) {
173
+ await this.#rpc.request("watch.unsubscribe", { subscription: result.subscription })
174
+ .catch(() => undefined);
175
+ return;
176
+ }
177
+ subscriptionId = result.subscription;
178
+ for (const notification of pendingNotifications.splice(0)) {
179
+ enqueue(async () => await handleNotification(notification));
180
+ }
181
+ };
182
+ const recover = async (boundaryReason) => {
183
+ if (closed)
184
+ return;
185
+ const previous = await this.#state.currentCheckpoint();
186
+ const qualification = await this.qualify();
187
+ if (closed)
188
+ return;
189
+ databaseGeneration = qualification.databaseGeneration;
190
+ this.#diagnostics = {
191
+ ...this.#diagnostics,
192
+ databaseGenerationDigest: qualification.databaseGeneration.slice(0, 16),
193
+ state: "starting",
194
+ };
195
+ if (boundaryReason !== undefined) {
196
+ await report({
197
+ action: "live-events-only",
198
+ reason: boundaryReason,
199
+ rows: 0,
200
+ status: "degraded",
201
+ });
202
+ await subscribeProvider(false);
203
+ return;
204
+ }
205
+ if (previous !== undefined &&
206
+ previous.databaseGeneration !== qualification.databaseGeneration) {
207
+ await report({
208
+ action: "live-events-only",
209
+ reason: "database-generation-changed",
210
+ rows: 0,
211
+ status: "degraded",
212
+ });
213
+ await subscribeProvider(false);
214
+ return;
215
+ }
216
+ if (previous !== undefined && !(await this.#checkpointWitnessMatches(previous))) {
217
+ await report({
218
+ action: "live-events-only",
219
+ reason: "database-generation-changed",
220
+ rows: 0,
221
+ status: "degraded",
222
+ });
223
+ await subscribeProvider(false);
224
+ return;
225
+ }
226
+ if (previous !== undefined) {
227
+ const outcome = await this.#catchUp(databaseGeneration, input, scheduleDeferredDelivery);
228
+ if (outcome.status === "degraded" && outcome.reason === "database-generation-changed") {
229
+ databaseGeneration = (await this.qualify()).databaseGeneration;
230
+ }
231
+ await report(outcome);
232
+ if (this.#inFlightDeliveries.size > 0)
233
+ return;
234
+ }
235
+ await subscribeProvider(this.#diagnostics.state !== "degraded");
236
+ if (this.#diagnostics.state !== "degraded") {
237
+ this.#diagnostics = { ...this.#diagnostics, state: "ready" };
238
+ }
239
+ };
240
+ let recoveryTail = Promise.resolve();
241
+ const recoverSerially = async (boundaryReason) => {
242
+ let release;
243
+ const previous = recoveryTail;
244
+ recoveryTail = new Promise((resolve) => {
245
+ release = resolve;
246
+ });
247
+ await previous;
248
+ try {
249
+ await recover(boundaryReason);
250
+ }
251
+ finally {
252
+ release();
253
+ }
254
+ };
255
+ const retryWhileOpen = async (operation, onFirstFailure) => {
256
+ let delayMs = 250;
257
+ let firstFailure = true;
258
+ while (!closed) {
259
+ try {
260
+ await operation();
261
+ return;
262
+ }
263
+ catch {
264
+ if (closed)
265
+ return;
266
+ this.#diagnostics = { ...this.#diagnostics, state: "recovering" };
267
+ if (firstFailure) {
268
+ firstFailure = false;
269
+ await onFirstFailure?.();
270
+ }
271
+ await Promise.race([
272
+ new Promise((resolve) => {
273
+ const timer = setTimeout(resolve, delayMs);
274
+ timer.unref?.();
275
+ }),
276
+ closedSignal,
277
+ ]);
278
+ delayMs = Math.min(30_000, delayMs * 2);
279
+ }
280
+ }
281
+ };
282
+ const recoverUntilSubscribed = async (boundaryReason) => {
283
+ await retryWhileOpen(async () => await recoverSerially(boundaryReason), async () => await report({
284
+ action: "live-events-only",
285
+ reason: "provider-unavailable",
286
+ rows: 0,
287
+ status: "degraded",
288
+ }));
289
+ };
290
+ const retryFailedDelivery = async (rawMessage, generation) => {
291
+ await retryWhileOpen(async () => {
292
+ try {
293
+ await this.#deliver(rawMessage, generation, input);
294
+ await recoverUntilSubscribed();
295
+ }
296
+ catch (error) {
297
+ if (isGenerationBoundary(error)) {
298
+ await recoverUntilSubscribed(error.reason);
299
+ return;
300
+ }
301
+ throw error;
302
+ }
303
+ });
304
+ };
305
+ const scheduleDeferredDelivery = (rawMessage, generation, error) => {
306
+ const settle = async () => {
307
+ if (closed)
308
+ return;
309
+ await detachProvider();
310
+ if (error === undefined) {
311
+ await recoverUntilSubscribed();
312
+ }
313
+ else {
314
+ await retryFailedDelivery(rawMessage, generation);
315
+ }
316
+ };
317
+ enqueue(settle);
318
+ };
319
+ const handleNotification = async (notification) => {
320
+ if (closed)
321
+ return;
322
+ const params = record(notification.params);
323
+ if (params.subscription !== subscriptionId)
324
+ return;
325
+ if (notification.method === "watch.overflow") {
326
+ if (params.terminal === true &&
327
+ typeof params.resume_after_rowid === "number" &&
328
+ Number.isSafeInteger(params.resume_after_rowid)) {
329
+ await Promise.resolve(input.onOverflow?.(params.resume_after_rowid)).catch(() => undefined);
330
+ subscriptionId = null;
331
+ await recoverUntilSubscribed();
332
+ }
333
+ return;
334
+ }
335
+ if (notification.method !== "message")
336
+ return;
337
+ const rawMessage = record(params.message);
338
+ let observedGeneration;
339
+ try {
340
+ observedGeneration = await this.#refreshGeneration();
341
+ }
342
+ catch {
343
+ await report({
344
+ action: "live-events-only",
345
+ reason: "database-generation-unavailable",
346
+ rows: 0,
347
+ status: "degraded",
348
+ });
349
+ return;
350
+ }
351
+ if (observedGeneration !== databaseGeneration) {
352
+ await detachProvider();
353
+ await recoverUntilSubscribed("database-generation-changed");
354
+ return;
355
+ }
356
+ try {
357
+ await this.#deliver(rawMessage, databaseGeneration, input);
358
+ }
359
+ catch (error) {
360
+ if (isGenerationBoundary(error)) {
361
+ await detachProvider();
362
+ await recoverUntilSubscribed(error.reason);
363
+ return;
364
+ }
365
+ await detachProvider();
366
+ await retryFailedDelivery(rawMessage, databaseGeneration);
367
+ return;
368
+ }
369
+ };
370
+ const dispose = this.#rpc.onNotification((notification) => {
371
+ if (subscriptionId === null) {
372
+ pendingNotifications.push(notification);
373
+ return;
374
+ }
375
+ enqueue(async () => await handleNotification(notification));
376
+ });
377
+ const disposeRestart = this.#rpc.onRestart(() => {
378
+ if (closed)
379
+ return;
380
+ subscriptionId = null;
381
+ enqueue(async () => await recoverUntilSubscribed());
382
+ });
383
+ try {
384
+ await recoverSerially();
385
+ return {
386
+ close: async () => {
387
+ if (closed)
388
+ return;
389
+ closed = true;
390
+ signalClosed();
391
+ dispose();
392
+ disposeRestart();
393
+ await queue;
394
+ await detachProvider();
395
+ this.#subscriptionActive = false;
396
+ },
397
+ terminated: this.#rpc.terminated,
398
+ };
399
+ }
400
+ catch (error) {
401
+ dispose();
402
+ disposeRestart();
403
+ this.#subscriptionActive = false;
404
+ throw error;
405
+ }
406
+ }
407
+ async reply(input) {
408
+ const conversation = await this.#scoped.conversation(input.conversation, true).catch((error) => {
409
+ if (error instanceof ConversationReferenceExpiredError) {
410
+ return null;
411
+ }
412
+ throw error;
413
+ });
414
+ if (conversation === null)
415
+ return { retryable: false, status: "failed" };
416
+ try {
417
+ const result = record(await this.#rpc.request("send", {
418
+ chat_id: conversation.chatId,
419
+ text: input.text,
420
+ }));
421
+ if (result.ok !== true)
422
+ return { retryable: false, status: "failed" };
423
+ return typeof result.guid === "string" && result.guid !== ""
424
+ ? { providerMessageId: result.guid, status: "confirmed" }
425
+ : { status: "ambiguous" };
426
+ }
427
+ catch (error) {
428
+ if (error instanceof RpcSubmissionUncertainError)
429
+ return { status: "ambiguous" };
430
+ if (!(error instanceof RpcRequestError))
431
+ return { retryable: false, status: "failed" };
432
+ const data = record(error.data);
433
+ return data.disposition === "may_have_completed" || data.disposition === "still_in_flight"
434
+ ? { status: "ambiguous" }
435
+ : { retryable: data.retry_safe === true, status: "failed" };
436
+ }
437
+ }
438
+ async #deliver(rawMessage, generation, input, budget, onDeferredSettlement) {
439
+ const within = async (operation) => budget === undefined
440
+ ? await operation()
441
+ : await this.#withinDeadline(budget, operation);
442
+ const chatId = rawMessage.chat_id;
443
+ const rowId = rawMessage.id;
444
+ if (typeof chatId !== "number" || !Number.isSafeInteger(chatId) || chatId <= 0 ||
445
+ typeof rowId !== "number" || !Number.isSafeInteger(rowId) || rowId <= 0) {
446
+ return;
447
+ }
448
+ const checkpoint = await within(async () => await this.#state.checkpoint(generation));
449
+ if (checkpoint !== undefined && rowId <= checkpoint.rowId)
450
+ return;
451
+ const stats = await within(async () => await this.#rpc.request("messages.stats", { chat_id: chatId }, budget === undefined ? 30_000 : this.#providerTimeout(budget)));
452
+ const event = normalizeEvent(rawMessage, normalizeConversationFacts(stats, chatId));
453
+ if (event === null)
454
+ return;
455
+ const normalizedEvent = {
456
+ ...event,
457
+ message: {
458
+ ...event.message,
459
+ selfChatMirror: await this.#isSelfChatMirror(event, budget),
460
+ },
461
+ };
462
+ const scopedEvent = await within(async () => await this.#scoped.decorateEvent(normalizedEvent, rawMessage, generation));
463
+ this.#rememberOutgoing(scopedEvent);
464
+ const deliveryKey = `${generation}:${rowId}`;
465
+ const existingDelivery = this.#inFlightDeliveries.get(deliveryKey);
466
+ if (existingDelivery !== undefined) {
467
+ if (budget !== undefined)
468
+ await within(async () => await existingDelivery);
469
+ return;
470
+ }
471
+ const delivery = (async () => {
472
+ await input.onEvent(scopedEvent);
473
+ const observedGeneration = await this.#refreshGeneration();
474
+ if (observedGeneration !== generation) {
475
+ throw new RecoveryBoundaryError("database-generation-changed", budget?.rows ?? 0);
476
+ }
477
+ await this.#state.advance(generation, rowId, {
478
+ providerMessageDigest: this.#providerMessageDigest(normalizedEvent.message.providerMessageId),
479
+ rowId,
480
+ });
481
+ })();
482
+ const trackedDelivery = delivery.finally(() => {
483
+ if (this.#inFlightDeliveries.get(deliveryKey) === trackedDelivery) {
484
+ this.#inFlightDeliveries.delete(deliveryKey);
485
+ }
486
+ });
487
+ this.#inFlightDeliveries.set(deliveryKey, trackedDelivery);
488
+ try {
489
+ await within(async () => await trackedDelivery);
490
+ }
491
+ catch (error) {
492
+ if (budget !== undefined &&
493
+ error instanceof RecoveryBoundaryError &&
494
+ error.reason === "duration-limit" &&
495
+ onDeferredSettlement !== undefined) {
496
+ void trackedDelivery.then(() => onDeferredSettlement(), (deliveryError) => onDeferredSettlement(deliveryError));
497
+ }
498
+ throw error;
499
+ }
500
+ }
501
+ async #catchUp(generation, input, onDeferredDelivery) {
502
+ const checkpoint = await this.#state.checkpoint(generation);
503
+ if (checkpoint === undefined)
504
+ return { rows: 0, status: "recovered" };
505
+ const startedAt = Date.now();
506
+ const deadline = startedAt + this.#limits.maxDurationMs;
507
+ let cursor = checkpoint.rowId;
508
+ let rows = 0;
509
+ try {
510
+ while (true) {
511
+ await this.#assertGeneration(generation, { deadline, rows });
512
+ const remaining = this.#limits.maxRows - rows;
513
+ if (remaining <= 0)
514
+ throw new RecoveryBoundaryError("row-limit", rows);
515
+ const response = record(await this.#withinDeadline({ deadline, rows }, async () => await this.#rpc.request("messages.after", {
516
+ attachments: true,
517
+ convert_attachments: false,
518
+ include_reactions: true,
519
+ limit: Math.min(500, remaining),
520
+ since_rowid: cursor,
521
+ }, this.#providerTimeout({ deadline, rows }))));
522
+ await this.#assertGeneration(generation, { deadline, rows });
523
+ const messages = response.messages;
524
+ const nextRowId = response.next_rowid;
525
+ if (!Array.isArray(messages) ||
526
+ typeof nextRowId !== "number" ||
527
+ !Number.isSafeInteger(nextRowId) ||
528
+ nextRowId < cursor ||
529
+ typeof response.has_more !== "boolean") {
530
+ throw new RecoveryBoundaryError("invalid-provider-page", rows);
531
+ }
532
+ if (rows + messages.length > this.#limits.maxRows) {
533
+ throw new RecoveryBoundaryError("row-limit", rows);
534
+ }
535
+ for (const raw of messages) {
536
+ await this.#assertGeneration(generation, { deadline, rows });
537
+ const occurredAt = record(raw).created_at ?? record(raw).date;
538
+ const occurredAtMs = typeof occurredAt === "string" ? Date.parse(occurredAt) : Number.NaN;
539
+ if (!Number.isFinite(occurredAtMs)) {
540
+ throw new RecoveryBoundaryError("invalid-provider-page", rows);
541
+ }
542
+ if (Date.now() - occurredAtMs > this.#limits.maxAgeMs) {
543
+ throw new RecoveryBoundaryError("age-limit", rows);
544
+ }
545
+ const rawMessage = record(raw);
546
+ await this.#deliver(rawMessage, generation, input, { deadline, rows }, (error) => onDeferredDelivery(rawMessage, generation, error));
547
+ rows += 1;
548
+ }
549
+ await this.#assertGeneration(generation, { deadline, rows });
550
+ await this.#withinDeadline({ deadline, rows }, async () => await this.#state.advance(generation, nextRowId));
551
+ if (response.has_more !== true)
552
+ return { rows, status: "recovered" };
553
+ if (nextRowId <= cursor)
554
+ throw new RecoveryBoundaryError("invalid-provider-page", rows);
555
+ cursor = nextRowId;
556
+ }
557
+ }
558
+ catch (error) {
559
+ if (error instanceof RecoveryBoundaryError) {
560
+ return {
561
+ action: "live-events-only",
562
+ reason: error.reason,
563
+ rows: error.rows,
564
+ status: "degraded",
565
+ };
566
+ }
567
+ throw error;
568
+ }
569
+ }
570
+ async #isSelfChatMirror(event, budget) {
571
+ if (event.message.fromMe || event.message.text === null)
572
+ return false;
573
+ for (const outgoing of this.#recentOutgoing.values()) {
574
+ if (isMirrorPair(event, outgoing))
575
+ return true;
576
+ }
577
+ const hasReplyLink = event.message.replyToProviderMessageId !== null &&
578
+ event.message.replyToText === event.message.text;
579
+ try {
580
+ const request = async () => await this.#rpc.request("messages.after", {
581
+ attachments: false,
582
+ include_reactions: true,
583
+ limit: 100,
584
+ since_rowid: Math.max(0, event.message.rowId - 101),
585
+ }, budget === undefined ? 30_000 : this.#providerTimeout(budget));
586
+ const result = record(await (budget === undefined
587
+ ? request()
588
+ : this.#withinDeadline(budget, request)));
589
+ for (const raw of Array.isArray(result.messages) ? result.messages : []) {
590
+ const original = normalizeEvent(raw, event.conversationFacts);
591
+ if (original === null)
592
+ continue;
593
+ if (hasReplyLink
594
+ ? original.message.providerMessageId === event.message.replyToProviderMessageId &&
595
+ isMirrorPair(event, original)
596
+ : isMirrorPair(event, original)) {
597
+ return true;
598
+ }
599
+ }
600
+ return false;
601
+ }
602
+ catch (error) {
603
+ if (error instanceof RecoveryBoundaryError)
604
+ throw error;
605
+ return hasReplyLink;
606
+ }
607
+ }
608
+ async #assertGeneration(generation, budget) {
609
+ let observed;
610
+ try {
611
+ observed = await this.#withinDeadline(budget, async () => await this.#refreshGeneration());
612
+ }
613
+ catch (error) {
614
+ if (error instanceof RecoveryBoundaryError)
615
+ throw error;
616
+ throw new RecoveryBoundaryError("database-generation-unavailable", budget.rows);
617
+ }
618
+ if (observed !== generation) {
619
+ throw new RecoveryBoundaryError("database-generation-changed", budget.rows);
620
+ }
621
+ }
622
+ #remainingDuration(budget) {
623
+ const remaining = budget.deadline - Date.now();
624
+ if (remaining <= 0)
625
+ throw new RecoveryBoundaryError("duration-limit", budget.rows);
626
+ return Math.max(1, remaining);
627
+ }
628
+ #providerTimeout(budget) {
629
+ return this.#remainingDuration(budget) + 1_000;
630
+ }
631
+ async #withinDeadline(budget, operation) {
632
+ const remaining = this.#remainingDuration(budget);
633
+ let timer;
634
+ try {
635
+ return await Promise.race([
636
+ operation(),
637
+ new Promise((_resolve, reject) => {
638
+ timer = setTimeout(() => reject(new RecoveryBoundaryError("duration-limit", budget.rows)), remaining);
639
+ timer.unref?.();
640
+ }),
641
+ ]);
642
+ }
643
+ finally {
644
+ if (timer !== undefined)
645
+ clearTimeout(timer);
646
+ }
647
+ }
648
+ async #refreshGeneration() {
649
+ if (this.#databasePath === undefined)
650
+ throw new Error("messages_database_generation_unavailable");
651
+ return await databaseGeneration(this.#databasePath);
652
+ }
653
+ async #checkpointWitnessMatches(checkpoint) {
654
+ if (checkpoint.witnesses === undefined || checkpoint.witnesses.length === 0)
655
+ return false;
656
+ const highWatermark = record(await this.#rpc.request("messages.after", {
657
+ attachments: false,
658
+ include_reactions: true,
659
+ limit: 1,
660
+ since_rowid: Math.max(0, checkpoint.rowId - 1),
661
+ }));
662
+ if (!Array.isArray(highWatermark.messages)) {
663
+ throw new Error("imsg returned invalid checkpoint evidence");
664
+ }
665
+ const tip = highWatermark.messages[0];
666
+ if (tip === undefined)
667
+ return false;
668
+ const tipMessage = record(tip);
669
+ if (typeof tipMessage.id !== "number" || !Number.isSafeInteger(tipMessage.id) ||
670
+ tipMessage.id < checkpoint.rowId) {
671
+ throw new Error("imsg returned invalid checkpoint evidence");
672
+ }
673
+ if (tipMessage.id === checkpoint.rowId) {
674
+ const witness = checkpoint.witnesses.find((candidate) => candidate.rowId === checkpoint.rowId);
675
+ return witness !== undefined && typeof tipMessage.guid === "string" &&
676
+ this.#providerMessageDigest(tipMessage.guid) === witness.providerMessageDigest;
677
+ }
678
+ for (const witness of [...checkpoint.witnesses].reverse()) {
679
+ const result = record(await this.#rpc.request("messages.after", {
680
+ attachments: false,
681
+ include_reactions: true,
682
+ limit: 1,
683
+ since_rowid: Math.max(0, witness.rowId - 1),
684
+ }));
685
+ if (!Array.isArray(result.messages)) {
686
+ throw new Error("imsg returned invalid checkpoint evidence");
687
+ }
688
+ const matchingRow = result.messages.find((value) => record(value).id === witness.rowId);
689
+ if (matchingRow === undefined)
690
+ continue;
691
+ const message = record(matchingRow);
692
+ return typeof message.guid === "string" &&
693
+ this.#providerMessageDigest(message.guid) === witness.providerMessageDigest;
694
+ }
695
+ return false;
696
+ }
697
+ #providerMessageDigest(providerMessageId) {
698
+ return createHash("sha256").update(providerMessageId).digest("base64url");
699
+ }
700
+ #rememberOutgoing(event) {
701
+ if (!event.message.fromMe)
702
+ return;
703
+ const key = `${event.conversation.chatId}:${event.message.providerMessageId}`;
704
+ this.#recentOutgoing.delete(key);
705
+ this.#recentOutgoing.set(key, event);
706
+ while (this.#recentOutgoing.size > 64) {
707
+ const oldest = this.#recentOutgoing.keys().next().value;
708
+ if (oldest === undefined)
709
+ return;
710
+ this.#recentOutgoing.delete(oldest);
711
+ }
712
+ }
713
+ async close() {
714
+ this.#closed = true;
715
+ await this.#rpc.close();
716
+ }
717
+ }
718
+ export function createProntoMessages(input) {
719
+ return new ProntoMessagesClient(input);
720
+ }
721
+ //# sourceMappingURL=index.js.map