@llblab/pi-telegram 0.22.1 → 0.23.1

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.
@@ -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.",