@llblab/pi-telegram 0.27.12 → 0.29.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/AGENTS.md +152 -258
- package/BACKLOG.md +1 -169
- package/CHANGELOG.md +398 -443
- package/README.md +10 -7
- package/api/updates.ts +5 -0
- package/docs/architecture.md +73 -28
- package/docs/multi-instance-bus.md +23 -5
- package/docs/outbound.md +2 -2
- package/docs/public-api.md +8 -7
- package/docs/ui-style.md +6 -6
- package/docs/updates.md +29 -11
- package/index.ts +358 -246
- package/lib/activity-verbosity.ts +26 -0
- package/lib/bindings.ts +240 -5
- package/lib/bus-follower.ts +436 -238
- package/lib/bus-leader.ts +395 -42
- package/lib/bus.ts +994 -153
- package/lib/commands.ts +184 -30
- package/lib/config.ts +23 -2
- package/lib/journal.ts +3140 -0
- package/lib/lifecycle.ts +4 -0
- package/lib/locks.ts +4 -2
- package/lib/media.ts +71 -32
- package/lib/menu-queue.ts +31 -17
- package/lib/menu.ts +5 -3
- package/lib/model.ts +51 -24
- package/lib/ownership.ts +42 -7
- package/lib/paths.ts +35 -0
- package/lib/polling.ts +591 -106
- package/lib/prompts.ts +20 -89
- package/lib/queue.ts +732 -143
- package/lib/routing.ts +291 -64
- package/lib/runtime.ts +26 -10
- package/lib/skills.ts +21 -0
- package/lib/status.ts +257 -18
- package/lib/sync.ts +131 -5
- package/lib/telegram-api.ts +41 -11
- package/lib/text-groups.ts +75 -35
- package/lib/threads.ts +112 -4
- package/lib/turns.ts +79 -14
- package/lib/updates.ts +3771 -223
- package/package.json +7 -3
- package/scripts/check-downgrade.mjs +435 -0
- package/skills/button-console/SKILL.md +139 -0
- package/skills/telegram-bridge/SKILL.md +138 -0
package/lib/updates.ts
CHANGED
|
@@ -1,15 +1,56 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Telegram updates domain helpers
|
|
3
3
|
* Zones: telegram inbound, authorization, routing plans
|
|
4
|
-
* Owns update extraction, authorization,
|
|
4
|
+
* Owns update extraction, authorization, execution planning, generation-fenced journal draining, and the public update-handler registry
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
|
|
7
9
|
import {
|
|
8
10
|
createTelegramPrivateTarget,
|
|
9
11
|
createTelegramThreadTarget,
|
|
10
12
|
type TelegramTarget,
|
|
11
13
|
} from "./target.ts";
|
|
14
|
+
import type {
|
|
15
|
+
TelegramBusEnvelope,
|
|
16
|
+
TelegramBusFollowerView,
|
|
17
|
+
TelegramBusForeignUpdateSettlement,
|
|
18
|
+
TelegramProcessLiveness,
|
|
19
|
+
} from "./bus.ts";
|
|
12
20
|
import type { TelegramMessageOwnershipStore } from "./ownership.ts";
|
|
21
|
+
import {
|
|
22
|
+
TELEGRAM_UPDATE_JOURNAL_FAILURE_CLASS_MAX_LENGTH,
|
|
23
|
+
TELEGRAM_UPDATE_JOURNAL_FAILURE_SUMMARY_MAX_LENGTH,
|
|
24
|
+
TELEGRAM_UPDATE_JOURNAL_QUEUE_OWNER_ID_MAX_LENGTH,
|
|
25
|
+
areTelegramUpdateJournalQueueOwnersEqual,
|
|
26
|
+
getTelegramUpdateJournalBindingPath,
|
|
27
|
+
isTelegramUpdateJournalQueueOwnerProcess,
|
|
28
|
+
parseTelegramUpdateJournalQueueOwner,
|
|
29
|
+
type TelegramJournaledUpdate,
|
|
30
|
+
type TelegramUpdateJournalDeadQueueOwnerRecoveryResult,
|
|
31
|
+
type TelegramUpdateJournalOperatorDispositionInput,
|
|
32
|
+
type TelegramUpdateJournalOperatorDispositionResult,
|
|
33
|
+
type TelegramUpdateJournalQueueDiscardResult,
|
|
34
|
+
type TelegramUpdateJournalQueueHandoffAcceptResult,
|
|
35
|
+
type TelegramUpdateJournalQueueHandoffCancelResult,
|
|
36
|
+
type TelegramUpdateJournalQueueHandoffInput,
|
|
37
|
+
type TelegramUpdateJournalQueueHandoffOfferResult,
|
|
38
|
+
type TelegramUpdateJournalQueueOwner,
|
|
39
|
+
type TelegramUpdateJournalQueueOwnerIdentity,
|
|
40
|
+
} from "./journal.ts";
|
|
41
|
+
import {
|
|
42
|
+
areTelegramQueueAdmissionReceiptsEqual,
|
|
43
|
+
createTelegramQueueHandoff,
|
|
44
|
+
removeTelegramQueueItemByReceipt,
|
|
45
|
+
type PendingTelegramControlItem,
|
|
46
|
+
type TelegramControlQueueHandoffPayload,
|
|
47
|
+
type TelegramQueueAdmissionReceipt,
|
|
48
|
+
type TelegramQueueHandoffPayload,
|
|
49
|
+
type TelegramQueueHandoffStageResult,
|
|
50
|
+
type TelegramQueueReactionDisposition,
|
|
51
|
+
type TelegramQueueHandoffStagingRuntime,
|
|
52
|
+
type TelegramQueueItem,
|
|
53
|
+
} from "./queue.ts";
|
|
13
54
|
import {
|
|
14
55
|
createTelegramUserPairingRuntime,
|
|
15
56
|
getTelegramAuthorizationState,
|
|
@@ -67,38 +108,49 @@ export function normalizeTelegramReactionEmoji(emoji: string): string {
|
|
|
67
108
|
export function collectTelegramReactionEmojis(
|
|
68
109
|
reactions: TelegramReactionType[],
|
|
69
110
|
): Set<string> {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
111
|
+
const emojis = new Set<string>();
|
|
112
|
+
for (const reaction of reactions) {
|
|
113
|
+
if (reaction.type === "emoji") {
|
|
114
|
+
const emojiReaction = reaction as TelegramReactionTypeEmoji;
|
|
115
|
+
emojis.add(normalizeTelegramReactionEmoji(emojiReaction.emoji));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return emojis;
|
|
78
119
|
}
|
|
79
120
|
|
|
80
|
-
function
|
|
121
|
+
function getTelegramReactionEmoji(
|
|
81
122
|
emojis: Set<string>,
|
|
82
123
|
candidates: readonly string[],
|
|
83
|
-
):
|
|
84
|
-
return candidates.
|
|
124
|
+
): string | undefined {
|
|
125
|
+
return candidates.find((emoji) => emojis.has(emoji));
|
|
85
126
|
}
|
|
86
127
|
|
|
87
|
-
function
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
128
|
+
export function getTelegramQueueReactionDisposition(
|
|
129
|
+
reactions: TelegramReactionType[],
|
|
130
|
+
): TelegramQueueReactionDisposition {
|
|
131
|
+
const emojis = collectTelegramReactionEmojis(reactions);
|
|
132
|
+
const suppressionEmoji = getTelegramReactionEmoji(
|
|
133
|
+
emojis,
|
|
134
|
+
TELEGRAM_REMOVAL_REACTION_EMOJIS,
|
|
94
135
|
);
|
|
136
|
+
if (suppressionEmoji) return { kind: "suppressed", emoji: suppressionEmoji };
|
|
137
|
+
const priorityEmoji = getTelegramReactionEmoji(
|
|
138
|
+
emojis,
|
|
139
|
+
TELEGRAM_PRIORITY_REACTION_EMOJIS,
|
|
140
|
+
);
|
|
141
|
+
if (priorityEmoji) return { kind: "priority", emoji: priorityEmoji };
|
|
142
|
+
return { kind: "default" };
|
|
95
143
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
144
|
+
|
|
145
|
+
function areTelegramQueueReactionDispositionsEqual(
|
|
146
|
+
left: TelegramQueueReactionDisposition,
|
|
147
|
+
right: TelegramQueueReactionDisposition,
|
|
100
148
|
): boolean {
|
|
101
|
-
return
|
|
149
|
+
return (
|
|
150
|
+
left.kind === right.kind &&
|
|
151
|
+
(left.kind === "default" ||
|
|
152
|
+
(right.kind !== "default" && left.emoji === right.emoji))
|
|
153
|
+
);
|
|
102
154
|
}
|
|
103
155
|
|
|
104
156
|
export function extractDeletedTelegramMessageIds(
|
|
@@ -251,6 +303,7 @@ export function getAuthorizedTelegramGuestMessage(
|
|
|
251
303
|
export interface TelegramMessageOwnershipView {
|
|
252
304
|
instanceId: string;
|
|
253
305
|
ownerGeneration?: string;
|
|
306
|
+
recipientBindingKey?: string;
|
|
254
307
|
}
|
|
255
308
|
|
|
256
309
|
export type TelegramMessageOwnershipLookup = (
|
|
@@ -261,6 +314,7 @@ export type TelegramMessageOwnershipLookup = (
|
|
|
261
314
|
export interface TelegramTargetOwnershipView {
|
|
262
315
|
instanceId: string;
|
|
263
316
|
ownerGeneration?: string;
|
|
317
|
+
recipientBindingKey?: string;
|
|
264
318
|
}
|
|
265
319
|
|
|
266
320
|
export type TelegramTargetOwnershipLookup = (
|
|
@@ -278,22 +332,77 @@ export interface TelegramForeignOwnedUpdateForwarder<
|
|
|
278
332
|
query: TCallbackQuery;
|
|
279
333
|
ownership: TelegramMessageOwnershipView;
|
|
280
334
|
ctx: TContext;
|
|
281
|
-
}) =>
|
|
335
|
+
}) =>
|
|
336
|
+
| Promise<TelegramBusForeignUpdateSettlement>
|
|
337
|
+
| TelegramBusForeignUpdateSettlement;
|
|
282
338
|
forwardReaction?: (input: {
|
|
283
339
|
reactionUpdate: TReactionUpdate;
|
|
284
340
|
ownership: TelegramMessageOwnershipView;
|
|
285
341
|
ctx: TContext;
|
|
286
|
-
}) =>
|
|
342
|
+
}) =>
|
|
343
|
+
| Promise<TelegramBusForeignUpdateSettlement>
|
|
344
|
+
| TelegramBusForeignUpdateSettlement;
|
|
287
345
|
forwardMessage?: (input: {
|
|
288
346
|
message: TMessage;
|
|
289
347
|
ownership: TelegramTargetOwnershipView;
|
|
290
348
|
ctx: TContext;
|
|
291
|
-
}) =>
|
|
349
|
+
}) =>
|
|
350
|
+
| Promise<TelegramBusForeignUpdateSettlement>
|
|
351
|
+
| TelegramBusForeignUpdateSettlement;
|
|
292
352
|
forwardEditedMessage?: (input: {
|
|
293
353
|
message: TMessage;
|
|
294
354
|
ownership: TelegramTargetOwnershipView;
|
|
295
355
|
ctx: TContext;
|
|
296
|
-
}) =>
|
|
356
|
+
}) =>
|
|
357
|
+
| Promise<TelegramBusForeignUpdateSettlement>
|
|
358
|
+
| TelegramBusForeignUpdateSettlement;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
type TelegramForeignUpdateSettlementFailure =
|
|
362
|
+
| Exclude<TelegramBusForeignUpdateSettlement, { status: "accepted" }>
|
|
363
|
+
| {
|
|
364
|
+
status: "terminal-rejected";
|
|
365
|
+
failureClass: "forwarder-unavailable";
|
|
366
|
+
message: string;
|
|
367
|
+
sourceUpdateId?: number;
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
export class TelegramForeignUpdateSettlementError extends Error {
|
|
371
|
+
readonly settlement: TelegramForeignUpdateSettlementFailure;
|
|
372
|
+
|
|
373
|
+
constructor(
|
|
374
|
+
operation: string,
|
|
375
|
+
settlement: TelegramForeignUpdateSettlementFailure,
|
|
376
|
+
) {
|
|
377
|
+
super(
|
|
378
|
+
`Telegram ${operation} forwarding did not settle: ${settlement.failureClass}.`,
|
|
379
|
+
);
|
|
380
|
+
this.name = "TelegramForeignUpdateSettlementError";
|
|
381
|
+
this.settlement = settlement;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function rejectTelegramForeignUpdateSettlement(
|
|
386
|
+
settlement: TelegramBusForeignUpdateSettlement | undefined,
|
|
387
|
+
operation: string,
|
|
388
|
+
source: unknown,
|
|
389
|
+
): never {
|
|
390
|
+
const sourceUpdateId =
|
|
391
|
+
source && typeof source === "object"
|
|
392
|
+
? Reflect.get(source, "pi_telegram_source_update_id")
|
|
393
|
+
: undefined;
|
|
394
|
+
const failure: TelegramForeignUpdateSettlementFailure =
|
|
395
|
+
settlement && settlement.status !== "accepted"
|
|
396
|
+
? settlement
|
|
397
|
+
: {
|
|
398
|
+
status: "terminal-rejected",
|
|
399
|
+
failureClass: "forwarder-unavailable",
|
|
400
|
+
message: `Telegram ${operation} forwarding is unavailable.`,
|
|
401
|
+
...(Number.isSafeInteger(sourceUpdateId) && sourceUpdateId >= 0
|
|
402
|
+
? { sourceUpdateId }
|
|
403
|
+
: {}),
|
|
404
|
+
};
|
|
405
|
+
throw new TelegramForeignUpdateSettlementError(operation, failure);
|
|
297
406
|
}
|
|
298
407
|
|
|
299
408
|
export interface TelegramMessageReactionUpdated {
|
|
@@ -314,6 +423,191 @@ export interface TelegramUpdateFlow
|
|
|
314
423
|
[TELEGRAM_INTERNAL_AGENT_MESSAGE]?: true;
|
|
315
424
|
}
|
|
316
425
|
|
|
426
|
+
export type TelegramUpdateAdmissionOutcome =
|
|
427
|
+
| { kind: "complete" }
|
|
428
|
+
| { kind: "deferred" }
|
|
429
|
+
| {
|
|
430
|
+
kind: "queued";
|
|
431
|
+
queueKind: "prompt" | "control";
|
|
432
|
+
receiptId: string;
|
|
433
|
+
sourceUpdateIds: readonly number[];
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
type TelegramQueuedUpdateAdmissionOutcome = Extract<
|
|
437
|
+
TelegramUpdateAdmissionOutcome,
|
|
438
|
+
{ kind: "queued" }
|
|
439
|
+
>;
|
|
440
|
+
|
|
441
|
+
const TELEGRAM_UPDATE_ADMISSION_BINDING = Symbol(
|
|
442
|
+
"telegram.update-admission.binding",
|
|
443
|
+
);
|
|
444
|
+
|
|
445
|
+
interface TelegramUpdateAdmissionBinding {
|
|
446
|
+
sourceUpdateId: number;
|
|
447
|
+
report: (
|
|
448
|
+
outcome: Extract<
|
|
449
|
+
TelegramUpdateAdmissionOutcome,
|
|
450
|
+
{ kind: "deferred" | "queued" }
|
|
451
|
+
>,
|
|
452
|
+
) => void;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
export type TelegramQueueAdmissionReceiptLike = TelegramQueueAdmissionReceipt;
|
|
456
|
+
|
|
457
|
+
function bindTelegramUpdateAdmissionCarrier<TValue>(
|
|
458
|
+
value: TValue | undefined,
|
|
459
|
+
binding: TelegramUpdateAdmissionBinding,
|
|
460
|
+
): TValue | undefined {
|
|
461
|
+
if (!value || typeof value !== "object") return value;
|
|
462
|
+
return {
|
|
463
|
+
...(value as Record<PropertyKey, unknown>),
|
|
464
|
+
pi_telegram_source_update_id: binding.sourceUpdateId,
|
|
465
|
+
[TELEGRAM_UPDATE_ADMISSION_BINDING]: binding,
|
|
466
|
+
} as TValue;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function getTelegramUpdateAdmissionBinding(
|
|
470
|
+
value: unknown,
|
|
471
|
+
): TelegramUpdateAdmissionBinding | undefined {
|
|
472
|
+
if (!value || typeof value !== "object") return undefined;
|
|
473
|
+
const binding = Reflect.get(value, TELEGRAM_UPDATE_ADMISSION_BINDING) as
|
|
474
|
+
| TelegramUpdateAdmissionBinding
|
|
475
|
+
| undefined;
|
|
476
|
+
return binding &&
|
|
477
|
+
Number.isSafeInteger(binding.sourceUpdateId) &&
|
|
478
|
+
binding.sourceUpdateId >= 0 &&
|
|
479
|
+
typeof binding.report === "function"
|
|
480
|
+
? binding
|
|
481
|
+
: undefined;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
export function bindTelegramUpdateAdmissionSource<
|
|
485
|
+
TUpdate extends TelegramUpdateFlow & { update_id: number },
|
|
486
|
+
>(
|
|
487
|
+
update: TUpdate,
|
|
488
|
+
report: TelegramUpdateAdmissionBinding["report"],
|
|
489
|
+
): TUpdate {
|
|
490
|
+
if (!Number.isSafeInteger(update.update_id) || update.update_id < 0) {
|
|
491
|
+
throw new TelegramUpdateAdmissionOutcomeError(
|
|
492
|
+
"Telegram update admission requires a safe update_id.",
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
const binding: TelegramUpdateAdmissionBinding = {
|
|
496
|
+
sourceUpdateId: update.update_id,
|
|
497
|
+
report,
|
|
498
|
+
};
|
|
499
|
+
const callbackQuery = update.callback_query
|
|
500
|
+
? bindTelegramUpdateAdmissionCarrier(
|
|
501
|
+
{
|
|
502
|
+
...update.callback_query,
|
|
503
|
+
message: bindTelegramUpdateAdmissionCarrier(
|
|
504
|
+
update.callback_query.message,
|
|
505
|
+
binding,
|
|
506
|
+
),
|
|
507
|
+
},
|
|
508
|
+
binding,
|
|
509
|
+
)
|
|
510
|
+
: undefined;
|
|
511
|
+
return {
|
|
512
|
+
...update,
|
|
513
|
+
...(update.message
|
|
514
|
+
? { message: bindTelegramUpdateAdmissionCarrier(update.message, binding) }
|
|
515
|
+
: {}),
|
|
516
|
+
...(update.edited_message
|
|
517
|
+
? {
|
|
518
|
+
edited_message: bindTelegramUpdateAdmissionCarrier(
|
|
519
|
+
update.edited_message,
|
|
520
|
+
binding,
|
|
521
|
+
),
|
|
522
|
+
}
|
|
523
|
+
: {}),
|
|
524
|
+
...(callbackQuery ? { callback_query: callbackQuery } : {}),
|
|
525
|
+
...(update.guest_message
|
|
526
|
+
? {
|
|
527
|
+
guest_message: bindTelegramUpdateAdmissionCarrier(
|
|
528
|
+
update.guest_message,
|
|
529
|
+
binding,
|
|
530
|
+
),
|
|
531
|
+
}
|
|
532
|
+
: {}),
|
|
533
|
+
...(update.message_reaction
|
|
534
|
+
? {
|
|
535
|
+
message_reaction: bindTelegramUpdateAdmissionCarrier(
|
|
536
|
+
update.message_reaction,
|
|
537
|
+
binding,
|
|
538
|
+
),
|
|
539
|
+
}
|
|
540
|
+
: {}),
|
|
541
|
+
} as TUpdate;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
export function collectTelegramAdmissionSourceUpdateIds(
|
|
545
|
+
values: readonly unknown[],
|
|
546
|
+
): number[] {
|
|
547
|
+
const sourceUpdateIds = new Set<number>();
|
|
548
|
+
for (const value of values) {
|
|
549
|
+
const binding = getTelegramUpdateAdmissionBinding(value);
|
|
550
|
+
if (binding) sourceUpdateIds.add(binding.sourceUpdateId);
|
|
551
|
+
}
|
|
552
|
+
return [...sourceUpdateIds].sort((left, right) => left - right);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
export function reportTelegramUpdateDeferred(value: unknown): boolean {
|
|
556
|
+
const binding = getTelegramUpdateAdmissionBinding(value);
|
|
557
|
+
if (!binding) return false;
|
|
558
|
+
binding.report({ kind: "deferred" });
|
|
559
|
+
return true;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
export function reportTelegramQueueAdmission(
|
|
563
|
+
values: readonly unknown[],
|
|
564
|
+
receipts: readonly TelegramQueueAdmissionReceiptLike[],
|
|
565
|
+
): boolean {
|
|
566
|
+
const bindings = new Map<number, TelegramUpdateAdmissionBinding>();
|
|
567
|
+
for (const value of values) {
|
|
568
|
+
const binding = getTelegramUpdateAdmissionBinding(value);
|
|
569
|
+
if (!binding) continue;
|
|
570
|
+
const existing = bindings.get(binding.sourceUpdateId);
|
|
571
|
+
if (existing && existing !== binding) {
|
|
572
|
+
throw new TelegramUpdateAdmissionOutcomeError(
|
|
573
|
+
`Telegram update ${binding.sourceUpdateId} has conflicting admission bindings.`,
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
bindings.set(binding.sourceUpdateId, binding);
|
|
577
|
+
}
|
|
578
|
+
if (bindings.size === 0) return false;
|
|
579
|
+
const receiptsByUpdateId = new Map<
|
|
580
|
+
number,
|
|
581
|
+
{ receipt: TelegramQueueAdmissionReceiptLike; count: number }
|
|
582
|
+
>();
|
|
583
|
+
for (const receipt of receipts) {
|
|
584
|
+
for (const sourceUpdateId of new Set(receipt.sourceUpdateIds)) {
|
|
585
|
+
const existing = receiptsByUpdateId.get(sourceUpdateId);
|
|
586
|
+
if (existing) existing.count += 1;
|
|
587
|
+
else receiptsByUpdateId.set(sourceUpdateId, { receipt, count: 1 });
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
const reports = [...bindings].map(([sourceUpdateId, binding]) => {
|
|
591
|
+
const match = receiptsByUpdateId.get(sourceUpdateId);
|
|
592
|
+
if (!match || match.count !== 1) {
|
|
593
|
+
throw new TelegramUpdateAdmissionOutcomeError(
|
|
594
|
+
`Telegram update ${sourceUpdateId} requires one exact queue receipt.`,
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
return {
|
|
598
|
+
binding,
|
|
599
|
+
outcome: {
|
|
600
|
+
kind: "queued" as const,
|
|
601
|
+
queueKind: match.receipt.queueKind,
|
|
602
|
+
receiptId: match.receipt.receiptId,
|
|
603
|
+
sourceUpdateIds: [...match.receipt.sourceUpdateIds],
|
|
604
|
+
},
|
|
605
|
+
};
|
|
606
|
+
});
|
|
607
|
+
for (const report of reports) report.binding.report(report.outcome);
|
|
608
|
+
return true;
|
|
609
|
+
}
|
|
610
|
+
|
|
317
611
|
export type TelegramUpdateFlowAction<
|
|
318
612
|
TReactionUpdate extends TelegramMessageReactionUpdated =
|
|
319
613
|
TelegramMessageReactionUpdated,
|
|
@@ -563,6 +857,7 @@ export interface TelegramUpdateRuntimeDeps<
|
|
|
563
857
|
TMessage extends TelegramUpdateMessage = TelegramUpdateMessage,
|
|
564
858
|
> {
|
|
565
859
|
ctx: TContext;
|
|
860
|
+
execution?: TelegramUpdateExecutionFence;
|
|
566
861
|
getCurrentInstanceId?: () => string | undefined;
|
|
567
862
|
getMessageOwnership?: TelegramMessageOwnershipLookup;
|
|
568
863
|
getTargetOwnership?: TelegramTargetOwnershipLookup;
|
|
@@ -586,7 +881,11 @@ export interface TelegramUpdateRuntimeDeps<
|
|
|
586
881
|
lifecycle: TelegramTopicLifecycleUpdate<TMessage>,
|
|
587
882
|
ctx: TContext,
|
|
588
883
|
) => Promise<void> | void;
|
|
589
|
-
pairTelegramUserIfNeeded: (
|
|
884
|
+
pairTelegramUserIfNeeded: (
|
|
885
|
+
userId: number,
|
|
886
|
+
ctx: TContext,
|
|
887
|
+
assertExecutionCurrent?: () => void,
|
|
888
|
+
) => Promise<boolean>;
|
|
590
889
|
answerCallbackQuery: (
|
|
591
890
|
callbackQueryId: string,
|
|
592
891
|
text?: string,
|
|
@@ -638,23 +937,24 @@ export interface TelegramUpdateRuntimeControllerDeps<
|
|
|
638
937
|
TMessage
|
|
639
938
|
>;
|
|
640
939
|
removePendingMediaGroupMessages: (messageIds: number[]) => void;
|
|
940
|
+
flushPendingMediaGroupMessage?: (messageId: number) => Promise<boolean>;
|
|
941
|
+
flushPendingTextGroupMessage?: (messageId: number) => Promise<boolean>;
|
|
641
942
|
removeQueuedTelegramTurnsByMessageIds: (
|
|
642
943
|
messageIds: number[],
|
|
643
944
|
ctx: TContext,
|
|
644
945
|
scope?: { chatId?: number; threadId?: number },
|
|
645
946
|
) => number;
|
|
646
|
-
|
|
947
|
+
applyQueuedTelegramTurnReactionByMessageId: (
|
|
647
948
|
messageId: number,
|
|
949
|
+
disposition: TelegramQueueReactionDisposition,
|
|
648
950
|
ctx: TContext,
|
|
649
951
|
scope?: { chatId?: number; threadId?: number },
|
|
650
952
|
) => boolean;
|
|
651
|
-
|
|
652
|
-
|
|
953
|
+
pairTelegramUserIfNeeded: (
|
|
954
|
+
userId: number,
|
|
653
955
|
ctx: TContext,
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
) => boolean;
|
|
657
|
-
pairTelegramUserIfNeeded: (userId: number, ctx: TContext) => Promise<boolean>;
|
|
956
|
+
assertExecutionCurrent?: () => void,
|
|
957
|
+
) => Promise<boolean>;
|
|
658
958
|
answerCallbackQuery: (
|
|
659
959
|
callbackQueryId: string,
|
|
660
960
|
text?: string,
|
|
@@ -701,7 +1001,11 @@ export interface TelegramUpdateRuntimeController<
|
|
|
701
1001
|
reactionUpdate: NonNullable<TUpdate["message_reaction"]>,
|
|
702
1002
|
ctx: TContext,
|
|
703
1003
|
) => Promise<void>;
|
|
704
|
-
handleUpdate: (
|
|
1004
|
+
handleUpdate: (
|
|
1005
|
+
update: TUpdate,
|
|
1006
|
+
ctx: TContext,
|
|
1007
|
+
execution?: TelegramUpdateExecutionFence,
|
|
1008
|
+
) => Promise<void>;
|
|
705
1009
|
}
|
|
706
1010
|
|
|
707
1011
|
function getTelegramCallbackQueryId(
|
|
@@ -850,18 +1154,19 @@ export function createTelegramPairedUpdateRuntime<
|
|
|
850
1154
|
handleTelegramTopicLifecycleUpdate: deps.handleTelegramTopicLifecycleUpdate,
|
|
851
1155
|
foreignOwnedUpdateForwarder: deps.foreignOwnedUpdateForwarder,
|
|
852
1156
|
removePendingMediaGroupMessages: deps.removePendingMediaGroupMessages,
|
|
1157
|
+
flushPendingMediaGroupMessage: deps.flushPendingMediaGroupMessage,
|
|
1158
|
+
flushPendingTextGroupMessage: deps.flushPendingTextGroupMessage,
|
|
853
1159
|
removeQueuedTelegramTurnsByMessageIds:
|
|
854
1160
|
deps.removeQueuedTelegramTurnsByMessageIds,
|
|
855
|
-
|
|
856
|
-
deps.
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
}).pairIfNeeded,
|
|
1161
|
+
applyQueuedTelegramTurnReactionByMessageId:
|
|
1162
|
+
deps.applyQueuedTelegramTurnReactionByMessageId,
|
|
1163
|
+
pairTelegramUserIfNeeded: (userId, ctx, assertExecutionCurrent) =>
|
|
1164
|
+
createTelegramUserPairingRuntime({
|
|
1165
|
+
getAllowedUserId: deps.getAllowedUserId,
|
|
1166
|
+
setAllowedUserId: deps.setAllowedUserId,
|
|
1167
|
+
persistConfig: deps.persistConfig,
|
|
1168
|
+
updateStatus: deps.updateStatus,
|
|
1169
|
+
}).pairIfNeeded(userId, ctx, assertExecutionCurrent),
|
|
865
1170
|
answerCallbackQuery: deps.answerCallbackQuery,
|
|
866
1171
|
answerGuestQuery: deps.answerGuestQuery,
|
|
867
1172
|
handleAuthorizedTelegramCallbackQuery:
|
|
@@ -893,23 +1198,24 @@ export function createTelegramUpdateRuntime<
|
|
|
893
1198
|
await handleAuthorizedTelegramReactionUpdate(reactionUpdate, {
|
|
894
1199
|
allowedUserId: deps.getAllowedUserId(),
|
|
895
1200
|
ctx,
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
deps.removeQueuedTelegramTurnsByMessageIds,
|
|
1201
|
+
flushPendingMediaGroupMessage: deps.flushPendingMediaGroupMessage,
|
|
1202
|
+
flushPendingTextGroupMessage: deps.flushPendingTextGroupMessage,
|
|
899
1203
|
getCurrentInstanceId: deps.getCurrentInstanceId,
|
|
900
1204
|
getMessageOwnership: deps.getMessageOwnership,
|
|
901
1205
|
foreignOwnedUpdateForwarder: deps.foreignOwnedUpdateForwarder,
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
1206
|
+
assertExecutionCurrent: createTelegramUpdateExecutionFenceGuard(
|
|
1207
|
+
reactionUpdate,
|
|
1208
|
+
),
|
|
1209
|
+
applyQueuedTelegramTurnReactionByMessageId:
|
|
1210
|
+
deps.applyQueuedTelegramTurnReactionByMessageId,
|
|
906
1211
|
});
|
|
907
1212
|
};
|
|
908
1213
|
return {
|
|
909
1214
|
handleAuthorizedReactionUpdate,
|
|
910
|
-
handleUpdate: (update, ctx) =>
|
|
1215
|
+
handleUpdate: (update, ctx, execution) =>
|
|
911
1216
|
executeTelegramUpdate(update, deps.getAllowedUserId(), {
|
|
912
1217
|
ctx,
|
|
1218
|
+
execution,
|
|
913
1219
|
getCurrentInstanceId: deps.getCurrentInstanceId,
|
|
914
1220
|
getMessageOwnership: deps.getMessageOwnership,
|
|
915
1221
|
getTargetOwnership: deps.getTargetOwnership,
|
|
@@ -944,21 +1250,13 @@ export interface AuthorizedTelegramReactionUpdateDeps<TContext> {
|
|
|
944
1250
|
getCurrentInstanceId?: () => string | undefined;
|
|
945
1251
|
getMessageOwnership?: TelegramMessageOwnershipLookup;
|
|
946
1252
|
foreignOwnedUpdateForwarder?: TelegramForeignOwnedUpdateForwarder<TContext>;
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
scope?: { chatId?: number; threadId?: number },
|
|
952
|
-
) => number;
|
|
953
|
-
clearQueuedTelegramTurnPriorityByMessageId: (
|
|
954
|
-
messageId: number,
|
|
955
|
-
ctx: TContext,
|
|
956
|
-
scope?: { chatId?: number; threadId?: number },
|
|
957
|
-
) => boolean;
|
|
958
|
-
prioritizeQueuedTelegramTurnByMessageId: (
|
|
1253
|
+
assertExecutionCurrent?: () => void;
|
|
1254
|
+
flushPendingMediaGroupMessage?: (messageId: number) => Promise<boolean>;
|
|
1255
|
+
flushPendingTextGroupMessage?: (messageId: number) => Promise<boolean>;
|
|
1256
|
+
applyQueuedTelegramTurnReactionByMessageId: (
|
|
959
1257
|
messageId: number,
|
|
1258
|
+
disposition: TelegramQueueReactionDisposition,
|
|
960
1259
|
ctx: TContext,
|
|
961
|
-
priorityEmoji?: string,
|
|
962
1260
|
scope?: { chatId?: number; threadId?: number },
|
|
963
1261
|
) => boolean;
|
|
964
1262
|
}
|
|
@@ -972,11 +1270,21 @@ export async function handleAuthorizedTelegramReactionUpdate<TContext>(
|
|
|
972
1270
|
deps,
|
|
973
1271
|
);
|
|
974
1272
|
if (foreignOwnership) {
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
1273
|
+
deps.assertExecutionCurrent?.();
|
|
1274
|
+
const settlement =
|
|
1275
|
+
await deps.foreignOwnedUpdateForwarder?.forwardReaction?.({
|
|
1276
|
+
reactionUpdate,
|
|
1277
|
+
ownership: foreignOwnership,
|
|
1278
|
+
ctx: deps.ctx,
|
|
1279
|
+
});
|
|
1280
|
+
deps.assertExecutionCurrent?.();
|
|
1281
|
+
if (settlement?.status !== "accepted") {
|
|
1282
|
+
rejectTelegramForeignUpdateSettlement(
|
|
1283
|
+
settlement,
|
|
1284
|
+
"reaction",
|
|
1285
|
+
reactionUpdate,
|
|
1286
|
+
);
|
|
1287
|
+
}
|
|
980
1288
|
return;
|
|
981
1289
|
}
|
|
982
1290
|
const reactionUser = reactionUpdate.user;
|
|
@@ -991,48 +1299,26 @@ export async function handleAuthorizedTelegramReactionUpdate<TContext>(
|
|
|
991
1299
|
typeof reactionUpdate.chat.id === "number"
|
|
992
1300
|
? { chatId: reactionUpdate.chat.id }
|
|
993
1301
|
: undefined;
|
|
994
|
-
const
|
|
995
|
-
|
|
1302
|
+
const oldDisposition = getTelegramQueueReactionDisposition(
|
|
1303
|
+
reactionUpdate.old_reaction,
|
|
1304
|
+
);
|
|
1305
|
+
const newDisposition = getTelegramQueueReactionDisposition(
|
|
1306
|
+
reactionUpdate.new_reaction,
|
|
1307
|
+
);
|
|
996
1308
|
if (
|
|
997
|
-
|
|
998
|
-
oldEmojis,
|
|
999
|
-
newEmojis,
|
|
1000
|
-
TELEGRAM_REMOVAL_REACTION_EMOJIS,
|
|
1001
|
-
)
|
|
1309
|
+
areTelegramQueueReactionDispositionsEqual(oldDisposition, newDisposition)
|
|
1002
1310
|
) {
|
|
1003
|
-
deps.removePendingMediaGroupMessages([reactionUpdate.message_id]);
|
|
1004
|
-
deps.removeQueuedTelegramTurnsByMessageIds(
|
|
1005
|
-
[reactionUpdate.message_id],
|
|
1006
|
-
deps.ctx,
|
|
1007
|
-
reactionScope,
|
|
1008
|
-
);
|
|
1009
1311
|
return;
|
|
1010
1312
|
}
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
);
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
TELEGRAM_PRIORITY_REACTION_EMOJIS,
|
|
1018
|
-
);
|
|
1019
|
-
if (hadPriorityReaction && !hasPriorityReaction) {
|
|
1020
|
-
deps.clearQueuedTelegramTurnPriorityByMessageId(
|
|
1021
|
-
reactionUpdate.message_id,
|
|
1022
|
-
deps.ctx,
|
|
1023
|
-
reactionScope,
|
|
1024
|
-
);
|
|
1025
|
-
}
|
|
1026
|
-
const addedPriorityEmoji = getAddedTelegramReactionEmoji(
|
|
1027
|
-
oldEmojis,
|
|
1028
|
-
newEmojis,
|
|
1029
|
-
TELEGRAM_PRIORITY_REACTION_EMOJIS,
|
|
1030
|
-
);
|
|
1031
|
-
if (!addedPriorityEmoji) return;
|
|
1032
|
-
deps.prioritizeQueuedTelegramTurnByMessageId(
|
|
1313
|
+
deps.assertExecutionCurrent?.();
|
|
1314
|
+
await deps.flushPendingMediaGroupMessage?.(reactionUpdate.message_id);
|
|
1315
|
+
deps.assertExecutionCurrent?.();
|
|
1316
|
+
await deps.flushPendingTextGroupMessage?.(reactionUpdate.message_id);
|
|
1317
|
+
deps.assertExecutionCurrent?.();
|
|
1318
|
+
deps.applyQueuedTelegramTurnReactionByMessageId(
|
|
1033
1319
|
reactionUpdate.message_id,
|
|
1320
|
+
newDisposition,
|
|
1034
1321
|
deps.ctx,
|
|
1035
|
-
addedPriorityEmoji,
|
|
1036
1322
|
reactionScope,
|
|
1037
1323
|
);
|
|
1038
1324
|
}
|
|
@@ -1061,20 +1347,26 @@ export async function executeTelegramUpdatePlan<
|
|
|
1061
1347
|
>,
|
|
1062
1348
|
): Promise<void> {
|
|
1063
1349
|
try {
|
|
1350
|
+
const assertExecutionCurrent = (): void =>
|
|
1351
|
+
deps.execution?.assertCurrent();
|
|
1064
1352
|
if (plan.kind === "ignore") return;
|
|
1065
1353
|
if (plan.kind === "deleted") {
|
|
1354
|
+
assertExecutionCurrent();
|
|
1066
1355
|
deps.removePendingMediaGroupMessages(plan.messageIds);
|
|
1067
1356
|
deps.removeQueuedTelegramTurnsByMessageIds(plan.messageIds, deps.ctx);
|
|
1068
1357
|
return;
|
|
1069
1358
|
}
|
|
1070
1359
|
if (plan.kind === "reaction") {
|
|
1360
|
+
assertExecutionCurrent();
|
|
1071
1361
|
await deps.handleAuthorizedTelegramReactionUpdate(
|
|
1072
1362
|
plan.reactionUpdate,
|
|
1073
1363
|
deps.ctx,
|
|
1074
1364
|
);
|
|
1365
|
+
assertExecutionCurrent();
|
|
1075
1366
|
return;
|
|
1076
1367
|
}
|
|
1077
1368
|
if (plan.kind === "topic-lifecycle") {
|
|
1369
|
+
assertExecutionCurrent();
|
|
1078
1370
|
await deps.handleTelegramTopicLifecycleUpdate?.(plan.lifecycle, deps.ctx);
|
|
1079
1371
|
return;
|
|
1080
1372
|
}
|
|
@@ -1084,29 +1376,46 @@ export async function executeTelegramUpdatePlan<
|
|
|
1084
1376
|
deps,
|
|
1085
1377
|
);
|
|
1086
1378
|
if (foreignOwnership) {
|
|
1087
|
-
|
|
1088
|
-
|
|
1379
|
+
assertExecutionCurrent();
|
|
1380
|
+
const settlement =
|
|
1381
|
+
await deps.foreignOwnedUpdateForwarder?.forwardCallback?.({
|
|
1089
1382
|
query: plan.query,
|
|
1090
1383
|
ownership: foreignOwnership,
|
|
1091
1384
|
ctx: deps.ctx,
|
|
1092
|
-
})
|
|
1093
|
-
if (
|
|
1385
|
+
});
|
|
1386
|
+
if (settlement?.status !== "accepted") {
|
|
1094
1387
|
const callbackQueryId = getTelegramCallbackQueryId(plan.query);
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1388
|
+
try {
|
|
1389
|
+
if (callbackQueryId) {
|
|
1390
|
+
assertExecutionCurrent();
|
|
1391
|
+
await deps.answerCallbackQuery(
|
|
1392
|
+
callbackQueryId,
|
|
1393
|
+
"This Telegram message belongs to another Pi instance.",
|
|
1394
|
+
);
|
|
1395
|
+
}
|
|
1396
|
+
} finally {
|
|
1397
|
+
rejectTelegramForeignUpdateSettlement(
|
|
1398
|
+
settlement,
|
|
1399
|
+
"callback",
|
|
1400
|
+
plan.query,
|
|
1099
1401
|
);
|
|
1100
1402
|
}
|
|
1101
1403
|
}
|
|
1404
|
+
assertExecutionCurrent();
|
|
1102
1405
|
return;
|
|
1103
1406
|
}
|
|
1104
1407
|
if (plan.shouldPair) {
|
|
1105
|
-
|
|
1408
|
+
assertExecutionCurrent();
|
|
1409
|
+
await deps.pairTelegramUserIfNeeded(
|
|
1410
|
+
plan.query.from.id,
|
|
1411
|
+
deps.ctx,
|
|
1412
|
+
assertExecutionCurrent,
|
|
1413
|
+
);
|
|
1106
1414
|
}
|
|
1107
1415
|
if (plan.shouldDeny) {
|
|
1108
1416
|
const callbackQueryId = getTelegramCallbackQueryId(plan.query);
|
|
1109
1417
|
if (callbackQueryId) {
|
|
1418
|
+
assertExecutionCurrent();
|
|
1110
1419
|
await deps.answerCallbackQuery(
|
|
1111
1420
|
callbackQueryId,
|
|
1112
1421
|
"This bot is not authorized for your account.",
|
|
@@ -1114,11 +1423,14 @@ export async function executeTelegramUpdatePlan<
|
|
|
1114
1423
|
}
|
|
1115
1424
|
return;
|
|
1116
1425
|
}
|
|
1426
|
+
assertExecutionCurrent();
|
|
1117
1427
|
await deps.handleAuthorizedTelegramCallbackQuery(plan.query, deps.ctx);
|
|
1428
|
+
assertExecutionCurrent();
|
|
1118
1429
|
return;
|
|
1119
1430
|
}
|
|
1120
1431
|
if (plan.kind === "guest") {
|
|
1121
1432
|
if (plan.shouldDeny) {
|
|
1433
|
+
assertExecutionCurrent();
|
|
1122
1434
|
await deps.answerGuestQuery(
|
|
1123
1435
|
plan.guestMessage.guest_query_id,
|
|
1124
1436
|
"🚫 Access denied.",
|
|
@@ -1126,10 +1438,12 @@ export async function executeTelegramUpdatePlan<
|
|
|
1126
1438
|
return;
|
|
1127
1439
|
}
|
|
1128
1440
|
if (deps.handleAuthorizedTelegramGuestMessage) {
|
|
1441
|
+
assertExecutionCurrent();
|
|
1129
1442
|
await deps.handleAuthorizedTelegramGuestMessage(
|
|
1130
1443
|
plan.guestMessage,
|
|
1131
1444
|
deps.ctx,
|
|
1132
1445
|
);
|
|
1446
|
+
assertExecutionCurrent();
|
|
1133
1447
|
}
|
|
1134
1448
|
return;
|
|
1135
1449
|
}
|
|
@@ -1138,19 +1452,27 @@ export async function executeTelegramUpdatePlan<
|
|
|
1138
1452
|
deps,
|
|
1139
1453
|
);
|
|
1140
1454
|
if (foreignMessageOwnership) {
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1455
|
+
assertExecutionCurrent();
|
|
1456
|
+
const settlement =
|
|
1457
|
+
plan.kind === "edited-message"
|
|
1458
|
+
? await deps.foreignOwnedUpdateForwarder?.forwardEditedMessage?.({
|
|
1459
|
+
message: plan.message,
|
|
1460
|
+
ownership: foreignMessageOwnership,
|
|
1461
|
+
ctx: deps.ctx,
|
|
1462
|
+
})
|
|
1463
|
+
: await deps.foreignOwnedUpdateForwarder?.forwardMessage?.({
|
|
1464
|
+
message: plan.message,
|
|
1465
|
+
ownership: foreignMessageOwnership,
|
|
1466
|
+
ctx: deps.ctx,
|
|
1467
|
+
});
|
|
1468
|
+
if (settlement?.status !== "accepted") {
|
|
1469
|
+
rejectTelegramForeignUpdateSettlement(
|
|
1470
|
+
settlement,
|
|
1471
|
+
plan.kind,
|
|
1472
|
+
plan.message,
|
|
1473
|
+
);
|
|
1153
1474
|
}
|
|
1475
|
+
assertExecutionCurrent();
|
|
1154
1476
|
return;
|
|
1155
1477
|
}
|
|
1156
1478
|
const messageTarget = getTelegramMessageTarget(plan.message);
|
|
@@ -1160,6 +1482,7 @@ export async function executeTelegramUpdatePlan<
|
|
|
1160
1482
|
);
|
|
1161
1483
|
if (foreignTargetOwnership) {
|
|
1162
1484
|
if (typeof plan.message.message_id === "number") {
|
|
1485
|
+
assertExecutionCurrent();
|
|
1163
1486
|
deps.recordMessageOwnership?.({
|
|
1164
1487
|
chatId: messageTarget!.chatId,
|
|
1165
1488
|
messageId: plan.message.message_id,
|
|
@@ -1167,19 +1490,27 @@ export async function executeTelegramUpdatePlan<
|
|
|
1167
1490
|
instanceId: foreignTargetOwnership.instanceId,
|
|
1168
1491
|
});
|
|
1169
1492
|
}
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1493
|
+
assertExecutionCurrent();
|
|
1494
|
+
const settlement =
|
|
1495
|
+
plan.kind === "edited-message"
|
|
1496
|
+
? await deps.foreignOwnedUpdateForwarder?.forwardEditedMessage?.({
|
|
1497
|
+
message: plan.message,
|
|
1498
|
+
ownership: foreignTargetOwnership,
|
|
1499
|
+
ctx: deps.ctx,
|
|
1500
|
+
})
|
|
1501
|
+
: await deps.foreignOwnedUpdateForwarder?.forwardMessage?.({
|
|
1502
|
+
message: plan.message,
|
|
1503
|
+
ownership: foreignTargetOwnership,
|
|
1504
|
+
ctx: deps.ctx,
|
|
1505
|
+
});
|
|
1506
|
+
if (settlement?.status !== "accepted") {
|
|
1507
|
+
rejectTelegramForeignUpdateSettlement(
|
|
1508
|
+
settlement,
|
|
1509
|
+
plan.kind,
|
|
1510
|
+
plan.message,
|
|
1511
|
+
);
|
|
1182
1512
|
}
|
|
1513
|
+
assertExecutionCurrent();
|
|
1183
1514
|
return;
|
|
1184
1515
|
}
|
|
1185
1516
|
if (
|
|
@@ -1187,11 +1518,18 @@ export async function executeTelegramUpdatePlan<
|
|
|
1187
1518
|
messageTarget?.threadId != null &&
|
|
1188
1519
|
deps.handleUnboundTelegramTopicMessage
|
|
1189
1520
|
) {
|
|
1521
|
+
assertExecutionCurrent();
|
|
1190
1522
|
await deps.handleUnboundTelegramTopicMessage(plan.message, deps.ctx);
|
|
1523
|
+
assertExecutionCurrent();
|
|
1191
1524
|
return;
|
|
1192
1525
|
}
|
|
1526
|
+
if (plan.shouldPair) assertExecutionCurrent();
|
|
1193
1527
|
const pairedNow = plan.shouldPair
|
|
1194
|
-
? await deps.pairTelegramUserIfNeeded(
|
|
1528
|
+
? await deps.pairTelegramUserIfNeeded(
|
|
1529
|
+
plan.message.from.id,
|
|
1530
|
+
deps.ctx,
|
|
1531
|
+
assertExecutionCurrent,
|
|
1532
|
+
)
|
|
1195
1533
|
: false;
|
|
1196
1534
|
const replyTarget = getTelegramMessageReplyTarget(plan.message);
|
|
1197
1535
|
if (
|
|
@@ -1200,15 +1538,18 @@ export async function executeTelegramUpdatePlan<
|
|
|
1200
1538
|
plan.shouldNotifyPaired &&
|
|
1201
1539
|
replyTarget
|
|
1202
1540
|
) {
|
|
1541
|
+
assertExecutionCurrent();
|
|
1203
1542
|
await deps.sendTextReply(
|
|
1204
1543
|
replyTarget.chatId,
|
|
1205
1544
|
replyTarget.messageId,
|
|
1206
1545
|
"Telegram bridge paired with this account.",
|
|
1207
1546
|
{ target: replyTarget },
|
|
1208
1547
|
);
|
|
1548
|
+
assertExecutionCurrent();
|
|
1209
1549
|
}
|
|
1210
1550
|
if (plan.shouldDeny) {
|
|
1211
1551
|
if (replyTarget) {
|
|
1552
|
+
assertExecutionCurrent();
|
|
1212
1553
|
await deps.sendTextReply(
|
|
1213
1554
|
replyTarget.chatId,
|
|
1214
1555
|
replyTarget.messageId,
|
|
@@ -1219,111 +1560,1816 @@ export async function executeTelegramUpdatePlan<
|
|
|
1219
1560
|
return;
|
|
1220
1561
|
}
|
|
1221
1562
|
if (plan.kind === "edited-message") {
|
|
1563
|
+
assertExecutionCurrent();
|
|
1222
1564
|
await deps.handleAuthorizedTelegramEditedMessage(plan.message, deps.ctx);
|
|
1565
|
+
assertExecutionCurrent();
|
|
1223
1566
|
return;
|
|
1224
1567
|
}
|
|
1568
|
+
assertExecutionCurrent();
|
|
1225
1569
|
await deps.handleAuthorizedTelegramMessage(plan.message, deps.ctx);
|
|
1570
|
+
assertExecutionCurrent();
|
|
1226
1571
|
} catch (error) {
|
|
1227
1572
|
if (!isTelegramStaleContextError(error)) throw error;
|
|
1228
1573
|
}
|
|
1229
1574
|
}
|
|
1230
1575
|
|
|
1231
|
-
// ---
|
|
1576
|
+
// --- Durable update worker ---
|
|
1232
1577
|
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
* - `"consume"` — the handler processed this update; pi-telegram skips default routing.
|
|
1237
|
-
* - `"pass"` (or `void`/`undefined`) — pi-telegram routes the update normally.
|
|
1238
|
-
*/
|
|
1239
|
-
export type TelegramUpdateHandlerVerdict = "consume" | "pass";
|
|
1578
|
+
export const TELEGRAM_UPDATE_RETRY_BASE_DELAY_MS = 1_000;
|
|
1579
|
+
export const TELEGRAM_UPDATE_RETRY_MAX_DELAY_MS = 60_000;
|
|
1580
|
+
export const TELEGRAM_UPDATE_WORKER_BATCH_SIZE = 64;
|
|
1240
1581
|
|
|
1241
|
-
export type
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
|
1245
|
-
|
|
|
1246
|
-
|
|
|
1582
|
+
export type TelegramUpdateWorkerPhase =
|
|
1583
|
+
| "stopped"
|
|
1584
|
+
| "idle"
|
|
1585
|
+
| "executing"
|
|
1586
|
+
| "retry-wait"
|
|
1587
|
+
| "failed"
|
|
1588
|
+
| "deferred"
|
|
1589
|
+
| "queued"
|
|
1590
|
+
| "blocked";
|
|
1247
1591
|
|
|
1248
|
-
export
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1592
|
+
export type TelegramUpdateWorkerBlockedReason =
|
|
1593
|
+
| "authority-lost"
|
|
1594
|
+
| "authority-check"
|
|
1595
|
+
| "journal-read"
|
|
1596
|
+
| "journal-write"
|
|
1597
|
+
| "execution"
|
|
1598
|
+
| "prior-generation-executing"
|
|
1599
|
+
| "invalid-outcome";
|
|
1600
|
+
|
|
1601
|
+
export interface TelegramUpdateWorkerStateSnapshot {
|
|
1602
|
+
phase: TelegramUpdateWorkerPhase;
|
|
1603
|
+
generation: number;
|
|
1604
|
+
phaseStartedAtMs?: number;
|
|
1605
|
+
currentUpdateId?: number;
|
|
1606
|
+
blockedReason?: TelegramUpdateWorkerBlockedReason;
|
|
1607
|
+
journalEntryCount: number;
|
|
1608
|
+
journalSerializedBytes: number;
|
|
1609
|
+
oldestAdmittedAtMs?: number;
|
|
1610
|
+
deferredClaimCount: number;
|
|
1611
|
+
queuedClaimCount: number;
|
|
1612
|
+
foreignQueuedCount: number;
|
|
1613
|
+
foreignQueuedOwner?: TelegramUpdateJournalQueueOwner;
|
|
1614
|
+
foreignQueuedOwnerLiveness?: TelegramProcessLiveness;
|
|
1615
|
+
retryWaitCount: number;
|
|
1616
|
+
failedCount: number;
|
|
1617
|
+
nextRetryUpdateId?: number;
|
|
1618
|
+
nextRetryAtMs?: number;
|
|
1619
|
+
nextRetryAttemptCount?: number;
|
|
1620
|
+
nextRetryFailureClass?: string;
|
|
1621
|
+
failedUpdateId?: number;
|
|
1622
|
+
failedFailureId?: string;
|
|
1623
|
+
failedAttemptCount?: number;
|
|
1624
|
+
failedClass?: string;
|
|
1625
|
+
failedSummary?: string;
|
|
1626
|
+
terminalFailureAtMs?: number;
|
|
1627
|
+
unsettledExecutionCount: number;
|
|
1628
|
+
lastCompletedUpdateId?: number;
|
|
1629
|
+
lastCompletedAtMs?: number;
|
|
1630
|
+
lastFailureAtMs?: number;
|
|
1631
|
+
lastFailurePhase?: string;
|
|
1266
1632
|
}
|
|
1267
1633
|
|
|
1268
|
-
|
|
1634
|
+
export interface TelegramUpdateWorkerJournalSnapshot {
|
|
1635
|
+
entries: readonly {
|
|
1636
|
+
updateId: number;
|
|
1637
|
+
update: TelegramJournaledUpdate;
|
|
1638
|
+
admittedAtMs: number;
|
|
1639
|
+
state: "pending" | "retry-wait" | "queued" | "failed";
|
|
1640
|
+
queueKind?: "prompt" | "control";
|
|
1641
|
+
queueReceiptId?: string;
|
|
1642
|
+
queueOwner?: TelegramUpdateJournalQueueOwner;
|
|
1643
|
+
queueHandoff?: {
|
|
1644
|
+
handoffId: string;
|
|
1645
|
+
offeredAtMs: number;
|
|
1646
|
+
recipientOwner: TelegramUpdateJournalQueueOwnerIdentity;
|
|
1647
|
+
};
|
|
1648
|
+
failure?: {
|
|
1649
|
+
attemptCount: number;
|
|
1650
|
+
failedAtMs: number;
|
|
1651
|
+
failureClass: string;
|
|
1652
|
+
summary: string;
|
|
1653
|
+
};
|
|
1654
|
+
nextRetryAtMs?: number;
|
|
1655
|
+
terminalAtMs?: number;
|
|
1656
|
+
terminalReason?: string;
|
|
1657
|
+
terminalFailureId?: string;
|
|
1658
|
+
}[];
|
|
1659
|
+
serializedBytes: number;
|
|
1660
|
+
}
|
|
1269
1661
|
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1662
|
+
export interface TelegramUpdateWorkerJournalPort {
|
|
1663
|
+
read: () => TelegramUpdateWorkerJournalSnapshot;
|
|
1664
|
+
markQueued: (receipt: {
|
|
1665
|
+
queueKind: "prompt" | "control";
|
|
1666
|
+
receiptId: string;
|
|
1667
|
+
sourceUpdateIds: readonly number[];
|
|
1668
|
+
owner: TelegramUpdateJournalQueueOwnerIdentity;
|
|
1669
|
+
}) => {
|
|
1670
|
+
queuedUpdateIds: readonly number[];
|
|
1671
|
+
duplicateUpdateIds: readonly number[];
|
|
1672
|
+
queueOwner?: TelegramUpdateJournalQueueOwner;
|
|
1673
|
+
};
|
|
1674
|
+
completeQueued: (
|
|
1675
|
+
receipts: readonly {
|
|
1676
|
+
queueKind: "prompt" | "control";
|
|
1677
|
+
receiptId: string;
|
|
1678
|
+
sourceUpdateIds: readonly number[];
|
|
1679
|
+
queueOwner: TelegramUpdateJournalQueueOwner;
|
|
1680
|
+
}[],
|
|
1681
|
+
) => {
|
|
1682
|
+
removedUpdateIds: readonly number[];
|
|
1683
|
+
};
|
|
1684
|
+
markExecutionFailure: (input: {
|
|
1685
|
+
updateId: number;
|
|
1686
|
+
expectedAttemptCount: number;
|
|
1687
|
+
failedAtMs: number;
|
|
1688
|
+
failureClass: string;
|
|
1689
|
+
summary: string;
|
|
1690
|
+
disposition: "retry-wait" | "failed";
|
|
1691
|
+
nextRetryAtMs?: number;
|
|
1692
|
+
terminalReason?: string;
|
|
1693
|
+
}) => {
|
|
1694
|
+
entry: TelegramUpdateWorkerJournalSnapshot["entries"][number];
|
|
1695
|
+
};
|
|
1696
|
+
removeCompleted: (updateIds: readonly number[]) => {
|
|
1697
|
+
removedUpdateIds: readonly number[];
|
|
1698
|
+
};
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
export interface TelegramUpdateRetryPolicy {
|
|
1702
|
+
baseDelayMs: number;
|
|
1703
|
+
maxDelayMs: number;
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
export interface TelegramUpdateExecutionFailureClassification {
|
|
1707
|
+
disposition: "retryable" | "terminal";
|
|
1708
|
+
failureClass: string;
|
|
1709
|
+
summary: string;
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
export interface TelegramUpdateWorkerRuntimeDeps<TContext> {
|
|
1713
|
+
journal: TelegramUpdateWorkerJournalPort;
|
|
1714
|
+
executeUpdate: (
|
|
1715
|
+
update: TelegramJournaledUpdate,
|
|
1716
|
+
ctx: TContext,
|
|
1717
|
+
signal: AbortSignal,
|
|
1718
|
+
) => Promise<TelegramUpdateAdmissionOutcome> | TelegramUpdateAdmissionOutcome;
|
|
1719
|
+
hasAuthority: (ctx: TContext) => boolean;
|
|
1720
|
+
getJournalBindingKey?: () => string | undefined;
|
|
1721
|
+
getQueueOwnerIdentity?: (
|
|
1722
|
+
ctx: TContext,
|
|
1723
|
+
) => TelegramUpdateJournalQueueOwnerIdentity;
|
|
1724
|
+
isContextCurrent?: (ctx: TContext) => boolean;
|
|
1725
|
+
createAbortController?: () => AbortController;
|
|
1726
|
+
getNowMs?: () => number;
|
|
1727
|
+
retryPolicy?: Partial<TelegramUpdateRetryPolicy>;
|
|
1728
|
+
classifyExecutionFailure?: (
|
|
1729
|
+
error: unknown,
|
|
1730
|
+
) => TelegramUpdateExecutionFailureClassification;
|
|
1731
|
+
scheduleRetry?: (callback: () => void, delayMs: number) => unknown;
|
|
1732
|
+
cancelRetry?: (handle: unknown) => void;
|
|
1733
|
+
batchSize?: number;
|
|
1734
|
+
yieldToEventLoop?: () => Promise<void>;
|
|
1735
|
+
onStateChange?: (state: TelegramUpdateWorkerStateSnapshot) => void;
|
|
1736
|
+
onQueueReceiptCommitted?: (
|
|
1737
|
+
receipt: TelegramQueueAdmissionReceiptLike,
|
|
1738
|
+
ctx: TContext,
|
|
1739
|
+
) => void;
|
|
1740
|
+
onUpdateCompleted?: (updateId: number, ctx: TContext) => void;
|
|
1741
|
+
recordRuntimeEvent?: (
|
|
1742
|
+
category: string,
|
|
1743
|
+
error: unknown,
|
|
1744
|
+
details?: Record<string, unknown>,
|
|
1745
|
+
) => void;
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
export type TelegramQueueReceiptCompletionReason =
|
|
1749
|
+
| "prompt-handoff"
|
|
1750
|
+
| "control-settlement"
|
|
1751
|
+
| "discard";
|
|
1752
|
+
|
|
1753
|
+
export interface TelegramUpdateWorkerRuntime<TContext> {
|
|
1754
|
+
start: (ctx: TContext) => void;
|
|
1755
|
+
signal: () => void;
|
|
1756
|
+
settleDeferred: (input: {
|
|
1757
|
+
updateId: number;
|
|
1758
|
+
outcome: Extract<
|
|
1759
|
+
TelegramUpdateAdmissionOutcome,
|
|
1760
|
+
{ kind: "deferred" | "queued" }
|
|
1761
|
+
>;
|
|
1762
|
+
signal: AbortSignal;
|
|
1763
|
+
}) => void;
|
|
1764
|
+
isQueueReceiptCommitted: (
|
|
1765
|
+
receipt: TelegramQueueAdmissionReceiptLike,
|
|
1766
|
+
) => boolean;
|
|
1767
|
+
getQueueReceiptOwner: (
|
|
1768
|
+
receipt: TelegramQueueAdmissionReceiptLike,
|
|
1769
|
+
) => TelegramUpdateJournalQueueOwner | undefined;
|
|
1770
|
+
completeQueueReceipts: (input: {
|
|
1771
|
+
receipts: readonly TelegramQueueAdmissionReceiptLike[];
|
|
1772
|
+
ctx: TContext;
|
|
1773
|
+
reason: TelegramQueueReceiptCompletionReason;
|
|
1774
|
+
}) => void;
|
|
1775
|
+
stop: () => Promise<void>;
|
|
1776
|
+
waitForDrain: () => Promise<void>;
|
|
1777
|
+
getState: () => TelegramUpdateWorkerStateSnapshot;
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
export class TelegramUpdateAdmissionOutcomeError extends Error {
|
|
1781
|
+
constructor(message: string) {
|
|
1782
|
+
super(message);
|
|
1783
|
+
this.name = "TelegramUpdateAdmissionOutcomeError";
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
interface TelegramUpdateWorkerOwner<TContext> {
|
|
1788
|
+
generation: number;
|
|
1789
|
+
ctx: TContext;
|
|
1790
|
+
controller: AbortController;
|
|
1791
|
+
queueOwnerIdentity: TelegramUpdateJournalQueueOwnerIdentity;
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
type TelegramUpdateWorkerClaim = "deferred" | "queued";
|
|
1795
|
+
type TelegramUpdateWorkerDrainResult = "idle" | "blocked" | "aborted";
|
|
1796
|
+
type TelegramUpdateWorkerExecutionSettlement =
|
|
1797
|
+
| { ok: true; outcome: TelegramUpdateAdmissionOutcome }
|
|
1798
|
+
| { ok: false; error: unknown };
|
|
1799
|
+
|
|
1800
|
+
const TELEGRAM_UPDATE_WORKER_EXECUTION_ABORTED = Symbol(
|
|
1801
|
+
"telegram.update-worker.execution-aborted",
|
|
1802
|
+
);
|
|
1803
|
+
|
|
1804
|
+
function getTelegramUpdateWorkerStateSnapshot(
|
|
1805
|
+
state: TelegramUpdateWorkerStateSnapshot,
|
|
1806
|
+
): TelegramUpdateWorkerStateSnapshot {
|
|
1807
|
+
return { ...state };
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
function isTelegramUpdateAdmissionRecord(
|
|
1811
|
+
value: unknown,
|
|
1812
|
+
): value is Record<string, unknown> {
|
|
1813
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
function isTelegramUpdateAdmissionString(value: unknown): value is string {
|
|
1817
|
+
return typeof value === "string" && value.length > 0;
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
function validateTelegramUpdateAdmissionOutcome(
|
|
1821
|
+
value: unknown,
|
|
1822
|
+
currentUpdateId: number,
|
|
1823
|
+
claimableUpdateIds: ReadonlySet<number>,
|
|
1824
|
+
): TelegramUpdateAdmissionOutcome {
|
|
1825
|
+
if (!isTelegramUpdateAdmissionRecord(value)) {
|
|
1826
|
+
throw new TelegramUpdateAdmissionOutcomeError(
|
|
1827
|
+
`Telegram update ${currentUpdateId} returned no admission outcome.`,
|
|
1828
|
+
);
|
|
1829
|
+
}
|
|
1830
|
+
if (value.kind === "complete") return { kind: "complete" };
|
|
1831
|
+
if (value.kind === "deferred") return { kind: "deferred" };
|
|
1832
|
+
if (value.kind === "queued") {
|
|
1833
|
+
if (
|
|
1834
|
+
(value.queueKind !== "prompt" && value.queueKind !== "control") ||
|
|
1835
|
+
!isTelegramUpdateAdmissionString(value.receiptId) ||
|
|
1836
|
+
!Array.isArray(value.sourceUpdateIds) ||
|
|
1837
|
+
value.sourceUpdateIds.length === 0 ||
|
|
1838
|
+
!value.sourceUpdateIds.every(
|
|
1839
|
+
(updateId) =>
|
|
1840
|
+
Number.isSafeInteger(updateId) &&
|
|
1841
|
+
(updateId as number) >= 0 &&
|
|
1842
|
+
claimableUpdateIds.has(updateId as number),
|
|
1843
|
+
)
|
|
1844
|
+
) {
|
|
1845
|
+
throw new TelegramUpdateAdmissionOutcomeError(
|
|
1846
|
+
`Telegram update ${currentUpdateId} returned an invalid queue receipt.`,
|
|
1847
|
+
);
|
|
1848
|
+
}
|
|
1849
|
+
const sourceUpdateIds = [...new Set(value.sourceUpdateIds as number[])];
|
|
1850
|
+
if (
|
|
1851
|
+
sourceUpdateIds.length !== value.sourceUpdateIds.length ||
|
|
1852
|
+
!sourceUpdateIds.includes(currentUpdateId)
|
|
1853
|
+
) {
|
|
1854
|
+
throw new TelegramUpdateAdmissionOutcomeError(
|
|
1855
|
+
`Telegram update ${currentUpdateId} returned a mismatched queue receipt.`,
|
|
1856
|
+
);
|
|
1857
|
+
}
|
|
1858
|
+
return {
|
|
1859
|
+
kind: "queued",
|
|
1860
|
+
queueKind: value.queueKind,
|
|
1861
|
+
receiptId: value.receiptId,
|
|
1862
|
+
sourceUpdateIds,
|
|
1863
|
+
};
|
|
1864
|
+
}
|
|
1865
|
+
throw new TelegramUpdateAdmissionOutcomeError(
|
|
1866
|
+
`Telegram update ${currentUpdateId} returned an unknown admission outcome.`,
|
|
1279
1867
|
);
|
|
1280
1868
|
}
|
|
1281
1869
|
|
|
1282
|
-
function
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
version: 1,
|
|
1289
|
-
add(handler) {
|
|
1290
|
-
handlers.add(handler);
|
|
1291
|
-
return () => handlers.delete(handler);
|
|
1292
|
-
},
|
|
1293
|
-
async dispatch(update) {
|
|
1294
|
-
for (const handler of handlers) {
|
|
1295
|
-
try {
|
|
1296
|
-
const result = await handler(update);
|
|
1297
|
-
if (result === "consume") return "consume";
|
|
1298
|
-
} catch {
|
|
1299
|
-
// Update handler errors must not break polling.
|
|
1300
|
-
}
|
|
1301
|
-
}
|
|
1302
|
-
return "pass";
|
|
1303
|
-
},
|
|
1870
|
+
function normalizeTelegramUpdateRetryPolicy(
|
|
1871
|
+
input: Partial<TelegramUpdateRetryPolicy> | undefined,
|
|
1872
|
+
): TelegramUpdateRetryPolicy {
|
|
1873
|
+
const policy = {
|
|
1874
|
+
baseDelayMs: input?.baseDelayMs ?? TELEGRAM_UPDATE_RETRY_BASE_DELAY_MS,
|
|
1875
|
+
maxDelayMs: input?.maxDelayMs ?? TELEGRAM_UPDATE_RETRY_MAX_DELAY_MS,
|
|
1304
1876
|
};
|
|
1305
|
-
|
|
1306
|
-
|
|
1877
|
+
if (
|
|
1878
|
+
!Number.isSafeInteger(policy.baseDelayMs) ||
|
|
1879
|
+
policy.baseDelayMs <= 0 ||
|
|
1880
|
+
!Number.isSafeInteger(policy.maxDelayMs) ||
|
|
1881
|
+
policy.maxDelayMs < policy.baseDelayMs
|
|
1882
|
+
) {
|
|
1883
|
+
throw new Error("Telegram update retry policy is invalid.");
|
|
1884
|
+
}
|
|
1885
|
+
return policy;
|
|
1307
1886
|
}
|
|
1308
1887
|
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
return
|
|
1888
|
+
function normalizeTelegramUpdateFailureClass(value: string): string {
|
|
1889
|
+
const normalized = value
|
|
1890
|
+
.trim()
|
|
1891
|
+
.replace(/[^A-Za-z0-9._:-]+/gu, "-")
|
|
1892
|
+
.replace(/^-+|-+$/gu, "")
|
|
1893
|
+
.slice(0, TELEGRAM_UPDATE_JOURNAL_FAILURE_CLASS_MAX_LENGTH);
|
|
1894
|
+
return normalized || "execution-error";
|
|
1316
1895
|
}
|
|
1317
1896
|
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1897
|
+
function normalizeTelegramUpdateFailureSummary(value: string): string {
|
|
1898
|
+
const normalized = value
|
|
1899
|
+
.trim()
|
|
1900
|
+
.slice(0, TELEGRAM_UPDATE_JOURNAL_FAILURE_SUMMARY_MAX_LENGTH);
|
|
1901
|
+
return normalized || "Telegram update execution failed.";
|
|
1321
1902
|
}
|
|
1322
1903
|
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1904
|
+
function classifyTelegramUpdateExecutionFailure(
|
|
1905
|
+
error: unknown,
|
|
1906
|
+
): TelegramUpdateExecutionFailureClassification {
|
|
1907
|
+
if (error instanceof TelegramForeignUpdateSettlementError) {
|
|
1908
|
+
return {
|
|
1909
|
+
disposition:
|
|
1910
|
+
error.settlement.status === "retryable" ? "retryable" : "terminal",
|
|
1911
|
+
failureClass: normalizeTelegramUpdateFailureClass(
|
|
1912
|
+
error.settlement.failureClass,
|
|
1913
|
+
),
|
|
1914
|
+
summary: normalizeTelegramUpdateFailureSummary(
|
|
1915
|
+
error.settlement.message,
|
|
1916
|
+
),
|
|
1917
|
+
};
|
|
1918
|
+
}
|
|
1919
|
+
const errorName =
|
|
1920
|
+
error instanceof Error && error.name ? error.name : "UnknownError";
|
|
1921
|
+
return {
|
|
1922
|
+
disposition: "retryable",
|
|
1923
|
+
failureClass: normalizeTelegramUpdateFailureClass(
|
|
1924
|
+
`execution-${errorName}`,
|
|
1925
|
+
),
|
|
1926
|
+
summary: normalizeTelegramUpdateFailureSummary(
|
|
1927
|
+
`${errorName}: Telegram update execution failed.`,
|
|
1928
|
+
),
|
|
1929
|
+
};
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
function getTelegramUpdateRetryDelayMs(
|
|
1933
|
+
attemptCount: number,
|
|
1934
|
+
policy: TelegramUpdateRetryPolicy,
|
|
1935
|
+
): number {
|
|
1936
|
+
const multiplier = 2 ** Math.max(0, attemptCount - 1);
|
|
1937
|
+
return Math.min(policy.maxDelayMs, policy.baseDelayMs * multiplier);
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
function scheduleTelegramUpdateRetry(
|
|
1941
|
+
callback: () => void,
|
|
1942
|
+
delayMs: number,
|
|
1943
|
+
): ReturnType<typeof setTimeout> {
|
|
1944
|
+
const handle = setTimeout(callback, delayMs);
|
|
1945
|
+
handle.unref?.();
|
|
1946
|
+
return handle;
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
function normalizeTelegramUpdateQueueOwnerIdentity(
|
|
1950
|
+
value: TelegramUpdateJournalQueueOwnerIdentity,
|
|
1951
|
+
): TelegramUpdateJournalQueueOwnerIdentity {
|
|
1952
|
+
if (
|
|
1953
|
+
typeof value.instanceId !== "string" ||
|
|
1954
|
+
value.instanceId.length === 0 ||
|
|
1955
|
+
value.instanceId.length >
|
|
1956
|
+
TELEGRAM_UPDATE_JOURNAL_QUEUE_OWNER_ID_MAX_LENGTH ||
|
|
1957
|
+
!Number.isSafeInteger(value.processId) ||
|
|
1958
|
+
value.processId <= 0 ||
|
|
1959
|
+
typeof value.processBirthId !== "string" ||
|
|
1960
|
+
value.processBirthId.length === 0 ||
|
|
1961
|
+
value.processBirthId.length >
|
|
1962
|
+
TELEGRAM_UPDATE_JOURNAL_QUEUE_OWNER_ID_MAX_LENGTH ||
|
|
1963
|
+
!Number.isSafeInteger(value.sessionGeneration) ||
|
|
1964
|
+
value.sessionGeneration <= 0
|
|
1965
|
+
) {
|
|
1966
|
+
throw new Error("Telegram update queue owner identity is invalid.");
|
|
1967
|
+
}
|
|
1968
|
+
return { ...value };
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
1972
|
+
deps: TelegramUpdateWorkerRuntimeDeps<TContext>,
|
|
1973
|
+
): TelegramUpdateWorkerRuntime<TContext> {
|
|
1974
|
+
if (
|
|
1975
|
+
(deps.scheduleRetry === undefined) !==
|
|
1976
|
+
(deps.cancelRetry === undefined)
|
|
1977
|
+
) {
|
|
1978
|
+
throw new Error(
|
|
1979
|
+
"Telegram update retry scheduling requires matching schedule and cancel ports.",
|
|
1980
|
+
);
|
|
1981
|
+
}
|
|
1982
|
+
const getNowMs = deps.getNowMs ?? Date.now;
|
|
1983
|
+
const batchSize = deps.batchSize ?? TELEGRAM_UPDATE_WORKER_BATCH_SIZE;
|
|
1984
|
+
if (!Number.isSafeInteger(batchSize) || batchSize <= 0) {
|
|
1985
|
+
throw new Error("Telegram update worker batch size must be positive.");
|
|
1986
|
+
}
|
|
1987
|
+
const yieldToEventLoop = deps.yieldToEventLoop ??
|
|
1988
|
+
(() => new Promise<void>((resolve) => setTimeout(resolve, 0)));
|
|
1989
|
+
const fallbackQueueOwnerInstanceId = `worker-${randomUUID()}`;
|
|
1990
|
+
const fallbackQueueOwnerProcessId = process.pid > 0 ? process.pid : 1;
|
|
1991
|
+
const createAbortController =
|
|
1992
|
+
deps.createAbortController ?? (() => new AbortController());
|
|
1993
|
+
const retryPolicy = normalizeTelegramUpdateRetryPolicy(deps.retryPolicy);
|
|
1994
|
+
const scheduleRetry = deps.scheduleRetry ?? scheduleTelegramUpdateRetry;
|
|
1995
|
+
const cancelRetry =
|
|
1996
|
+
deps.cancelRetry ??
|
|
1997
|
+
((handle: unknown) =>
|
|
1998
|
+
clearTimeout(handle as ReturnType<typeof setTimeout>));
|
|
1999
|
+
const resolveQueueOwnerIdentity = (
|
|
2000
|
+
ctx: TContext,
|
|
2001
|
+
generation: number,
|
|
2002
|
+
): TelegramUpdateJournalQueueOwnerIdentity =>
|
|
2003
|
+
normalizeTelegramUpdateQueueOwnerIdentity(
|
|
2004
|
+
deps.getQueueOwnerIdentity?.(ctx) ?? {
|
|
2005
|
+
instanceId: fallbackQueueOwnerInstanceId,
|
|
2006
|
+
processId: fallbackQueueOwnerProcessId,
|
|
2007
|
+
processBirthId: `${fallbackQueueOwnerProcessId}:${fallbackQueueOwnerInstanceId}`,
|
|
2008
|
+
sessionGeneration: generation,
|
|
2009
|
+
},
|
|
2010
|
+
);
|
|
2011
|
+
const state: TelegramUpdateWorkerStateSnapshot = {
|
|
2012
|
+
phase: "stopped",
|
|
2013
|
+
generation: 0,
|
|
2014
|
+
journalEntryCount: 0,
|
|
2015
|
+
journalSerializedBytes: 0,
|
|
2016
|
+
deferredClaimCount: 0,
|
|
2017
|
+
queuedClaimCount: 0,
|
|
2018
|
+
foreignQueuedCount: 0,
|
|
2019
|
+
retryWaitCount: 0,
|
|
2020
|
+
failedCount: 0,
|
|
2021
|
+
unsettledExecutionCount: 0,
|
|
2022
|
+
};
|
|
2023
|
+
const claims = new Map<number, TelegramUpdateWorkerClaim>();
|
|
2024
|
+
const unsettledExecutionsByUpdateId = new Map<
|
|
2025
|
+
number,
|
|
2026
|
+
Set<Promise<TelegramUpdateWorkerExecutionSettlement>>
|
|
2027
|
+
>();
|
|
2028
|
+
const committedQueueReceipts = new Map<
|
|
2029
|
+
string,
|
|
2030
|
+
{
|
|
2031
|
+
receipt: TelegramQueueAdmissionReceiptLike;
|
|
2032
|
+
queueOwner: TelegramUpdateJournalQueueOwner;
|
|
2033
|
+
}
|
|
2034
|
+
>();
|
|
2035
|
+
const unsettledExecutions =
|
|
2036
|
+
new Set<Promise<TelegramUpdateWorkerExecutionSettlement>>();
|
|
2037
|
+
let owner: TelegramUpdateWorkerOwner<TContext> | undefined;
|
|
2038
|
+
let drainPromise: Promise<void> | undefined;
|
|
2039
|
+
let pendingSignal = false;
|
|
2040
|
+
let blocked = false;
|
|
2041
|
+
let nextGeneration = 0;
|
|
2042
|
+
let retryTimer: unknown;
|
|
2043
|
+
let retryTimerAtMs: number | undefined;
|
|
2044
|
+
let retryTimerToken: object | undefined;
|
|
2045
|
+
let launchDrain: () => void = () => {};
|
|
2046
|
+
|
|
2047
|
+
const recordRuntimeEvent = (
|
|
2048
|
+
error: unknown,
|
|
2049
|
+
details: Record<string, unknown>,
|
|
2050
|
+
): void => {
|
|
2051
|
+
try {
|
|
2052
|
+
deps.recordRuntimeEvent?.("inbound-worker", error, details);
|
|
2053
|
+
} catch {
|
|
2054
|
+
// Diagnostics cannot own or terminate worker progress.
|
|
2055
|
+
}
|
|
2056
|
+
};
|
|
2057
|
+
|
|
2058
|
+
const notifyStateChange = (): void => {
|
|
2059
|
+
try {
|
|
2060
|
+
deps.onStateChange?.(getTelegramUpdateWorkerStateSnapshot(state));
|
|
2061
|
+
} catch (error) {
|
|
2062
|
+
recordRuntimeEvent(error, { phase: "state-observer" });
|
|
2063
|
+
}
|
|
2064
|
+
};
|
|
2065
|
+
|
|
2066
|
+
const updateClaimCounts = (): void => {
|
|
2067
|
+
let deferredClaimCount = 0;
|
|
2068
|
+
let queuedClaimCount = 0;
|
|
2069
|
+
for (const claim of claims.values()) {
|
|
2070
|
+
if (claim === "queued") queuedClaimCount += 1;
|
|
2071
|
+
else deferredClaimCount += 1;
|
|
2072
|
+
}
|
|
2073
|
+
state.deferredClaimCount = deferredClaimCount;
|
|
2074
|
+
state.queuedClaimCount = queuedClaimCount;
|
|
2075
|
+
};
|
|
2076
|
+
|
|
2077
|
+
const releaseDeferredClaims = (updateIds: readonly number[]): void => {
|
|
2078
|
+
let changed = false;
|
|
2079
|
+
for (const updateId of updateIds) {
|
|
2080
|
+
if (claims.get(updateId) !== "deferred") continue;
|
|
2081
|
+
claims.delete(updateId);
|
|
2082
|
+
changed = true;
|
|
2083
|
+
}
|
|
2084
|
+
if (!changed) return;
|
|
2085
|
+
updateClaimCounts();
|
|
2086
|
+
notifyStateChange();
|
|
2087
|
+
};
|
|
2088
|
+
|
|
2089
|
+
const transition = (
|
|
2090
|
+
phase: TelegramUpdateWorkerPhase,
|
|
2091
|
+
currentUpdateId?: number,
|
|
2092
|
+
blockedReason?: TelegramUpdateWorkerBlockedReason,
|
|
2093
|
+
): void => {
|
|
2094
|
+
state.phase = phase;
|
|
2095
|
+
state.phaseStartedAtMs = getNowMs();
|
|
2096
|
+
state.currentUpdateId = currentUpdateId;
|
|
2097
|
+
state.blockedReason = phase === "blocked" ? blockedReason : undefined;
|
|
2098
|
+
state.unsettledExecutionCount = unsettledExecutions.size;
|
|
2099
|
+
updateClaimCounts();
|
|
2100
|
+
notifyStateChange();
|
|
2101
|
+
};
|
|
2102
|
+
|
|
2103
|
+
const blockWithFailure = (
|
|
2104
|
+
blockedReason: Exclude<TelegramUpdateWorkerBlockedReason, "authority-lost">,
|
|
2105
|
+
failurePhase: string,
|
|
2106
|
+
error: unknown,
|
|
2107
|
+
currentUpdateId?: number,
|
|
2108
|
+
): "blocked" => {
|
|
2109
|
+
blocked = true;
|
|
2110
|
+
state.lastFailureAtMs = getNowMs();
|
|
2111
|
+
state.lastFailurePhase = failurePhase;
|
|
2112
|
+
recordRuntimeEvent(error, {
|
|
2113
|
+
phase: failurePhase,
|
|
2114
|
+
generation: owner?.generation,
|
|
2115
|
+
...(currentUpdateId !== undefined ? { updateId: currentUpdateId } : {}),
|
|
2116
|
+
});
|
|
2117
|
+
transition("blocked", currentUpdateId, blockedReason);
|
|
2118
|
+
return "blocked";
|
|
2119
|
+
};
|
|
2120
|
+
|
|
2121
|
+
const normalizeQueueReceipt = (
|
|
2122
|
+
receipt: TelegramQueueAdmissionReceiptLike,
|
|
2123
|
+
): TelegramQueueAdmissionReceiptLike => ({
|
|
2124
|
+
queueKind: receipt.queueKind,
|
|
2125
|
+
receiptId: receipt.receiptId,
|
|
2126
|
+
sourceUpdateIds: [...receipt.sourceUpdateIds].sort(
|
|
2127
|
+
(left, right) => left - right,
|
|
2128
|
+
),
|
|
2129
|
+
...(receipt.journalBindingKey
|
|
2130
|
+
? { journalBindingKey: receipt.journalBindingKey }
|
|
2131
|
+
: {}),
|
|
2132
|
+
});
|
|
2133
|
+
const bindQueueReceiptToJournal = (
|
|
2134
|
+
receipt: TelegramQueueAdmissionReceiptLike,
|
|
2135
|
+
): TelegramQueueAdmissionReceiptLike => ({
|
|
2136
|
+
...normalizeQueueReceipt(receipt),
|
|
2137
|
+
...(deps.getJournalBindingKey?.()
|
|
2138
|
+
? { journalBindingKey: deps.getJournalBindingKey() }
|
|
2139
|
+
: {}),
|
|
2140
|
+
});
|
|
2141
|
+
|
|
2142
|
+
const publishCommittedQueueReceipt = (
|
|
2143
|
+
receipt: TelegramQueueAdmissionReceiptLike,
|
|
2144
|
+
queueOwner: TelegramUpdateJournalQueueOwner,
|
|
2145
|
+
ctx: TContext,
|
|
2146
|
+
): boolean => {
|
|
2147
|
+
const normalized = bindQueueReceiptToJournal(receipt);
|
|
2148
|
+
const existing = committedQueueReceipts.get(receipt.receiptId);
|
|
2149
|
+
if (existing) {
|
|
2150
|
+
if (
|
|
2151
|
+
!areTelegramQueueAdmissionReceiptsEqual(
|
|
2152
|
+
existing.receipt,
|
|
2153
|
+
normalized,
|
|
2154
|
+
) ||
|
|
2155
|
+
!areTelegramUpdateJournalQueueOwnersEqual(
|
|
2156
|
+
existing.queueOwner,
|
|
2157
|
+
queueOwner,
|
|
2158
|
+
)
|
|
2159
|
+
) {
|
|
2160
|
+
throw new TelegramUpdateAdmissionOutcomeError(
|
|
2161
|
+
`Telegram queue receipt ${receipt.receiptId} has conflicting committed authority.`,
|
|
2162
|
+
);
|
|
2163
|
+
}
|
|
2164
|
+
return false;
|
|
2165
|
+
}
|
|
2166
|
+
committedQueueReceipts.set(receipt.receiptId, {
|
|
2167
|
+
receipt: normalized,
|
|
2168
|
+
queueOwner: { ...queueOwner },
|
|
2169
|
+
});
|
|
2170
|
+
try {
|
|
2171
|
+
deps.onQueueReceiptCommitted?.(normalized, ctx);
|
|
2172
|
+
} catch (error) {
|
|
2173
|
+
recordRuntimeEvent(error, {
|
|
2174
|
+
phase: "queue-receipt-observer",
|
|
2175
|
+
receiptId: normalized.receiptId,
|
|
2176
|
+
});
|
|
2177
|
+
}
|
|
2178
|
+
return true;
|
|
2179
|
+
};
|
|
2180
|
+
|
|
2181
|
+
const clearRetryTimer = (): void => {
|
|
2182
|
+
if (retryTimer !== undefined) cancelRetry(retryTimer);
|
|
2183
|
+
retryTimer = undefined;
|
|
2184
|
+
retryTimerAtMs = undefined;
|
|
2185
|
+
retryTimerToken = undefined;
|
|
2186
|
+
};
|
|
2187
|
+
|
|
2188
|
+
const scheduleNextRetry = (
|
|
2189
|
+
nextRetryAtMs: number | undefined,
|
|
2190
|
+
expectedOwner: TelegramUpdateWorkerOwner<TContext>,
|
|
2191
|
+
): void => {
|
|
2192
|
+
if (nextRetryAtMs === undefined) {
|
|
2193
|
+
clearRetryTimer();
|
|
2194
|
+
return;
|
|
2195
|
+
}
|
|
2196
|
+
if (retryTimer !== undefined && retryTimerAtMs === nextRetryAtMs) {
|
|
2197
|
+
return;
|
|
2198
|
+
}
|
|
2199
|
+
clearRetryTimer();
|
|
2200
|
+
const token = {};
|
|
2201
|
+
retryTimerAtMs = nextRetryAtMs;
|
|
2202
|
+
retryTimerToken = token;
|
|
2203
|
+
retryTimer = scheduleRetry(() => {
|
|
2204
|
+
if (retryTimerToken !== token) return;
|
|
2205
|
+
retryTimer = undefined;
|
|
2206
|
+
retryTimerAtMs = undefined;
|
|
2207
|
+
retryTimerToken = undefined;
|
|
2208
|
+
if (
|
|
2209
|
+
owner !== expectedOwner ||
|
|
2210
|
+
expectedOwner.controller.signal.aborted
|
|
2211
|
+
) {
|
|
2212
|
+
return;
|
|
2213
|
+
}
|
|
2214
|
+
pendingSignal = true;
|
|
2215
|
+
launchDrain();
|
|
2216
|
+
}, Math.max(0, nextRetryAtMs - getNowMs()));
|
|
2217
|
+
};
|
|
2218
|
+
|
|
2219
|
+
const refreshJournalState = (
|
|
2220
|
+
snapshot: TelegramUpdateWorkerJournalSnapshot,
|
|
2221
|
+
expectedOwner: TelegramUpdateWorkerOwner<TContext>,
|
|
2222
|
+
): number | undefined => {
|
|
2223
|
+
const availableUpdateIds = new Set<number>();
|
|
2224
|
+
const queuedReceiptEntries = new Map<
|
|
2225
|
+
string,
|
|
2226
|
+
{
|
|
2227
|
+
queueKind: "prompt" | "control";
|
|
2228
|
+
sourceUpdateIds: number[];
|
|
2229
|
+
queueOwner: TelegramUpdateJournalQueueOwner;
|
|
2230
|
+
}
|
|
2231
|
+
>();
|
|
2232
|
+
const locallyOwnedQueuedUpdateIds = new Set<number>();
|
|
2233
|
+
let foreignQueuedCount = 0;
|
|
2234
|
+
let foreignQueuedOwner: TelegramUpdateJournalQueueOwner | undefined;
|
|
2235
|
+
let oldestAdmittedAtMs: number | undefined;
|
|
2236
|
+
let retryWaitCount = 0;
|
|
2237
|
+
let scheduledRetryAtMs: number | undefined;
|
|
2238
|
+
let nextRetry: TelegramUpdateWorkerJournalSnapshot["entries"][number] | undefined;
|
|
2239
|
+
let failedCount = 0;
|
|
2240
|
+
let latestFailure: TelegramUpdateWorkerJournalSnapshot["entries"][number] | undefined;
|
|
2241
|
+
for (const entry of snapshot.entries) {
|
|
2242
|
+
availableUpdateIds.add(entry.updateId);
|
|
2243
|
+
oldestAdmittedAtMs =
|
|
2244
|
+
oldestAdmittedAtMs === undefined
|
|
2245
|
+
? entry.admittedAtMs
|
|
2246
|
+
: Math.min(oldestAdmittedAtMs, entry.admittedAtMs);
|
|
2247
|
+
if (entry.state === "retry-wait" && entry.nextRetryAtMs !== undefined) {
|
|
2248
|
+
scheduledRetryAtMs =
|
|
2249
|
+
scheduledRetryAtMs === undefined
|
|
2250
|
+
? entry.nextRetryAtMs
|
|
2251
|
+
: Math.min(scheduledRetryAtMs, entry.nextRetryAtMs);
|
|
2252
|
+
}
|
|
2253
|
+
if (
|
|
2254
|
+
entry.state === "retry-wait" &&
|
|
2255
|
+
entry.failure !== undefined &&
|
|
2256
|
+
entry.nextRetryAtMs !== undefined
|
|
2257
|
+
) {
|
|
2258
|
+
retryWaitCount += 1;
|
|
2259
|
+
if (
|
|
2260
|
+
!nextRetry ||
|
|
2261
|
+
nextRetry.nextRetryAtMs === undefined ||
|
|
2262
|
+
entry.nextRetryAtMs < nextRetry.nextRetryAtMs ||
|
|
2263
|
+
(entry.nextRetryAtMs === nextRetry.nextRetryAtMs &&
|
|
2264
|
+
entry.updateId < nextRetry.updateId)
|
|
2265
|
+
) {
|
|
2266
|
+
nextRetry = entry;
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2269
|
+
if (
|
|
2270
|
+
entry.state === "failed" &&
|
|
2271
|
+
entry.failure !== undefined &&
|
|
2272
|
+
entry.terminalAtMs !== undefined
|
|
2273
|
+
) {
|
|
2274
|
+
failedCount += 1;
|
|
2275
|
+
if (
|
|
2276
|
+
!latestFailure ||
|
|
2277
|
+
latestFailure.terminalAtMs === undefined ||
|
|
2278
|
+
entry.terminalAtMs > latestFailure.terminalAtMs ||
|
|
2279
|
+
(entry.terminalAtMs === latestFailure.terminalAtMs &&
|
|
2280
|
+
entry.updateId > latestFailure.updateId)
|
|
2281
|
+
) {
|
|
2282
|
+
latestFailure = entry;
|
|
2283
|
+
}
|
|
2284
|
+
}
|
|
2285
|
+
if (entry.state !== "queued") continue;
|
|
2286
|
+
if (!entry.queueKind || !entry.queueReceiptId) {
|
|
2287
|
+
throw new TelegramUpdateAdmissionOutcomeError(
|
|
2288
|
+
`Telegram queued update ${entry.updateId} has no receipt metadata.`,
|
|
2289
|
+
);
|
|
2290
|
+
}
|
|
2291
|
+
if (
|
|
2292
|
+
entry.queueHandoff ||
|
|
2293
|
+
!entry.queueOwner ||
|
|
2294
|
+
!isTelegramUpdateJournalQueueOwnerProcess(
|
|
2295
|
+
entry.queueOwner,
|
|
2296
|
+
expectedOwner.queueOwnerIdentity,
|
|
2297
|
+
)
|
|
2298
|
+
) {
|
|
2299
|
+
foreignQueuedCount += 1;
|
|
2300
|
+
foreignQueuedOwner ??= entry.queueOwner;
|
|
2301
|
+
continue;
|
|
2302
|
+
}
|
|
2303
|
+
locallyOwnedQueuedUpdateIds.add(entry.updateId);
|
|
2304
|
+
claims.set(entry.updateId, "queued");
|
|
2305
|
+
const receipt = queuedReceiptEntries.get(entry.queueReceiptId);
|
|
2306
|
+
if (
|
|
2307
|
+
receipt &&
|
|
2308
|
+
(receipt.queueKind !== entry.queueKind ||
|
|
2309
|
+
!areTelegramUpdateJournalQueueOwnersEqual(
|
|
2310
|
+
receipt.queueOwner,
|
|
2311
|
+
entry.queueOwner,
|
|
2312
|
+
))
|
|
2313
|
+
) {
|
|
2314
|
+
throw new TelegramUpdateAdmissionOutcomeError(
|
|
2315
|
+
`Telegram queue receipt ${entry.queueReceiptId} has conflicting authority.`,
|
|
2316
|
+
);
|
|
2317
|
+
}
|
|
2318
|
+
if (receipt) receipt.sourceUpdateIds.push(entry.updateId);
|
|
2319
|
+
else {
|
|
2320
|
+
queuedReceiptEntries.set(entry.queueReceiptId, {
|
|
2321
|
+
queueKind: entry.queueKind,
|
|
2322
|
+
sourceUpdateIds: [entry.updateId],
|
|
2323
|
+
queueOwner: { ...entry.queueOwner },
|
|
2324
|
+
});
|
|
2325
|
+
}
|
|
2326
|
+
}
|
|
2327
|
+
for (const [updateId, claim] of claims) {
|
|
2328
|
+
if (
|
|
2329
|
+
!availableUpdateIds.has(updateId) ||
|
|
2330
|
+
(claim === "queued" && !locallyOwnedQueuedUpdateIds.has(updateId))
|
|
2331
|
+
) {
|
|
2332
|
+
claims.delete(updateId);
|
|
2333
|
+
}
|
|
2334
|
+
}
|
|
2335
|
+
for (const [receiptId, receipt] of queuedReceiptEntries) {
|
|
2336
|
+
publishCommittedQueueReceipt(
|
|
2337
|
+
{
|
|
2338
|
+
queueKind: receipt.queueKind,
|
|
2339
|
+
receiptId,
|
|
2340
|
+
sourceUpdateIds: receipt.sourceUpdateIds,
|
|
2341
|
+
},
|
|
2342
|
+
receipt.queueOwner,
|
|
2343
|
+
expectedOwner.ctx,
|
|
2344
|
+
);
|
|
2345
|
+
}
|
|
2346
|
+
for (const [receiptId] of committedQueueReceipts) {
|
|
2347
|
+
if (!queuedReceiptEntries.has(receiptId)) {
|
|
2348
|
+
committedQueueReceipts.delete(receiptId);
|
|
2349
|
+
}
|
|
2350
|
+
}
|
|
2351
|
+
state.foreignQueuedCount = foreignQueuedCount;
|
|
2352
|
+
if (foreignQueuedOwner) state.foreignQueuedOwner = foreignQueuedOwner;
|
|
2353
|
+
else delete state.foreignQueuedOwner;
|
|
2354
|
+
state.journalEntryCount = snapshot.entries.length;
|
|
2355
|
+
state.journalSerializedBytes = snapshot.serializedBytes;
|
|
2356
|
+
state.oldestAdmittedAtMs = oldestAdmittedAtMs;
|
|
2357
|
+
state.retryWaitCount = retryWaitCount;
|
|
2358
|
+
state.nextRetryUpdateId = nextRetry?.updateId;
|
|
2359
|
+
state.nextRetryAtMs = nextRetry?.nextRetryAtMs;
|
|
2360
|
+
state.nextRetryAttemptCount = nextRetry?.failure?.attemptCount;
|
|
2361
|
+
state.nextRetryFailureClass = nextRetry?.failure?.failureClass;
|
|
2362
|
+
state.failedCount = failedCount;
|
|
2363
|
+
state.failedUpdateId = latestFailure?.updateId;
|
|
2364
|
+
state.failedFailureId = latestFailure?.terminalFailureId;
|
|
2365
|
+
state.failedAttemptCount = latestFailure?.failure?.attemptCount;
|
|
2366
|
+
state.failedClass = latestFailure?.failure?.failureClass;
|
|
2367
|
+
state.failedSummary = latestFailure?.failure?.summary;
|
|
2368
|
+
state.terminalFailureAtMs = latestFailure?.terminalAtMs;
|
|
2369
|
+
state.unsettledExecutionCount = unsettledExecutions.size;
|
|
2370
|
+
updateClaimCounts();
|
|
2371
|
+
return scheduledRetryAtMs;
|
|
2372
|
+
};
|
|
2373
|
+
|
|
2374
|
+
const checkAuthority = (
|
|
2375
|
+
expectedOwner: TelegramUpdateWorkerOwner<TContext>,
|
|
2376
|
+
currentUpdateId?: number,
|
|
2377
|
+
):
|
|
2378
|
+
| Exclude<TelegramUpdateWorkerDrainResult, "idle">
|
|
2379
|
+
| undefined => {
|
|
2380
|
+
if (blocked) return "blocked";
|
|
2381
|
+
if (owner !== expectedOwner || expectedOwner.controller.signal.aborted) {
|
|
2382
|
+
return "aborted";
|
|
2383
|
+
}
|
|
2384
|
+
try {
|
|
2385
|
+
if (deps.hasAuthority(expectedOwner.ctx)) return undefined;
|
|
2386
|
+
} catch (error) {
|
|
2387
|
+
return blockWithFailure(
|
|
2388
|
+
"authority-check",
|
|
2389
|
+
"authority-check",
|
|
2390
|
+
error,
|
|
2391
|
+
currentUpdateId,
|
|
2392
|
+
);
|
|
2393
|
+
}
|
|
2394
|
+
transition("blocked", currentUpdateId, "authority-lost");
|
|
2395
|
+
return "blocked";
|
|
2396
|
+
};
|
|
2397
|
+
|
|
2398
|
+
const executeWithinOwner = async (
|
|
2399
|
+
expectedOwner: TelegramUpdateWorkerOwner<TContext>,
|
|
2400
|
+
update: TelegramJournaledUpdate,
|
|
2401
|
+
): Promise<
|
|
2402
|
+
| TelegramUpdateWorkerExecutionSettlement
|
|
2403
|
+
| typeof TELEGRAM_UPDATE_WORKER_EXECUTION_ABORTED
|
|
2404
|
+
> => {
|
|
2405
|
+
if (expectedOwner.controller.signal.aborted) {
|
|
2406
|
+
return TELEGRAM_UPDATE_WORKER_EXECUTION_ABORTED;
|
|
2407
|
+
}
|
|
2408
|
+
const execution = Promise.resolve().then(() =>
|
|
2409
|
+
deps.executeUpdate(
|
|
2410
|
+
update,
|
|
2411
|
+
expectedOwner.ctx,
|
|
2412
|
+
expectedOwner.controller.signal,
|
|
2413
|
+
),
|
|
2414
|
+
);
|
|
2415
|
+
const settlement: Promise<TelegramUpdateWorkerExecutionSettlement> =
|
|
2416
|
+
execution.then(
|
|
2417
|
+
(outcome) => ({ ok: true, outcome }),
|
|
2418
|
+
(error: unknown) => ({ ok: false, error }),
|
|
2419
|
+
);
|
|
2420
|
+
unsettledExecutions.add(settlement);
|
|
2421
|
+
const updateExecutions =
|
|
2422
|
+
unsettledExecutionsByUpdateId.get(update.update_id) ?? new Set();
|
|
2423
|
+
updateExecutions.add(settlement);
|
|
2424
|
+
unsettledExecutionsByUpdateId.set(update.update_id, updateExecutions);
|
|
2425
|
+
state.unsettledExecutionCount = unsettledExecutions.size;
|
|
2426
|
+
notifyStateChange();
|
|
2427
|
+
void settlement.then((result) => {
|
|
2428
|
+
unsettledExecutions.delete(settlement);
|
|
2429
|
+
const currentExecutions = unsettledExecutionsByUpdateId.get(
|
|
2430
|
+
update.update_id,
|
|
2431
|
+
);
|
|
2432
|
+
currentExecutions?.delete(settlement);
|
|
2433
|
+
if (currentExecutions?.size === 0) {
|
|
2434
|
+
unsettledExecutionsByUpdateId.delete(update.update_id);
|
|
2435
|
+
}
|
|
2436
|
+
state.unsettledExecutionCount = unsettledExecutions.size;
|
|
2437
|
+
if (owner !== expectedOwner || expectedOwner.controller.signal.aborted) {
|
|
2438
|
+
recordRuntimeEvent(
|
|
2439
|
+
result.ok
|
|
2440
|
+
? "Superseded Telegram update execution settled successfully."
|
|
2441
|
+
: result.error,
|
|
2442
|
+
{
|
|
2443
|
+
phase: result.ok ? "late-execution-success" : "late-execution",
|
|
2444
|
+
generation: expectedOwner.generation,
|
|
2445
|
+
updateId: update.update_id,
|
|
2446
|
+
},
|
|
2447
|
+
);
|
|
2448
|
+
}
|
|
2449
|
+
notifyStateChange();
|
|
2450
|
+
});
|
|
2451
|
+
let removeAbortListener = (): void => {};
|
|
2452
|
+
const aborted = new Promise<typeof TELEGRAM_UPDATE_WORKER_EXECUTION_ABORTED>(
|
|
2453
|
+
(resolve) => {
|
|
2454
|
+
const onAbort = () => resolve(TELEGRAM_UPDATE_WORKER_EXECUTION_ABORTED);
|
|
2455
|
+
removeAbortListener = () =>
|
|
2456
|
+
expectedOwner.controller.signal.removeEventListener(
|
|
2457
|
+
"abort",
|
|
2458
|
+
onAbort,
|
|
2459
|
+
);
|
|
2460
|
+
expectedOwner.controller.signal.addEventListener("abort", onAbort, {
|
|
2461
|
+
once: true,
|
|
2462
|
+
});
|
|
2463
|
+
if (expectedOwner.controller.signal.aborted) onAbort();
|
|
2464
|
+
},
|
|
2465
|
+
);
|
|
2466
|
+
try {
|
|
2467
|
+
return await Promise.race([settlement, aborted]);
|
|
2468
|
+
} finally {
|
|
2469
|
+
removeAbortListener();
|
|
2470
|
+
}
|
|
2471
|
+
};
|
|
2472
|
+
|
|
2473
|
+
const commitQueuedOutcome = (
|
|
2474
|
+
expectedOwner: TelegramUpdateWorkerOwner<TContext>,
|
|
2475
|
+
currentUpdateId: number,
|
|
2476
|
+
outcome: TelegramQueuedUpdateAdmissionOutcome,
|
|
2477
|
+
):
|
|
2478
|
+
| "committed"
|
|
2479
|
+
| "duplicate"
|
|
2480
|
+
| Exclude<TelegramUpdateWorkerDrainResult, "idle"> => {
|
|
2481
|
+
const normalized = normalizeQueueReceipt(outcome);
|
|
2482
|
+
const existing = committedQueueReceipts.get(normalized.receiptId);
|
|
2483
|
+
if (existing) {
|
|
2484
|
+
if (
|
|
2485
|
+
!areTelegramQueueAdmissionReceiptsEqual(
|
|
2486
|
+
existing.receipt,
|
|
2487
|
+
normalized,
|
|
2488
|
+
) ||
|
|
2489
|
+
!isTelegramUpdateJournalQueueOwnerProcess(
|
|
2490
|
+
existing.queueOwner,
|
|
2491
|
+
expectedOwner.queueOwnerIdentity,
|
|
2492
|
+
)
|
|
2493
|
+
) {
|
|
2494
|
+
return blockWithFailure(
|
|
2495
|
+
"invalid-outcome",
|
|
2496
|
+
"queue-receipt-conflict",
|
|
2497
|
+
new TelegramUpdateAdmissionOutcomeError(
|
|
2498
|
+
`Telegram queue receipt ${normalized.receiptId} conflicts with committed authority.`,
|
|
2499
|
+
),
|
|
2500
|
+
currentUpdateId,
|
|
2501
|
+
);
|
|
2502
|
+
}
|
|
2503
|
+
return "duplicate";
|
|
2504
|
+
}
|
|
2505
|
+
const commitAuthority = checkAuthority(expectedOwner, currentUpdateId);
|
|
2506
|
+
if (commitAuthority) return commitAuthority;
|
|
2507
|
+
let queueOwner: TelegramUpdateJournalQueueOwner;
|
|
2508
|
+
try {
|
|
2509
|
+
const committed = deps.journal.markQueued({
|
|
2510
|
+
...normalized,
|
|
2511
|
+
owner: expectedOwner.queueOwnerIdentity,
|
|
2512
|
+
});
|
|
2513
|
+
const committedUpdateIds = new Set([
|
|
2514
|
+
...committed.queuedUpdateIds,
|
|
2515
|
+
...committed.duplicateUpdateIds,
|
|
2516
|
+
]);
|
|
2517
|
+
if (
|
|
2518
|
+
normalized.sourceUpdateIds.some(
|
|
2519
|
+
(updateId) => !committedUpdateIds.has(updateId),
|
|
2520
|
+
)
|
|
2521
|
+
) {
|
|
2522
|
+
throw new Error(
|
|
2523
|
+
`Telegram queue receipt ${normalized.receiptId} did not commit every source update.`,
|
|
2524
|
+
);
|
|
2525
|
+
}
|
|
2526
|
+
if (
|
|
2527
|
+
!committed.queueOwner ||
|
|
2528
|
+
!isTelegramUpdateJournalQueueOwnerProcess(
|
|
2529
|
+
committed.queueOwner,
|
|
2530
|
+
expectedOwner.queueOwnerIdentity,
|
|
2531
|
+
)
|
|
2532
|
+
) {
|
|
2533
|
+
throw new Error(
|
|
2534
|
+
`Telegram queue receipt ${normalized.receiptId} belongs to another live process.`,
|
|
2535
|
+
);
|
|
2536
|
+
}
|
|
2537
|
+
queueOwner = committed.queueOwner;
|
|
2538
|
+
} catch (error) {
|
|
2539
|
+
return blockWithFailure(
|
|
2540
|
+
"journal-write",
|
|
2541
|
+
"queue-receipt-commit",
|
|
2542
|
+
error,
|
|
2543
|
+
currentUpdateId,
|
|
2544
|
+
);
|
|
2545
|
+
}
|
|
2546
|
+
for (const sourceUpdateId of normalized.sourceUpdateIds) {
|
|
2547
|
+
claims.set(sourceUpdateId, "queued");
|
|
2548
|
+
}
|
|
2549
|
+
try {
|
|
2550
|
+
publishCommittedQueueReceipt(
|
|
2551
|
+
normalized,
|
|
2552
|
+
queueOwner,
|
|
2553
|
+
expectedOwner.ctx,
|
|
2554
|
+
);
|
|
2555
|
+
} catch (error) {
|
|
2556
|
+
return blockWithFailure(
|
|
2557
|
+
"invalid-outcome",
|
|
2558
|
+
"queue-receipt-publish",
|
|
2559
|
+
error,
|
|
2560
|
+
currentUpdateId,
|
|
2561
|
+
);
|
|
2562
|
+
}
|
|
2563
|
+
return "committed";
|
|
2564
|
+
};
|
|
2565
|
+
|
|
2566
|
+
const persistExecutionFailure = (
|
|
2567
|
+
expectedOwner: TelegramUpdateWorkerOwner<TContext>,
|
|
2568
|
+
entry: TelegramUpdateWorkerJournalSnapshot["entries"][number],
|
|
2569
|
+
error: unknown,
|
|
2570
|
+
):
|
|
2571
|
+
| "retry-wait"
|
|
2572
|
+
| "failed"
|
|
2573
|
+
| Exclude<TelegramUpdateWorkerDrainResult, "idle"> => {
|
|
2574
|
+
const authorityResult = checkAuthority(expectedOwner, entry.updateId);
|
|
2575
|
+
if (authorityResult) return authorityResult;
|
|
2576
|
+
let rawClassification: TelegramUpdateExecutionFailureClassification;
|
|
2577
|
+
try {
|
|
2578
|
+
rawClassification = deps.classifyExecutionFailure
|
|
2579
|
+
? deps.classifyExecutionFailure(error)
|
|
2580
|
+
: classifyTelegramUpdateExecutionFailure(error);
|
|
2581
|
+
if (
|
|
2582
|
+
(rawClassification.disposition !== "retryable" &&
|
|
2583
|
+
rawClassification.disposition !== "terminal") ||
|
|
2584
|
+
typeof rawClassification.failureClass !== "string" ||
|
|
2585
|
+
typeof rawClassification.summary !== "string"
|
|
2586
|
+
) {
|
|
2587
|
+
throw new Error("Telegram update failure classifier returned invalid data.");
|
|
2588
|
+
}
|
|
2589
|
+
} catch (classificationError) {
|
|
2590
|
+
return blockWithFailure(
|
|
2591
|
+
"execution",
|
|
2592
|
+
"failure-classification",
|
|
2593
|
+
classificationError,
|
|
2594
|
+
entry.updateId,
|
|
2595
|
+
);
|
|
2596
|
+
}
|
|
2597
|
+
const failureClass = normalizeTelegramUpdateFailureClass(
|
|
2598
|
+
rawClassification.failureClass,
|
|
2599
|
+
);
|
|
2600
|
+
const summary = normalizeTelegramUpdateFailureSummary(
|
|
2601
|
+
rawClassification.summary,
|
|
2602
|
+
);
|
|
2603
|
+
const expectedAttemptCount = entry.failure?.attemptCount ?? 0;
|
|
2604
|
+
const attemptCount = expectedAttemptCount + 1;
|
|
2605
|
+
const failedAtMs = getNowMs();
|
|
2606
|
+
const disposition = "retry-wait" as const;
|
|
2607
|
+
const nextRetryAtMs =
|
|
2608
|
+
failedAtMs + getTelegramUpdateRetryDelayMs(attemptCount, retryPolicy);
|
|
2609
|
+
try {
|
|
2610
|
+
const result = deps.journal.markExecutionFailure({
|
|
2611
|
+
updateId: entry.updateId,
|
|
2612
|
+
expectedAttemptCount,
|
|
2613
|
+
failedAtMs,
|
|
2614
|
+
failureClass,
|
|
2615
|
+
summary,
|
|
2616
|
+
disposition,
|
|
2617
|
+
nextRetryAtMs,
|
|
2618
|
+
});
|
|
2619
|
+
if (result.entry.state !== disposition) {
|
|
2620
|
+
throw new Error(
|
|
2621
|
+
`Telegram update ${entry.updateId} failure disposition did not persist.`,
|
|
2622
|
+
);
|
|
2623
|
+
}
|
|
2624
|
+
} catch (journalError) {
|
|
2625
|
+
return blockWithFailure(
|
|
2626
|
+
"journal-write",
|
|
2627
|
+
"execution-failure-commit",
|
|
2628
|
+
journalError,
|
|
2629
|
+
entry.updateId,
|
|
2630
|
+
);
|
|
2631
|
+
}
|
|
2632
|
+
state.lastFailureAtMs = failedAtMs;
|
|
2633
|
+
state.lastFailurePhase = "execute";
|
|
2634
|
+
recordRuntimeEvent(error, {
|
|
2635
|
+
phase: "execute",
|
|
2636
|
+
generation: expectedOwner.generation,
|
|
2637
|
+
updateId: entry.updateId,
|
|
2638
|
+
failureClass,
|
|
2639
|
+
attemptCount,
|
|
2640
|
+
disposition,
|
|
2641
|
+
nextRetryAtMs,
|
|
2642
|
+
});
|
|
2643
|
+
transition(disposition, entry.updateId);
|
|
2644
|
+
return disposition;
|
|
2645
|
+
};
|
|
2646
|
+
|
|
2647
|
+
const commitCompletedBatch = (
|
|
2648
|
+
expectedOwner: TelegramUpdateWorkerOwner<TContext>,
|
|
2649
|
+
updateIds: readonly number[],
|
|
2650
|
+
): Exclude<TelegramUpdateWorkerDrainResult, "idle"> | undefined => {
|
|
2651
|
+
if (updateIds.length === 0) return undefined;
|
|
2652
|
+
const commitAuthority = checkAuthority(
|
|
2653
|
+
expectedOwner,
|
|
2654
|
+
updateIds[updateIds.length - 1],
|
|
2655
|
+
);
|
|
2656
|
+
if (commitAuthority) return commitAuthority;
|
|
2657
|
+
try {
|
|
2658
|
+
const removed = deps.journal.removeCompleted(updateIds);
|
|
2659
|
+
const removedIds = new Set(removed.removedUpdateIds);
|
|
2660
|
+
if (updateIds.some((updateId) => !removedIds.has(updateId))) {
|
|
2661
|
+
throw new Error(
|
|
2662
|
+
"Telegram update batch changed before completion commit.",
|
|
2663
|
+
);
|
|
2664
|
+
}
|
|
2665
|
+
} catch (error) {
|
|
2666
|
+
return blockWithFailure(
|
|
2667
|
+
"journal-write",
|
|
2668
|
+
"completion-commit",
|
|
2669
|
+
error,
|
|
2670
|
+
updateIds[updateIds.length - 1],
|
|
2671
|
+
);
|
|
2672
|
+
}
|
|
2673
|
+
const completedAtMs = getNowMs();
|
|
2674
|
+
for (const updateId of updateIds) {
|
|
2675
|
+
claims.delete(updateId);
|
|
2676
|
+
state.lastCompletedUpdateId = updateId;
|
|
2677
|
+
state.lastCompletedAtMs = completedAtMs;
|
|
2678
|
+
try {
|
|
2679
|
+
deps.onUpdateCompleted?.(updateId, expectedOwner.ctx);
|
|
2680
|
+
} catch (error) {
|
|
2681
|
+
recordRuntimeEvent(error, {
|
|
2682
|
+
phase: "update-completion-observer",
|
|
2683
|
+
updateId,
|
|
2684
|
+
});
|
|
2685
|
+
}
|
|
2686
|
+
}
|
|
2687
|
+
return undefined;
|
|
2688
|
+
};
|
|
2689
|
+
|
|
2690
|
+
const drain = async (
|
|
2691
|
+
expectedOwner: TelegramUpdateWorkerOwner<TContext>,
|
|
2692
|
+
): Promise<TelegramUpdateWorkerDrainResult> => {
|
|
2693
|
+
while (owner === expectedOwner && !expectedOwner.controller.signal.aborted) {
|
|
2694
|
+
const authorityResult = checkAuthority(expectedOwner);
|
|
2695
|
+
if (authorityResult) return authorityResult;
|
|
2696
|
+
let snapshot: TelegramUpdateWorkerJournalSnapshot;
|
|
2697
|
+
let scheduledRetryAtMs: number | undefined;
|
|
2698
|
+
try {
|
|
2699
|
+
snapshot = deps.journal.read();
|
|
2700
|
+
scheduledRetryAtMs = refreshJournalState(snapshot, expectedOwner);
|
|
2701
|
+
} catch (error) {
|
|
2702
|
+
return blockWithFailure("journal-read", "journal-read", error);
|
|
2703
|
+
}
|
|
2704
|
+
const nowMs = getNowMs();
|
|
2705
|
+
const entries: TelegramUpdateWorkerJournalSnapshot["entries"][number][] =
|
|
2706
|
+
[];
|
|
2707
|
+
let hasMoreEntries = false;
|
|
2708
|
+
for (const candidate of snapshot.entries) {
|
|
2709
|
+
if (
|
|
2710
|
+
!claims.has(candidate.updateId) &&
|
|
2711
|
+
(candidate.state === "pending" ||
|
|
2712
|
+
(candidate.state === "retry-wait" &&
|
|
2713
|
+
candidate.nextRetryAtMs !== undefined &&
|
|
2714
|
+
candidate.nextRetryAtMs <= nowMs))
|
|
2715
|
+
) {
|
|
2716
|
+
if (entries.length === batchSize) {
|
|
2717
|
+
hasMoreEntries = true;
|
|
2718
|
+
break;
|
|
2719
|
+
}
|
|
2720
|
+
entries.push(candidate);
|
|
2721
|
+
}
|
|
2722
|
+
}
|
|
2723
|
+
if (entries.length === 0) {
|
|
2724
|
+
scheduleNextRetry(scheduledRetryAtMs, expectedOwner);
|
|
2725
|
+
transition("idle");
|
|
2726
|
+
return "idle";
|
|
2727
|
+
}
|
|
2728
|
+
const completedUpdateIds: number[] = [];
|
|
2729
|
+
let snapshotInvalidated = false;
|
|
2730
|
+
for (const entry of entries) {
|
|
2731
|
+
const priorExecutions = unsettledExecutionsByUpdateId.get(entry.updateId);
|
|
2732
|
+
if (priorExecutions?.size) {
|
|
2733
|
+
const completionResult = commitCompletedBatch(
|
|
2734
|
+
expectedOwner,
|
|
2735
|
+
completedUpdateIds,
|
|
2736
|
+
);
|
|
2737
|
+
if (completionResult) return completionResult;
|
|
2738
|
+
transition("blocked", entry.updateId, "prior-generation-executing");
|
|
2739
|
+
await Promise.allSettled([...priorExecutions]);
|
|
2740
|
+
if (
|
|
2741
|
+
owner !== expectedOwner ||
|
|
2742
|
+
expectedOwner.controller.signal.aborted
|
|
2743
|
+
) {
|
|
2744
|
+
return "aborted";
|
|
2745
|
+
}
|
|
2746
|
+
snapshotInvalidated = true;
|
|
2747
|
+
break;
|
|
2748
|
+
}
|
|
2749
|
+
clearRetryTimer();
|
|
2750
|
+
transition("executing", entry.updateId);
|
|
2751
|
+
const execution = await executeWithinOwner(expectedOwner, entry.update);
|
|
2752
|
+
if (execution === TELEGRAM_UPDATE_WORKER_EXECUTION_ABORTED) {
|
|
2753
|
+
return "aborted";
|
|
2754
|
+
}
|
|
2755
|
+
if (!execution.ok) {
|
|
2756
|
+
const completionResult = commitCompletedBatch(
|
|
2757
|
+
expectedOwner,
|
|
2758
|
+
completedUpdateIds,
|
|
2759
|
+
);
|
|
2760
|
+
if (completionResult) return completionResult;
|
|
2761
|
+
const failureResult = persistExecutionFailure(
|
|
2762
|
+
expectedOwner,
|
|
2763
|
+
entry,
|
|
2764
|
+
execution.error,
|
|
2765
|
+
);
|
|
2766
|
+
if (failureResult === "blocked" || failureResult === "aborted") {
|
|
2767
|
+
return failureResult;
|
|
2768
|
+
}
|
|
2769
|
+
snapshotInvalidated = true;
|
|
2770
|
+
break;
|
|
2771
|
+
}
|
|
2772
|
+
const postExecutionAuthority = checkAuthority(
|
|
2773
|
+
expectedOwner,
|
|
2774
|
+
entry.updateId,
|
|
2775
|
+
);
|
|
2776
|
+
if (postExecutionAuthority) return postExecutionAuthority;
|
|
2777
|
+
const claimableUpdateIds = new Set<number>([
|
|
2778
|
+
entry.updateId,
|
|
2779
|
+
...claims.keys(),
|
|
2780
|
+
]);
|
|
2781
|
+
let outcome: TelegramUpdateAdmissionOutcome;
|
|
2782
|
+
try {
|
|
2783
|
+
outcome = validateTelegramUpdateAdmissionOutcome(
|
|
2784
|
+
execution.outcome,
|
|
2785
|
+
entry.updateId,
|
|
2786
|
+
claimableUpdateIds,
|
|
2787
|
+
);
|
|
2788
|
+
} catch (error) {
|
|
2789
|
+
return blockWithFailure(
|
|
2790
|
+
"invalid-outcome",
|
|
2791
|
+
"invalid-outcome",
|
|
2792
|
+
error,
|
|
2793
|
+
entry.updateId,
|
|
2794
|
+
);
|
|
2795
|
+
}
|
|
2796
|
+
if (outcome.kind === "deferred") {
|
|
2797
|
+
claims.set(entry.updateId, "deferred");
|
|
2798
|
+
transition("deferred", entry.updateId);
|
|
2799
|
+
continue;
|
|
2800
|
+
}
|
|
2801
|
+
if (outcome.kind === "queued") {
|
|
2802
|
+
const completionResult = commitCompletedBatch(
|
|
2803
|
+
expectedOwner,
|
|
2804
|
+
completedUpdateIds,
|
|
2805
|
+
);
|
|
2806
|
+
if (completionResult) return completionResult;
|
|
2807
|
+
const queuedResult = commitQueuedOutcome(
|
|
2808
|
+
expectedOwner,
|
|
2809
|
+
entry.updateId,
|
|
2810
|
+
outcome,
|
|
2811
|
+
);
|
|
2812
|
+
if (queuedResult === "blocked" || queuedResult === "aborted") {
|
|
2813
|
+
return queuedResult;
|
|
2814
|
+
}
|
|
2815
|
+
transition("queued", entry.updateId);
|
|
2816
|
+
snapshotInvalidated = true;
|
|
2817
|
+
break;
|
|
2818
|
+
}
|
|
2819
|
+
completedUpdateIds.push(entry.updateId);
|
|
2820
|
+
}
|
|
2821
|
+
const completionResult = commitCompletedBatch(
|
|
2822
|
+
expectedOwner,
|
|
2823
|
+
completedUpdateIds,
|
|
2824
|
+
);
|
|
2825
|
+
if (completionResult) return completionResult;
|
|
2826
|
+
if (!snapshotInvalidated && !hasMoreEntries) continue;
|
|
2827
|
+
await yieldToEventLoop();
|
|
2828
|
+
if (
|
|
2829
|
+
owner !== expectedOwner ||
|
|
2830
|
+
expectedOwner.controller.signal.aborted
|
|
2831
|
+
) {
|
|
2832
|
+
return "aborted";
|
|
2833
|
+
}
|
|
2834
|
+
}
|
|
2835
|
+
return "aborted";
|
|
2836
|
+
};
|
|
2837
|
+
|
|
2838
|
+
launchDrain = (): void => {
|
|
2839
|
+
const expectedOwner = owner;
|
|
2840
|
+
if (!expectedOwner || drainPromise) return;
|
|
2841
|
+
const run = async (): Promise<void> => {
|
|
2842
|
+
while (
|
|
2843
|
+
pendingSignal &&
|
|
2844
|
+
owner === expectedOwner &&
|
|
2845
|
+
!expectedOwner.controller.signal.aborted
|
|
2846
|
+
) {
|
|
2847
|
+
pendingSignal = false;
|
|
2848
|
+
const result = await drain(expectedOwner);
|
|
2849
|
+
if (result !== "idle") {
|
|
2850
|
+
pendingSignal = false;
|
|
2851
|
+
return;
|
|
2852
|
+
}
|
|
2853
|
+
}
|
|
2854
|
+
};
|
|
2855
|
+
const operation = run();
|
|
2856
|
+
let tracked: Promise<void>;
|
|
2857
|
+
const finish = (): void => {
|
|
2858
|
+
if (owner === expectedOwner && drainPromise === tracked) {
|
|
2859
|
+
drainPromise = undefined;
|
|
2860
|
+
if (pendingSignal && !expectedOwner.controller.signal.aborted) {
|
|
2861
|
+
launchDrain();
|
|
2862
|
+
}
|
|
2863
|
+
}
|
|
2864
|
+
};
|
|
2865
|
+
tracked = operation.then(
|
|
2866
|
+
() => finish(),
|
|
2867
|
+
(error: unknown) => {
|
|
2868
|
+
pendingSignal = false;
|
|
2869
|
+
if (owner === expectedOwner) {
|
|
2870
|
+
blockWithFailure("execution", "worker-loop", error);
|
|
2871
|
+
}
|
|
2872
|
+
finish();
|
|
2873
|
+
},
|
|
2874
|
+
);
|
|
2875
|
+
drainPromise = tracked;
|
|
2876
|
+
};
|
|
2877
|
+
|
|
2878
|
+
return {
|
|
2879
|
+
start(ctx) {
|
|
2880
|
+
if (owner) {
|
|
2881
|
+
if (!owner.controller.signal.aborted) {
|
|
2882
|
+
pendingSignal = true;
|
|
2883
|
+
launchDrain();
|
|
2884
|
+
}
|
|
2885
|
+
return;
|
|
2886
|
+
}
|
|
2887
|
+
clearRetryTimer();
|
|
2888
|
+
const nowMs = getNowMs();
|
|
2889
|
+
const generation = ++nextGeneration;
|
|
2890
|
+
const queueOwnerIdentity = resolveQueueOwnerIdentity(ctx, generation);
|
|
2891
|
+
owner = {
|
|
2892
|
+
generation,
|
|
2893
|
+
ctx,
|
|
2894
|
+
controller: createAbortController(),
|
|
2895
|
+
queueOwnerIdentity,
|
|
2896
|
+
};
|
|
2897
|
+
claims.clear();
|
|
2898
|
+
committedQueueReceipts.clear();
|
|
2899
|
+
blocked = false;
|
|
2900
|
+
pendingSignal = true;
|
|
2901
|
+
state.phase = "idle";
|
|
2902
|
+
state.generation = generation;
|
|
2903
|
+
state.phaseStartedAtMs = nowMs;
|
|
2904
|
+
state.currentUpdateId = undefined;
|
|
2905
|
+
state.blockedReason = undefined;
|
|
2906
|
+
state.journalEntryCount = 0;
|
|
2907
|
+
state.journalSerializedBytes = 0;
|
|
2908
|
+
state.oldestAdmittedAtMs = undefined;
|
|
2909
|
+
state.deferredClaimCount = 0;
|
|
2910
|
+
state.queuedClaimCount = 0;
|
|
2911
|
+
state.foreignQueuedCount = 0;
|
|
2912
|
+
delete state.foreignQueuedOwner;
|
|
2913
|
+
state.retryWaitCount = 0;
|
|
2914
|
+
state.failedCount = 0;
|
|
2915
|
+
state.nextRetryUpdateId = undefined;
|
|
2916
|
+
state.nextRetryAtMs = undefined;
|
|
2917
|
+
state.nextRetryAttemptCount = undefined;
|
|
2918
|
+
state.nextRetryFailureClass = undefined;
|
|
2919
|
+
state.failedUpdateId = undefined;
|
|
2920
|
+
state.failedFailureId = undefined;
|
|
2921
|
+
state.failedAttemptCount = undefined;
|
|
2922
|
+
state.failedClass = undefined;
|
|
2923
|
+
state.failedSummary = undefined;
|
|
2924
|
+
state.terminalFailureAtMs = undefined;
|
|
2925
|
+
state.unsettledExecutionCount = unsettledExecutions.size;
|
|
2926
|
+
state.lastCompletedUpdateId = undefined;
|
|
2927
|
+
state.lastCompletedAtMs = undefined;
|
|
2928
|
+
state.lastFailureAtMs = undefined;
|
|
2929
|
+
state.lastFailurePhase = undefined;
|
|
2930
|
+
notifyStateChange();
|
|
2931
|
+
launchDrain();
|
|
2932
|
+
},
|
|
2933
|
+
signal() {
|
|
2934
|
+
if (!owner || owner.controller.signal.aborted) return;
|
|
2935
|
+
blocked = false;
|
|
2936
|
+
pendingSignal = true;
|
|
2937
|
+
launchDrain();
|
|
2938
|
+
},
|
|
2939
|
+
settleDeferred(input) {
|
|
2940
|
+
const expectedOwner = owner;
|
|
2941
|
+
if (
|
|
2942
|
+
!expectedOwner ||
|
|
2943
|
+
expectedOwner.controller.signal !== input.signal ||
|
|
2944
|
+
input.signal.aborted
|
|
2945
|
+
) {
|
|
2946
|
+
return;
|
|
2947
|
+
}
|
|
2948
|
+
const authorityResult = checkAuthority(expectedOwner, input.updateId);
|
|
2949
|
+
if (authorityResult) {
|
|
2950
|
+
if (authorityResult === "blocked") {
|
|
2951
|
+
releaseDeferredClaims(
|
|
2952
|
+
input.outcome.kind === "queued"
|
|
2953
|
+
? input.outcome.sourceUpdateIds
|
|
2954
|
+
: [input.updateId],
|
|
2955
|
+
);
|
|
2956
|
+
}
|
|
2957
|
+
return;
|
|
2958
|
+
}
|
|
2959
|
+
const claim = claims.get(input.updateId);
|
|
2960
|
+
if (input.outcome.kind === "deferred") {
|
|
2961
|
+
if (claim === "queued") return;
|
|
2962
|
+
if (claim !== "deferred") {
|
|
2963
|
+
blockWithFailure(
|
|
2964
|
+
"invalid-outcome",
|
|
2965
|
+
"late-outcome-unclaimed",
|
|
2966
|
+
new TelegramUpdateAdmissionOutcomeError(
|
|
2967
|
+
`Telegram update ${input.updateId} reported a late deferred outcome without a live claim.`,
|
|
2968
|
+
),
|
|
2969
|
+
input.updateId,
|
|
2970
|
+
);
|
|
2971
|
+
return;
|
|
2972
|
+
}
|
|
2973
|
+
transition("deferred", input.updateId);
|
|
2974
|
+
return;
|
|
2975
|
+
}
|
|
2976
|
+
let outcome: TelegramUpdateAdmissionOutcome;
|
|
2977
|
+
try {
|
|
2978
|
+
outcome = validateTelegramUpdateAdmissionOutcome(
|
|
2979
|
+
input.outcome,
|
|
2980
|
+
input.updateId,
|
|
2981
|
+
new Set([input.updateId, ...claims.keys()]),
|
|
2982
|
+
);
|
|
2983
|
+
} catch (error) {
|
|
2984
|
+
blockWithFailure(
|
|
2985
|
+
"invalid-outcome",
|
|
2986
|
+
"late-invalid-outcome",
|
|
2987
|
+
error,
|
|
2988
|
+
input.updateId,
|
|
2989
|
+
);
|
|
2990
|
+
releaseDeferredClaims(input.outcome.sourceUpdateIds);
|
|
2991
|
+
return;
|
|
2992
|
+
}
|
|
2993
|
+
if (outcome.kind !== "queued") {
|
|
2994
|
+
blockWithFailure(
|
|
2995
|
+
"invalid-outcome",
|
|
2996
|
+
"late-invalid-outcome",
|
|
2997
|
+
new TelegramUpdateAdmissionOutcomeError(
|
|
2998
|
+
`Telegram update ${input.updateId} reported a non-queue late outcome.`,
|
|
2999
|
+
),
|
|
3000
|
+
input.updateId,
|
|
3001
|
+
);
|
|
3002
|
+
return;
|
|
3003
|
+
}
|
|
3004
|
+
if (claim !== "deferred" && claim !== "queued") {
|
|
3005
|
+
blockWithFailure(
|
|
3006
|
+
"invalid-outcome",
|
|
3007
|
+
"late-outcome-unclaimed",
|
|
3008
|
+
new TelegramUpdateAdmissionOutcomeError(
|
|
3009
|
+
`Telegram update ${input.updateId} reported a late queue outcome without a live claim.`,
|
|
3010
|
+
),
|
|
3011
|
+
input.updateId,
|
|
3012
|
+
);
|
|
3013
|
+
releaseDeferredClaims(outcome.sourceUpdateIds);
|
|
3014
|
+
return;
|
|
3015
|
+
}
|
|
3016
|
+
const result = commitQueuedOutcome(
|
|
3017
|
+
expectedOwner,
|
|
3018
|
+
input.updateId,
|
|
3019
|
+
outcome,
|
|
3020
|
+
);
|
|
3021
|
+
if (result === "blocked") {
|
|
3022
|
+
releaseDeferredClaims(outcome.sourceUpdateIds);
|
|
3023
|
+
return;
|
|
3024
|
+
}
|
|
3025
|
+
if (result === "aborted") return;
|
|
3026
|
+
transition("queued", input.updateId);
|
|
3027
|
+
},
|
|
3028
|
+
isQueueReceiptCommitted(receipt) {
|
|
3029
|
+
const committed = committedQueueReceipts.get(receipt.receiptId);
|
|
3030
|
+
return (
|
|
3031
|
+
committed !== undefined &&
|
|
3032
|
+
areTelegramQueueAdmissionReceiptsEqual(
|
|
3033
|
+
committed.receipt,
|
|
3034
|
+
normalizeQueueReceipt(receipt),
|
|
3035
|
+
)
|
|
3036
|
+
);
|
|
3037
|
+
},
|
|
3038
|
+
getQueueReceiptOwner(receipt) {
|
|
3039
|
+
const committed = committedQueueReceipts.get(receipt.receiptId);
|
|
3040
|
+
return committed &&
|
|
3041
|
+
areTelegramQueueAdmissionReceiptsEqual(
|
|
3042
|
+
committed.receipt,
|
|
3043
|
+
normalizeQueueReceipt(receipt),
|
|
3044
|
+
)
|
|
3045
|
+
? { ...committed.queueOwner }
|
|
3046
|
+
: undefined;
|
|
3047
|
+
},
|
|
3048
|
+
completeQueueReceipts(input) {
|
|
3049
|
+
const expectedOwner = owner;
|
|
3050
|
+
if (
|
|
3051
|
+
!expectedOwner ||
|
|
3052
|
+
expectedOwner.controller.signal.aborted ||
|
|
3053
|
+
!(deps.isContextCurrent?.(input.ctx) ?? expectedOwner.ctx === input.ctx)
|
|
3054
|
+
) {
|
|
3055
|
+
return;
|
|
3056
|
+
}
|
|
3057
|
+
if (input.receipts.length === 0) return;
|
|
3058
|
+
const normalizedReceipts = input.receipts.map(normalizeQueueReceipt);
|
|
3059
|
+
const receiptIds = new Set<string>();
|
|
3060
|
+
const sourceUpdateIds = new Set<number>();
|
|
3061
|
+
const queuedCompletions: Array<{
|
|
3062
|
+
queueKind: "prompt" | "control";
|
|
3063
|
+
receiptId: string;
|
|
3064
|
+
sourceUpdateIds: readonly number[];
|
|
3065
|
+
queueOwner: TelegramUpdateJournalQueueOwner;
|
|
3066
|
+
}> = [];
|
|
3067
|
+
for (const receipt of normalizedReceipts) {
|
|
3068
|
+
const committed = committedQueueReceipts.get(receipt.receiptId);
|
|
3069
|
+
if (!committed) continue;
|
|
3070
|
+
if (
|
|
3071
|
+
receiptIds.has(receipt.receiptId) ||
|
|
3072
|
+
!areTelegramQueueAdmissionReceiptsEqual(
|
|
3073
|
+
committed.receipt,
|
|
3074
|
+
receipt,
|
|
3075
|
+
) ||
|
|
3076
|
+
!isTelegramUpdateJournalQueueOwnerProcess(
|
|
3077
|
+
committed.queueOwner,
|
|
3078
|
+
expectedOwner.queueOwnerIdentity,
|
|
3079
|
+
) ||
|
|
3080
|
+
receipt.sourceUpdateIds.some((updateId) =>
|
|
3081
|
+
sourceUpdateIds.has(updateId),
|
|
3082
|
+
)
|
|
3083
|
+
) {
|
|
3084
|
+
blockWithFailure(
|
|
3085
|
+
"invalid-outcome",
|
|
3086
|
+
"queue-receipt-completion-invalid",
|
|
3087
|
+
new TelegramUpdateAdmissionOutcomeError(
|
|
3088
|
+
`Telegram ${input.reason} requested invalid queue receipt ${receipt.receiptId}.`,
|
|
3089
|
+
),
|
|
3090
|
+
);
|
|
3091
|
+
return;
|
|
3092
|
+
}
|
|
3093
|
+
receiptIds.add(receipt.receiptId);
|
|
3094
|
+
queuedCompletions.push({
|
|
3095
|
+
...receipt,
|
|
3096
|
+
queueOwner: { ...committed.queueOwner },
|
|
3097
|
+
});
|
|
3098
|
+
for (const updateId of receipt.sourceUpdateIds) {
|
|
3099
|
+
sourceUpdateIds.add(updateId);
|
|
3100
|
+
}
|
|
3101
|
+
}
|
|
3102
|
+
if (receiptIds.size === 0) return;
|
|
3103
|
+
let removedUpdateIds: readonly number[];
|
|
3104
|
+
try {
|
|
3105
|
+
removedUpdateIds = deps.journal.completeQueued(
|
|
3106
|
+
queuedCompletions,
|
|
3107
|
+
).removedUpdateIds;
|
|
3108
|
+
} catch (error) {
|
|
3109
|
+
blockWithFailure(
|
|
3110
|
+
"journal-write",
|
|
3111
|
+
"queue-receipt-completion",
|
|
3112
|
+
error,
|
|
3113
|
+
);
|
|
3114
|
+
return;
|
|
3115
|
+
}
|
|
3116
|
+
const removed = new Set(removedUpdateIds);
|
|
3117
|
+
if (
|
|
3118
|
+
removed.size !== sourceUpdateIds.size ||
|
|
3119
|
+
[...sourceUpdateIds].some((updateId) => !removed.has(updateId))
|
|
3120
|
+
) {
|
|
3121
|
+
blockWithFailure(
|
|
3122
|
+
"journal-write",
|
|
3123
|
+
"queue-receipt-completion",
|
|
3124
|
+
new Error(
|
|
3125
|
+
`Telegram ${input.reason} did not complete every receipt source.`,
|
|
3126
|
+
),
|
|
3127
|
+
);
|
|
3128
|
+
return;
|
|
3129
|
+
}
|
|
3130
|
+
for (const updateId of sourceUpdateIds) claims.delete(updateId);
|
|
3131
|
+
for (const receiptId of receiptIds) {
|
|
3132
|
+
committedQueueReceipts.delete(receiptId);
|
|
3133
|
+
}
|
|
3134
|
+
const completedUpdateIds = [...sourceUpdateIds];
|
|
3135
|
+
state.lastCompletedUpdateId = Math.max(
|
|
3136
|
+
state.lastCompletedUpdateId ?? -1,
|
|
3137
|
+
...completedUpdateIds,
|
|
3138
|
+
);
|
|
3139
|
+
state.lastCompletedAtMs = getNowMs();
|
|
3140
|
+
state.journalEntryCount = Math.max(
|
|
3141
|
+
0,
|
|
3142
|
+
state.journalEntryCount - completedUpdateIds.length,
|
|
3143
|
+
);
|
|
3144
|
+
updateClaimCounts();
|
|
3145
|
+
notifyStateChange();
|
|
3146
|
+
pendingSignal = true;
|
|
3147
|
+
launchDrain();
|
|
3148
|
+
},
|
|
3149
|
+
async stop() {
|
|
3150
|
+
const expectedOwner = owner;
|
|
3151
|
+
if (!expectedOwner) return;
|
|
3152
|
+
pendingSignal = false;
|
|
3153
|
+
clearRetryTimer();
|
|
3154
|
+
expectedOwner.controller.abort();
|
|
3155
|
+
await drainPromise?.catch(() => undefined);
|
|
3156
|
+
if (owner !== expectedOwner) return;
|
|
3157
|
+
owner = undefined;
|
|
3158
|
+
drainPromise = undefined;
|
|
3159
|
+
claims.clear();
|
|
3160
|
+
committedQueueReceipts.clear();
|
|
3161
|
+
blocked = false;
|
|
3162
|
+
state.phase = "stopped";
|
|
3163
|
+
state.phaseStartedAtMs = getNowMs();
|
|
3164
|
+
state.currentUpdateId = undefined;
|
|
3165
|
+
state.blockedReason = undefined;
|
|
3166
|
+
state.deferredClaimCount = 0;
|
|
3167
|
+
state.queuedClaimCount = 0;
|
|
3168
|
+
state.foreignQueuedCount = 0;
|
|
3169
|
+
delete state.foreignQueuedOwner;
|
|
3170
|
+
state.retryWaitCount = 0;
|
|
3171
|
+
state.failedCount = 0;
|
|
3172
|
+
state.nextRetryUpdateId = undefined;
|
|
3173
|
+
state.nextRetryAtMs = undefined;
|
|
3174
|
+
state.nextRetryAttemptCount = undefined;
|
|
3175
|
+
state.nextRetryFailureClass = undefined;
|
|
3176
|
+
state.failedUpdateId = undefined;
|
|
3177
|
+
state.failedFailureId = undefined;
|
|
3178
|
+
state.failedAttemptCount = undefined;
|
|
3179
|
+
state.failedClass = undefined;
|
|
3180
|
+
state.failedSummary = undefined;
|
|
3181
|
+
state.terminalFailureAtMs = undefined;
|
|
3182
|
+
state.unsettledExecutionCount = unsettledExecutions.size;
|
|
3183
|
+
notifyStateChange();
|
|
3184
|
+
},
|
|
3185
|
+
waitForDrain() {
|
|
3186
|
+
return drainPromise ?? Promise.resolve();
|
|
3187
|
+
},
|
|
3188
|
+
getState() {
|
|
3189
|
+
return getTelegramUpdateWorkerStateSnapshot(state);
|
|
3190
|
+
},
|
|
3191
|
+
};
|
|
3192
|
+
}
|
|
3193
|
+
|
|
3194
|
+
// --- Public update handler registry ---
|
|
3195
|
+
|
|
3196
|
+
/**
|
|
3197
|
+
* Verdict returned by a public Telegram update handler.
|
|
3198
|
+
*
|
|
3199
|
+
* - `"consume"` — the handler processed this update; pi-telegram skips default routing.
|
|
3200
|
+
* - `"pass"` (or `void`/`undefined`) — pi-telegram routes the update normally.
|
|
3201
|
+
*/
|
|
3202
|
+
export type TelegramUpdateHandlerVerdict = "consume" | "pass";
|
|
3203
|
+
|
|
3204
|
+
export interface TelegramUpdateExecutionFence {
|
|
3205
|
+
readonly generation: number;
|
|
3206
|
+
readonly updateId: number;
|
|
3207
|
+
readonly signal: AbortSignal;
|
|
3208
|
+
isCurrent: () => boolean;
|
|
3209
|
+
assertCurrent: () => void;
|
|
3210
|
+
}
|
|
3211
|
+
|
|
3212
|
+
const TELEGRAM_UPDATE_EXECUTION_FENCE = Symbol(
|
|
3213
|
+
"pi-telegram.update-execution-fence",
|
|
3214
|
+
);
|
|
3215
|
+
|
|
3216
|
+
type TelegramExecutionFencedUpdate = {
|
|
3217
|
+
[TELEGRAM_UPDATE_EXECUTION_FENCE]?: TelegramUpdateExecutionFence;
|
|
3218
|
+
};
|
|
3219
|
+
|
|
3220
|
+
export function getTelegramUpdateExecutionFence(
|
|
3221
|
+
update: unknown,
|
|
3222
|
+
): TelegramUpdateExecutionFence | undefined {
|
|
3223
|
+
if (!update || typeof update !== "object") return undefined;
|
|
3224
|
+
return (update as TelegramExecutionFencedUpdate)[
|
|
3225
|
+
TELEGRAM_UPDATE_EXECUTION_FENCE
|
|
3226
|
+
];
|
|
3227
|
+
}
|
|
3228
|
+
|
|
3229
|
+
function bindTelegramUpdateExecutionFenceCarrier<TValue>(
|
|
3230
|
+
value: TValue | undefined,
|
|
3231
|
+
execution: TelegramUpdateExecutionFence,
|
|
3232
|
+
): TValue | undefined {
|
|
3233
|
+
if (!value || typeof value !== "object") return value;
|
|
3234
|
+
Object.defineProperty(value, TELEGRAM_UPDATE_EXECUTION_FENCE, {
|
|
3235
|
+
configurable: true,
|
|
3236
|
+
enumerable: false,
|
|
3237
|
+
value: execution,
|
|
3238
|
+
});
|
|
3239
|
+
return value;
|
|
3240
|
+
}
|
|
3241
|
+
|
|
3242
|
+
function bindTelegramUpdateExecutionFence<
|
|
3243
|
+
TUpdate extends TelegramUpdateFlow & object,
|
|
3244
|
+
>(
|
|
3245
|
+
update: TUpdate,
|
|
3246
|
+
execution: TelegramUpdateExecutionFence,
|
|
3247
|
+
): TUpdate {
|
|
3248
|
+
bindTelegramUpdateExecutionFenceCarrier(update, execution);
|
|
3249
|
+
bindTelegramUpdateExecutionFenceCarrier(update.message, execution);
|
|
3250
|
+
bindTelegramUpdateExecutionFenceCarrier(update.edited_message, execution);
|
|
3251
|
+
bindTelegramUpdateExecutionFenceCarrier(update.callback_query, execution);
|
|
3252
|
+
bindTelegramUpdateExecutionFenceCarrier(
|
|
3253
|
+
update.callback_query?.message,
|
|
3254
|
+
execution,
|
|
3255
|
+
);
|
|
3256
|
+
bindTelegramUpdateExecutionFenceCarrier(update.guest_message, execution);
|
|
3257
|
+
bindTelegramUpdateExecutionFenceCarrier(update.message_reaction, execution);
|
|
3258
|
+
return update;
|
|
3259
|
+
}
|
|
3260
|
+
|
|
3261
|
+
export function assertTelegramUpdateExecutionCurrent(update: unknown): void {
|
|
3262
|
+
getTelegramUpdateExecutionFence(update)?.assertCurrent();
|
|
3263
|
+
}
|
|
3264
|
+
|
|
3265
|
+
export function createTelegramUpdateExecutionFenceGuard(
|
|
3266
|
+
update: unknown,
|
|
3267
|
+
): () => void {
|
|
3268
|
+
const execution = getTelegramUpdateExecutionFence(update);
|
|
3269
|
+
return (): void => execution?.assertCurrent();
|
|
3270
|
+
}
|
|
3271
|
+
|
|
3272
|
+
export function carryTelegramUpdateExecutionFence<TTarget extends object>(
|
|
3273
|
+
source: unknown,
|
|
3274
|
+
target: TTarget,
|
|
3275
|
+
): TTarget {
|
|
3276
|
+
const execution = getTelegramUpdateExecutionFence(source);
|
|
3277
|
+
return execution
|
|
3278
|
+
? bindTelegramUpdateExecutionFenceCarrier(target, execution)!
|
|
3279
|
+
: target;
|
|
3280
|
+
}
|
|
3281
|
+
|
|
3282
|
+
export type TelegramUpdateHandler = (
|
|
3283
|
+
update: unknown,
|
|
3284
|
+
execution?: TelegramUpdateExecutionFence,
|
|
3285
|
+
) =>
|
|
3286
|
+
| TelegramUpdateHandlerVerdict
|
|
3287
|
+
| void
|
|
3288
|
+
| Promise<TelegramUpdateHandlerVerdict | void>;
|
|
3289
|
+
|
|
3290
|
+
export interface TelegramUpdateHandlerRegistry {
|
|
3291
|
+
/** Schema version of this registry shape. */
|
|
3292
|
+
readonly version: 1;
|
|
3293
|
+
/**
|
|
3294
|
+
* Register an update handler. Returns a disposer that removes it.
|
|
3295
|
+
*
|
|
3296
|
+
* Handlers are invoked in registration order on every Telegram update,
|
|
3297
|
+
* before pi-telegram's own routing. The first handler that returns
|
|
3298
|
+
* `"consume"` wins and stops the chain for that update.
|
|
3299
|
+
*/
|
|
3300
|
+
add: (handler: TelegramUpdateHandler) => () => void;
|
|
3301
|
+
/**
|
|
3302
|
+
* Run all registered handlers against an update.
|
|
3303
|
+
*
|
|
3304
|
+
* Used by pi-telegram's polling runtime; extension consumers should call
|
|
3305
|
+
* {@link registerTelegramUpdateHandler} or `add` instead of dispatching directly.
|
|
3306
|
+
*/
|
|
3307
|
+
dispatch: (
|
|
3308
|
+
update: unknown,
|
|
3309
|
+
execution?: TelegramUpdateExecutionFence,
|
|
3310
|
+
) => Promise<TelegramUpdateHandlerVerdict>;
|
|
3311
|
+
}
|
|
3312
|
+
|
|
3313
|
+
const UPDATE_HANDLER_REGISTRY_KEY = "__piTelegramUpdateHandlerRegistry__";
|
|
3314
|
+
|
|
3315
|
+
function isValidV1UpdateHandlerRegistry(
|
|
3316
|
+
candidate: unknown,
|
|
3317
|
+
): candidate is TelegramUpdateHandlerRegistry {
|
|
3318
|
+
if (!candidate || typeof candidate !== "object") return false;
|
|
3319
|
+
const r = candidate as Partial<TelegramUpdateHandlerRegistry>;
|
|
3320
|
+
return (
|
|
3321
|
+
r.version === 1 &&
|
|
3322
|
+
typeof r.add === "function" &&
|
|
3323
|
+
typeof r.dispatch === "function"
|
|
3324
|
+
);
|
|
3325
|
+
}
|
|
3326
|
+
|
|
3327
|
+
function getOrCreateUpdateHandlerRegistry(): TelegramUpdateHandlerRegistry {
|
|
3328
|
+
const g = globalThis as Record<string, unknown>;
|
|
3329
|
+
const existing = g[UPDATE_HANDLER_REGISTRY_KEY];
|
|
3330
|
+
if (isValidV1UpdateHandlerRegistry(existing)) return existing;
|
|
3331
|
+
const handlers = new Set<TelegramUpdateHandler>();
|
|
3332
|
+
const registry: TelegramUpdateHandlerRegistry = {
|
|
3333
|
+
version: 1,
|
|
3334
|
+
add(handler) {
|
|
3335
|
+
handlers.add(handler);
|
|
3336
|
+
return () => handlers.delete(handler);
|
|
3337
|
+
},
|
|
3338
|
+
async dispatch(update, execution) {
|
|
3339
|
+
for (const handler of handlers) {
|
|
3340
|
+
execution?.assertCurrent();
|
|
3341
|
+
try {
|
|
3342
|
+
const result = await handler(update, execution);
|
|
3343
|
+
if (result === "consume") return "consume";
|
|
3344
|
+
} catch {
|
|
3345
|
+
// Update handler errors must not break polling.
|
|
3346
|
+
}
|
|
3347
|
+
}
|
|
3348
|
+
return "pass";
|
|
3349
|
+
},
|
|
3350
|
+
};
|
|
3351
|
+
g[UPDATE_HANDLER_REGISTRY_KEY] = registry;
|
|
3352
|
+
return registry;
|
|
3353
|
+
}
|
|
3354
|
+
|
|
3355
|
+
/**
|
|
3356
|
+
* Called by pi-telegram's own runtime to obtain the registry it dispatches
|
|
3357
|
+
* through. Extension consumers should not call this; use
|
|
3358
|
+
* {@link registerTelegramUpdateHandler} instead.
|
|
3359
|
+
*/
|
|
3360
|
+
export function getTelegramUpdateHandlerRegistry(): TelegramUpdateHandlerRegistry {
|
|
3361
|
+
return getOrCreateUpdateHandlerRegistry();
|
|
3362
|
+
}
|
|
3363
|
+
|
|
3364
|
+
export interface TelegramUpdateHandlerWrapDeps<TUpdate, TContext> {
|
|
3365
|
+
defaultHandle: (update: TUpdate, ctx: TContext) => Promise<void>;
|
|
3366
|
+
registry?: TelegramUpdateHandlerRegistry;
|
|
3367
|
+
}
|
|
3368
|
+
|
|
3369
|
+
/**
|
|
3370
|
+
* Wrap a default polling `handleUpdate` with the public update handler registry.
|
|
3371
|
+
*/
|
|
3372
|
+
export function createTelegramUpdateHandle<TUpdate, TContext>(
|
|
1327
3373
|
deps: TelegramUpdateHandlerWrapDeps<TUpdate, TContext>,
|
|
1328
3374
|
): (update: TUpdate, ctx: TContext) => Promise<void> {
|
|
1329
3375
|
const registry = deps.registry ?? getOrCreateUpdateHandlerRegistry();
|
|
@@ -1335,6 +3381,1508 @@ export function createTelegramUpdateHandle<TUpdate, TContext>(
|
|
|
1335
3381
|
};
|
|
1336
3382
|
}
|
|
1337
3383
|
|
|
3384
|
+
export interface TelegramUpdateAdmissionHandleDeps<
|
|
3385
|
+
TUpdate extends TelegramUpdateFlow & { update_id: number },
|
|
3386
|
+
TContext,
|
|
3387
|
+
> {
|
|
3388
|
+
defaultHandle: (
|
|
3389
|
+
update: TUpdate,
|
|
3390
|
+
ctx: TContext,
|
|
3391
|
+
execution?: TelegramUpdateExecutionFence,
|
|
3392
|
+
) => Promise<void>;
|
|
3393
|
+
registry?: TelegramUpdateHandlerRegistry;
|
|
3394
|
+
onLateOutcome?: (
|
|
3395
|
+
outcome: Extract<
|
|
3396
|
+
TelegramUpdateAdmissionOutcome,
|
|
3397
|
+
{ kind: "deferred" | "queued" }
|
|
3398
|
+
>,
|
|
3399
|
+
details: {
|
|
3400
|
+
updateId: number;
|
|
3401
|
+
ctx: TContext;
|
|
3402
|
+
signal: AbortSignal;
|
|
3403
|
+
},
|
|
3404
|
+
) => void | Promise<void>;
|
|
3405
|
+
onLateOutcomeError?: (error: unknown, updateId: number) => void;
|
|
3406
|
+
}
|
|
3407
|
+
|
|
3408
|
+
function mergeTelegramReportedAdmissionOutcome(
|
|
3409
|
+
current:
|
|
3410
|
+
| Extract<
|
|
3411
|
+
TelegramUpdateAdmissionOutcome,
|
|
3412
|
+
{ kind: "deferred" | "queued" }
|
|
3413
|
+
>
|
|
3414
|
+
| undefined,
|
|
3415
|
+
next: Extract<
|
|
3416
|
+
TelegramUpdateAdmissionOutcome,
|
|
3417
|
+
{ kind: "deferred" | "queued" }
|
|
3418
|
+
>,
|
|
3419
|
+
updateId: number,
|
|
3420
|
+
): Extract<
|
|
3421
|
+
TelegramUpdateAdmissionOutcome,
|
|
3422
|
+
{ kind: "deferred" | "queued" }
|
|
3423
|
+
> {
|
|
3424
|
+
if (!current || current.kind === "deferred") return next;
|
|
3425
|
+
if (next.kind === "deferred") return current;
|
|
3426
|
+
if (areTelegramQueueAdmissionReceiptsEqual(current, next)) return current;
|
|
3427
|
+
throw new TelegramUpdateAdmissionOutcomeError(
|
|
3428
|
+
`Telegram update ${updateId} reported conflicting queue outcomes.`,
|
|
3429
|
+
);
|
|
3430
|
+
}
|
|
3431
|
+
|
|
3432
|
+
/**
|
|
3433
|
+
* Compose the stable public handler registry with source-bound semantic
|
|
3434
|
+
* admission. Production polling switches to this only with the journal worker.
|
|
3435
|
+
*/
|
|
3436
|
+
export function createTelegramUpdateAdmissionHandle<
|
|
3437
|
+
TUpdate extends TelegramUpdateFlow & { update_id: number },
|
|
3438
|
+
TContext,
|
|
3439
|
+
>(
|
|
3440
|
+
deps: TelegramUpdateAdmissionHandleDeps<TUpdate, TContext>,
|
|
3441
|
+
): (
|
|
3442
|
+
update: TUpdate,
|
|
3443
|
+
ctx: TContext,
|
|
3444
|
+
signal: AbortSignal,
|
|
3445
|
+
) => Promise<TelegramUpdateAdmissionOutcome> {
|
|
3446
|
+
if (deps.onLateOutcome && !deps.onLateOutcomeError) {
|
|
3447
|
+
throw new Error(
|
|
3448
|
+
"Telegram late admission outcomes require a diagnostic error sink.",
|
|
3449
|
+
);
|
|
3450
|
+
}
|
|
3451
|
+
const registry = deps.registry ?? getOrCreateUpdateHandlerRegistry();
|
|
3452
|
+
let nextExecutionGeneration = 0;
|
|
3453
|
+
return async (update, ctx, signal) => {
|
|
3454
|
+
const generation = ++nextExecutionGeneration;
|
|
3455
|
+
const execution: TelegramUpdateExecutionFence = {
|
|
3456
|
+
generation,
|
|
3457
|
+
updateId: update.update_id,
|
|
3458
|
+
signal,
|
|
3459
|
+
isCurrent: () => !signal.aborted,
|
|
3460
|
+
assertCurrent() {
|
|
3461
|
+
if (signal.aborted) {
|
|
3462
|
+
throw signal.reason ?? new DOMException("Aborted", "AbortError");
|
|
3463
|
+
}
|
|
3464
|
+
},
|
|
3465
|
+
};
|
|
3466
|
+
execution.assertCurrent();
|
|
3467
|
+
const verdict = await registry.dispatch(update, execution);
|
|
3468
|
+
execution.assertCurrent();
|
|
3469
|
+
if (verdict === "consume") return { kind: "complete" };
|
|
3470
|
+
let immediate = true;
|
|
3471
|
+
let outcome:
|
|
3472
|
+
| Extract<
|
|
3473
|
+
TelegramUpdateAdmissionOutcome,
|
|
3474
|
+
{ kind: "deferred" | "queued" }
|
|
3475
|
+
>
|
|
3476
|
+
| undefined;
|
|
3477
|
+
const boundUpdate = bindTelegramUpdateExecutionFence(
|
|
3478
|
+
bindTelegramUpdateAdmissionSource(update, (next) => {
|
|
3479
|
+
if (immediate) {
|
|
3480
|
+
outcome = mergeTelegramReportedAdmissionOutcome(
|
|
3481
|
+
outcome,
|
|
3482
|
+
next,
|
|
3483
|
+
update.update_id,
|
|
3484
|
+
);
|
|
3485
|
+
return;
|
|
3486
|
+
}
|
|
3487
|
+
if (!deps.onLateOutcome) {
|
|
3488
|
+
throw new TelegramUpdateAdmissionOutcomeError(
|
|
3489
|
+
`Telegram update ${update.update_id} reported a late outcome without an owner.`,
|
|
3490
|
+
);
|
|
3491
|
+
}
|
|
3492
|
+
void Promise.resolve()
|
|
3493
|
+
.then(() =>
|
|
3494
|
+
deps.onLateOutcome!(next, {
|
|
3495
|
+
updateId: update.update_id,
|
|
3496
|
+
ctx,
|
|
3497
|
+
signal,
|
|
3498
|
+
}),
|
|
3499
|
+
)
|
|
3500
|
+
.catch((error) => {
|
|
3501
|
+
try {
|
|
3502
|
+
deps.onLateOutcomeError?.(error, update.update_id);
|
|
3503
|
+
} catch {
|
|
3504
|
+
// Diagnostic sinks must not create an unhandled late Promise.
|
|
3505
|
+
}
|
|
3506
|
+
});
|
|
3507
|
+
}),
|
|
3508
|
+
execution,
|
|
3509
|
+
);
|
|
3510
|
+
try {
|
|
3511
|
+
execution.assertCurrent();
|
|
3512
|
+
await deps.defaultHandle(boundUpdate, ctx, execution);
|
|
3513
|
+
} finally {
|
|
3514
|
+
immediate = false;
|
|
3515
|
+
}
|
|
3516
|
+
return outcome ?? { kind: "complete" };
|
|
3517
|
+
};
|
|
3518
|
+
}
|
|
3519
|
+
|
|
3520
|
+
export interface TelegramQueueAdmissionItemLike {
|
|
3521
|
+
admissionReceipts?: readonly TelegramQueueAdmissionReceiptLike[];
|
|
3522
|
+
}
|
|
3523
|
+
|
|
3524
|
+
export interface TelegramQueueAdmissionSettlementRuntime<TContext> {
|
|
3525
|
+
isItemReady: (item: TelegramQueueAdmissionItemLike) => boolean;
|
|
3526
|
+
getQueueReceiptOwner: (
|
|
3527
|
+
receipt: TelegramQueueAdmissionReceiptLike,
|
|
3528
|
+
) => TelegramUpdateJournalQueueOwner | undefined;
|
|
3529
|
+
onPromptHandedOff: (
|
|
3530
|
+
item: TelegramQueueAdmissionItemLike,
|
|
3531
|
+
ctx: TContext,
|
|
3532
|
+
) => void;
|
|
3533
|
+
onControlSettled: (
|
|
3534
|
+
item: TelegramQueueAdmissionItemLike,
|
|
3535
|
+
ctx: TContext,
|
|
3536
|
+
) => void;
|
|
3537
|
+
onItemsDiscarded: (
|
|
3538
|
+
items: readonly TelegramQueueAdmissionItemLike[],
|
|
3539
|
+
ctx: TContext,
|
|
3540
|
+
) => void;
|
|
3541
|
+
}
|
|
3542
|
+
|
|
3543
|
+
export function createTelegramQueueAdmissionSettlementMuxRuntime<TContext>(
|
|
3544
|
+
runtimes: readonly TelegramQueueAdmissionSettlementRuntime<TContext>[],
|
|
3545
|
+
): TelegramQueueAdmissionSettlementRuntime<TContext> {
|
|
3546
|
+
const settle = (
|
|
3547
|
+
operation: (
|
|
3548
|
+
runtime: TelegramQueueAdmissionSettlementRuntime<TContext>,
|
|
3549
|
+
) => void,
|
|
3550
|
+
): void => {
|
|
3551
|
+
for (const runtime of runtimes) operation(runtime);
|
|
3552
|
+
};
|
|
3553
|
+
return {
|
|
3554
|
+
isItemReady: (item) =>
|
|
3555
|
+
(item.admissionReceipts ?? []).every((receipt) =>
|
|
3556
|
+
runtimes.some((runtime) =>
|
|
3557
|
+
runtime.isItemReady({ admissionReceipts: [receipt] }),
|
|
3558
|
+
),
|
|
3559
|
+
),
|
|
3560
|
+
getQueueReceiptOwner(receipt) {
|
|
3561
|
+
let owner: TelegramUpdateJournalQueueOwner | undefined;
|
|
3562
|
+
for (const runtime of runtimes) {
|
|
3563
|
+
const candidate = runtime.getQueueReceiptOwner(receipt);
|
|
3564
|
+
if (!candidate) continue;
|
|
3565
|
+
if (
|
|
3566
|
+
owner &&
|
|
3567
|
+
!areTelegramUpdateJournalQueueOwnersEqual(owner, candidate)
|
|
3568
|
+
) {
|
|
3569
|
+
throw new TelegramUpdateAdmissionOutcomeError(
|
|
3570
|
+
`Telegram queue receipt ${receipt.receiptId} has multiple live owners.`,
|
|
3571
|
+
);
|
|
3572
|
+
}
|
|
3573
|
+
owner = candidate;
|
|
3574
|
+
}
|
|
3575
|
+
return owner ? { ...owner } : undefined;
|
|
3576
|
+
},
|
|
3577
|
+
onPromptHandedOff: (item, ctx) =>
|
|
3578
|
+
settle((runtime) => runtime.onPromptHandedOff(item, ctx)),
|
|
3579
|
+
onControlSettled: (item, ctx) =>
|
|
3580
|
+
settle((runtime) => runtime.onControlSettled(item, ctx)),
|
|
3581
|
+
onItemsDiscarded: (items, ctx) =>
|
|
3582
|
+
settle((runtime) => runtime.onItemsDiscarded(items, ctx)),
|
|
3583
|
+
};
|
|
3584
|
+
}
|
|
3585
|
+
|
|
3586
|
+
export function createTelegramQueueAdmissionSettlementRuntime<TContext>(
|
|
3587
|
+
worker: TelegramUpdateWorkerRuntime<TContext>,
|
|
3588
|
+
): TelegramQueueAdmissionSettlementRuntime<TContext> {
|
|
3589
|
+
const complete = (
|
|
3590
|
+
items: readonly TelegramQueueAdmissionItemLike[],
|
|
3591
|
+
ctx: TContext,
|
|
3592
|
+
reason: TelegramQueueReceiptCompletionReason,
|
|
3593
|
+
): void => {
|
|
3594
|
+
const receipts: TelegramQueueAdmissionReceiptLike[] = [];
|
|
3595
|
+
for (const item of items) {
|
|
3596
|
+
if (item.admissionReceipts) receipts.push(...item.admissionReceipts);
|
|
3597
|
+
}
|
|
3598
|
+
worker.completeQueueReceipts({ receipts, ctx, reason });
|
|
3599
|
+
};
|
|
3600
|
+
return {
|
|
3601
|
+
isItemReady: (item) =>
|
|
3602
|
+
(item.admissionReceipts ?? []).every(
|
|
3603
|
+
worker.isQueueReceiptCommitted,
|
|
3604
|
+
),
|
|
3605
|
+
getQueueReceiptOwner: worker.getQueueReceiptOwner,
|
|
3606
|
+
onPromptHandedOff: (item, ctx) =>
|
|
3607
|
+
complete([item], ctx, "prompt-handoff"),
|
|
3608
|
+
onControlSettled: (item, ctx) =>
|
|
3609
|
+
complete([item], ctx, "control-settlement"),
|
|
3610
|
+
onItemsDiscarded: (items, ctx) =>
|
|
3611
|
+
complete(items, ctx, "discard"),
|
|
3612
|
+
};
|
|
3613
|
+
}
|
|
3614
|
+
|
|
3615
|
+
export interface TelegramUpdateAdmissionLifecycleJournalBinding {
|
|
3616
|
+
runtimeKey: string;
|
|
3617
|
+
recoveryKey: string;
|
|
3618
|
+
journal: TelegramUpdateWorkerJournalPort & {
|
|
3619
|
+
appendBatch: (updates: readonly TelegramJournaledUpdate[]) => unknown;
|
|
3620
|
+
applyOperatorDisposition?: (
|
|
3621
|
+
input: TelegramUpdateJournalOperatorDispositionInput,
|
|
3622
|
+
) => TelegramUpdateJournalOperatorDispositionResult;
|
|
3623
|
+
discardQueued?: (input: {
|
|
3624
|
+
queueKind: "prompt" | "control";
|
|
3625
|
+
receiptId: string;
|
|
3626
|
+
sourceUpdateIds: readonly number[];
|
|
3627
|
+
expectedOwner: TelegramUpdateJournalQueueOwner;
|
|
3628
|
+
}) => TelegramUpdateJournalQueueDiscardResult;
|
|
3629
|
+
offerQueuedHandoff?: (
|
|
3630
|
+
input: TelegramUpdateJournalQueueHandoffInput,
|
|
3631
|
+
) => TelegramUpdateJournalQueueHandoffOfferResult;
|
|
3632
|
+
acceptQueuedHandoff?: (
|
|
3633
|
+
input: TelegramUpdateJournalQueueHandoffInput,
|
|
3634
|
+
) => TelegramUpdateJournalQueueHandoffAcceptResult;
|
|
3635
|
+
cancelQueuedHandoff?: (
|
|
3636
|
+
input: TelegramUpdateJournalQueueHandoffInput,
|
|
3637
|
+
) => TelegramUpdateJournalQueueHandoffCancelResult;
|
|
3638
|
+
recoverDeadQueueOwner?: (input: {
|
|
3639
|
+
queueKind: "prompt" | "control";
|
|
3640
|
+
receiptId: string;
|
|
3641
|
+
sourceUpdateIds: readonly number[];
|
|
3642
|
+
deadOwner: TelegramUpdateJournalQueueOwner;
|
|
3643
|
+
recoveryOwner: TelegramUpdateJournalQueueOwnerIdentity;
|
|
3644
|
+
}) => TelegramUpdateJournalDeadQueueOwnerRecoveryResult;
|
|
3645
|
+
};
|
|
3646
|
+
hasAuthority?: () => boolean;
|
|
3647
|
+
}
|
|
3648
|
+
|
|
3649
|
+
export interface TelegramQueueHandoffControlExecutionDeps<TContext> {
|
|
3650
|
+
isContextCurrent: (ctx: TContext) => boolean;
|
|
3651
|
+
showStatus: (
|
|
3652
|
+
chatId: number,
|
|
3653
|
+
replyToMessageId: number,
|
|
3654
|
+
ctx: TContext,
|
|
3655
|
+
threadId?: number,
|
|
3656
|
+
) => Promise<void>;
|
|
3657
|
+
openModelMenu: (
|
|
3658
|
+
chatId: number,
|
|
3659
|
+
replyToMessageId: number,
|
|
3660
|
+
ctx: TContext,
|
|
3661
|
+
threadId?: number,
|
|
3662
|
+
) => Promise<void>;
|
|
3663
|
+
}
|
|
3664
|
+
|
|
3665
|
+
export function createTelegramQueueHandoffControlExecutionFactory<TContext>(
|
|
3666
|
+
deps: TelegramQueueHandoffControlExecutionDeps<TContext>,
|
|
3667
|
+
): (
|
|
3668
|
+
payload: TelegramControlQueueHandoffPayload,
|
|
3669
|
+
) => PendingTelegramControlItem<TContext>["execute"] {
|
|
3670
|
+
return (payload) => async (ctx) => {
|
|
3671
|
+
if (!deps.isContextCurrent(ctx)) return;
|
|
3672
|
+
if (payload.controlType === "status") {
|
|
3673
|
+
await deps.showStatus(
|
|
3674
|
+
payload.chatId,
|
|
3675
|
+
payload.replyToMessageId,
|
|
3676
|
+
ctx,
|
|
3677
|
+
payload.target?.threadId,
|
|
3678
|
+
);
|
|
3679
|
+
return;
|
|
3680
|
+
}
|
|
3681
|
+
await deps.openModelMenu(
|
|
3682
|
+
payload.chatId,
|
|
3683
|
+
payload.replyToMessageId,
|
|
3684
|
+
ctx,
|
|
3685
|
+
payload.target?.threadId,
|
|
3686
|
+
);
|
|
3687
|
+
};
|
|
3688
|
+
}
|
|
3689
|
+
|
|
3690
|
+
export interface TelegramQueueHandoffCoordinatorInput<TContext> {
|
|
3691
|
+
item: TelegramQueueItem<TContext>;
|
|
3692
|
+
expectedOwner: TelegramUpdateJournalQueueOwner;
|
|
3693
|
+
recipientOwner: TelegramUpdateJournalQueueOwnerIdentity;
|
|
3694
|
+
handoffToken: string;
|
|
3695
|
+
stageRemote: (input: {
|
|
3696
|
+
handoffToken: string;
|
|
3697
|
+
expectedOwner: TelegramUpdateJournalQueueOwner;
|
|
3698
|
+
recipientOwner: TelegramUpdateJournalQueueOwnerIdentity;
|
|
3699
|
+
payload: TelegramQueueHandoffPayload;
|
|
3700
|
+
}) => Promise<TelegramQueueHandoffStageResult>;
|
|
3701
|
+
lifecycle: Pick<
|
|
3702
|
+
TelegramUpdateAdmissionLifecycleRuntime<TContext>,
|
|
3703
|
+
| "offerQueueReceiptHandoff"
|
|
3704
|
+
| "acceptQueueReceiptHandoff"
|
|
3705
|
+
| "cancelQueueReceiptHandoff"
|
|
3706
|
+
>;
|
|
3707
|
+
removeDonorItem: (receipt: TelegramQueueAdmissionReceipt) => boolean;
|
|
3708
|
+
}
|
|
3709
|
+
|
|
3710
|
+
export type TelegramQueueHandoffCoordinatorResult =
|
|
3711
|
+
| {
|
|
3712
|
+
status: "transferred";
|
|
3713
|
+
receipt: TelegramQueueAdmissionReceipt;
|
|
3714
|
+
queueOwner: TelegramUpdateJournalQueueOwner;
|
|
3715
|
+
}
|
|
3716
|
+
| {
|
|
3717
|
+
status: "retained";
|
|
3718
|
+
receipt: TelegramQueueAdmissionReceipt;
|
|
3719
|
+
error: unknown;
|
|
3720
|
+
cancelled: boolean;
|
|
3721
|
+
};
|
|
3722
|
+
|
|
3723
|
+
function assertTelegramQueueHandoffStageMatches(
|
|
3724
|
+
stage: TelegramQueueHandoffStageResult,
|
|
3725
|
+
receipt: TelegramQueueAdmissionReceipt,
|
|
3726
|
+
): void {
|
|
3727
|
+
if (
|
|
3728
|
+
stage.status !== "staged" ||
|
|
3729
|
+
stage.receiptId !== receipt.receiptId ||
|
|
3730
|
+
stage.sourceUpdateIds.length !== receipt.sourceUpdateIds.length ||
|
|
3731
|
+
stage.sourceUpdateIds.some(
|
|
3732
|
+
(updateId, index) => updateId !== receipt.sourceUpdateIds[index],
|
|
3733
|
+
)
|
|
3734
|
+
) {
|
|
3735
|
+
throw new Error(
|
|
3736
|
+
"Telegram queue handoff staging returned a mismatched receipt.",
|
|
3737
|
+
);
|
|
3738
|
+
}
|
|
3739
|
+
}
|
|
3740
|
+
|
|
3741
|
+
export async function coordinateTelegramQueueHandoff<TContext>(
|
|
3742
|
+
input: TelegramQueueHandoffCoordinatorInput<TContext>,
|
|
3743
|
+
): Promise<TelegramQueueHandoffCoordinatorResult> {
|
|
3744
|
+
const handoff = createTelegramQueueHandoff({
|
|
3745
|
+
handoffToken: input.handoffToken,
|
|
3746
|
+
item: input.item,
|
|
3747
|
+
});
|
|
3748
|
+
const receipt = handoff.payload.admissionReceipts[0];
|
|
3749
|
+
if (!receipt || handoff.payload.admissionReceipts.length !== 1) {
|
|
3750
|
+
throw new Error(
|
|
3751
|
+
"Telegram queue handoff requires exactly one complete receipt.",
|
|
3752
|
+
);
|
|
3753
|
+
}
|
|
3754
|
+
const handoffInput: TelegramUpdateJournalQueueHandoffInput = {
|
|
3755
|
+
queueKind: receipt.queueKind,
|
|
3756
|
+
receiptId: receipt.receiptId,
|
|
3757
|
+
sourceUpdateIds: receipt.sourceUpdateIds,
|
|
3758
|
+
expectedOwner: input.expectedOwner,
|
|
3759
|
+
recipientOwner: input.recipientOwner,
|
|
3760
|
+
handoffToken: input.handoffToken,
|
|
3761
|
+
};
|
|
3762
|
+
input.lifecycle.offerQueueReceiptHandoff(handoffInput);
|
|
3763
|
+
let stage: TelegramQueueHandoffStageResult;
|
|
3764
|
+
try {
|
|
3765
|
+
stage = await input.stageRemote({
|
|
3766
|
+
handoffToken: input.handoffToken,
|
|
3767
|
+
expectedOwner: input.expectedOwner,
|
|
3768
|
+
recipientOwner: input.recipientOwner,
|
|
3769
|
+
payload: handoff.payload,
|
|
3770
|
+
});
|
|
3771
|
+
assertTelegramQueueHandoffStageMatches(stage, receipt);
|
|
3772
|
+
} catch (error) {
|
|
3773
|
+
let cancelled = false;
|
|
3774
|
+
try {
|
|
3775
|
+
input.lifecycle.cancelQueueReceiptHandoff(handoffInput);
|
|
3776
|
+
cancelled = true;
|
|
3777
|
+
} catch {
|
|
3778
|
+
return {
|
|
3779
|
+
status: "retained",
|
|
3780
|
+
receipt: { ...receipt, sourceUpdateIds: [...receipt.sourceUpdateIds] },
|
|
3781
|
+
error,
|
|
3782
|
+
cancelled: false,
|
|
3783
|
+
};
|
|
3784
|
+
}
|
|
3785
|
+
return {
|
|
3786
|
+
status: "retained",
|
|
3787
|
+
receipt: { ...receipt, sourceUpdateIds: [...receipt.sourceUpdateIds] },
|
|
3788
|
+
error,
|
|
3789
|
+
cancelled,
|
|
3790
|
+
};
|
|
3791
|
+
}
|
|
3792
|
+
let donorRemoved = false;
|
|
3793
|
+
try {
|
|
3794
|
+
donorRemoved = input.removeDonorItem(receipt);
|
|
3795
|
+
} catch (error) {
|
|
3796
|
+
throw new Error(
|
|
3797
|
+
`Telegram queue handoff donor removal failed after acceptance: ${error instanceof Error ? error.message : String(error)}`,
|
|
3798
|
+
);
|
|
3799
|
+
}
|
|
3800
|
+
if (!donorRemoved) {
|
|
3801
|
+
throw new Error(
|
|
3802
|
+
`Telegram queue handoff donor item ${receipt.receiptId} disappeared after acceptance.`,
|
|
3803
|
+
);
|
|
3804
|
+
}
|
|
3805
|
+
return {
|
|
3806
|
+
status: "transferred",
|
|
3807
|
+
receipt: { ...receipt, sourceUpdateIds: [...receipt.sourceUpdateIds] },
|
|
3808
|
+
queueOwner: { ...stage.queueOwner },
|
|
3809
|
+
};
|
|
3810
|
+
}
|
|
3811
|
+
|
|
3812
|
+
export interface TelegramQueueHandoffReconciliationBinding<TContext> {
|
|
3813
|
+
request: (ctx: TContext) => void;
|
|
3814
|
+
set: (reconcile: (ctx: TContext) => Promise<void>) => void;
|
|
3815
|
+
}
|
|
3816
|
+
|
|
3817
|
+
export function createTelegramQueueHandoffReconciliationBinding<TContext>(
|
|
3818
|
+
recordFailure?: (error: unknown) => void,
|
|
3819
|
+
): TelegramQueueHandoffReconciliationBinding<TContext> {
|
|
3820
|
+
let reconcile: ((ctx: TContext) => Promise<void>) | undefined;
|
|
3821
|
+
return {
|
|
3822
|
+
request(ctx) {
|
|
3823
|
+
void reconcile?.(ctx).catch((error) => recordFailure?.(error));
|
|
3824
|
+
},
|
|
3825
|
+
set(next) {
|
|
3826
|
+
reconcile = next;
|
|
3827
|
+
},
|
|
3828
|
+
};
|
|
3829
|
+
}
|
|
3830
|
+
|
|
3831
|
+
export interface TelegramQueueHandoffRecipientRuntimeDeps<TContext> {
|
|
3832
|
+
staging: TelegramQueueHandoffStagingRuntime;
|
|
3833
|
+
getRecipientOwner: () => TelegramUpdateJournalQueueOwnerIdentity;
|
|
3834
|
+
getLifecycleForBinding: (
|
|
3835
|
+
journalBindingKey: string,
|
|
3836
|
+
) => TelegramUpdateAdmissionLifecycleRuntime<TContext> | undefined;
|
|
3837
|
+
isTransportStampActive?: (
|
|
3838
|
+
stamp: TelegramQueueHandoffPayload["transportStamp"],
|
|
3839
|
+
) => boolean;
|
|
3840
|
+
dispatchNext: (ctx: TContext) => void;
|
|
3841
|
+
}
|
|
3842
|
+
|
|
3843
|
+
export function createTelegramQueueHandoffRecipientRuntime<TContext>(
|
|
3844
|
+
deps: TelegramQueueHandoffRecipientRuntimeDeps<TContext>,
|
|
3845
|
+
): (
|
|
3846
|
+
envelope: Extract<
|
|
3847
|
+
TelegramBusEnvelope,
|
|
3848
|
+
{ kind: "leader.offerQueueHandoff" }
|
|
3849
|
+
>,
|
|
3850
|
+
ctx: TContext,
|
|
3851
|
+
) => Promise<TelegramQueueHandoffStageResult> {
|
|
3852
|
+
return async (envelope, ctx) => {
|
|
3853
|
+
const stage = deps.staging.stage(envelope.payload);
|
|
3854
|
+
const receipt = envelope.payload.admissionReceipts[0];
|
|
3855
|
+
if (!receipt || envelope.payload.admissionReceipts.length !== 1) {
|
|
3856
|
+
deps.staging.cancel(
|
|
3857
|
+
receipt ?? {
|
|
3858
|
+
queueKind: envelope.payload.kind,
|
|
3859
|
+
receiptId: stage.receiptId,
|
|
3860
|
+
sourceUpdateIds: stage.sourceUpdateIds,
|
|
3861
|
+
},
|
|
3862
|
+
);
|
|
3863
|
+
throw new Error(
|
|
3864
|
+
"Telegram queue handoff requires exactly one complete receipt.",
|
|
3865
|
+
);
|
|
3866
|
+
}
|
|
3867
|
+
const journalBindingKey = receipt.journalBindingKey;
|
|
3868
|
+
if (!journalBindingKey) {
|
|
3869
|
+
deps.staging.cancel(receipt);
|
|
3870
|
+
throw new Error(
|
|
3871
|
+
"Telegram queue handoff receipt omitted its journal binding.",
|
|
3872
|
+
);
|
|
3873
|
+
}
|
|
3874
|
+
if (
|
|
3875
|
+
deps.isTransportStampActive &&
|
|
3876
|
+
!deps.isTransportStampActive(envelope.payload.transportStamp)
|
|
3877
|
+
) {
|
|
3878
|
+
deps.staging.cancel(receipt);
|
|
3879
|
+
throw new Error(
|
|
3880
|
+
"Telegram queue handoff payload belongs to an inactive transport generation.",
|
|
3881
|
+
);
|
|
3882
|
+
}
|
|
3883
|
+
const lifecycle = deps.getLifecycleForBinding(journalBindingKey);
|
|
3884
|
+
if (!lifecycle) {
|
|
3885
|
+
deps.staging.cancel(receipt);
|
|
3886
|
+
throw new Error(
|
|
3887
|
+
"Telegram queue handoff journal binding is not active.",
|
|
3888
|
+
);
|
|
3889
|
+
}
|
|
3890
|
+
const donorOwner: TelegramUpdateJournalQueueOwner = {
|
|
3891
|
+
instanceId: envelope.donorInstanceId,
|
|
3892
|
+
processId: envelope.donorProcessId,
|
|
3893
|
+
processBirthId: envelope.donorProcessBirthId,
|
|
3894
|
+
sessionGeneration: envelope.donorSessionGeneration,
|
|
3895
|
+
acquisitionId: envelope.donorAcquisitionId,
|
|
3896
|
+
acquiredAtMs: envelope.donorAcquiredAtMs,
|
|
3897
|
+
};
|
|
3898
|
+
let accepted: TelegramUpdateJournalQueueHandoffAcceptResult;
|
|
3899
|
+
try {
|
|
3900
|
+
accepted = lifecycle.acceptQueueReceiptHandoff({
|
|
3901
|
+
queueKind: receipt.queueKind,
|
|
3902
|
+
receiptId: receipt.receiptId,
|
|
3903
|
+
sourceUpdateIds: receipt.sourceUpdateIds,
|
|
3904
|
+
expectedOwner: donorOwner,
|
|
3905
|
+
recipientOwner: deps.getRecipientOwner(),
|
|
3906
|
+
handoffToken: envelope.handoffToken,
|
|
3907
|
+
});
|
|
3908
|
+
await lifecycle.publishAcceptedQueueReceipt({
|
|
3909
|
+
receipt,
|
|
3910
|
+
queueOwner: accepted.queueOwner,
|
|
3911
|
+
ctx,
|
|
3912
|
+
});
|
|
3913
|
+
if (!deps.staging.accept(receipt)) {
|
|
3914
|
+
throw new Error(
|
|
3915
|
+
"Telegram queue handoff payload disappeared before readiness publication.",
|
|
3916
|
+
);
|
|
3917
|
+
}
|
|
3918
|
+
} catch (error) {
|
|
3919
|
+
deps.staging.cancel(receipt);
|
|
3920
|
+
throw error;
|
|
3921
|
+
}
|
|
3922
|
+
deps.dispatchNext(ctx);
|
|
3923
|
+
return { ...stage, queueOwner: { ...accepted.queueOwner } };
|
|
3924
|
+
};
|
|
3925
|
+
}
|
|
3926
|
+
|
|
3927
|
+
export interface TelegramQueueHandoffReconcilerDeps<TContext> {
|
|
3928
|
+
ownsDirect: () => boolean;
|
|
3929
|
+
isFollowerRegistered: () => boolean;
|
|
3930
|
+
isBusEnabled: () => boolean;
|
|
3931
|
+
canHandoffWithLeader?: () => boolean;
|
|
3932
|
+
listFollowers: () => readonly TelegramBusFollowerView[];
|
|
3933
|
+
createRecipientJournalBindingKey: (
|
|
3934
|
+
recipient: TelegramBusFollowerView,
|
|
3935
|
+
) => string | undefined;
|
|
3936
|
+
getQueuedItems: () => readonly TelegramQueueItem<TContext>[];
|
|
3937
|
+
getReceiptOwner: (
|
|
3938
|
+
receipt: TelegramQueueAdmissionReceipt,
|
|
3939
|
+
) => TelegramUpdateJournalQueueOwner | undefined;
|
|
3940
|
+
getLifecycleForReceipt: (
|
|
3941
|
+
receipt: TelegramQueueAdmissionReceipt,
|
|
3942
|
+
) => TelegramUpdateAdmissionLifecycleRuntime<TContext> | undefined;
|
|
3943
|
+
createHandoffToken: () => string;
|
|
3944
|
+
createRequestId: () => string;
|
|
3945
|
+
donorInstanceId: string;
|
|
3946
|
+
authSecret?: string;
|
|
3947
|
+
stageThroughFollower: (input: {
|
|
3948
|
+
recipient: TelegramBusFollowerView;
|
|
3949
|
+
expectedOwner: TelegramUpdateJournalQueueOwner;
|
|
3950
|
+
handoffToken: string;
|
|
3951
|
+
payload: TelegramQueueHandoffPayload;
|
|
3952
|
+
}) => Promise<TelegramQueueHandoffStageResult>;
|
|
3953
|
+
routeThroughLeader: (input: {
|
|
3954
|
+
requestId: string;
|
|
3955
|
+
auth?: string;
|
|
3956
|
+
recipientInstanceId: string;
|
|
3957
|
+
recipientRegistrationGeneration: string;
|
|
3958
|
+
donorInstanceId: string;
|
|
3959
|
+
donorProcessId: number;
|
|
3960
|
+
donorProcessBirthId: string;
|
|
3961
|
+
donorSessionGeneration: number;
|
|
3962
|
+
donorAcquisitionId: string;
|
|
3963
|
+
donorAcquiredAtMs: number;
|
|
3964
|
+
handoffToken: string;
|
|
3965
|
+
payload: TelegramQueueHandoffPayload;
|
|
3966
|
+
sentAtMs: number;
|
|
3967
|
+
}) => Promise<TelegramBusEnvelope>;
|
|
3968
|
+
removeDonorItem: (
|
|
3969
|
+
receipt: TelegramQueueAdmissionReceipt,
|
|
3970
|
+
ctx: TContext,
|
|
3971
|
+
) => boolean;
|
|
3972
|
+
recordFailure?: (
|
|
3973
|
+
error: unknown,
|
|
3974
|
+
details: Record<string, unknown>,
|
|
3975
|
+
) => void;
|
|
3976
|
+
}
|
|
3977
|
+
|
|
3978
|
+
export interface TelegramQueueHandoffReconciliationRuntimeAssemblyDeps<
|
|
3979
|
+
TContext,
|
|
3980
|
+
> {
|
|
3981
|
+
ownsDirect: () => boolean;
|
|
3982
|
+
isFollowerRegistered: () => boolean;
|
|
3983
|
+
isBusEnabled: () => boolean;
|
|
3984
|
+
canHandoffWithLeader?: () => boolean;
|
|
3985
|
+
listFollowers: () => readonly TelegramBusFollowerView[];
|
|
3986
|
+
createRecipientJournalResolver: (
|
|
3987
|
+
profileKey: string,
|
|
3988
|
+
) => (() => { recoveryKey: string } | undefined);
|
|
3989
|
+
queueStore: {
|
|
3990
|
+
getQueuedItems: () => TelegramQueueItem<TContext>[];
|
|
3991
|
+
setQueuedItems: (items: TelegramQueueItem<TContext>[]) => void;
|
|
3992
|
+
};
|
|
3993
|
+
admission: Pick<
|
|
3994
|
+
TelegramUpdateAdmissionRuntimeBinding<TContext>,
|
|
3995
|
+
"getSettlement" | "getLifecycleForJournalBinding"
|
|
3996
|
+
>;
|
|
3997
|
+
createHandoffToken: () => string;
|
|
3998
|
+
createRequestId: () => string;
|
|
3999
|
+
donorInstanceId: string;
|
|
4000
|
+
authSecret?: string;
|
|
4001
|
+
stageThroughFollower: (input: {
|
|
4002
|
+
recipientInstanceId: string;
|
|
4003
|
+
recipientRegistrationGeneration: string;
|
|
4004
|
+
donorProcessId: number;
|
|
4005
|
+
donorProcessBirthId: string;
|
|
4006
|
+
donorSessionGeneration: number;
|
|
4007
|
+
donorAcquisitionId: string;
|
|
4008
|
+
donorAcquiredAtMs: number;
|
|
4009
|
+
handoffToken: string;
|
|
4010
|
+
payload: TelegramQueueHandoffPayload;
|
|
4011
|
+
}) => Promise<TelegramQueueHandoffStageResult>;
|
|
4012
|
+
routeThroughLeader: TelegramQueueHandoffReconcilerDeps<TContext>["routeThroughLeader"];
|
|
4013
|
+
recordRuntimeEvent?: (
|
|
4014
|
+
category: string,
|
|
4015
|
+
error: unknown,
|
|
4016
|
+
details?: Record<string, unknown>,
|
|
4017
|
+
) => void;
|
|
4018
|
+
}
|
|
4019
|
+
|
|
4020
|
+
/** Own queue-handoff projections over journals, admission, IPC, and live queue state. */
|
|
4021
|
+
export function createTelegramQueueHandoffReconciliationRuntimeAssembly<
|
|
4022
|
+
TContext,
|
|
4023
|
+
>(
|
|
4024
|
+
deps: TelegramQueueHandoffReconciliationRuntimeAssemblyDeps<TContext>,
|
|
4025
|
+
): (ctx: TContext) => Promise<void> {
|
|
4026
|
+
return createTelegramQueueHandoffReconciler({
|
|
4027
|
+
ownsDirect: deps.ownsDirect,
|
|
4028
|
+
isFollowerRegistered: deps.isFollowerRegistered,
|
|
4029
|
+
isBusEnabled: deps.isBusEnabled,
|
|
4030
|
+
canHandoffWithLeader: deps.canHandoffWithLeader,
|
|
4031
|
+
listFollowers: deps.listFollowers,
|
|
4032
|
+
createRecipientJournalBindingKey(recipient) {
|
|
4033
|
+
if (!recipient.profileKey) return undefined;
|
|
4034
|
+
return deps.createRecipientJournalResolver(recipient.profileKey)()
|
|
4035
|
+
?.recoveryKey;
|
|
4036
|
+
},
|
|
4037
|
+
getQueuedItems: deps.queueStore.getQueuedItems,
|
|
4038
|
+
getReceiptOwner(receipt) {
|
|
4039
|
+
return deps.admission.getSettlement()?.getQueueReceiptOwner(receipt);
|
|
4040
|
+
},
|
|
4041
|
+
getLifecycleForReceipt(receipt) {
|
|
4042
|
+
const bindingKey = receipt.journalBindingKey;
|
|
4043
|
+
return bindingKey
|
|
4044
|
+
? deps.admission.getLifecycleForJournalBinding(bindingKey)
|
|
4045
|
+
: undefined;
|
|
4046
|
+
},
|
|
4047
|
+
createHandoffToken: deps.createHandoffToken,
|
|
4048
|
+
createRequestId: deps.createRequestId,
|
|
4049
|
+
donorInstanceId: deps.donorInstanceId,
|
|
4050
|
+
authSecret: deps.authSecret,
|
|
4051
|
+
stageThroughFollower(input) {
|
|
4052
|
+
const registrationGeneration = input.recipient.registrationGeneration;
|
|
4053
|
+
if (!registrationGeneration) {
|
|
4054
|
+
throw new Error(
|
|
4055
|
+
"Telegram queue handoff recipient registration generation is unavailable.",
|
|
4056
|
+
);
|
|
4057
|
+
}
|
|
4058
|
+
return deps.stageThroughFollower({
|
|
4059
|
+
recipientInstanceId: input.recipient.instanceId,
|
|
4060
|
+
recipientRegistrationGeneration: registrationGeneration,
|
|
4061
|
+
donorProcessId: input.expectedOwner.processId,
|
|
4062
|
+
donorProcessBirthId: input.expectedOwner.processBirthId,
|
|
4063
|
+
donorSessionGeneration: input.expectedOwner.sessionGeneration,
|
|
4064
|
+
donorAcquisitionId: input.expectedOwner.acquisitionId,
|
|
4065
|
+
donorAcquiredAtMs: input.expectedOwner.acquiredAtMs,
|
|
4066
|
+
handoffToken: input.handoffToken,
|
|
4067
|
+
payload: input.payload,
|
|
4068
|
+
});
|
|
4069
|
+
},
|
|
4070
|
+
routeThroughLeader: deps.routeThroughLeader,
|
|
4071
|
+
removeDonorItem(receipt) {
|
|
4072
|
+
return removeTelegramQueueItemByReceipt({
|
|
4073
|
+
receipt,
|
|
4074
|
+
store: deps.queueStore,
|
|
4075
|
+
});
|
|
4076
|
+
},
|
|
4077
|
+
recordFailure(error, details) {
|
|
4078
|
+
deps.recordRuntimeEvent?.("inbound-worker", error, details);
|
|
4079
|
+
},
|
|
4080
|
+
});
|
|
4081
|
+
}
|
|
4082
|
+
|
|
4083
|
+
export function createTelegramQueueHandoffReconciler<TContext>(
|
|
4084
|
+
deps: TelegramQueueHandoffReconcilerDeps<TContext>,
|
|
4085
|
+
): (ctx: TContext) => Promise<void> {
|
|
4086
|
+
let operation: Promise<void> | undefined;
|
|
4087
|
+
const reconcile = async (ctx: TContext): Promise<void> => {
|
|
4088
|
+
const followerRegistered = deps.isFollowerRegistered();
|
|
4089
|
+
if (
|
|
4090
|
+
(!deps.ownsDirect() && !followerRegistered) ||
|
|
4091
|
+
!deps.isBusEnabled() ||
|
|
4092
|
+
(followerRegistered && deps.canHandoffWithLeader?.() === false)
|
|
4093
|
+
) {
|
|
4094
|
+
return;
|
|
4095
|
+
}
|
|
4096
|
+
const followers = deps.listFollowers().filter(
|
|
4097
|
+
(follower) => follower.instanceId !== deps.donorInstanceId,
|
|
4098
|
+
);
|
|
4099
|
+
if (followers.length === 0) return;
|
|
4100
|
+
for (const item of [...deps.getQueuedItems()]) {
|
|
4101
|
+
const target = item.target;
|
|
4102
|
+
const recipient = target
|
|
4103
|
+
? followers.find(
|
|
4104
|
+
(candidate) =>
|
|
4105
|
+
candidate.target?.chatId === target.chatId &&
|
|
4106
|
+
candidate.target?.threadId === target.threadId,
|
|
4107
|
+
)
|
|
4108
|
+
: undefined;
|
|
4109
|
+
if (!recipient) continue;
|
|
4110
|
+
const receipt = item.admissionReceipts?.[0];
|
|
4111
|
+
if (!receipt || item.admissionReceipts?.length !== 1) continue;
|
|
4112
|
+
if (
|
|
4113
|
+
!receipt.journalBindingKey ||
|
|
4114
|
+
!getTelegramUpdateJournalBindingPath(receipt.journalBindingKey)
|
|
4115
|
+
) {
|
|
4116
|
+
continue;
|
|
4117
|
+
}
|
|
4118
|
+
const recipientJournalBindingKey =
|
|
4119
|
+
deps.createRecipientJournalBindingKey(recipient);
|
|
4120
|
+
if (!recipientJournalBindingKey) continue;
|
|
4121
|
+
const recipientItem = structuredClone(item);
|
|
4122
|
+
recipientItem.admissionReceipts = [
|
|
4123
|
+
{ ...receipt, journalBindingKey: recipientJournalBindingKey },
|
|
4124
|
+
];
|
|
4125
|
+
const expectedOwner = deps.getReceiptOwner(receipt);
|
|
4126
|
+
const lifecycle = deps.getLifecycleForReceipt(receipt);
|
|
4127
|
+
if (
|
|
4128
|
+
!expectedOwner ||
|
|
4129
|
+
!lifecycle ||
|
|
4130
|
+
!recipient.registrationGeneration ||
|
|
4131
|
+
!recipient.pid ||
|
|
4132
|
+
!recipient.processBirthId ||
|
|
4133
|
+
!recipient.sessionGeneration
|
|
4134
|
+
) {
|
|
4135
|
+
continue;
|
|
4136
|
+
}
|
|
4137
|
+
const handoffToken = deps.createHandoffToken();
|
|
4138
|
+
const result = await coordinateTelegramQueueHandoff({
|
|
4139
|
+
item,
|
|
4140
|
+
expectedOwner,
|
|
4141
|
+
recipientOwner: {
|
|
4142
|
+
instanceId: recipient.instanceId,
|
|
4143
|
+
processId: recipient.pid,
|
|
4144
|
+
processBirthId: recipient.processBirthId,
|
|
4145
|
+
sessionGeneration: recipient.sessionGeneration,
|
|
4146
|
+
},
|
|
4147
|
+
handoffToken,
|
|
4148
|
+
lifecycle,
|
|
4149
|
+
stageRemote: async () => {
|
|
4150
|
+
const payload = createTelegramQueueHandoff({
|
|
4151
|
+
handoffToken,
|
|
4152
|
+
item: recipientItem,
|
|
4153
|
+
}).payload;
|
|
4154
|
+
if (followerRegistered) {
|
|
4155
|
+
return deps.stageThroughFollower({
|
|
4156
|
+
recipient,
|
|
4157
|
+
expectedOwner,
|
|
4158
|
+
handoffToken,
|
|
4159
|
+
payload,
|
|
4160
|
+
});
|
|
4161
|
+
}
|
|
4162
|
+
const response = await deps.routeThroughLeader({
|
|
4163
|
+
requestId: deps.createRequestId(),
|
|
4164
|
+
auth: deps.authSecret,
|
|
4165
|
+
recipientInstanceId: recipient.instanceId,
|
|
4166
|
+
recipientRegistrationGeneration:
|
|
4167
|
+
recipient.registrationGeneration!,
|
|
4168
|
+
donorInstanceId: deps.donorInstanceId,
|
|
4169
|
+
donorProcessId: expectedOwner.processId,
|
|
4170
|
+
donorProcessBirthId: expectedOwner.processBirthId,
|
|
4171
|
+
donorSessionGeneration: expectedOwner.sessionGeneration,
|
|
4172
|
+
donorAcquisitionId: expectedOwner.acquisitionId,
|
|
4173
|
+
donorAcquiredAtMs: expectedOwner.acquiredAtMs,
|
|
4174
|
+
handoffToken,
|
|
4175
|
+
payload,
|
|
4176
|
+
sentAtMs: Date.now(),
|
|
4177
|
+
});
|
|
4178
|
+
const queueOwner =
|
|
4179
|
+
response.kind === "bus.ack" &&
|
|
4180
|
+
response.result &&
|
|
4181
|
+
typeof response.result === "object"
|
|
4182
|
+
? parseTelegramUpdateJournalQueueOwner(
|
|
4183
|
+
(response.result as Record<string, unknown>).queueOwner,
|
|
4184
|
+
)
|
|
4185
|
+
: undefined;
|
|
4186
|
+
if (
|
|
4187
|
+
response.kind !== "bus.ack" ||
|
|
4188
|
+
!response.ok ||
|
|
4189
|
+
!response.result ||
|
|
4190
|
+
typeof response.result !== "object" ||
|
|
4191
|
+
!queueOwner
|
|
4192
|
+
) {
|
|
4193
|
+
throw new Error(
|
|
4194
|
+
response.kind === "bus.ack"
|
|
4195
|
+
? response.message ?? "Telegram queue handoff was rejected."
|
|
4196
|
+
: "Telegram queue handoff returned no acknowledgement.",
|
|
4197
|
+
);
|
|
4198
|
+
}
|
|
4199
|
+
return {
|
|
4200
|
+
...(response.result as Omit<TelegramQueueHandoffStageResult, "queueOwner">),
|
|
4201
|
+
queueOwner,
|
|
4202
|
+
};
|
|
4203
|
+
},
|
|
4204
|
+
removeDonorItem: (exactReceipt) =>
|
|
4205
|
+
deps.removeDonorItem(exactReceipt, ctx),
|
|
4206
|
+
});
|
|
4207
|
+
if (result.status === "retained") {
|
|
4208
|
+
deps.recordFailure?.(result.error, {
|
|
4209
|
+
phase: "queue-handoff-retained",
|
|
4210
|
+
receiptId: result.receipt.receiptId,
|
|
4211
|
+
recipientInstanceId: recipient.instanceId,
|
|
4212
|
+
cancelled: result.cancelled,
|
|
4213
|
+
});
|
|
4214
|
+
}
|
|
4215
|
+
}
|
|
4216
|
+
};
|
|
4217
|
+
return (ctx) => {
|
|
4218
|
+
if (operation) return operation;
|
|
4219
|
+
const current = reconcile(ctx).finally(() => {
|
|
4220
|
+
if (operation === current) operation = undefined;
|
|
4221
|
+
});
|
|
4222
|
+
operation = current;
|
|
4223
|
+
return current;
|
|
4224
|
+
};
|
|
4225
|
+
}
|
|
4226
|
+
|
|
4227
|
+
export interface TelegramQueueMutationDependencyItem {
|
|
4228
|
+
chatId: number;
|
|
4229
|
+
target?: { chatId: number };
|
|
4230
|
+
replyToMessageId: number;
|
|
4231
|
+
sourceMessageIds?: readonly number[];
|
|
4232
|
+
}
|
|
4233
|
+
|
|
4234
|
+
export interface TelegramUpdateAdmissionLifecycleRuntimeDeps<TContext> {
|
|
4235
|
+
resolveBinding: () =>
|
|
4236
|
+
| TelegramUpdateAdmissionLifecycleJournalBinding
|
|
4237
|
+
| undefined;
|
|
4238
|
+
getQueueOwnerIdentity?: (
|
|
4239
|
+
ctx: TContext,
|
|
4240
|
+
) => TelegramUpdateJournalQueueOwnerIdentity;
|
|
4241
|
+
createWorker: (
|
|
4242
|
+
journal: TelegramUpdateWorkerJournalPort,
|
|
4243
|
+
binding: TelegramUpdateAdmissionLifecycleJournalBinding,
|
|
4244
|
+
) => TelegramUpdateWorkerRuntime<TContext>;
|
|
4245
|
+
recordRuntimeEvent?: TelegramUpdateWorkerRuntimeDeps<TContext>["recordRuntimeEvent"];
|
|
4246
|
+
}
|
|
4247
|
+
|
|
4248
|
+
export interface TelegramUpdateAdmissionLifecycleRuntime<TContext>
|
|
4249
|
+
extends TelegramQueueAdmissionSettlementRuntime<TContext> {
|
|
4250
|
+
onSessionStart: (ctx: TContext) => Promise<void>;
|
|
4251
|
+
onSessionShutdown: () => Promise<void>;
|
|
4252
|
+
onTransportChanged: (ctx?: TContext) => Promise<void>;
|
|
4253
|
+
appendBatch: (updates: readonly TelegramJournaledUpdate[]) => unknown;
|
|
4254
|
+
discardQueueReceipt: (input: {
|
|
4255
|
+
queueKind: "prompt" | "control";
|
|
4256
|
+
receiptId: string;
|
|
4257
|
+
sourceUpdateIds: readonly number[];
|
|
4258
|
+
expectedOwner: TelegramUpdateJournalQueueOwner;
|
|
4259
|
+
}) => TelegramUpdateJournalQueueDiscardResult;
|
|
4260
|
+
recoverDeadQueueReceipt: (input: {
|
|
4261
|
+
queueKind: "prompt" | "control";
|
|
4262
|
+
receiptId: string;
|
|
4263
|
+
sourceUpdateIds: readonly number[];
|
|
4264
|
+
deadOwner: TelegramUpdateJournalQueueOwner;
|
|
4265
|
+
recoveryOwner: TelegramUpdateJournalQueueOwnerIdentity;
|
|
4266
|
+
}) => TelegramUpdateJournalDeadQueueOwnerRecoveryResult;
|
|
4267
|
+
offerQueueReceiptHandoff: (
|
|
4268
|
+
input: TelegramUpdateJournalQueueHandoffInput,
|
|
4269
|
+
) => TelegramUpdateJournalQueueHandoffOfferResult;
|
|
4270
|
+
acceptQueueReceiptHandoff: (
|
|
4271
|
+
input: TelegramUpdateJournalQueueHandoffInput,
|
|
4272
|
+
) => TelegramUpdateJournalQueueHandoffAcceptResult;
|
|
4273
|
+
cancelQueueReceiptHandoff: (
|
|
4274
|
+
input: TelegramUpdateJournalQueueHandoffInput,
|
|
4275
|
+
) => TelegramUpdateJournalQueueHandoffCancelResult;
|
|
4276
|
+
publishAcceptedQueueReceipt: (input: {
|
|
4277
|
+
receipt: TelegramQueueAdmissionReceiptLike;
|
|
4278
|
+
queueOwner: TelegramUpdateJournalQueueOwner;
|
|
4279
|
+
ctx: TContext;
|
|
4280
|
+
}) => Promise<void>;
|
|
4281
|
+
getQueueReceiptOwner: (
|
|
4282
|
+
receipt: TelegramQueueAdmissionReceiptLike,
|
|
4283
|
+
) => TelegramUpdateJournalQueueOwner | undefined;
|
|
4284
|
+
getJournalBindingKey: () => string | undefined;
|
|
4285
|
+
getJournalPath: () => string | undefined;
|
|
4286
|
+
ownsJournalBinding: (journalBindingKey: string) => boolean;
|
|
4287
|
+
getJournalEntryCount: () => number;
|
|
4288
|
+
getForeignQueueOwnerLiveness: () => TelegramProcessLiveness | undefined;
|
|
4289
|
+
hasPendingQueueMutationForItem: (
|
|
4290
|
+
item: TelegramQueueMutationDependencyItem,
|
|
4291
|
+
) => boolean;
|
|
4292
|
+
signal: () => void;
|
|
4293
|
+
getState: () => TelegramUpdateWorkerStateSnapshot | undefined;
|
|
4294
|
+
}
|
|
4295
|
+
|
|
4296
|
+
export interface TelegramUpdateWorkerOwnerRuntime<TContext> {
|
|
4297
|
+
getQueueOwnerIdentity: () => TelegramUpdateJournalQueueOwnerIdentity;
|
|
4298
|
+
onQueueReceiptCommitted: (receipt: unknown, ctx: TContext) => void;
|
|
4299
|
+
onUpdateCompleted: (updateId: number, ctx: TContext) => void;
|
|
4300
|
+
}
|
|
4301
|
+
|
|
4302
|
+
export interface TelegramUpdateWorkerOwnerRuntimeDeps<TContext> {
|
|
4303
|
+
instanceId: string;
|
|
4304
|
+
processId: number;
|
|
4305
|
+
processBirthId: string;
|
|
4306
|
+
getSessionGeneration: () => number;
|
|
4307
|
+
isContextCurrent: (ctx: TContext) => boolean;
|
|
4308
|
+
dispatchNext: (ctx: TContext) => void;
|
|
4309
|
+
requestQueueHandoffReconciliation: (ctx: TContext) => void;
|
|
4310
|
+
}
|
|
4311
|
+
|
|
4312
|
+
export function createTelegramUpdateWorkerOwnerRuntime<TContext>(
|
|
4313
|
+
deps: TelegramUpdateWorkerOwnerRuntimeDeps<TContext>,
|
|
4314
|
+
): TelegramUpdateWorkerOwnerRuntime<TContext> {
|
|
4315
|
+
return {
|
|
4316
|
+
getQueueOwnerIdentity() {
|
|
4317
|
+
return {
|
|
4318
|
+
instanceId: deps.instanceId,
|
|
4319
|
+
processId: deps.processId,
|
|
4320
|
+
processBirthId: deps.processBirthId,
|
|
4321
|
+
sessionGeneration: deps.getSessionGeneration(),
|
|
4322
|
+
};
|
|
4323
|
+
},
|
|
4324
|
+
onQueueReceiptCommitted(_receipt, ctx) {
|
|
4325
|
+
if (!deps.isContextCurrent(ctx)) return;
|
|
4326
|
+
deps.dispatchNext(ctx);
|
|
4327
|
+
deps.requestQueueHandoffReconciliation(ctx);
|
|
4328
|
+
},
|
|
4329
|
+
onUpdateCompleted(_updateId, ctx) {
|
|
4330
|
+
if (deps.isContextCurrent(ctx)) deps.dispatchNext(ctx);
|
|
4331
|
+
},
|
|
4332
|
+
};
|
|
4333
|
+
}
|
|
4334
|
+
|
|
4335
|
+
export interface TelegramUpdateAdmissionRuntimeBinding<TContext> {
|
|
4336
|
+
bind: (input: {
|
|
4337
|
+
leader: TelegramUpdateAdmissionLifecycleRuntime<TContext>;
|
|
4338
|
+
follower: TelegramUpdateAdmissionLifecycleRuntime<TContext>;
|
|
4339
|
+
}) => void;
|
|
4340
|
+
getLeader: () => TelegramUpdateAdmissionLifecycleRuntime<TContext> | undefined;
|
|
4341
|
+
getFollower: () => TelegramUpdateAdmissionLifecycleRuntime<TContext> | undefined;
|
|
4342
|
+
getActive: () => TelegramUpdateAdmissionLifecycleRuntime<TContext> | undefined;
|
|
4343
|
+
getSettlement: () => TelegramQueueAdmissionSettlementRuntime<TContext> | undefined;
|
|
4344
|
+
getLifecycleForJournalBinding: (
|
|
4345
|
+
journalBindingKey: string,
|
|
4346
|
+
) => TelegramUpdateAdmissionLifecycleRuntime<TContext> | undefined;
|
|
4347
|
+
hasPendingQueueMutationForItem: (
|
|
4348
|
+
item: TelegramQueueMutationDependencyItem,
|
|
4349
|
+
) => boolean;
|
|
4350
|
+
onSessionShutdown: () => Promise<void>;
|
|
4351
|
+
}
|
|
4352
|
+
|
|
4353
|
+
export function createTelegramUpdateAdmissionRuntimeBinding<TContext>(deps: {
|
|
4354
|
+
isFollowerRegistered: () => boolean;
|
|
4355
|
+
}): TelegramUpdateAdmissionRuntimeBinding<TContext> {
|
|
4356
|
+
let leader: TelegramUpdateAdmissionLifecycleRuntime<TContext> | undefined;
|
|
4357
|
+
let follower: TelegramUpdateAdmissionLifecycleRuntime<TContext> | undefined;
|
|
4358
|
+
let settlement: TelegramQueueAdmissionSettlementRuntime<TContext> | undefined;
|
|
4359
|
+
return {
|
|
4360
|
+
bind(input) {
|
|
4361
|
+
leader = input.leader;
|
|
4362
|
+
follower = input.follower;
|
|
4363
|
+
settlement = createTelegramQueueAdmissionSettlementMuxRuntime([
|
|
4364
|
+
leader,
|
|
4365
|
+
follower,
|
|
4366
|
+
]);
|
|
4367
|
+
},
|
|
4368
|
+
getLeader: () => leader,
|
|
4369
|
+
getFollower: () => follower,
|
|
4370
|
+
getActive: () => (deps.isFollowerRegistered() ? follower : leader),
|
|
4371
|
+
getSettlement: () => settlement,
|
|
4372
|
+
getLifecycleForJournalBinding(journalBindingKey) {
|
|
4373
|
+
if (follower?.ownsJournalBinding(journalBindingKey)) return follower;
|
|
4374
|
+
return leader?.ownsJournalBinding(journalBindingKey) ? leader : undefined;
|
|
4375
|
+
},
|
|
4376
|
+
hasPendingQueueMutationForItem(item) {
|
|
4377
|
+
return Boolean(
|
|
4378
|
+
leader?.hasPendingQueueMutationForItem(item) ||
|
|
4379
|
+
follower?.hasPendingQueueMutationForItem(item),
|
|
4380
|
+
);
|
|
4381
|
+
},
|
|
4382
|
+
async onSessionShutdown() {
|
|
4383
|
+
await Promise.all([
|
|
4384
|
+
leader?.onSessionShutdown(),
|
|
4385
|
+
follower?.onSessionShutdown(),
|
|
4386
|
+
]);
|
|
4387
|
+
},
|
|
4388
|
+
};
|
|
4389
|
+
}
|
|
4390
|
+
|
|
4391
|
+
function isTelegramReactionDependencyForQueueItem(
|
|
4392
|
+
update: TelegramJournaledUpdate,
|
|
4393
|
+
item: TelegramQueueMutationDependencyItem,
|
|
4394
|
+
): boolean {
|
|
4395
|
+
const reaction = update.message_reaction;
|
|
4396
|
+
if (!isTelegramUpdateAdmissionRecord(reaction)) return false;
|
|
4397
|
+
const chat = reaction.chat;
|
|
4398
|
+
if (
|
|
4399
|
+
!isTelegramUpdateAdmissionRecord(chat) ||
|
|
4400
|
+
!Number.isSafeInteger(chat.id) ||
|
|
4401
|
+
!Number.isSafeInteger(reaction.message_id)
|
|
4402
|
+
) {
|
|
4403
|
+
return false;
|
|
4404
|
+
}
|
|
4405
|
+
const itemMessageIds = new Set([
|
|
4406
|
+
item.replyToMessageId,
|
|
4407
|
+
...(item.sourceMessageIds ?? []),
|
|
4408
|
+
]);
|
|
4409
|
+
return (
|
|
4410
|
+
chat.id === (item.target?.chatId ?? item.chatId) &&
|
|
4411
|
+
itemMessageIds.has(reaction.message_id as number)
|
|
4412
|
+
);
|
|
4413
|
+
}
|
|
4414
|
+
|
|
4415
|
+
/** Own one worker per active transport identity without assuming queued-owner death. */
|
|
4416
|
+
export function createTelegramUpdateAdmissionLifecycleRuntime<TContext>(
|
|
4417
|
+
deps: TelegramUpdateAdmissionLifecycleRuntimeDeps<TContext>,
|
|
4418
|
+
): TelegramUpdateAdmissionLifecycleRuntime<TContext> {
|
|
4419
|
+
let activeRuntimeKey: string | undefined;
|
|
4420
|
+
let journal: TelegramUpdateAdmissionLifecycleJournalBinding["journal"] | undefined;
|
|
4421
|
+
let worker: TelegramUpdateWorkerRuntime<TContext> | undefined;
|
|
4422
|
+
let settlement: TelegramQueueAdmissionSettlementRuntime<TContext> | undefined;
|
|
4423
|
+
let journalBindingKey: string | undefined;
|
|
4424
|
+
let operation: Promise<void> = Promise.resolve();
|
|
4425
|
+
let foreignQueueOwnerLiveness: TelegramProcessLiveness | undefined;
|
|
4426
|
+
|
|
4427
|
+
const stopCurrent = async (forget: boolean): Promise<void> => {
|
|
4428
|
+
await worker?.stop();
|
|
4429
|
+
if (!forget) return;
|
|
4430
|
+
journal = undefined;
|
|
4431
|
+
worker = undefined;
|
|
4432
|
+
settlement = undefined;
|
|
4433
|
+
journalBindingKey = undefined;
|
|
4434
|
+
activeRuntimeKey = undefined;
|
|
4435
|
+
foreignQueueOwnerLiveness = undefined;
|
|
4436
|
+
};
|
|
4437
|
+
const bind = async (ctx: TContext): Promise<void> => {
|
|
4438
|
+
const binding = deps.resolveBinding();
|
|
4439
|
+
if (!binding) {
|
|
4440
|
+
await stopCurrent(true);
|
|
4441
|
+
return;
|
|
4442
|
+
}
|
|
4443
|
+
if (!worker || activeRuntimeKey !== binding.runtimeKey) {
|
|
4444
|
+
await stopCurrent(true);
|
|
4445
|
+
journal = binding.journal;
|
|
4446
|
+
worker = deps.createWorker(binding.journal, binding);
|
|
4447
|
+
settlement = createTelegramQueueAdmissionSettlementRuntime(worker);
|
|
4448
|
+
journalBindingKey = binding.recoveryKey;
|
|
4449
|
+
activeRuntimeKey = binding.runtimeKey;
|
|
4450
|
+
}
|
|
4451
|
+
if (binding.journal.applyOperatorDisposition) {
|
|
4452
|
+
for (const entry of binding.journal.read().entries) {
|
|
4453
|
+
if (entry.state !== "failed" || !entry.terminalFailureId) continue;
|
|
4454
|
+
const result = binding.journal.applyOperatorDisposition({
|
|
4455
|
+
action: "retry",
|
|
4456
|
+
updateId: entry.updateId,
|
|
4457
|
+
failureId: entry.terminalFailureId,
|
|
4458
|
+
});
|
|
4459
|
+
if (result.duplicate) continue;
|
|
4460
|
+
try {
|
|
4461
|
+
deps.recordRuntimeEvent?.(
|
|
4462
|
+
"inbound-worker",
|
|
4463
|
+
"Resumed legacy terminal update under automatic retry policy.",
|
|
4464
|
+
{
|
|
4465
|
+
phase: "automatic-terminal-retry",
|
|
4466
|
+
updateId: entry.updateId,
|
|
4467
|
+
attemptCount: result.disposition.attemptCount,
|
|
4468
|
+
},
|
|
4469
|
+
);
|
|
4470
|
+
} catch {
|
|
4471
|
+
// Diagnostics cannot revoke the committed retry.
|
|
4472
|
+
}
|
|
4473
|
+
}
|
|
4474
|
+
}
|
|
4475
|
+
if (deps.getQueueOwnerIdentity && binding.journal.recoverDeadQueueOwner) {
|
|
4476
|
+
const recoveryOwner = deps.getQueueOwnerIdentity(ctx);
|
|
4477
|
+
const foreignReceipts = new Map<
|
|
4478
|
+
string,
|
|
4479
|
+
{
|
|
4480
|
+
queueKind: "prompt" | "control";
|
|
4481
|
+
sourceUpdateIds: number[];
|
|
4482
|
+
deadOwner: TelegramUpdateJournalQueueOwner;
|
|
4483
|
+
}
|
|
4484
|
+
>();
|
|
4485
|
+
for (const entry of binding.journal.read().entries) {
|
|
4486
|
+
if (
|
|
4487
|
+
entry.state !== "queued" ||
|
|
4488
|
+
!entry.queueKind ||
|
|
4489
|
+
!entry.queueReceiptId ||
|
|
4490
|
+
!entry.queueOwner ||
|
|
4491
|
+
isTelegramUpdateJournalQueueOwnerProcess(
|
|
4492
|
+
entry.queueOwner,
|
|
4493
|
+
recoveryOwner,
|
|
4494
|
+
)
|
|
4495
|
+
) {
|
|
4496
|
+
continue;
|
|
4497
|
+
}
|
|
4498
|
+
const receipt = foreignReceipts.get(entry.queueReceiptId);
|
|
4499
|
+
if (receipt) receipt.sourceUpdateIds.push(entry.updateId);
|
|
4500
|
+
else {
|
|
4501
|
+
foreignReceipts.set(entry.queueReceiptId, {
|
|
4502
|
+
queueKind: entry.queueKind,
|
|
4503
|
+
sourceUpdateIds: [entry.updateId],
|
|
4504
|
+
deadOwner: { ...entry.queueOwner },
|
|
4505
|
+
});
|
|
4506
|
+
}
|
|
4507
|
+
}
|
|
4508
|
+
for (const [receiptId, receipt] of foreignReceipts) {
|
|
4509
|
+
const result = binding.journal.recoverDeadQueueOwner({
|
|
4510
|
+
queueKind: receipt.queueKind,
|
|
4511
|
+
receiptId,
|
|
4512
|
+
sourceUpdateIds: receipt.sourceUpdateIds,
|
|
4513
|
+
deadOwner: receipt.deadOwner,
|
|
4514
|
+
recoveryOwner,
|
|
4515
|
+
});
|
|
4516
|
+
foreignQueueOwnerLiveness =
|
|
4517
|
+
result.status === "recovered"
|
|
4518
|
+
? "dead"
|
|
4519
|
+
: result.status === "owner-alive"
|
|
4520
|
+
? "alive"
|
|
4521
|
+
: "unverifiable";
|
|
4522
|
+
if (result.status !== "recovered") continue;
|
|
4523
|
+
try {
|
|
4524
|
+
deps.recordRuntimeEvent?.(
|
|
4525
|
+
"inbound-worker",
|
|
4526
|
+
"Recovered queued authority from a confirmed-dead process.",
|
|
4527
|
+
{
|
|
4528
|
+
phase: "dead-queue-owner-recovery",
|
|
4529
|
+
receiptId,
|
|
4530
|
+
recoveredUpdateCount: result.recoveredUpdateIds.length,
|
|
4531
|
+
},
|
|
4532
|
+
);
|
|
4533
|
+
} catch {
|
|
4534
|
+
// Diagnostics cannot revoke the committed recovery.
|
|
4535
|
+
}
|
|
4536
|
+
}
|
|
4537
|
+
}
|
|
4538
|
+
worker.start(ctx);
|
|
4539
|
+
};
|
|
4540
|
+
const runExclusive = (task: () => Promise<void>): Promise<void> => {
|
|
4541
|
+
const next = operation.then(task, task);
|
|
4542
|
+
operation = next.catch(() => undefined);
|
|
4543
|
+
return next;
|
|
4544
|
+
};
|
|
4545
|
+
return {
|
|
4546
|
+
onSessionStart: (ctx) => runExclusive(() => bind(ctx)),
|
|
4547
|
+
onSessionShutdown: () => runExclusive(() => stopCurrent(false)),
|
|
4548
|
+
onTransportChanged: (ctx) =>
|
|
4549
|
+
runExclusive(async () => {
|
|
4550
|
+
await stopCurrent(true);
|
|
4551
|
+
if (ctx !== undefined) await bind(ctx);
|
|
4552
|
+
}),
|
|
4553
|
+
appendBatch(updates) {
|
|
4554
|
+
if (!journal || !worker) {
|
|
4555
|
+
throw new Error("Telegram update admission worker is not active.");
|
|
4556
|
+
}
|
|
4557
|
+
return journal.appendBatch(updates);
|
|
4558
|
+
},
|
|
4559
|
+
discardQueueReceipt(input) {
|
|
4560
|
+
if (!journal || !worker || !journal.discardQueued) {
|
|
4561
|
+
throw new Error(
|
|
4562
|
+
"Telegram update journal queue discard is not available.",
|
|
4563
|
+
);
|
|
4564
|
+
}
|
|
4565
|
+
const result = journal.discardQueued(input);
|
|
4566
|
+
worker.signal();
|
|
4567
|
+
return result;
|
|
4568
|
+
},
|
|
4569
|
+
recoverDeadQueueReceipt(input) {
|
|
4570
|
+
if (!journal || !worker || !journal.recoverDeadQueueOwner) {
|
|
4571
|
+
throw new Error(
|
|
4572
|
+
"Telegram update journal dead-owner recovery is not available.",
|
|
4573
|
+
);
|
|
4574
|
+
}
|
|
4575
|
+
const result = journal.recoverDeadQueueOwner(input);
|
|
4576
|
+
worker.signal();
|
|
4577
|
+
return result;
|
|
4578
|
+
},
|
|
4579
|
+
offerQueueReceiptHandoff(input) {
|
|
4580
|
+
if (!journal || !worker || !journal.offerQueuedHandoff) {
|
|
4581
|
+
throw new Error(
|
|
4582
|
+
"Telegram update journal queue handoff offer is not available.",
|
|
4583
|
+
);
|
|
4584
|
+
}
|
|
4585
|
+
const result = journal.offerQueuedHandoff(input);
|
|
4586
|
+
worker.signal();
|
|
4587
|
+
return result;
|
|
4588
|
+
},
|
|
4589
|
+
acceptQueueReceiptHandoff(input) {
|
|
4590
|
+
if (!journal || !worker || !journal.acceptQueuedHandoff) {
|
|
4591
|
+
throw new Error(
|
|
4592
|
+
"Telegram update journal queue handoff acceptance is not available.",
|
|
4593
|
+
);
|
|
4594
|
+
}
|
|
4595
|
+
return journal.acceptQueuedHandoff(input);
|
|
4596
|
+
},
|
|
4597
|
+
cancelQueueReceiptHandoff(input) {
|
|
4598
|
+
if (!journal || !worker || !journal.cancelQueuedHandoff) {
|
|
4599
|
+
throw new Error(
|
|
4600
|
+
"Telegram update journal queue handoff cancellation is not available.",
|
|
4601
|
+
);
|
|
4602
|
+
}
|
|
4603
|
+
const result = journal.cancelQueuedHandoff(input);
|
|
4604
|
+
worker.signal();
|
|
4605
|
+
return result;
|
|
4606
|
+
},
|
|
4607
|
+
async publishAcceptedQueueReceipt(input) {
|
|
4608
|
+
if (!worker || !journal) {
|
|
4609
|
+
throw new Error("Telegram update admission worker is not active.");
|
|
4610
|
+
}
|
|
4611
|
+
const entry = journal
|
|
4612
|
+
.read()
|
|
4613
|
+
.entries.find(
|
|
4614
|
+
(candidate) =>
|
|
4615
|
+
candidate.state === "queued" &&
|
|
4616
|
+
candidate.queueReceiptId === input.receipt.receiptId &&
|
|
4617
|
+
candidate.queueOwner?.acquisitionId ===
|
|
4618
|
+
input.queueOwner.acquisitionId,
|
|
4619
|
+
);
|
|
4620
|
+
if (!entry) {
|
|
4621
|
+
throw new Error(
|
|
4622
|
+
`Telegram queue handoff receipt ${input.receipt.receiptId} is not owned by this journal.`,
|
|
4623
|
+
);
|
|
4624
|
+
}
|
|
4625
|
+
worker.signal();
|
|
4626
|
+
await worker.waitForDrain();
|
|
4627
|
+
const currentOwner = worker.getQueueReceiptOwner(input.receipt);
|
|
4628
|
+
if (
|
|
4629
|
+
!currentOwner ||
|
|
4630
|
+
!areTelegramUpdateJournalQueueOwnersEqual(
|
|
4631
|
+
currentOwner,
|
|
4632
|
+
input.queueOwner,
|
|
4633
|
+
)
|
|
4634
|
+
) {
|
|
4635
|
+
throw new Error(
|
|
4636
|
+
`Telegram queue handoff receipt ${input.receipt.receiptId} is not owned by this runtime.`,
|
|
4637
|
+
);
|
|
4638
|
+
}
|
|
4639
|
+
},
|
|
4640
|
+
getQueueReceiptOwner(receipt) {
|
|
4641
|
+
if (
|
|
4642
|
+
!journalBindingKey ||
|
|
4643
|
+
receipt.journalBindingKey !== journalBindingKey
|
|
4644
|
+
) {
|
|
4645
|
+
return undefined;
|
|
4646
|
+
}
|
|
4647
|
+
return worker?.getQueueReceiptOwner(receipt);
|
|
4648
|
+
},
|
|
4649
|
+
getJournalBindingKey: () => journalBindingKey,
|
|
4650
|
+
getJournalPath: () =>
|
|
4651
|
+
journalBindingKey
|
|
4652
|
+
? getTelegramUpdateJournalBindingPath(journalBindingKey)
|
|
4653
|
+
: undefined,
|
|
4654
|
+
ownsJournalBinding: (candidate) =>
|
|
4655
|
+
journalBindingKey !== undefined && journalBindingKey === candidate,
|
|
4656
|
+
getForeignQueueOwnerLiveness: () => foreignQueueOwnerLiveness,
|
|
4657
|
+
getJournalEntryCount() {
|
|
4658
|
+
if (!journal || !worker) {
|
|
4659
|
+
throw new Error("Telegram update admission worker is not active.");
|
|
4660
|
+
}
|
|
4661
|
+
return journal.read().entries.length;
|
|
4662
|
+
},
|
|
4663
|
+
hasPendingQueueMutationForItem(item) {
|
|
4664
|
+
return Boolean(
|
|
4665
|
+
journal &&
|
|
4666
|
+
worker &&
|
|
4667
|
+
journal
|
|
4668
|
+
.read()
|
|
4669
|
+
.entries.some(
|
|
4670
|
+
(entry) =>
|
|
4671
|
+
entry.state !== "queued" &&
|
|
4672
|
+
isTelegramReactionDependencyForQueueItem(
|
|
4673
|
+
entry.update,
|
|
4674
|
+
item,
|
|
4675
|
+
),
|
|
4676
|
+
),
|
|
4677
|
+
);
|
|
4678
|
+
},
|
|
4679
|
+
signal: () => worker?.signal(),
|
|
4680
|
+
getState: () => {
|
|
4681
|
+
const state = worker?.getState();
|
|
4682
|
+
if (!state) return undefined;
|
|
4683
|
+
return {
|
|
4684
|
+
...state,
|
|
4685
|
+
...(state.foreignQueuedOwner && foreignQueueOwnerLiveness
|
|
4686
|
+
? { foreignQueuedOwnerLiveness: foreignQueueOwnerLiveness }
|
|
4687
|
+
: {}),
|
|
4688
|
+
};
|
|
4689
|
+
},
|
|
4690
|
+
isItemReady: (item) =>
|
|
4691
|
+
settlement?.isItemReady(item) ??
|
|
4692
|
+
(item.admissionReceipts?.length ?? 0) === 0,
|
|
4693
|
+
onPromptHandedOff: (item, ctx) =>
|
|
4694
|
+
settlement?.onPromptHandedOff(item, ctx),
|
|
4695
|
+
onControlSettled: (item, ctx) =>
|
|
4696
|
+
settlement?.onControlSettled(item, ctx),
|
|
4697
|
+
onItemsDiscarded: (items, ctx) =>
|
|
4698
|
+
settlement?.onItemsDiscarded(items, ctx),
|
|
4699
|
+
};
|
|
4700
|
+
}
|
|
4701
|
+
|
|
4702
|
+
export interface TelegramUpdateAdmissionLifecycleAssembly<TContext> {
|
|
4703
|
+
leader: TelegramUpdateAdmissionLifecycleRuntime<TContext>;
|
|
4704
|
+
follower: TelegramUpdateAdmissionLifecycleRuntime<TContext>;
|
|
4705
|
+
}
|
|
4706
|
+
|
|
4707
|
+
export interface TelegramUpdateAdmissionLifecycleAssemblyDeps<
|
|
4708
|
+
TUpdate extends TelegramJournaledUpdate & TelegramUpdateFlow,
|
|
4709
|
+
TContext,
|
|
4710
|
+
> {
|
|
4711
|
+
runtimeBinding: TelegramUpdateAdmissionRuntimeBinding<TContext>;
|
|
4712
|
+
worker: Omit<
|
|
4713
|
+
TelegramUpdateAdmissionWorkerRuntimeDeps<TUpdate, TContext>,
|
|
4714
|
+
| "journal"
|
|
4715
|
+
| "getJournalBindingKey"
|
|
4716
|
+
| "hasAuthority"
|
|
4717
|
+
| "prepareUpdateForExecution"
|
|
4718
|
+
>;
|
|
4719
|
+
leader: {
|
|
4720
|
+
resolveBinding: () =>
|
|
4721
|
+
| TelegramUpdateAdmissionLifecycleJournalBinding
|
|
4722
|
+
| undefined;
|
|
4723
|
+
hasAuthority: (ctx: TContext) => boolean;
|
|
4724
|
+
};
|
|
4725
|
+
follower: {
|
|
4726
|
+
resolveBinding: () =>
|
|
4727
|
+
| TelegramUpdateAdmissionLifecycleJournalBinding
|
|
4728
|
+
| undefined;
|
|
4729
|
+
isRegistered: () => boolean;
|
|
4730
|
+
getGeneration: () => string | undefined;
|
|
4731
|
+
prepareUpdateForExecution: (update: TUpdate) => TUpdate;
|
|
4732
|
+
};
|
|
4733
|
+
recordRuntimeEvent?: TelegramUpdateWorkerRuntimeDeps<TContext>["recordRuntimeEvent"];
|
|
4734
|
+
}
|
|
4735
|
+
|
|
4736
|
+
export type TelegramUpdateAdmissionWorkerRuntimeDeps<
|
|
4737
|
+
TUpdate extends TelegramJournaledUpdate & TelegramUpdateFlow,
|
|
4738
|
+
TContext,
|
|
4739
|
+
> = Omit<TelegramUpdateWorkerRuntimeDeps<TContext>, "executeUpdate"> & {
|
|
4740
|
+
defaultHandle: (
|
|
4741
|
+
update: TUpdate,
|
|
4742
|
+
ctx: TContext,
|
|
4743
|
+
execution?: TelegramUpdateExecutionFence,
|
|
4744
|
+
) => Promise<void>;
|
|
4745
|
+
prepareUpdateForExecution?: (update: TUpdate) => TUpdate;
|
|
4746
|
+
registry?: TelegramUpdateHandlerRegistry;
|
|
4747
|
+
};
|
|
4748
|
+
|
|
4749
|
+
/** Compose source-bound routing and late grouped settlement under one worker. */
|
|
4750
|
+
export function createTelegramUpdateAdmissionWorkerRuntime<
|
|
4751
|
+
TUpdate extends TelegramJournaledUpdate & TelegramUpdateFlow,
|
|
4752
|
+
TContext,
|
|
4753
|
+
>(
|
|
4754
|
+
deps: TelegramUpdateAdmissionWorkerRuntimeDeps<TUpdate, TContext>,
|
|
4755
|
+
): TelegramUpdateWorkerRuntime<TContext> {
|
|
4756
|
+
let worker: TelegramUpdateWorkerRuntime<TContext> | undefined;
|
|
4757
|
+
const executeUpdate = createTelegramUpdateAdmissionHandle<TUpdate, TContext>({
|
|
4758
|
+
defaultHandle: deps.defaultHandle,
|
|
4759
|
+
registry: deps.registry,
|
|
4760
|
+
onLateOutcome(outcome, details) {
|
|
4761
|
+
worker?.settleDeferred({
|
|
4762
|
+
updateId: details.updateId,
|
|
4763
|
+
outcome,
|
|
4764
|
+
signal: details.signal,
|
|
4765
|
+
});
|
|
4766
|
+
},
|
|
4767
|
+
onLateOutcomeError(error, updateId) {
|
|
4768
|
+
deps.recordRuntimeEvent?.("inbound-worker", error, {
|
|
4769
|
+
phase: "late-admission-handler",
|
|
4770
|
+
updateId,
|
|
4771
|
+
});
|
|
4772
|
+
},
|
|
4773
|
+
});
|
|
4774
|
+
worker = createTelegramUpdateWorkerRuntime({
|
|
4775
|
+
...deps,
|
|
4776
|
+
executeUpdate: (update, ctx, signal) => {
|
|
4777
|
+
const typedUpdate = update as TUpdate;
|
|
4778
|
+
return executeUpdate(
|
|
4779
|
+
deps.prepareUpdateForExecution?.(typedUpdate) ?? typedUpdate,
|
|
4780
|
+
ctx,
|
|
4781
|
+
signal,
|
|
4782
|
+
);
|
|
4783
|
+
},
|
|
4784
|
+
});
|
|
4785
|
+
return worker;
|
|
4786
|
+
}
|
|
4787
|
+
|
|
4788
|
+
/** Own leader/follower journal lifecycle construction and generation fencing. */
|
|
4789
|
+
export function createTelegramUpdateAdmissionLifecycleAssembly<
|
|
4790
|
+
TUpdate extends TelegramJournaledUpdate & TelegramUpdateFlow,
|
|
4791
|
+
TContext,
|
|
4792
|
+
>(
|
|
4793
|
+
deps: TelegramUpdateAdmissionLifecycleAssemblyDeps<TUpdate, TContext>,
|
|
4794
|
+
): TelegramUpdateAdmissionLifecycleAssembly<TContext> {
|
|
4795
|
+
const leader = createTelegramUpdateAdmissionLifecycleRuntime({
|
|
4796
|
+
resolveBinding: deps.leader.resolveBinding,
|
|
4797
|
+
getQueueOwnerIdentity: deps.worker.getQueueOwnerIdentity,
|
|
4798
|
+
createWorker(journal, binding) {
|
|
4799
|
+
return createTelegramUpdateAdmissionWorkerRuntime({
|
|
4800
|
+
...deps.worker,
|
|
4801
|
+
journal,
|
|
4802
|
+
getJournalBindingKey: () => binding.recoveryKey,
|
|
4803
|
+
hasAuthority: deps.leader.hasAuthority,
|
|
4804
|
+
});
|
|
4805
|
+
},
|
|
4806
|
+
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
4807
|
+
});
|
|
4808
|
+
const follower = createTelegramUpdateAdmissionLifecycleRuntime({
|
|
4809
|
+
getQueueOwnerIdentity: deps.worker.getQueueOwnerIdentity,
|
|
4810
|
+
resolveBinding() {
|
|
4811
|
+
const generation = deps.follower.getGeneration();
|
|
4812
|
+
if (!deps.follower.isRegistered() || !generation) return undefined;
|
|
4813
|
+
const binding = deps.follower.resolveBinding();
|
|
4814
|
+
if (!binding) return undefined;
|
|
4815
|
+
return {
|
|
4816
|
+
...binding,
|
|
4817
|
+
runtimeKey: `${binding.runtimeKey}\u0000${generation}`,
|
|
4818
|
+
hasAuthority() {
|
|
4819
|
+
return (
|
|
4820
|
+
deps.follower.isRegistered() &&
|
|
4821
|
+
deps.follower.getGeneration() === generation
|
|
4822
|
+
);
|
|
4823
|
+
},
|
|
4824
|
+
};
|
|
4825
|
+
},
|
|
4826
|
+
createWorker(journal, binding) {
|
|
4827
|
+
return createTelegramUpdateAdmissionWorkerRuntime({
|
|
4828
|
+
...deps.worker,
|
|
4829
|
+
journal,
|
|
4830
|
+
getJournalBindingKey: () => binding.recoveryKey,
|
|
4831
|
+
hasAuthority: () => binding.hasAuthority?.() ?? false,
|
|
4832
|
+
prepareUpdateForExecution: deps.follower.prepareUpdateForExecution,
|
|
4833
|
+
});
|
|
4834
|
+
},
|
|
4835
|
+
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
4836
|
+
});
|
|
4837
|
+
deps.runtimeBinding.bind({ leader, follower });
|
|
4838
|
+
return { leader, follower };
|
|
4839
|
+
}
|
|
4840
|
+
|
|
4841
|
+
export interface TelegramUpdateAdmissionRuntimeAssembly<TContext>
|
|
4842
|
+
extends TelegramUpdateAdmissionLifecycleAssembly<TContext> {
|
|
4843
|
+
owner: TelegramUpdateWorkerOwnerRuntime<TContext>;
|
|
4844
|
+
}
|
|
4845
|
+
|
|
4846
|
+
export type TelegramUpdateAdmissionRuntimeAssemblyDeps<
|
|
4847
|
+
TUpdate extends TelegramJournaledUpdate & TelegramUpdateFlow,
|
|
4848
|
+
TContext,
|
|
4849
|
+
> = Omit<
|
|
4850
|
+
TelegramUpdateAdmissionLifecycleAssemblyDeps<TUpdate, TContext>,
|
|
4851
|
+
"worker" | "recordRuntimeEvent"
|
|
4852
|
+
> & {
|
|
4853
|
+
owner: TelegramUpdateWorkerOwnerRuntimeDeps<TContext>;
|
|
4854
|
+
worker: Omit<
|
|
4855
|
+
TelegramUpdateAdmissionLifecycleAssemblyDeps<TUpdate, TContext>["worker"],
|
|
4856
|
+
| keyof TelegramUpdateWorkerOwnerRuntime<TContext>
|
|
4857
|
+
| "isContextCurrent"
|
|
4858
|
+
| "recordRuntimeEvent"
|
|
4859
|
+
>;
|
|
4860
|
+
recordRuntimeEvent?: TelegramUpdateWorkerRuntimeDeps<TContext>["recordRuntimeEvent"];
|
|
4861
|
+
};
|
|
4862
|
+
|
|
4863
|
+
/** Own queue-owner projection and shared leader/follower worker composition. */
|
|
4864
|
+
export function createTelegramUpdateAdmissionRuntimeAssembly<
|
|
4865
|
+
TUpdate extends TelegramJournaledUpdate & TelegramUpdateFlow,
|
|
4866
|
+
TContext,
|
|
4867
|
+
>(
|
|
4868
|
+
deps: TelegramUpdateAdmissionRuntimeAssemblyDeps<TUpdate, TContext>,
|
|
4869
|
+
): TelegramUpdateAdmissionRuntimeAssembly<TContext> {
|
|
4870
|
+
const owner = createTelegramUpdateWorkerOwnerRuntime(deps.owner);
|
|
4871
|
+
const lifecycle = createTelegramUpdateAdmissionLifecycleAssembly({
|
|
4872
|
+
runtimeBinding: deps.runtimeBinding,
|
|
4873
|
+
worker: {
|
|
4874
|
+
...deps.worker,
|
|
4875
|
+
...owner,
|
|
4876
|
+
isContextCurrent: deps.owner.isContextCurrent,
|
|
4877
|
+
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
4878
|
+
},
|
|
4879
|
+
leader: deps.leader,
|
|
4880
|
+
follower: deps.follower,
|
|
4881
|
+
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
4882
|
+
});
|
|
4883
|
+
return { owner, ...lifecycle };
|
|
4884
|
+
}
|
|
4885
|
+
|
|
1338
4886
|
/**
|
|
1339
4887
|
* Register a handler that runs before pi-telegram routes a Telegram update
|
|
1340
4888
|
* through its built-in handlers.
|