@llblab/pi-telegram 0.22.1 → 0.23.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 +11 -7
- package/BACKLOG.md +0 -50
- package/CHANGELOG.md +49 -2
- package/README.md +3 -1
- package/docs/README.md +1 -1
- package/docs/activity.md +8 -0
- package/docs/architecture.md +16 -14
- package/docs/locks.md +21 -15
- package/docs/multi-instance-bus.md +17 -16
- package/docs/outbound.md +16 -0
- package/docs/public-api.md +9 -4
- package/index.ts +74 -59
- package/lib/activity.ts +100 -3
- package/lib/bindings.ts +81 -6
- package/lib/bus-follower.ts +205 -17
- package/lib/bus-leader.ts +339 -133
- package/lib/bus.ts +82 -19
- package/lib/commands.ts +28 -4
- package/lib/config.ts +40 -28
- package/lib/media.ts +102 -1
- package/lib/menu-settings.ts +4 -1
- package/lib/outbound-attachments.ts +150 -1
- package/lib/outbound.ts +106 -1
- package/lib/polling.ts +5 -0
- package/lib/queue.ts +41 -48
- package/lib/routing.ts +88 -1
- package/lib/sync.ts +40 -3
- package/lib/telegram-api.ts +77 -13
- package/lib/text-groups.ts +183 -16
- package/lib/thread-reconciler.ts +66 -22
- package/lib/threads.ts +131 -23
- package/lib/turns.ts +102 -13
- package/lib/updates.ts +2 -0
- package/package.json +1 -1
package/lib/bus-leader.ts
CHANGED
|
@@ -162,6 +162,7 @@ export interface TelegramBusLeaderRuntimeAssemblyDeps<TContext> {
|
|
|
162
162
|
TelegramBusLeaderRuntimeDeps<TContext>,
|
|
163
163
|
| "callApi"
|
|
164
164
|
| "onFollowerPruned"
|
|
165
|
+
| "onFollowerDisconnected"
|
|
165
166
|
| "provisionFollowerTarget"
|
|
166
167
|
| "provisionLeaderTarget"
|
|
167
168
|
| "reconcileFollowerBindings"
|
|
@@ -220,6 +221,9 @@ export function createTelegramBusLeaderRuntimeAssembly<TContext>(
|
|
|
220
221
|
onFollowerPruned: createTelegramBusFollowerPruneHandler({
|
|
221
222
|
...provisionerPorts,
|
|
222
223
|
}),
|
|
224
|
+
onFollowerDisconnected: createTelegramBusFollowerDisconnectHandler({
|
|
225
|
+
...provisionerPorts,
|
|
226
|
+
}),
|
|
223
227
|
provisionFollowerTarget: createTelegramBusFollowerTargetProvisioner({
|
|
224
228
|
...provisionerPorts,
|
|
225
229
|
}),
|
|
@@ -301,6 +305,9 @@ export interface TelegramBusLeaderRuntimeDeps<TContext> {
|
|
|
301
305
|
onFollowerPruned?: (
|
|
302
306
|
follower: TelegramBusFollowerView,
|
|
303
307
|
) => Promise<void> | void;
|
|
308
|
+
onFollowerDisconnected?: (
|
|
309
|
+
follower: TelegramBusFollowerView,
|
|
310
|
+
) => Promise<void> | void;
|
|
304
311
|
recordRuntimeEvent?: (
|
|
305
312
|
category: string,
|
|
306
313
|
error: unknown,
|
|
@@ -311,65 +318,12 @@ export interface TelegramBusLeaderRuntimeDeps<TContext> {
|
|
|
311
318
|
export function createTelegramBusFollowerBindingRealityReconciler(
|
|
312
319
|
deps: TelegramBusFollowerBindingRealityDeps,
|
|
313
320
|
): () => Promise<number> {
|
|
314
|
-
const getNowMs = deps.getNowMs ?? Date.now;
|
|
315
|
-
const recentBindingGraceMs =
|
|
316
|
-
deps.recentBindingGraceMs ?? TELEGRAM_BUS_RECENT_FOLLOWER_BINDING_GRACE_MS;
|
|
317
321
|
return async () => {
|
|
318
322
|
await deps.topicTargetStore.load();
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
const staleRecords = deps.topicTargetStore.list().filter((record) => {
|
|
324
|
-
return (
|
|
325
|
-
record.owner?.kind === "manual-follower" &&
|
|
326
|
-
!!record.instanceId &&
|
|
327
|
-
!liveInstanceIds.has(record.instanceId) &&
|
|
328
|
-
nowMs - record.updatedAtMs > recentBindingGraceMs
|
|
329
|
-
);
|
|
330
|
-
});
|
|
331
|
-
const staleInstanceIds = new Set(
|
|
332
|
-
staleRecords.flatMap((record) =>
|
|
333
|
-
record.instanceId ? [record.instanceId] : [],
|
|
334
|
-
),
|
|
335
|
-
);
|
|
336
|
-
let removed = 0;
|
|
337
|
-
for (const instanceId of staleInstanceIds) {
|
|
338
|
-
removed += deps.topicTargetStore.markOfflineByInstanceId(instanceId);
|
|
339
|
-
}
|
|
340
|
-
for (const record of staleRecords) {
|
|
341
|
-
const profileKey =
|
|
342
|
-
record.profileKey ??
|
|
343
|
-
(record.owner
|
|
344
|
-
? Threads.getTelegramThreadOwnerKey(record.owner)
|
|
345
|
-
: undefined);
|
|
346
|
-
if (profileKey)
|
|
347
|
-
deps.topicTargetStore.forgetIdentityByProfileKey(profileKey);
|
|
348
|
-
}
|
|
349
|
-
if (removed === 0) return 0;
|
|
350
|
-
const cursorRealigned = Threads.reconcileTelegramFreshAllocationCursor(
|
|
351
|
-
deps.topicTargetStore,
|
|
352
|
-
);
|
|
353
|
-
await deps.topicTargetStore.persist();
|
|
354
|
-
deps.recordRuntimeEvent(
|
|
355
|
-
"bus",
|
|
356
|
-
"Historical follower bindings removed from live state",
|
|
357
|
-
{
|
|
358
|
-
phase: "follower-binding-reality",
|
|
359
|
-
removed,
|
|
360
|
-
},
|
|
361
|
-
);
|
|
362
|
-
if (cursorRealigned) {
|
|
363
|
-
deps.recordRuntimeEvent(
|
|
364
|
-
"bus",
|
|
365
|
-
"Fresh allocation cursor realigned after live-state compaction",
|
|
366
|
-
{
|
|
367
|
-
phase: "follower-binding-reality-cursor",
|
|
368
|
-
lastSlot: deps.topicTargetStore.getBotState().lastSlot,
|
|
369
|
-
},
|
|
370
|
-
);
|
|
371
|
-
}
|
|
372
|
-
return removed;
|
|
323
|
+
// A missing live registration proves only that the follower process is
|
|
324
|
+
// currently absent. Keep its durable owner/target binding so a replacement
|
|
325
|
+
// process with the same manual profile can reclaim the existing thread.
|
|
326
|
+
return 0;
|
|
373
327
|
};
|
|
374
328
|
}
|
|
375
329
|
|
|
@@ -473,29 +427,29 @@ export function createTelegramBusFollowerTargetProvisioner(
|
|
|
473
427
|
const recordsBeforeProvision = deps.topicTargetStore.list();
|
|
474
428
|
const followerProfileKey =
|
|
475
429
|
registration.profileKey ?? `manual:${registration.instanceId}`;
|
|
476
|
-
const
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
430
|
+
const requestedTarget = registration.target;
|
|
431
|
+
const reconnectRecord = recordsBeforeProvision.find((record) => {
|
|
432
|
+
return (
|
|
433
|
+
record.owner?.kind === "manual-follower" &&
|
|
434
|
+
(record.instanceId === registration.instanceId ||
|
|
435
|
+
record.profileKey === followerProfileKey) &&
|
|
436
|
+
(!requestedTarget ||
|
|
437
|
+
(record.target.chatId === requestedTarget.chatId &&
|
|
438
|
+
record.target.threadId === requestedTarget.threadId))
|
|
439
|
+
);
|
|
440
|
+
});
|
|
487
441
|
const followerOwner =
|
|
488
442
|
Threads.getTelegramThreadOwnerFromProfileKey(followerProfileKey);
|
|
489
443
|
const recoverableTarget =
|
|
490
444
|
!reconnectRecord &&
|
|
491
|
-
|
|
492
|
-
|
|
445
|
+
requestedTarget?.chatId === chatId &&
|
|
446
|
+
requestedTarget.threadId !== undefined &&
|
|
493
447
|
!recordsBeforeProvision.some(
|
|
494
448
|
(record) =>
|
|
495
|
-
record.target.chatId ===
|
|
496
|
-
record.target.threadId ===
|
|
449
|
+
record.target.chatId === requestedTarget.chatId &&
|
|
450
|
+
record.target.threadId === requestedTarget.threadId,
|
|
497
451
|
)
|
|
498
|
-
?
|
|
452
|
+
? requestedTarget
|
|
499
453
|
: undefined;
|
|
500
454
|
const recoveryHint = recoverableTarget
|
|
501
455
|
? deps.topicTargetStore.getFollowerRecoveryHintByTarget?.(
|
|
@@ -526,13 +480,14 @@ export function createTelegramBusFollowerTargetProvisioner(
|
|
|
526
480
|
};
|
|
527
481
|
const recoverRequestedTarget = async () => {
|
|
528
482
|
const nowMs = getNowMs();
|
|
483
|
+
const carriedThreadName = registration.threadName;
|
|
529
484
|
const requestedThreadName =
|
|
530
|
-
|
|
485
|
+
carriedThreadName &&
|
|
531
486
|
Threads.isTelegramTopicThreadNameValidForSlot(
|
|
532
|
-
|
|
487
|
+
carriedThreadName,
|
|
533
488
|
registration.slot,
|
|
534
489
|
)
|
|
535
|
-
?
|
|
490
|
+
? carriedThreadName
|
|
536
491
|
: recoveryHint?.threadName &&
|
|
537
492
|
Threads.isTelegramTopicThreadNameValidForSlot(
|
|
538
493
|
recoveryHint.threadName,
|
|
@@ -540,7 +495,7 @@ export function createTelegramBusFollowerTargetProvisioner(
|
|
|
540
495
|
)
|
|
541
496
|
? recoveryHint.threadName
|
|
542
497
|
: undefined;
|
|
543
|
-
let recoveredRecord =
|
|
498
|
+
let recoveredRecord: Threads.TelegramTopicTargetRecord = {
|
|
544
499
|
profileKey: followerProfileKey,
|
|
545
500
|
owner:
|
|
546
501
|
followerOwner.kind === "manual-follower"
|
|
@@ -560,19 +515,15 @@ export function createTelegramBusFollowerTargetProvisioner(
|
|
|
560
515
|
...(requestedThreadName ? { threadName: requestedThreadName } : {}),
|
|
561
516
|
lastSyncObservedAtMs: nowMs,
|
|
562
517
|
lastReconcileAction: "follower-live-target-recovery",
|
|
563
|
-
}
|
|
518
|
+
};
|
|
564
519
|
const requestedSlot = registration.slot ?? recoveryHint?.slot;
|
|
565
520
|
const requestedSlotAvailable =
|
|
566
521
|
!!requestedSlot &&
|
|
567
522
|
/^[A-Z]$/.test(requestedSlot) &&
|
|
568
523
|
!recordsBeforeProvision.some((record) => record.slot === requestedSlot);
|
|
569
524
|
if (requestedSlotAvailable) {
|
|
570
|
-
recoveredRecord =
|
|
571
|
-
...recoveredRecord,
|
|
572
|
-
slot: requestedSlot,
|
|
573
|
-
});
|
|
525
|
+
recoveredRecord = { ...recoveredRecord, slot: requestedSlot };
|
|
574
526
|
}
|
|
575
|
-
await deps.topicTargetStore.persist();
|
|
576
527
|
return {
|
|
577
528
|
target: recoveredRecord.target,
|
|
578
529
|
reused: true,
|
|
@@ -591,25 +542,117 @@ export function createTelegramBusFollowerTargetProvisioner(
|
|
|
591
542
|
: recoverableTarget
|
|
592
543
|
? await recoverRequestedTarget()
|
|
593
544
|
: await provisionTarget();
|
|
594
|
-
|
|
545
|
+
const crossSessionReuse =
|
|
546
|
+
!!reconnectRecord &&
|
|
547
|
+
reconnectRecord.instanceId !== registration.instanceId;
|
|
548
|
+
if (reconnectRecord && !crossSessionReuse) {
|
|
595
549
|
const nowMs = getNowMs();
|
|
596
|
-
const
|
|
550
|
+
const refreshedRecord = deps.topicTargetStore.upsert({
|
|
597
551
|
...reconnectRecord,
|
|
598
552
|
instanceId: registration.instanceId,
|
|
599
553
|
updatedAtMs: nowMs,
|
|
600
554
|
lastSyncObservedAtMs: nowMs,
|
|
601
|
-
lastReconcileAction:
|
|
602
|
-
reconnectRecord.instanceId === registration.instanceId
|
|
603
|
-
? "follower-register-reuse"
|
|
604
|
-
: "follower-session-handoff",
|
|
555
|
+
lastReconcileAction: "follower-register-reuse",
|
|
605
556
|
});
|
|
606
557
|
await deps.topicTargetStore.persist();
|
|
607
558
|
result = {
|
|
608
|
-
target:
|
|
559
|
+
target: refreshedRecord.target,
|
|
609
560
|
reused: true,
|
|
610
|
-
record:
|
|
561
|
+
record: refreshedRecord,
|
|
611
562
|
};
|
|
612
563
|
}
|
|
564
|
+
const probeRequiredRecord =
|
|
565
|
+
reconnectRecord?.status === "probe-required";
|
|
566
|
+
const requiresVisibilityProbe =
|
|
567
|
+
crossSessionReuse ||
|
|
568
|
+
probeRequiredRecord ||
|
|
569
|
+
recoverableTarget !== undefined;
|
|
570
|
+
let connectedAnnouncement =
|
|
571
|
+
!result.reused || requiresVisibilityProbe
|
|
572
|
+
? createTelegramBusInstanceLifecycleAnnouncement({
|
|
573
|
+
target: result.target,
|
|
574
|
+
threadName: result.record.threadName,
|
|
575
|
+
slot: result.record.slot,
|
|
576
|
+
state: "connected",
|
|
577
|
+
})
|
|
578
|
+
: undefined;
|
|
579
|
+
if (requiresVisibilityProbe && connectedAnnouncement) {
|
|
580
|
+
try {
|
|
581
|
+
await deps.callApi("sendMessage", {
|
|
582
|
+
chat_id: connectedAnnouncement.target.chatId,
|
|
583
|
+
message_thread_id: connectedAnnouncement.target.threadId,
|
|
584
|
+
text: connectedAnnouncement.text,
|
|
585
|
+
parse_mode: connectedAnnouncement.parseMode,
|
|
586
|
+
});
|
|
587
|
+
if (recoverableTarget || probeRequiredRecord) {
|
|
588
|
+
const activatedRecord = deps.topicTargetStore.upsert({
|
|
589
|
+
...result.record,
|
|
590
|
+
status: "active",
|
|
591
|
+
updatedAtMs: getNowMs(),
|
|
592
|
+
lastSyncObservedAtMs: getNowMs(),
|
|
593
|
+
lastReconcileAction: "follower-live-target-recovery",
|
|
594
|
+
});
|
|
595
|
+
await deps.topicTargetStore.persist();
|
|
596
|
+
result = {
|
|
597
|
+
target: activatedRecord.target,
|
|
598
|
+
reused: true,
|
|
599
|
+
record: activatedRecord,
|
|
600
|
+
};
|
|
601
|
+
} else if (crossSessionReuse && reconnectRecord) {
|
|
602
|
+
const nowMs = getNowMs();
|
|
603
|
+
const transferredRecord = deps.topicTargetStore.upsert({
|
|
604
|
+
...reconnectRecord,
|
|
605
|
+
instanceId: registration.instanceId,
|
|
606
|
+
updatedAtMs: nowMs,
|
|
607
|
+
lastSyncObservedAtMs: nowMs,
|
|
608
|
+
lastReconcileAction: "follower-session-handoff",
|
|
609
|
+
});
|
|
610
|
+
await deps.topicTargetStore.persist();
|
|
611
|
+
result = {
|
|
612
|
+
target: transferredRecord.target,
|
|
613
|
+
reused: true,
|
|
614
|
+
record: transferredRecord,
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
connectedAnnouncement = undefined;
|
|
618
|
+
} catch (error) {
|
|
619
|
+
if (Threads.isTelegramTopicTargetStaleError(error)) {
|
|
620
|
+
deps.topicTargetStore.markStaleByTarget(
|
|
621
|
+
result.target,
|
|
622
|
+
"deleted",
|
|
623
|
+
error instanceof Error ? error.message : String(error),
|
|
624
|
+
);
|
|
625
|
+
await deps.topicTargetStore.persist();
|
|
626
|
+
result = await provisionTarget();
|
|
627
|
+
connectedAnnouncement =
|
|
628
|
+
createTelegramBusInstanceLifecycleAnnouncement({
|
|
629
|
+
target: result.target,
|
|
630
|
+
threadName: result.record.threadName,
|
|
631
|
+
slot: result.record.slot,
|
|
632
|
+
state: "connected",
|
|
633
|
+
});
|
|
634
|
+
} else {
|
|
635
|
+
deps.recordRuntimeEvent("telegram", error, {
|
|
636
|
+
phase: "follower-topic-reuse-probe",
|
|
637
|
+
instanceId: registration.instanceId,
|
|
638
|
+
chatId: result.target.chatId,
|
|
639
|
+
threadId: result.target.threadId,
|
|
640
|
+
});
|
|
641
|
+
if (recoverableTarget) {
|
|
642
|
+
deps.topicTargetStore.upsert({
|
|
643
|
+
...result.record,
|
|
644
|
+
status: "probe-required",
|
|
645
|
+
updatedAtMs: getNowMs(),
|
|
646
|
+
lastSyncError:
|
|
647
|
+
error instanceof Error ? error.message : String(error),
|
|
648
|
+
lastReconcileAction: "follower-visibility-probe-required",
|
|
649
|
+
});
|
|
650
|
+
await deps.topicTargetStore.persist();
|
|
651
|
+
}
|
|
652
|
+
throw error;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
}
|
|
613
656
|
deps.setSyncState(
|
|
614
657
|
Sync.markTelegramSyncSliceFresh(
|
|
615
658
|
deps.getSyncState(),
|
|
@@ -620,14 +663,6 @@ export function createTelegramBusFollowerTargetProvisioner(
|
|
|
620
663
|
},
|
|
621
664
|
),
|
|
622
665
|
);
|
|
623
|
-
const connectedAnnouncement = result.reused
|
|
624
|
-
? undefined
|
|
625
|
-
: createTelegramBusInstanceLifecycleAnnouncement({
|
|
626
|
-
target: result.target,
|
|
627
|
-
threadName: result.record.threadName,
|
|
628
|
-
slot: result.record.slot,
|
|
629
|
-
state: "connected",
|
|
630
|
-
});
|
|
631
666
|
recordSlowTelegramBusFollowerRegistrationStep(deps, {
|
|
632
667
|
phase: "follower-register-critical",
|
|
633
668
|
elapsedMs: Date.now() - registrationStartedAtMs,
|
|
@@ -733,6 +768,58 @@ export function createTelegramBusFollowerPruneHandler(
|
|
|
733
768
|
};
|
|
734
769
|
}
|
|
735
770
|
|
|
771
|
+
export function createTelegramBusFollowerDisconnectHandler(
|
|
772
|
+
deps: TelegramBusFollowerPruneHandlerDeps,
|
|
773
|
+
): (follower: TelegramBusFollowerView) => Promise<void> {
|
|
774
|
+
return async (follower) => {
|
|
775
|
+
const target = follower.target;
|
|
776
|
+
if (!target?.threadId) return;
|
|
777
|
+
const leaderEpoch = deps.getCurrentLeaderEpoch?.();
|
|
778
|
+
if (deps.getCurrentLeaderEpoch && leaderEpoch === undefined) {
|
|
779
|
+
throw new Error("Follower disconnect cleanup requires leader ownership.");
|
|
780
|
+
}
|
|
781
|
+
const cleanup = await ThreadReconciler.applyThreadReconciliationPlan(
|
|
782
|
+
ThreadReconciler.planDisconnectedInstanceThreadCleanup({
|
|
783
|
+
target: { chatId: target.chatId, threadId: target.threadId },
|
|
784
|
+
instanceId: follower.instanceId,
|
|
785
|
+
leaderEpoch,
|
|
786
|
+
}),
|
|
787
|
+
{
|
|
788
|
+
callApi: deps.callApi,
|
|
789
|
+
persist: deps.topicTargetStore.persist,
|
|
790
|
+
getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
|
|
791
|
+
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
792
|
+
},
|
|
793
|
+
);
|
|
794
|
+
if (cleanup.incompleteActions?.length) {
|
|
795
|
+
throw new Error(
|
|
796
|
+
"Telegram follower thread deletion was not confirmed; reconnect the leader and retry /telegram-disconnect.",
|
|
797
|
+
);
|
|
798
|
+
}
|
|
799
|
+
if (
|
|
800
|
+
deps.getCurrentLeaderEpoch &&
|
|
801
|
+
deps.getCurrentLeaderEpoch() !== leaderEpoch
|
|
802
|
+
) {
|
|
803
|
+
throw new Error("Follower disconnect cleanup lost leader ownership.");
|
|
804
|
+
}
|
|
805
|
+
const changed =
|
|
806
|
+
deps.topicTargetStore.markOfflineByInstanceId(follower.instanceId) > 0;
|
|
807
|
+
if (changed) await deps.topicTargetStore.persist();
|
|
808
|
+
deps.setSyncState(
|
|
809
|
+
Sync.markTelegramSyncSliceFresh(deps.getSyncState(), "target-bindings", {
|
|
810
|
+
nowMs: (deps.getNowMs ?? Date.now)(),
|
|
811
|
+
action: "manual-follower-disconnect",
|
|
812
|
+
}),
|
|
813
|
+
);
|
|
814
|
+
deps.recordRuntimeEvent("bus", "Telegram bus follower disconnected", {
|
|
815
|
+
phase: "follower-disconnect",
|
|
816
|
+
instanceId: follower.instanceId,
|
|
817
|
+
chatId: target.chatId,
|
|
818
|
+
threadId: target.threadId,
|
|
819
|
+
});
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
|
|
736
823
|
export function createTelegramBusLeaderTargetProvisioner<TContext>(
|
|
737
824
|
deps: TelegramBusLeaderTargetProvisionerDeps<TContext>,
|
|
738
825
|
): (ctx: TContext) => Promise<void> {
|
|
@@ -875,12 +962,49 @@ export function createTelegramBusLeaderEnvelopeHandler(deps: {
|
|
|
875
962
|
recordFollowerMessageOwnership?: TelegramBusFollowerMessageOwnershipRecorder;
|
|
876
963
|
provisionFollowerTarget?: (
|
|
877
964
|
registration: TelegramBusInstanceRegistration,
|
|
878
|
-
) =>
|
|
965
|
+
) =>
|
|
966
|
+
| Promise<
|
|
967
|
+
| (TelegramTarget & { slot?: string; threadName?: string })
|
|
968
|
+
| undefined
|
|
969
|
+
>
|
|
970
|
+
| (TelegramTarget & { slot?: string; threadName?: string })
|
|
971
|
+
| undefined;
|
|
972
|
+
onFollowerDisconnected?: (
|
|
973
|
+
follower: TelegramBusFollowerView,
|
|
974
|
+
) => Promise<void> | void;
|
|
879
975
|
getCurrentLeaderEpoch?: () => number | string | undefined;
|
|
880
976
|
}): (
|
|
881
977
|
envelope: TelegramBusEnvelope,
|
|
882
978
|
) => Promise<TelegramBusEnvelope> | TelegramBusEnvelope {
|
|
883
979
|
const getNowMs = deps.getNowMs ?? Date.now;
|
|
980
|
+
const followerMutationTails = new Map<string, Promise<void>>();
|
|
981
|
+
const getFollowerMutationKey = (follower: {
|
|
982
|
+
instanceId: string;
|
|
983
|
+
profileKey?: string;
|
|
984
|
+
}): string =>
|
|
985
|
+
follower.profileKey
|
|
986
|
+
? `profile:${follower.profileKey}`
|
|
987
|
+
: `instance:${follower.instanceId}`;
|
|
988
|
+
const runFollowerMutation = async <T>(
|
|
989
|
+
mutationKey: string,
|
|
990
|
+
operation: () => Promise<T>,
|
|
991
|
+
): Promise<T> => {
|
|
992
|
+
const previous = followerMutationTails.get(mutationKey);
|
|
993
|
+
let release!: () => void;
|
|
994
|
+
const current = new Promise<void>((resolve) => {
|
|
995
|
+
release = resolve;
|
|
996
|
+
});
|
|
997
|
+
followerMutationTails.set(mutationKey, current);
|
|
998
|
+
if (previous) await previous;
|
|
999
|
+
try {
|
|
1000
|
+
return await operation();
|
|
1001
|
+
} finally {
|
|
1002
|
+
release();
|
|
1003
|
+
if (followerMutationTails.get(mutationKey) === current) {
|
|
1004
|
+
followerMutationTails.delete(mutationKey);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
};
|
|
884
1008
|
const forwardToFollower = async (
|
|
885
1009
|
envelope: Extract<
|
|
886
1010
|
TelegramBusEnvelope,
|
|
@@ -943,47 +1067,115 @@ export function createTelegramBusLeaderEnvelopeHandler(deps: {
|
|
|
943
1067
|
}
|
|
944
1068
|
switch (envelope.kind) {
|
|
945
1069
|
case "follower.register": {
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
1070
|
+
return runFollowerMutation(
|
|
1071
|
+
getFollowerMutationKey(envelope.registration),
|
|
1072
|
+
async () => {
|
|
1073
|
+
try {
|
|
1074
|
+
if (!envelope.registration.registrationGeneration) {
|
|
1075
|
+
throw new Error(
|
|
1076
|
+
"Telegram follower registration requires an exact generation.",
|
|
1077
|
+
);
|
|
1078
|
+
}
|
|
1079
|
+
const leaderEpoch = deps.getCurrentLeaderEpoch?.();
|
|
1080
|
+
if (deps.getCurrentLeaderEpoch && leaderEpoch === undefined) {
|
|
1081
|
+
throw new Error(
|
|
1082
|
+
"Telegram follower registration requires leader ownership.",
|
|
1083
|
+
);
|
|
1084
|
+
}
|
|
1085
|
+
const target = await deps.provisionFollowerTarget?.(
|
|
1086
|
+
envelope.registration,
|
|
951
1087
|
);
|
|
1088
|
+
if (
|
|
1089
|
+
deps.getCurrentLeaderEpoch &&
|
|
1090
|
+
deps.getCurrentLeaderEpoch() !== leaderEpoch
|
|
1091
|
+
) {
|
|
1092
|
+
throw new Error(
|
|
1093
|
+
"Telegram follower registration lost leader ownership.",
|
|
1094
|
+
);
|
|
1095
|
+
}
|
|
1096
|
+
const registeredTarget = target ?? envelope.registration.target;
|
|
1097
|
+
deps.followerRegistry.register({
|
|
1098
|
+
...envelope.registration,
|
|
1099
|
+
connectedAtMs: getNowMs(),
|
|
1100
|
+
target: registeredTarget,
|
|
1101
|
+
...((target?.slot ?? envelope.registration.slot)
|
|
1102
|
+
? { slot: target?.slot ?? envelope.registration.slot }
|
|
1103
|
+
: {}),
|
|
1104
|
+
...((target?.threadName ?? envelope.registration.threadName)
|
|
1105
|
+
? {
|
|
1106
|
+
threadName:
|
|
1107
|
+
target?.threadName ?? envelope.registration.threadName,
|
|
1108
|
+
}
|
|
1109
|
+
: {}),
|
|
1110
|
+
});
|
|
1111
|
+
return {
|
|
1112
|
+
kind: "bus.ack" as const,
|
|
1113
|
+
requestId: envelope.requestId,
|
|
1114
|
+
ok: true,
|
|
1115
|
+
...(registeredTarget ? { result: registeredTarget } : {}),
|
|
1116
|
+
};
|
|
1117
|
+
} catch (error) {
|
|
1118
|
+
return {
|
|
1119
|
+
kind: "bus.ack" as const,
|
|
1120
|
+
requestId: envelope.requestId,
|
|
1121
|
+
ok: false,
|
|
1122
|
+
message:
|
|
1123
|
+
error instanceof Error
|
|
1124
|
+
? error.message
|
|
1125
|
+
: "Telegram bus follower target provisioning failed.",
|
|
1126
|
+
};
|
|
1127
|
+
}
|
|
1128
|
+
},
|
|
1129
|
+
);
|
|
1130
|
+
}
|
|
1131
|
+
case "follower.disconnect": {
|
|
1132
|
+
const registeredFollower = deps.followerRegistry.get(envelope.instanceId);
|
|
1133
|
+
return runFollowerMutation(
|
|
1134
|
+
getFollowerMutationKey(
|
|
1135
|
+
registeredFollower ?? { instanceId: envelope.instanceId },
|
|
1136
|
+
),
|
|
1137
|
+
async () => {
|
|
1138
|
+
const follower = deps.followerRegistry.get(envelope.instanceId);
|
|
1139
|
+
if (!follower) {
|
|
1140
|
+
return {
|
|
1141
|
+
kind: "bus.ack" as const,
|
|
1142
|
+
requestId: envelope.requestId,
|
|
1143
|
+
ok: false,
|
|
1144
|
+
message: "Unknown Telegram bus follower instance.",
|
|
1145
|
+
};
|
|
952
1146
|
}
|
|
953
|
-
const target = await deps.provisionFollowerTarget?.(
|
|
954
|
-
envelope.registration,
|
|
955
|
-
);
|
|
956
1147
|
if (
|
|
957
|
-
|
|
958
|
-
|
|
1148
|
+
!follower.registrationGeneration ||
|
|
1149
|
+
!envelope.registrationGeneration ||
|
|
1150
|
+
envelope.registrationGeneration !== follower.registrationGeneration
|
|
959
1151
|
) {
|
|
960
|
-
|
|
961
|
-
"
|
|
962
|
-
|
|
1152
|
+
return {
|
|
1153
|
+
kind: "bus.ack" as const,
|
|
1154
|
+
requestId: envelope.requestId,
|
|
1155
|
+
ok: false,
|
|
1156
|
+
message: "Stale Telegram bus follower registration generation.",
|
|
1157
|
+
};
|
|
963
1158
|
}
|
|
964
|
-
|
|
965
|
-
deps.followerRegistry.
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
1159
|
+
await deps.onFollowerDisconnected?.(follower);
|
|
1160
|
+
const current = deps.followerRegistry.get(follower.instanceId);
|
|
1161
|
+
if (
|
|
1162
|
+
current?.registrationGeneration !== follower.registrationGeneration
|
|
1163
|
+
) {
|
|
1164
|
+
return {
|
|
1165
|
+
kind: "bus.ack" as const,
|
|
1166
|
+
requestId: envelope.requestId,
|
|
1167
|
+
ok: false,
|
|
1168
|
+
message: "Stale Telegram bus follower registration generation.",
|
|
1169
|
+
};
|
|
1170
|
+
}
|
|
1171
|
+
deps.followerRegistry.remove(follower.instanceId);
|
|
970
1172
|
return {
|
|
971
|
-
kind: "bus.ack",
|
|
1173
|
+
kind: "bus.ack" as const,
|
|
972
1174
|
requestId: envelope.requestId,
|
|
973
1175
|
ok: true,
|
|
974
|
-
...(registeredTarget ? { result: registeredTarget } : {}),
|
|
975
|
-
};
|
|
976
|
-
} catch (error) {
|
|
977
|
-
return {
|
|
978
|
-
kind: "bus.ack",
|
|
979
|
-
requestId: envelope.requestId,
|
|
980
|
-
ok: false,
|
|
981
|
-
message:
|
|
982
|
-
error instanceof Error
|
|
983
|
-
? error.message
|
|
984
|
-
: "Telegram bus follower target provisioning failed.",
|
|
985
1176
|
};
|
|
986
|
-
|
|
1177
|
+
},
|
|
1178
|
+
);
|
|
987
1179
|
}
|
|
988
1180
|
case "follower.heartbeat": {
|
|
989
1181
|
const current = deps.followerRegistry.get(envelope.instanceId);
|
|
@@ -1003,7 +1195,20 @@ export function createTelegramBusLeaderEnvelopeHandler(deps: {
|
|
|
1003
1195
|
getNowMs(),
|
|
1004
1196
|
);
|
|
1005
1197
|
return follower
|
|
1006
|
-
? {
|
|
1198
|
+
? {
|
|
1199
|
+
kind: "bus.ack",
|
|
1200
|
+
requestId: envelope.requestId,
|
|
1201
|
+
ok: true,
|
|
1202
|
+
result: {
|
|
1203
|
+
eligibleElectionSlots: deps.followerRegistry
|
|
1204
|
+
.list()
|
|
1205
|
+
.map((candidate) => candidate.slot)
|
|
1206
|
+
.filter((slot): slot is string =>
|
|
1207
|
+
typeof slot === "string" && /^[A-Z]$/.test(slot),
|
|
1208
|
+
)
|
|
1209
|
+
.sort(),
|
|
1210
|
+
},
|
|
1211
|
+
}
|
|
1007
1212
|
: {
|
|
1008
1213
|
kind: "bus.ack",
|
|
1009
1214
|
requestId: envelope.requestId,
|
|
@@ -1330,6 +1535,7 @@ export function createTelegramBusLeaderRuntime<TContext>(
|
|
|
1330
1535
|
authorizeFollowerApiCall: deps.authorizeFollowerApiCall,
|
|
1331
1536
|
recordFollowerMessageOwnership: deps.recordFollowerMessageOwnership,
|
|
1332
1537
|
provisionFollowerTarget: deps.provisionFollowerTarget,
|
|
1538
|
+
onFollowerDisconnected: deps.onFollowerDisconnected,
|
|
1333
1539
|
getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
|
|
1334
1540
|
}),
|
|
1335
1541
|
});
|