@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/lib/activity.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Telegram activity lifecycle normalization and extension dispatch
3
3
  * Zones: pi agent lifecycle, extension API, operational delivery
4
- * Owns stable handler registration, evidence-based activity/source identity, assistant segment and reasoning normalization, executed-tool and compaction events, isolated non-blocking queues, shutdown fencing, diagnostics, and fresh delivery contexts; excludes Pi hook wiring, visibility policy, Telegram rendering, and consumer-extension behavior
4
+ * Owns stable handler registration, evidence-based activity/source identity, assistant segment and reasoning normalization, ordered public-output projection, executed-tool and compaction events, isolated non-blocking queues, shutdown fencing, diagnostics, and fresh delivery contexts; excludes Pi hook wiring, Telegram rendering implementation, raw transport clients, and consumer-extension behavior
5
5
  */
6
6
 
7
7
  import {
@@ -91,6 +91,9 @@ export type TelegramActivityPayload =
91
91
  export type TelegramActivityEvent = TelegramActivityEnvelope &
92
92
  TelegramActivityPayload;
93
93
 
94
+ export type TelegramAssistantSegmentEvent = TelegramActivityEnvelope &
95
+ Extract<TelegramActivityPayload, { type: "assistant-segment" }>;
96
+
94
97
  export interface TelegramActivityContext {
95
98
  activityId: string;
96
99
  sequence: number;
@@ -383,6 +386,7 @@ export function createTelegramActivityDispatcher(deps: {
383
386
  /** @internal */
384
387
  export function createTelegramActivityBridgeRuntime(deps: {
385
388
  generation: string;
389
+ observeEvent?: (event: TelegramActivityEvent) => void;
386
390
  recordFailure?: (
387
391
  handlerId: string,
388
392
  event: TelegramActivityEvent,
@@ -401,6 +405,11 @@ export function createTelegramActivityBridgeRuntime(deps: {
401
405
  dispatcher: createTelegramActivityDispatcher({
402
406
  recordFailure: deps.recordFailure,
403
407
  }),
408
+ observeEvent: deps.observeEvent,
409
+ recordObserverFailure: deps.recordFailure
410
+ ? (event, error) =>
411
+ deps.recordFailure!("@llblab/pi-telegram/proactive", event, error)
412
+ : undefined,
404
413
  now: deps.now,
405
414
  });
406
415
  },
@@ -507,6 +516,11 @@ interface PendingAssistantSegment {
507
516
  export function createTelegramActivityRuntime(deps: {
508
517
  generation: string;
509
518
  dispatcher: TelegramActivityDispatcher;
519
+ observeEvent?: (event: TelegramActivityEvent) => void;
520
+ recordObserverFailure?: (
521
+ event: TelegramActivityEvent,
522
+ error: unknown,
523
+ ) => void;
510
524
  now?: () => number;
511
525
  }): TelegramActivityRuntime {
512
526
  const now = deps.now ?? Date.now;
@@ -542,14 +556,20 @@ export function createTelegramActivityRuntime(deps: {
542
556
  const emit = (event: TelegramActivityPayload): void => {
543
557
  const currentActivityId = ensureActivity();
544
558
  sequence += 1;
545
- deps.dispatcher.dispatch({
559
+ const normalizedEvent = {
546
560
  ...event,
547
561
  activityId: currentActivityId,
548
562
  sequence,
549
563
  source: activitySource,
550
564
  ...(activityTarget ? { target: activityTarget } : {}),
551
565
  timestamp: now(),
552
- } as TelegramActivityEvent);
566
+ } as TelegramActivityEvent;
567
+ try {
568
+ deps.observeEvent?.(normalizedEvent);
569
+ } catch (error) {
570
+ deps.recordObserverFailure?.(normalizedEvent, error);
571
+ }
572
+ deps.dispatcher.dispatch(normalizedEvent);
553
573
  };
554
574
  const flushPendingSegment = (
555
575
  placement: "intermediate" | "final" | "terminal-partial",
@@ -681,3 +701,80 @@ export function createTelegramActivityRuntime(deps: {
681
701
  },
682
702
  };
683
703
  }
704
+
705
+ // --- Public Assistant Output Projection ---
706
+
707
+ export interface TelegramAssistantOutputRuntime {
708
+ start: () => void;
709
+ accept: (event: TelegramAssistantSegmentEvent) => void;
710
+ waitForIdle: () => Promise<void>;
711
+ stop: () => void;
712
+ }
713
+
714
+ export function createTelegramAssistantOutputRuntime<TAuthority = undefined>(deps: {
715
+ isEnabled: () => boolean;
716
+ captureAuthority?: () => TAuthority;
717
+ isAuthorityActive?: (authority: TAuthority) => boolean;
718
+ canDeliver: (event: TelegramAssistantSegmentEvent) => boolean;
719
+ send: (
720
+ event: TelegramAssistantSegmentEvent,
721
+ authority: TAuthority,
722
+ isAuthorityActive: () => boolean,
723
+ ) => Promise<void>;
724
+ recordFailure?: (
725
+ event: TelegramAssistantSegmentEvent,
726
+ error: unknown,
727
+ ) => void;
728
+ }): TelegramAssistantOutputRuntime {
729
+ let generation = 0;
730
+ let running = false;
731
+ let tail: Promise<void> = Promise.resolve();
732
+ const admitted = new Set<string>();
733
+ const isEligibleSource = (
734
+ source: TelegramAssistantSegmentEvent["source"],
735
+ ): boolean => source === "local" || source === "autonomous";
736
+
737
+ return {
738
+ start() {
739
+ generation += 1;
740
+ running = true;
741
+ admitted.clear();
742
+ tail = Promise.resolve();
743
+ },
744
+ accept(event) {
745
+ if (!running || !deps.isEnabled()) return;
746
+ if (!isEligibleSource(event.source) || !event.text.trim()) return;
747
+ const key = `${event.activityId}:${event.sequence}`;
748
+ if (admitted.has(key)) return;
749
+ admitted.add(key);
750
+ const admittedGeneration = generation;
751
+ const admittedAuthority = deps.captureAuthority?.();
752
+ tail = tail.then(async () => {
753
+ const isAdmittedAuthorityActive = () =>
754
+ running &&
755
+ generation === admittedGeneration &&
756
+ deps.isEnabled() &&
757
+ (deps.isAuthorityActive === undefined ||
758
+ deps.isAuthorityActive(admittedAuthority as TAuthority));
759
+ if (!isAdmittedAuthorityActive() || !deps.canDeliver(event)) return;
760
+ try {
761
+ await deps.send(
762
+ event,
763
+ admittedAuthority as TAuthority,
764
+ isAdmittedAuthorityActive,
765
+ );
766
+ } catch (error) {
767
+ deps.recordFailure?.(event, error);
768
+ }
769
+ });
770
+ },
771
+ waitForIdle() {
772
+ return tail;
773
+ },
774
+ stop() {
775
+ generation += 1;
776
+ running = false;
777
+ admitted.clear();
778
+ },
779
+ };
780
+ }
package/lib/bindings.ts CHANGED
@@ -19,6 +19,7 @@ import * as Preview from "./preview.ts";
19
19
  import * as Prompts from "./prompts.ts";
20
20
  import * as Queue from "./queue.ts";
21
21
  import * as Replies from "./replies.ts";
22
+ import * as Routing from "./routing.ts";
22
23
  import * as Runtime from "./runtime.ts";
23
24
  import * as Setup from "./setup.ts";
24
25
  import * as Status from "./status.ts";
@@ -35,6 +36,60 @@ type TelegramRuntimeEventRecorder = (
35
36
  type TelegramBridgeStatusUpdater =
36
37
  Status.TelegramStatusRuntime<Pi.ExtensionContext>["updateStatus"];
37
38
 
39
+ export interface TelegramAssistantOutputBindingRuntime<
40
+ TTransportStamp,
41
+ > {
42
+ runtime: Activity.TelegramAssistantOutputRuntime;
43
+ observeEvent: (event: Activity.TelegramActivityEvent) => void;
44
+ authority: Routing.TelegramAssistantOutputAuthorityRuntime<TTransportStamp>;
45
+ }
46
+
47
+ export function createTelegramAssistantOutputBindingRuntime<
48
+ TTransportStamp,
49
+ >(deps: {
50
+ isEnabled: () => boolean;
51
+ authority: {
52
+ getPreferredTarget: () => OutboundAttachments.TelegramQueuedOutboundAttachmentTurnView["target"] | undefined;
53
+ getFallbackChatId: () => number | undefined;
54
+ getTransportStamp: () => TTransportStamp;
55
+ isTransportStampActive: (stamp: TTransportStamp) => boolean;
56
+ ownsDirect: () => boolean;
57
+ getDirectEpoch: () => number | string | undefined;
58
+ isFollowerRegistered: () => boolean;
59
+ getFollowerGeneration: () => string | undefined;
60
+ };
61
+ sender: Parameters<
62
+ typeof OutboundHandlers.createTelegramAssistantOutputSender<TTransportStamp>
63
+ >[0];
64
+ recordRuntimeEvent: TelegramRuntimeEventRecorder;
65
+ }): TelegramAssistantOutputBindingRuntime<TTransportStamp> {
66
+ const authority =
67
+ Routing.createTelegramAssistantOutputAuthorityRuntime(deps.authority);
68
+ const send =
69
+ OutboundHandlers.createTelegramAssistantOutputSender<TTransportStamp>(
70
+ deps.sender,
71
+ );
72
+ const runtime = Activity.createTelegramAssistantOutputRuntime({
73
+ isEnabled: deps.isEnabled,
74
+ ...authority,
75
+ send,
76
+ recordFailure(event, error) {
77
+ deps.recordRuntimeEvent("proactive-push", error, {
78
+ activityId: event.activityId,
79
+ sequence: event.sequence,
80
+ placement: event.placement,
81
+ });
82
+ },
83
+ });
84
+ return {
85
+ runtime,
86
+ authority,
87
+ observeEvent(event) {
88
+ if (event.type === "assistant-segment") runtime.accept(event);
89
+ },
90
+ };
91
+ }
92
+
38
93
  interface TelegramCommandsAndToolsBindingDeps {
39
94
  pi: Pi.ExtensionAPI;
40
95
  configStore: Config.TelegramConfigStore;
@@ -43,6 +98,7 @@ interface TelegramCommandsAndToolsBindingDeps {
43
98
  activeTurnRuntime: Queue.TelegramActiveTurnStore<Queue.PendingTelegramTurn>;
44
99
  lockedPollingRuntime: Locks.TelegramLockedPollingRuntime<Pi.ExtensionContext>;
45
100
  stopPolling?: () => Promise<void | string>;
101
+ getDisconnectThreadName?: () => string | undefined;
46
102
  onTransportChanged?: () => Promise<void> | void;
47
103
  getStatusLines: (
48
104
  options?: Status.TelegramBridgeStatusLineOptions,
@@ -70,6 +126,7 @@ export function registerTelegramCommandsAndTools({
70
126
  activeTurnRuntime,
71
127
  lockedPollingRuntime,
72
128
  stopPolling,
129
+ getDisconnectThreadName,
73
130
  onTransportChanged,
74
131
  getStatusLines,
75
132
  buttonActionStore,
@@ -186,6 +243,7 @@ export function registerTelegramCommandsAndTools({
186
243
  hasBotToken: configStore.hasBotToken,
187
244
  startPolling: lockedPollingRuntime.start,
188
245
  stopPolling: stopPolling ?? lockedPollingRuntime.stop,
246
+ getDisconnectThreadName,
189
247
  updateStatus,
190
248
  getProfileNames: () =>
191
249
  Config.getTelegramProfileNames(configStore.getStoredConfig()),
@@ -217,6 +275,10 @@ export function registerTelegramCommandsAndTools({
217
275
  interface TelegramLifecycleBindingDeps {
218
276
  pi: Pi.ExtensionAPI;
219
277
  activityRuntime: Activity.TelegramActivityRuntime;
278
+ assistantOutputRuntime: Pick<
279
+ Activity.TelegramAssistantOutputRuntime,
280
+ "start" | "stop"
281
+ >;
220
282
  sessionLifecycleRuntime: Pick<
221
283
  Lifecycle.TelegramLifecycleRegistrationDeps,
222
284
  "onSessionStart" | "onSessionShutdown" | "onModelSelect"
@@ -285,7 +347,12 @@ interface TelegramLifecycleBindingDeps {
285
347
  proactivePushChatIdGetter: () => number | undefined;
286
348
  proactivePushTargetGetter: () => Queue.TelegramQueueTarget | undefined;
287
349
  isProactivePushEnabled: () => boolean;
288
- canSendProactivePush: (ctx: Pi.ExtensionContext) => boolean;
350
+ getAssistantRenderingMode: () => "rich" | "html";
351
+ recordMessageOwnership?: (input: {
352
+ chatId: number;
353
+ messageId: number;
354
+ target?: Queue.TelegramQueueTarget;
355
+ }) => void;
289
356
  canSendAgentActivity: (ctx: Pi.ExtensionContext) => boolean;
290
357
  isSessionContextActive: (ctx: Pi.ExtensionContext) => boolean;
291
358
  isTurnTransportActive?: (turn: Queue.PendingTelegramTurn) => boolean;
@@ -296,6 +363,7 @@ interface TelegramLifecycleBindingDeps {
296
363
  export function registerTelegramLifecycleRuntimeHooks({
297
364
  pi,
298
365
  activityRuntime,
366
+ assistantOutputRuntime,
299
367
  sessionLifecycleRuntime,
300
368
  configStore,
301
369
  abort,
@@ -322,7 +390,8 @@ export function registerTelegramLifecycleRuntimeHooks({
322
390
  proactivePushChatIdGetter,
323
391
  proactivePushTargetGetter,
324
392
  isProactivePushEnabled,
325
- canSendProactivePush,
393
+ getAssistantRenderingMode,
394
+ recordMessageOwnership,
326
395
  canSendAgentActivity,
327
396
  isSessionContextActive = () => true,
328
397
  isTurnTransportActive,
@@ -343,6 +412,13 @@ export function registerTelegramLifecycleRuntimeHooks({
343
412
  sendTextReply,
344
413
  recordRuntimeEvent,
345
414
  });
415
+ const richAttachmentSender =
416
+ OutboundAttachments.createTelegramRichOutboundAttachmentSender({
417
+ sendMultipart: callMultipart,
418
+ getRenderingMode: getAssistantRenderingMode,
419
+ recordOwnership: recordMessageOwnership,
420
+ recordRuntimeEvent,
421
+ });
346
422
  const sendGuestAttachment = async (
347
423
  turn: Queue.PendingTelegramTurn,
348
424
  attachment: Queue.QueuedAttachment,
@@ -499,16 +575,13 @@ export function registerTelegramLifecycleRuntimeHooks({
499
575
  sendMarkdownReply,
500
576
  sendTextReply,
501
577
  sendQueuedAttachments: queuedAttachmentSender,
578
+ sendRichAttachmentReply: richAttachmentSender,
502
579
  answerGuestQuery,
503
580
  sendGuestReply,
504
581
  sendGuestAttachment,
505
582
  sendGuestVoiceReply,
506
583
  planOutboundReply: outboundReplyPlanner,
507
584
  sendOutboundReplyArtifacts: outboundReplyArtifactSender,
508
- getDefaultChatId: proactivePushChatIdGetter,
509
- getDefaultTarget: proactivePushTargetGetter,
510
- isProactivePushEnabled,
511
- canSendProactivePush,
512
585
  recordRuntimeEvent,
513
586
  getActiveToolExecutions: lifecycle.getActiveToolExecutions,
514
587
  setActiveToolExecutions: lifecycle.setActiveToolExecutions,
@@ -565,12 +638,14 @@ export function registerTelegramLifecycleRuntimeHooks({
565
638
  },
566
639
  async onSessionStart(event, ctx) {
567
640
  previewRuntime.invalidate();
641
+ assistantOutputRuntime.start();
568
642
  activityRuntime.onSessionStart?.();
569
643
  await sessionLifecycleRuntime.onSessionStart(event, ctx);
570
644
  },
571
645
  async onSessionShutdown(event, ctx) {
572
646
  if (!isSessionContextActive(ctx)) return;
573
647
  activityRuntime.onSessionShutdown();
648
+ assistantOutputRuntime.stop();
574
649
  compactionObserver.onSessionShutdown();
575
650
  await sessionLifecycleRuntime.onSessionShutdown(event, ctx);
576
651
  },
@@ -98,6 +98,7 @@ export interface TelegramBusFollowerRegistrationRuntime<TContext> {
98
98
  options?: { target?: TelegramTarget },
99
99
  ) => Promise<boolean>;
100
100
  setContext: (ctx: TContext) => void;
101
+ disconnectFromLeader?: () => Promise<boolean>;
101
102
  stop: () => void;
102
103
  }
103
104
 
@@ -108,6 +109,10 @@ export interface TelegramBusFollowerSessionReplacementSuspenderDeps {
108
109
  >;
109
110
  instanceId: string;
110
111
  suspendPolling: () => Promise<void>;
112
+ isLeader?: () => boolean;
113
+ getLeaderBinding?: () => TelegramBusFollowerPromotedBinding | undefined;
114
+ getActiveContext?: () => { cwd?: string } | undefined;
115
+ getActiveProfileName?: () => string | undefined;
111
116
  recordRuntimeEvent: (
112
117
  category: string,
113
118
  error: unknown,
@@ -139,6 +144,8 @@ export interface TelegramBusFollowerRegistrationState {
139
144
  getSlot: () => string | undefined;
140
145
  getThreadName: () => string | undefined;
141
146
  getGeneration: () => string | undefined;
147
+ getEligibleElectionSlots: () => readonly string[];
148
+ setEligibleElectionSlots: (slots: readonly string[]) => void;
142
149
  setRegistered: (
143
150
  registered: boolean,
144
151
  target?: TelegramTarget,
@@ -151,12 +158,20 @@ export interface TelegramBusForwardedUpdateReceiverRuntime {
151
158
  stop: () => Promise<void>;
152
159
  }
153
160
 
154
- export interface TelegramBusFollowerClientRuntimeDeps {
161
+ export interface TelegramBusFollowerClientRuntimeDeps<TMessage = unknown> {
155
162
  socketPath: TelegramBusSocketPathSource;
156
163
  instanceId: string;
157
164
  getApiAuthSecret?: () => string | undefined;
158
165
  getForwardingAuthSecret?: () => string | undefined;
159
166
  getRegistrationGeneration?: () => string | undefined;
167
+ getForwardCommentBatchPosition?: (
168
+ message: TMessage,
169
+ ) => "comment" | "forward" | undefined;
170
+ recordRuntimeEvent?: (
171
+ category: string,
172
+ error: unknown,
173
+ details?: Record<string, unknown>,
174
+ ) => void;
160
175
  timeoutMs?: number;
161
176
  }
162
177
 
@@ -239,9 +254,11 @@ export function createTelegramBusFollowerPromotionHandler<
239
254
  error: unknown,
240
255
  details?: Record<string, unknown>,
241
256
  ) => void;
257
+ getNowMs?: () => number;
258
+ getPid?: () => number;
242
259
  }): TelegramBusFollowerPromotionHandler<TContext> {
243
- return async (ctx, binding, election) =>
244
- input.startLeader(ctx, election, async () => {
260
+ return async (ctx, binding, election) => {
261
+ const promoted = await input.startLeader(ctx, election, async () => {
245
262
  const promotedRecord =
246
263
  await Threads.promoteTelegramFollowerBindingToLeader({
247
264
  store: input.topicTargetStore,
@@ -266,6 +283,39 @@ export function createTelegramBusFollowerPromotionHandler<
266
283
  );
267
284
  }
268
285
  });
286
+ if (promoted && typeof binding.target?.threadId === "number") {
287
+ const profileKey = Threads.getTelegramThreadOwnerKey({
288
+ kind: "leader",
289
+ cwd: ctx.cwd,
290
+ instanceId: input.instanceId,
291
+ telegramProfile: input.getActiveProfileName(),
292
+ });
293
+ Threads.setTelegramLeaderSessionHandoff({
294
+ pid: input.getPid?.() ?? process.pid,
295
+ instanceId: input.instanceId,
296
+ createdAtMs: input.getNowMs?.() ?? Date.now(),
297
+ profileKey,
298
+ target: {
299
+ chatId: binding.target.chatId,
300
+ threadId: binding.target.threadId,
301
+ },
302
+ slot: binding.slot,
303
+ threadName: binding.threadName,
304
+ });
305
+ input.recordRuntimeEvent(
306
+ "bus",
307
+ "Promoted leader binding retained for session replacement",
308
+ {
309
+ phase: "follower-promoted-session-handoff",
310
+ chatId: binding.target.chatId,
311
+ threadId: binding.target.threadId,
312
+ slot: binding.slot,
313
+ threadName: binding.threadName,
314
+ },
315
+ );
316
+ }
317
+ return promoted;
318
+ };
269
319
  }
270
320
 
271
321
  export interface TelegramBusFollowerTargetReplacementHandlerDeps<TContext> {
@@ -309,7 +359,11 @@ export interface TelegramBusFollowerPromotedBinding {
309
359
  export interface TelegramBusFollowerHeartbeatRecoveryHandlerDeps<TContext> {
310
360
  registrationState: Pick<
311
361
  TelegramBusFollowerRegistrationState,
312
- "getTarget" | "getSlot" | "getThreadName" | "setRegistered"
362
+ | "getTarget"
363
+ | "getSlot"
364
+ | "getThreadName"
365
+ | "getEligibleElectionSlots"
366
+ | "setRegistered"
313
367
  >;
314
368
  getRegistrationRuntime: () => TelegramBusFollowerRegistrationRuntime<TContext>;
315
369
  getLeaderState: () => TelegramBusFollowerLeaderState;
@@ -350,6 +404,10 @@ export interface TelegramBusForwardedUpdateReceiverRuntimeDeps<
350
404
  reactionUpdate: TReactionUpdate,
351
405
  ctx: TContext,
352
406
  ) => Promise<void> | void;
407
+ prepareForwardedMessage?: (
408
+ message: TMessage,
409
+ position: "comment" | "forward",
410
+ ) => void;
353
411
  handleForwardedMessage?: (
354
412
  message: TMessage,
355
413
  ctx: TContext,
@@ -564,7 +622,7 @@ export function createTelegramBusFollowerClientRuntime<
564
622
  TReactionUpdate,
565
623
  TCallbackQuery,
566
624
  TMessage = unknown,
567
- >(deps: TelegramBusFollowerClientRuntimeDeps) {
625
+ >(deps: TelegramBusFollowerClientRuntimeDeps<TMessage>) {
568
626
  const createRequestId = createTelegramBusRequestIdFactory(deps.instanceId);
569
627
  const sharedClientDeps = {
570
628
  socketPath: deps.socketPath,
@@ -587,6 +645,9 @@ export function createTelegramBusFollowerClientRuntime<
587
645
  >({
588
646
  ...sharedClientDeps,
589
647
  getAuthSecret: deps.getForwardingAuthSecret,
648
+ getForwardCommentBatchPosition:
649
+ deps.getForwardCommentBatchPosition,
650
+ recordRuntimeEvent: deps.recordRuntimeEvent,
590
651
  }),
591
652
  targetController: createTelegramBusFollowerTargetController({
592
653
  ...sharedClientDeps,
@@ -689,6 +750,41 @@ export function createTelegramBusFollowerSessionReplacementSuspender(
689
750
  threadId: target.threadId,
690
751
  },
691
752
  );
753
+ } else if (deps.isLeader?.()) {
754
+ const leaderBinding = deps.getLeaderBinding?.();
755
+ if (typeof leaderBinding?.target?.threadId === "number") {
756
+ const activeContext = deps.getActiveContext?.();
757
+ const profileKey = Threads.getTelegramThreadOwnerKey({
758
+ kind: "leader",
759
+ cwd: activeContext?.cwd,
760
+ instanceId: deps.instanceId,
761
+ telegramProfile: deps.getActiveProfileName?.(),
762
+ });
763
+ Threads.setTelegramLeaderSessionHandoff({
764
+ pid: getPid(),
765
+ instanceId: deps.instanceId,
766
+ createdAtMs: getNowMs(),
767
+ profileKey,
768
+ target: {
769
+ chatId: leaderBinding.target.chatId,
770
+ threadId: leaderBinding.target.threadId,
771
+ },
772
+ slot: leaderBinding.slot,
773
+ threadName: leaderBinding.threadName,
774
+ });
775
+ deps.recordRuntimeEvent(
776
+ "bus",
777
+ "Telegram leader binding suspended for session replacement",
778
+ {
779
+ phase: "leader-session-handoff",
780
+ instanceId: deps.instanceId,
781
+ chatId: leaderBinding.target.chatId,
782
+ threadId: leaderBinding.target.threadId,
783
+ slot: leaderBinding.slot,
784
+ threadName: leaderBinding.threadName,
785
+ },
786
+ );
787
+ }
692
788
  }
693
789
  await deps.suspendPolling();
694
790
  };
@@ -751,12 +847,19 @@ export function createTelegramBusFollowerRegistrationState(): TelegramBusFollowe
751
847
  let slot: string | undefined;
752
848
  let threadName: string | undefined;
753
849
  let generation: string | undefined;
850
+ let eligibleElectionSlots: string[] = [];
754
851
  return {
755
852
  isRegistered: () => registered,
756
853
  getTarget: () => (target ? { ...target } : undefined),
757
854
  getSlot: () => slot,
758
855
  getThreadName: () => threadName,
759
856
  getGeneration: () => generation,
857
+ getEligibleElectionSlots: () => [...eligibleElectionSlots],
858
+ setEligibleElectionSlots: (slots) => {
859
+ eligibleElectionSlots = Array.from(
860
+ new Set(slots.filter((slot) => /^[A-Z]$/.test(slot))),
861
+ ).sort();
862
+ },
760
863
  setRegistered: (next, nextTarget, metadata) => {
761
864
  registered = next;
762
865
  target = next ? (nextTarget ? { ...nextTarget } : undefined) : undefined;
@@ -868,7 +971,7 @@ export function createTelegramBusFollowerHeartbeatRecoveryHandler<TContext>(
868
971
  deps.getRegistrationRuntime().stop();
869
972
  deps.setLifecyclePhase("electing");
870
973
  safeUpdateStatus(ctx);
871
- deps.recordRuntimeEvent("bus", "Telegram follower elected for promotion", {
974
+ deps.recordRuntimeEvent("bus", "Telegram follower attempting promotion", {
872
975
  phase: "follower-promotion-electing",
873
976
  });
874
977
  const promoted = await deps.promoteToLeader(ctx, binding, election);
@@ -887,6 +990,51 @@ export function createTelegramBusFollowerHeartbeatRecoveryHandler<TContext>(
887
990
  );
888
991
  if (!promoted) scheduleRecovery(reason, ctx, binding);
889
992
  };
993
+ const attemptPreferredPromotion = async (
994
+ reason: unknown,
995
+ ctx: TContext,
996
+ binding: TelegramBusFollowerPromotedBinding,
997
+ candidateState: TelegramBusFollowerLeaderState,
998
+ ): Promise<void> => {
999
+ const slot = binding.slot;
1000
+ const lowerEligibleSlot = slot
1001
+ ? deps.registrationState
1002
+ .getEligibleElectionSlots()
1003
+ .find((candidate) => candidate < slot)
1004
+ : undefined;
1005
+ if (lowerEligibleSlot) {
1006
+ deps.recordRuntimeEvent(
1007
+ "bus",
1008
+ "Telegram follower deferring to a lower-slot election candidate",
1009
+ {
1010
+ phase: "follower-promotion-slot-priority",
1011
+ slot,
1012
+ lowerEligibleSlot,
1013
+ },
1014
+ );
1015
+ await sleep(promotionGraceMs);
1016
+ candidateState = deps.getLeaderState();
1017
+ if (candidateState.kind === "active-elsewhere") {
1018
+ if (
1019
+ !(await tryRegisterWithLeader(
1020
+ ctx,
1021
+ candidateState.lock,
1022
+ "follower-register-preferred-successor",
1023
+ binding,
1024
+ ))
1025
+ ) {
1026
+ scheduleRecovery(reason, ctx, binding);
1027
+ }
1028
+ return;
1029
+ }
1030
+ }
1031
+ if (candidateState.kind !== "stale" && candidateState.kind !== "inactive")
1032
+ return;
1033
+ await promoteToLeader(reason, ctx, binding, {
1034
+ expectedOwner:
1035
+ candidateState.kind === "stale" ? candidateState.lock : undefined,
1036
+ });
1037
+ };
890
1038
  const recover = async (
891
1039
  error: unknown,
892
1040
  ctx: TContext,
@@ -943,19 +1091,15 @@ export function createTelegramBusFollowerHeartbeatRecoveryHandler<TContext>(
943
1091
  scheduleRecovery(error, ctx, initialBinding);
944
1092
  return;
945
1093
  }
946
- if (graceState.kind === "stale" || graceState.kind === "inactive") {
947
- await promoteToLeader(error, ctx, initialBinding, {
948
- expectedOwner:
949
- graceState.kind === "stale" ? graceState.lock : undefined,
950
- });
951
- }
1094
+ await attemptPreferredPromotion(
1095
+ error,
1096
+ ctx,
1097
+ initialBinding,
1098
+ graceState,
1099
+ );
952
1100
  return;
953
1101
  }
954
- if (state.kind === "stale" || state.kind === "inactive") {
955
- await promoteToLeader(error, ctx, initialBinding, {
956
- expectedOwner: state.kind === "stale" ? state.lock : undefined,
957
- });
958
- }
1102
+ await attemptPreferredPromotion(error, ctx, initialBinding, state);
959
1103
  } catch (promotionError) {
960
1104
  deps.setLifecyclePhase(undefined);
961
1105
  safeUpdateStatus(ctx);
@@ -1035,6 +1179,17 @@ export function createTelegramBusFollowerRegistrationRuntime<
1035
1179
  sentAtMs: getNowMs(),
1036
1180
  },
1037
1181
  });
1182
+ if (response?.kind === "bus.ack" && response.ok) {
1183
+ const heartbeatResult = isRecord(response.result)
1184
+ ? response.result
1185
+ : undefined;
1186
+ const slots = Array.isArray(heartbeatResult?.eligibleElectionSlots)
1187
+ ? heartbeatResult.eligibleElectionSlots.filter(
1188
+ (slot): slot is string => typeof slot === "string",
1189
+ )
1190
+ : [];
1191
+ deps.registrationState?.setEligibleElectionSlots(slots);
1192
+ }
1038
1193
  if (response?.kind === "bus.ack" && !response.ok) {
1039
1194
  throw new Error(
1040
1195
  response.message ?? "Telegram bus follower heartbeat was rejected.",
@@ -1172,6 +1327,33 @@ export function createTelegramBusFollowerRegistrationRuntime<
1172
1327
  setContext(ctx) {
1173
1328
  activeContext = ctx;
1174
1329
  },
1330
+ async disconnectFromLeader() {
1331
+ if (!activeLeaderSocketPath || !activeRegistrationGeneration) {
1332
+ return false;
1333
+ }
1334
+ const response = await sendTelegramBusLocalEnvelope({
1335
+ socketPath: activeLeaderSocketPath,
1336
+ timeoutMs: registrationTimeoutMs,
1337
+ retry: getTelegramBusTransportRetryPolicy({
1338
+ endpoint: activeLeaderSocketPath,
1339
+ operation: "operation",
1340
+ }),
1341
+ envelope: {
1342
+ kind: "follower.disconnect",
1343
+ requestId: deps.createRequestId(),
1344
+ auth: activeAuthSecret,
1345
+ instanceId: deps.instanceId,
1346
+ registrationGeneration: activeRegistrationGeneration,
1347
+ sentAtMs: getNowMs(),
1348
+ },
1349
+ });
1350
+ if (response?.kind === "bus.ack" && response.ok) return true;
1351
+ throw new Error(
1352
+ response?.kind === "bus.ack"
1353
+ ? (response.message ?? "Telegram follower disconnect was rejected.")
1354
+ : "Telegram follower disconnect was not acknowledged.",
1355
+ );
1356
+ },
1175
1357
  stop,
1176
1358
  };
1177
1359
  }
@@ -1250,6 +1432,12 @@ export function createTelegramBusForwardedUpdateReceiverRuntime<
1250
1432
  ctx,
1251
1433
  );
1252
1434
  } else if (envelope.kind === "leader.forwardMessage") {
1435
+ if (envelope.forwardCommentBatchPosition) {
1436
+ deps.prepareForwardedMessage?.(
1437
+ envelope.message as TMessage,
1438
+ envelope.forwardCommentBatchPosition,
1439
+ );
1440
+ }
1253
1441
  if (!deps.handleForwardedMessage) {
1254
1442
  throw new Error(
1255
1443
  "Telegram bus receiver cannot handle this envelope.",