@llblab/pi-kit 0.5.0 → 0.5.2

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.
Files changed (27) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +1 -1
  3. package/node_modules/@llblab/pi-telegram/BACKLOG.md +1 -0
  4. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +21 -0
  5. package/node_modules/@llblab/pi-telegram/README.md +2 -0
  6. package/node_modules/@llblab/pi-telegram/docs/architecture.md +6 -4
  7. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +5 -1
  8. package/node_modules/@llblab/pi-telegram/docs/outbound.md +19 -3
  9. package/node_modules/@llblab/pi-telegram/index.ts +10 -0
  10. package/node_modules/@llblab/pi-telegram/lib/activity-verbosity.ts +43 -25
  11. package/node_modules/@llblab/pi-telegram/lib/activity.ts +60 -1
  12. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +101 -90
  13. package/node_modules/@llblab/pi-telegram/lib/lifecycle.ts +7 -2
  14. package/node_modules/@llblab/pi-telegram/lib/locks.ts +99 -16
  15. package/node_modules/@llblab/pi-telegram/lib/outbound-attachments.ts +18 -7
  16. package/node_modules/@llblab/pi-telegram/lib/outbound-voice.ts +11 -0
  17. package/node_modules/@llblab/pi-telegram/lib/outbound.ts +10 -3
  18. package/node_modules/@llblab/pi-telegram/lib/polling.ts +142 -30
  19. package/node_modules/@llblab/pi-telegram/lib/preview.ts +19 -3
  20. package/node_modules/@llblab/pi-telegram/lib/prompts.ts +8 -6
  21. package/node_modules/@llblab/pi-telegram/lib/queue.ts +101 -66
  22. package/node_modules/@llblab/pi-telegram/lib/routing.ts +192 -58
  23. package/node_modules/@llblab/pi-telegram/lib/status.ts +4 -0
  24. package/node_modules/@llblab/pi-telegram/lib/updates.ts +33 -35
  25. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  26. package/node_modules/@llblab/pi-telegram/skills/telegram-bridge/references/diagnosis.md +2 -0
  27. package/package.json +2 -2
@@ -21,6 +21,7 @@ const TELEGRAM_LONG_POLL_LIMIT = 10;
21
21
  const TELEGRAM_LONG_POLL_TIMEOUT_SECONDS = 30;
22
22
  const TELEGRAM_THREAD_CAPABILITY_MONITOR_INTERVAL_MS = 2_500;
23
23
  const TELEGRAM_THREAD_CAPABILITY_DISABLED_CONFIRMATION_PROBES = 2;
24
+ export const TELEGRAM_GET_UPDATES_CONFLICT_STOP_LIMIT = 10;
24
25
  const TELEGRAM_GET_UPDATES_CONFLICT_FAST_RETRY_LIMIT = 3;
25
26
  const TELEGRAM_GET_UPDATES_CONFLICT_FAST_RETRY_MS = 1_000;
26
27
  const TELEGRAM_GET_UPDATES_CONFLICT_SLOW_RETRY_MS = 3_000;
@@ -69,6 +70,15 @@ export function getLatestTelegramUpdateId(
69
70
  return updates.at(-1)?.update_id;
70
71
  }
71
72
 
73
+ export class TelegramPersistentGetUpdatesConflictError extends Error {
74
+ readonly count: number;
75
+ constructor(count: number) {
76
+ super(`Telegram polling stopped after ${count} consecutive getUpdates conflicts.`);
77
+ this.name = "TelegramPersistentGetUpdatesConflictError";
78
+ this.count = count;
79
+ }
80
+ }
81
+
72
82
  export class TelegramGetUpdatesTimeoutError extends Error {
73
83
  readonly timeoutMs: number;
74
84
 
@@ -126,7 +136,8 @@ export type TelegramPollingStopReason =
126
136
  | "not-started"
127
137
  | "requested"
128
138
  | "completed"
129
- | "failed";
139
+ | "failed"
140
+ | "persistent-conflict";
130
141
 
131
142
  export interface TelegramPollingStateSnapshot {
132
143
  phase: TelegramPollingPhase;
@@ -200,6 +211,7 @@ export interface TelegramPollingRuntimeDeps<
200
211
  createAbortController?: () => AbortController;
201
212
  getNowMs?: () => number;
202
213
  onPollingStateChange?: () => void;
214
+ onPersistentConflict?: (ctx: TContext, count: number) => MaybePromise<void>;
203
215
  onPollingStarted?: () => void;
204
216
  onPollingStopped?: (reason: TelegramPollingStopReason) => void;
205
217
  }
@@ -227,20 +239,31 @@ export interface TelegramPollingAdmissionRuntime<TContext> {
227
239
  export function createTelegramPollingAdmissionRuntime<TContext>(deps: {
228
240
  polling: TelegramPollingController<TContext>;
229
241
  prepareStart?: () => MaybePromise<void>;
242
+ canStart?: (ctx: TContext) => boolean;
230
243
  validateStart?: () => void;
231
244
  worker: {
232
245
  onSessionStart: (ctx: TContext) => Promise<void>;
233
246
  };
234
247
  }): TelegramPollingAdmissionRuntime<TContext> {
248
+ let generation = 0;
235
249
  return {
236
250
  isActive: deps.polling.isActive,
237
251
  async start(ctx) {
252
+ if (!(deps.canStart?.(ctx) ?? true)) return;
253
+ const expectedGeneration = ++generation;
254
+ const isCurrent = () => expectedGeneration === generation && (deps.canStart?.(ctx) ?? true);
255
+ if (!isCurrent()) return;
238
256
  await deps.prepareStart?.();
257
+ if (!isCurrent()) return;
239
258
  deps.validateStart?.();
240
259
  await deps.worker.onSessionStart(ctx);
241
- await deps.polling.start(ctx);
260
+ if (!isCurrent()) return;
261
+ deps.polling.start(ctx);
262
+ },
263
+ async stop() {
264
+ generation += 1;
265
+ await deps.polling.stop();
242
266
  },
243
- stop: deps.polling.stop,
244
267
  };
245
268
  }
246
269
 
@@ -256,6 +279,7 @@ export type TelegramDurablePollingRuntimeAssemblyDeps<
256
279
  TelegramPollingControllerRuntimeDeps<TUpdate, TContext>,
257
280
  "appendUpdateBatch" | "getJournalEntryCount" | "signalUpdateWorker"
258
281
  > & {
282
+ canStart?: (ctx: TContext) => boolean;
259
283
  journal: {
260
284
  appendBatch: (
261
285
  updates: readonly TUpdate[],
@@ -287,6 +311,7 @@ export function createTelegramDurablePollingRuntimeAssembly<
287
311
  const admission = createTelegramPollingAdmissionRuntime({
288
312
  polling: controller,
289
313
  prepareStart: deps.journal.prepareCursorCutover,
314
+ canStart: deps.canStart,
290
315
  validateStart() {
291
316
  if (deps.journal.getAcceptedThroughUpdateId() !== undefined) return;
292
317
  if (deps.journal.getBootstrapEntryCount() === 0) return;
@@ -312,6 +337,7 @@ export type TelegramPollingControllerRuntimeDeps<
312
337
  createAbortController?: () => AbortController;
313
338
  getNowMs?: () => number;
314
339
  onPollingStateChange?: () => void;
340
+ onPersistentConflict?: (ctx: TContext, count: number) => MaybePromise<void>;
315
341
  };
316
342
 
317
343
  function notifyTelegramPollingStateChange(
@@ -390,6 +416,7 @@ export function createTelegramPollingControllerRuntime<
390
416
  createAbortController: deps.createAbortController,
391
417
  getNowMs,
392
418
  onPollingStateChange: deps.onPollingStateChange,
419
+ onPersistentConflict: deps.onPersistentConflict,
393
420
  recordRuntimeEvent: deps.recordRuntimeEvent,
394
421
  });
395
422
  }
@@ -503,6 +530,7 @@ export function startTelegramPollingRuntime<TContext>(
503
530
  deps.setPollingController(controller);
504
531
  deps.onPollingStarted?.();
505
532
  let failed = false;
533
+ let persistentConflict: TelegramPersistentGetUpdatesConflictError | undefined;
506
534
  let runPromise: Promise<void>;
507
535
  try {
508
536
  runPromise = deps.runPollLoop(ctx, controller.signal);
@@ -513,24 +541,40 @@ export function startTelegramPollingRuntime<TContext>(
513
541
  promise = runPromise
514
542
  .catch((error) => {
515
543
  if (shouldStopTelegramPolling(controller.signal.aborted, error)) return;
544
+ if (error instanceof TelegramPersistentGetUpdatesConflictError) {
545
+ persistentConflict = error;
546
+ return;
547
+ }
516
548
  failed = true;
517
549
  deps.recordRuntimeEvent?.("polling", error, {
518
550
  phase: "controller",
519
551
  });
520
552
  })
521
- .finally(() => {
553
+ .finally(async () => {
522
554
  const ownsPromise = deps.getPollingPromise() === promise;
523
555
  const ownsController = deps.getPollingController() === controller;
524
556
  if (ownsPromise) deps.setPollingPromise(undefined);
525
557
  if (ownsController) deps.setPollingController(undefined);
526
- if (ownsPromise || ownsController) {
527
- deps.onPollingStopped?.(
528
- failed
529
- ? "failed"
530
- : controller.signal.aborted
531
- ? "requested"
532
- : "completed",
533
- );
558
+ if (!ownsPromise && !ownsController) return;
559
+ deps.onPollingStopped?.(
560
+ controller.signal.aborted ? "requested" : persistentConflict
561
+ ? "persistent-conflict" : failed ? "failed" : "completed",
562
+ );
563
+ // Detach the inner promise before outer teardown calls polling.stop().
564
+ if (persistentConflict && !controller.signal.aborted) {
565
+ try {
566
+ if (deps.onPersistentConflict) {
567
+ await deps.onPersistentConflict(ctx, persistentConflict.count);
568
+ } else {
569
+ deps.stopTypingLoop();
570
+ deps.recordRuntimeEvent?.("polling", persistentConflict, {
571
+ phase: "persistent-conflict", count: persistentConflict.count,
572
+ });
573
+ }
574
+ } catch (error) {
575
+ deps.recordRuntimeEvent?.("polling", error, { phase: "conflict-stand-down" });
576
+ }
577
+ if (deps.getPollingController() || deps.getPollingPromise()) return;
534
578
  }
535
579
  updateTelegramPollingStatusSafely(deps.updateStatus, ctx, {
536
580
  recordRuntimeEvent: deps.recordRuntimeEvent,
@@ -581,6 +625,11 @@ export interface TelegramThreadCapabilityReaderDeps {
581
625
  ) => Promise<TResponse>;
582
626
  }
583
627
 
628
+ interface TelegramThreadCapabilityLifecycle {
629
+ capture: () => () => boolean;
630
+ invalidate: () => void;
631
+ }
632
+
584
633
  export interface TelegramStartupThreadCapabilityProbeDeps extends TelegramThreadCapabilityReaderDeps {
585
634
  topicTargetStore: TelegramThreadCapabilityStore;
586
635
  recordEvent: (
@@ -595,6 +644,7 @@ export interface TelegramStartupThreadCapabilityProbeDeps extends TelegramThread
595
644
  export interface TelegramThreadCapabilityRuntimeDeps<
596
645
  TContext,
597
646
  > extends TelegramThreadCapabilityReaderDeps {
647
+ lifecycle?: TelegramThreadCapabilityLifecycle;
598
648
  topicTargetStore: TelegramThreadCapabilityStore;
599
649
  ownsLock: (ctx: TContext) => boolean;
600
650
  isFollowerRegistered?: () => boolean;
@@ -672,6 +722,7 @@ export interface TelegramThreadAwarePollingDeps<
672
722
  TContext,
673
723
  TOwner,
674
724
  > extends TelegramStartupThreadCapabilityProbeDeps {
725
+ lifecycle?: TelegramThreadCapabilityLifecycle;
675
726
  isBusRuntimeEnabled: () => boolean;
676
727
  isTopicModeUnavailableError: (error: unknown) => boolean;
677
728
  getPollingStartedWithTelegramBus: () => boolean;
@@ -749,7 +800,16 @@ export function createTelegramThreadCapabilityStateRuntime(): TelegramThreadCapa
749
800
  export function createTelegramThreadCapabilityOrchestration<TContext, TOwner>(
750
801
  deps: TelegramThreadCapabilityOrchestrationDeps<TContext, TOwner>,
751
802
  ): TelegramThreadCapabilityOrchestration<TContext, TOwner> {
803
+ let generation = 0;
804
+ const lifecycle: TelegramThreadCapabilityLifecycle = {
805
+ capture() {
806
+ const expected = generation;
807
+ return () => expected === generation;
808
+ },
809
+ invalidate() { generation++; },
810
+ };
752
811
  const capabilityDeps: TelegramThreadCapabilityRuntimeDeps<TContext> = {
812
+ lifecycle,
753
813
  getAllowedUserId: deps.getAllowedUserId,
754
814
  callApi: deps.callApi,
755
815
  topicTargetStore: deps.topicTargetStore,
@@ -773,6 +833,7 @@ export function createTelegramThreadCapabilityOrchestration<TContext, TOwner>(
773
833
  monitor: createTelegramThreadCapabilityMonitor(capabilityDeps),
774
834
  observeTarget: createTelegramThreadTargetObservationHandler(capabilityDeps),
775
835
  pollingPorts: createTelegramThreadAwarePollingPorts({
836
+ lifecycle,
776
837
  getAllowedUserId: deps.getAllowedUserId,
777
838
  callApi: deps.callApi,
778
839
  topicTargetStore: deps.topicTargetStore,
@@ -807,8 +868,11 @@ export async function readTelegramThreadCapability(
807
868
 
808
869
  export async function probeTelegramStartupThreadCapability(
809
870
  deps: TelegramStartupThreadCapabilityProbeDeps,
871
+ isCurrent: () => boolean = () => true,
810
872
  ): Promise<boolean | undefined> {
873
+ if (!isCurrent()) return;
811
874
  const threadModeEnabled = await readTelegramThreadCapability(deps);
875
+ if (!isCurrent()) return;
812
876
  const nowMs = (deps.getNowMs ?? Date.now)();
813
877
  if (threadModeEnabled === false) {
814
878
  deps.topicTargetStore.setBotState({
@@ -817,6 +881,7 @@ export async function probeTelegramStartupThreadCapability(
817
881
  lastReconcileAction: "startup-bot-topics-disabled",
818
882
  });
819
883
  await deps.topicTargetStore.persist();
884
+ if (!isCurrent()) return;
820
885
  deps.recordEvent("bus", "Telegram Threaded Mode unavailable on startup", {
821
886
  phase: "startup-bot-topics-disabled",
822
887
  });
@@ -831,6 +896,7 @@ export async function probeTelegramStartupThreadCapability(
831
896
  lastReconcileAction: "startup-bot-topics-enabled",
832
897
  });
833
898
  await deps.topicTargetStore.persist();
899
+ if (!isCurrent()) return;
834
900
  deps.setTopicModeUnavailable(false);
835
901
  }
836
902
  return threadModeEnabled;
@@ -865,8 +931,11 @@ export async function applyTelegramThreadCapability<TContext>(
865
931
  threadModeEnabled: boolean,
866
932
  phase: string,
867
933
  deps: TelegramThreadCapabilityRuntimeDeps<TContext>,
934
+ isCurrent: () => boolean = deps.lifecycle?.capture() ?? (() => true),
868
935
  ): Promise<void> {
936
+ if (!isCurrent()) return;
869
937
  await deps.topicTargetStore.load();
938
+ if (!isCurrent()) return;
870
939
  const nowMs = (deps.getNowMs ?? Date.now)();
871
940
  const previousBotState = deps.topicTargetStore.getBotState();
872
941
  if (!threadModeEnabled) {
@@ -886,6 +955,7 @@ export async function applyTelegramThreadCapability<TContext>(
886
955
  lastReconcileAction: phase,
887
956
  });
888
957
  await deps.topicTargetStore.persist();
958
+ if (!isCurrent()) return;
889
959
  deps.setTopicModeUnavailable(true);
890
960
  deps.stopFollowerRegistration();
891
961
  if (
@@ -894,16 +964,20 @@ export async function applyTelegramThreadCapability<TContext>(
894
964
  ) {
895
965
  deps.stopLeaderHealth();
896
966
  await deps.stopBusPolling();
967
+ if (!isCurrent()) return;
897
968
  deps.setPollingStartedWithTelegramBus(false);
898
969
  try {
899
970
  await deps.startClassicPolling(ctx);
971
+ if (!isCurrent()) return;
900
972
  } catch (classicError) {
973
+ if (!isCurrent()) return;
901
974
  deps.topicTargetStore.setBotState({
902
975
  threadMode: "disabled",
903
976
  updatedAtMs: (deps.getNowMs ?? Date.now)(),
904
977
  lastReconcileAction: `${phase}-classic-restore-failed`,
905
978
  });
906
979
  await deps.topicTargetStore.persist();
980
+ if (!isCurrent()) return;
907
981
  deps.recordEvent("bus", classicError, {
908
982
  phase: `${phase}-classic-restore`,
909
983
  });
@@ -919,14 +993,18 @@ export async function applyTelegramThreadCapability<TContext>(
919
993
  lastReconcileAction: phase,
920
994
  });
921
995
  await deps.topicTargetStore.persist();
996
+ if (!isCurrent()) return;
922
997
  deps.setTopicModeUnavailable(false);
923
998
  if (!deps.getPollingStartedWithTelegramBus() && deps.ownsLock(ctx)) {
924
999
  await deps.stopClassicPolling();
1000
+ if (!isCurrent()) return;
925
1001
  deps.setPollingStartedWithTelegramBus(true);
926
1002
  try {
927
1003
  await deps.startBusPolling(ctx);
1004
+ if (!isCurrent()) return;
928
1005
  deps.startLeaderHealth();
929
1006
  } catch (error) {
1007
+ if (!isCurrent()) return;
930
1008
  deps.setPollingStartedWithTelegramBus(false);
931
1009
  const threadModeUnavailable =
932
1010
  deps.isTopicModeUnavailableError?.(error) === true;
@@ -937,17 +1015,21 @@ export async function applyTelegramThreadCapability<TContext>(
937
1015
  lastReconcileAction: `${phase}-unavailable`,
938
1016
  });
939
1017
  await deps.topicTargetStore.persist();
1018
+ if (!isCurrent()) return;
940
1019
  deps.setTopicModeUnavailable(true);
941
1020
  }
942
1021
  try {
943
1022
  await deps.startClassicPolling(ctx);
1023
+ if (!isCurrent()) return;
944
1024
  } catch (classicError) {
1025
+ if (!isCurrent()) return;
945
1026
  deps.topicTargetStore.setBotState({
946
1027
  threadMode: "disabled",
947
1028
  updatedAtMs: (deps.getNowMs ?? Date.now)(),
948
1029
  lastReconcileAction: `${phase}-classic-restore-failed`,
949
1030
  });
950
1031
  await deps.topicTargetStore.persist();
1032
+ if (!isCurrent()) return;
951
1033
  deps.recordEvent("bus", classicError, {
952
1034
  phase: `${phase}-classic-restore`,
953
1035
  });
@@ -963,17 +1045,25 @@ export async function applyTelegramThreadCapability<TContext>(
963
1045
  export function createTelegramThreadAwarePollingPorts<TContext, TOwner>(
964
1046
  deps: TelegramThreadAwarePollingDeps<TContext, TOwner>,
965
1047
  ): TelegramThreadAwarePollingPorts<TContext, TOwner> {
1048
+ let generation = 0;
966
1049
  const startPolling = async (
967
1050
  ctx: TContext,
968
1051
  options?: { forceFreshLeaderThread?: boolean },
969
1052
  ): Promise<void> => {
1053
+ const expectedGeneration = ++generation;
1054
+ deps.lifecycle?.invalidate();
1055
+ const isLifecycleCurrent = deps.lifecycle?.capture() ?? (() => true);
1056
+ const isCurrent = () => expectedGeneration === generation && isLifecycleCurrent();
970
1057
  await deps.topicTargetStore.load();
1058
+ if (!isCurrent()) return;
971
1059
  let startupThreadCapability: boolean | undefined;
972
1060
  try {
973
- startupThreadCapability = await probeTelegramStartupThreadCapability(deps);
1061
+ startupThreadCapability = await probeTelegramStartupThreadCapability(deps, isCurrent);
974
1062
  } catch (error) {
1063
+ if (!isCurrent()) return;
975
1064
  deps.recordEvent("bus", error, { phase: "startup-thread-mode-probe" });
976
1065
  }
1066
+ if (!isCurrent()) return;
977
1067
  deps.setTopicModeUnavailable(startupThreadCapability !== true);
978
1068
  if (deps.isBusRuntimeEnabled()) {
979
1069
  deps.setTopicModeUnavailable(false);
@@ -983,32 +1073,39 @@ export function createTelegramThreadAwarePollingPorts<TContext, TOwner>(
983
1073
  !!options?.forceFreshLeaderThread,
984
1074
  );
985
1075
  await deps.startBusLeaderPolling(ctx);
1076
+ if (!isCurrent()) return;
986
1077
  deps.startLeaderHealth();
987
1078
  return;
988
1079
  } catch (error) {
1080
+ if (!isCurrent()) return;
989
1081
  deps.setPollingStartedWithTelegramBus(false);
990
1082
  if (!deps.isTopicModeUnavailableError(error)) throw error;
991
1083
  deps.setTopicModeUnavailable(true);
992
1084
  await deps.topicTargetStore.load();
1085
+ if (!isCurrent()) return;
993
1086
  deps.topicTargetStore.setBotState({
994
1087
  threadMode: "disabled",
995
1088
  updatedAtMs: Date.now(),
996
1089
  lastReconcileAction: "thread-mode-unavailable",
997
1090
  });
998
1091
  await deps.topicTargetStore.persist();
1092
+ if (!isCurrent()) return;
999
1093
  deps.recordEvent("bus", error, { phase: "thread-mode-unavailable" });
1000
1094
  } finally {
1001
- deps.setForceFreshLeaderThreadOnNextStart(false);
1095
+ if (isCurrent()) deps.setForceFreshLeaderThreadOnNextStart(false);
1002
1096
  }
1003
1097
  }
1004
1098
  deps.setPollingStartedWithTelegramBus(false);
1005
1099
  await deps.startClassicPolling(ctx);
1006
1100
  };
1007
1101
  const stopPolling = async (): Promise<void> => {
1102
+ const expectedGeneration = ++generation;
1103
+ deps.lifecycle?.invalidate();
1104
+ deps.setForceFreshLeaderThreadOnNextStart(false);
1105
+ deps.stopLeaderHealth();
1008
1106
  if (deps.getPollingStartedWithTelegramBus()) {
1009
- deps.stopLeaderHealth();
1010
1107
  await deps.stopBusLeaderPolling();
1011
- deps.setPollingStartedWithTelegramBus(false);
1108
+ if (expectedGeneration === generation) deps.setPollingStartedWithTelegramBus(false);
1012
1109
  return;
1013
1110
  }
1014
1111
  await deps.stopClassicPolling();
@@ -1043,14 +1140,17 @@ export function createTelegramThreadTargetObservationHandler<TContext>(
1043
1140
  if (transitionPending) return;
1044
1141
  if (deps.topicTargetStore.getBotState().threadMode === "enabled") return;
1045
1142
  transitionPending = true;
1143
+ const isCurrent = deps.lifecycle?.capture() ?? (() => true);
1046
1144
  try {
1047
1145
  await applyTelegramThreadCapability(
1048
1146
  ctx,
1049
1147
  true,
1050
1148
  "thread-target-observed",
1051
1149
  deps,
1150
+ isCurrent,
1052
1151
  );
1053
1152
  } catch (error) {
1153
+ if (!isCurrent()) return;
1054
1154
  deps.recordEvent("bus", error, { phase: "thread-target-observed" });
1055
1155
  } finally {
1056
1156
  transitionPending = false;
@@ -1079,6 +1179,7 @@ export function createTelegramThreadCapabilityMonitor<TContext>(
1079
1179
  let consecutiveDisabledProbes = 0;
1080
1180
  const stop = (): void => {
1081
1181
  generation += 1;
1182
+ deps.lifecycle?.invalidate();
1082
1183
  if (interval) clearInterval(interval);
1083
1184
  interval = undefined;
1084
1185
  };
@@ -1087,7 +1188,8 @@ export function createTelegramThreadCapabilityMonitor<TContext>(
1087
1188
  return;
1088
1189
  }
1089
1190
  const expectedGeneration = generation;
1090
- const isCurrent = (): boolean => generation === expectedGeneration;
1191
+ const isLifecycleCurrent = deps.lifecycle?.capture() ?? (() => true);
1192
+ const isCurrent = (): boolean => generation === expectedGeneration && isLifecycleCurrent();
1091
1193
  let tracked: Promise<void>;
1092
1194
  tracked = readTelegramThreadCapability(deps)
1093
1195
  .then(async (threadModeEnabled) => {
@@ -1103,6 +1205,7 @@ export function createTelegramThreadCapabilityMonitor<TContext>(
1103
1205
  true,
1104
1206
  "capability-monitor-retry",
1105
1207
  deps,
1208
+ isCurrent,
1106
1209
  );
1107
1210
  }
1108
1211
  return;
@@ -1123,6 +1226,7 @@ export function createTelegramThreadCapabilityMonitor<TContext>(
1123
1226
  false,
1124
1227
  "capability-monitor-disabled-confirmed",
1125
1228
  deps,
1229
+ isCurrent,
1126
1230
  );
1127
1231
  return;
1128
1232
  }
@@ -1153,6 +1257,7 @@ export function createTelegramThreadCapabilityMonitor<TContext>(
1153
1257
  ? "capability-monitor-disabled-confirmed"
1154
1258
  : "capability-monitor-disabled",
1155
1259
  deps,
1260
+ isCurrent,
1156
1261
  );
1157
1262
  })
1158
1263
  .catch((error) => {
@@ -1526,6 +1631,19 @@ export async function runTelegramPollLoop<
1526
1631
  TContext = unknown,
1527
1632
  >(deps: TelegramPollLoopDeps<TUpdate, TContext>): Promise<void> {
1528
1633
  if (!deps.config.botToken) return;
1634
+ let consecutiveGetUpdatesConflicts = 0;
1635
+ const retryConflict = async () => {
1636
+ consecutiveGetUpdatesConflicts += 1;
1637
+ if (consecutiveGetUpdatesConflicts >= TELEGRAM_GET_UPDATES_CONFLICT_STOP_LIMIT) {
1638
+ throw new TelegramPersistentGetUpdatesConflictError(consecutiveGetUpdatesConflicts);
1639
+ }
1640
+ await deps.sleep(
1641
+ consecutiveGetUpdatesConflicts < TELEGRAM_GET_UPDATES_CONFLICT_FAST_RETRY_LIMIT
1642
+ ? TELEGRAM_GET_UPDATES_CONFLICT_FAST_RETRY_MS
1643
+ : TELEGRAM_GET_UPDATES_CONFLICT_SLOW_RETRY_MS,
1644
+ deps.signal,
1645
+ );
1646
+ };
1529
1647
  try {
1530
1648
  await deps.deleteWebhook(deps.signal);
1531
1649
  } catch {
@@ -1564,7 +1682,9 @@ export async function runTelegramPollLoop<
1564
1682
  } catch (error) {
1565
1683
  if (shouldStopTelegramPolling(deps.signal.aborted, error)) return;
1566
1684
  reportTelegramPollingPhase(deps, "retrying");
1567
- deps.recordRuntimeEvent?.("polling", error, {
1685
+ if (isTelegramGetUpdatesConflictError(error)) {
1686
+ await retryConflict();
1687
+ } else deps.recordRuntimeEvent?.("polling", error, {
1568
1688
  phase: "initial-sync",
1569
1689
  ...(error instanceof TelegramGetUpdatesTimeoutError
1570
1690
  ? { timeoutMs: error.timeoutMs }
@@ -1572,7 +1692,6 @@ export async function runTelegramPollLoop<
1572
1692
  });
1573
1693
  }
1574
1694
  }
1575
- let consecutiveGetUpdatesConflicts = 0;
1576
1695
  let currentUpdateId: number | undefined;
1577
1696
  while (!deps.signal.aborted) {
1578
1697
  try {
@@ -1600,6 +1719,10 @@ export async function runTelegramPollLoop<
1600
1719
  } catch (error) {
1601
1720
  if (shouldStopTelegramPolling(deps.signal.aborted, error)) return;
1602
1721
  reportTelegramPollingPhase(deps, "retrying", currentUpdateId);
1722
+ if (isTelegramGetUpdatesConflictError(error)) {
1723
+ await retryConflict();
1724
+ continue;
1725
+ }
1603
1726
  deps.recordRuntimeEvent?.("polling", error, {
1604
1727
  phase:
1605
1728
  error instanceof TelegramGetUpdatesTimeoutError
@@ -1609,17 +1732,6 @@ export async function runTelegramPollLoop<
1609
1732
  ? { timeoutMs: error.timeoutMs }
1610
1733
  : {}),
1611
1734
  });
1612
- if (isTelegramGetUpdatesConflictError(error)) {
1613
- consecutiveGetUpdatesConflicts += 1;
1614
- await deps.sleep(
1615
- consecutiveGetUpdatesConflicts <
1616
- TELEGRAM_GET_UPDATES_CONFLICT_FAST_RETRY_LIMIT
1617
- ? TELEGRAM_GET_UPDATES_CONFLICT_FAST_RETRY_MS
1618
- : TELEGRAM_GET_UPDATES_CONFLICT_SLOW_RETRY_MS,
1619
- deps.signal,
1620
- );
1621
- continue;
1622
- }
1623
1735
  consecutiveGetUpdatesConflicts = 0;
1624
1736
  deps.onErrorStatus(getTelegramPollingErrorMessage(error));
1625
1737
  await deps.sleep(TELEGRAM_POLLING_RETRY_MS, deps.signal);
@@ -140,6 +140,10 @@ export interface TelegramPreviewControllerDeps {
140
140
  }
141
141
 
142
142
  export interface TelegramPreviewController {
143
+ prepareClear: (
144
+ chatId: number,
145
+ options?: { target?: TelegramTarget; isDeliveryActive?: () => boolean },
146
+ ) => () => Promise<void>;
143
147
  getState: () => TelegramPreviewRuntimeState | undefined;
144
148
  setState: (state: TelegramPreviewRuntimeState | undefined) => void;
145
149
  setPendingText: (text: string) => void;
@@ -322,6 +326,17 @@ export function createTelegramPreviewController(
322
326
  generation += 1;
323
327
  state = undefined;
324
328
  },
329
+ prepareClear: (chatId, options) => {
330
+ const admittedState = state;
331
+ const runtime = getRuntimeDeps();
332
+ return async () => {
333
+ if (state !== admittedState) return;
334
+ await clearTelegramPreview(chatId, runtime, {
335
+ ...options,
336
+ isDeliveryActive: () => runtime.canSend?.() !== false && options?.isDeliveryActive?.() !== false,
337
+ });
338
+ };
339
+ },
325
340
  clear: (chatId, options) =>
326
341
  clearTelegramPreview(chatId, getRuntimeDeps(), options),
327
342
  flush: (chatId, options) =>
@@ -454,17 +469,18 @@ export function shouldUseTelegramDraftPreview(_options: {
454
469
  export async function clearTelegramPreview(
455
470
  chatId: number,
456
471
  deps: TelegramPreviewRuntimeDeps,
457
- options: { awaitFlush?: boolean; target?: TelegramTarget } = {},
472
+ options: { awaitFlush?: boolean; target?: TelegramTarget; isDeliveryActive?: () => boolean } = {},
458
473
  ): Promise<void> {
459
474
  const state = deps.getState();
460
- if (!state) return;
475
+ if (!state || options.isDeliveryActive?.() === false) return;
461
476
  if (state.flushPromise && options.awaitFlush !== false) {
462
477
  state.flushRequested = false;
463
478
  await state.flushPromise.catch(() => {});
464
479
  if (deps.getState() !== state) return;
465
480
  }
481
+ if (options.isDeliveryActive?.() === false) return;
466
482
  deps.setState(undefined);
467
- if (state.mode === "draft" && state.draftId !== undefined) {
483
+ if (state.mode === "draft" && state.draftId !== undefined && deps.canSend?.() !== false) {
468
484
  try {
469
485
  await deps.sendDraft(chatId, state.draftId, undefined, {
470
486
  ...getTelegramTargetThreadParams(options.target ?? { chatId }),
@@ -152,7 +152,7 @@ type TelegramBeforeAgentStartEvent = Omit<
152
152
  BeforeAgentStartEvent,
153
153
  "systemPrompt"
154
154
  > & {
155
- systemPrompt: TelegramSystemPrompt;
155
+ systemPrompt?: TelegramSystemPrompt | null;
156
156
  };
157
157
 
158
158
  type TelegramBeforeAgentStartResult = {
@@ -165,11 +165,12 @@ type TelegramBeforeAgentStartHook = (
165
165
 
166
166
  export function buildTelegramBridgeSystemPrompt(options: {
167
167
  prompt: string;
168
- systemPrompt: TelegramSystemPrompt;
168
+ systemPrompt?: TelegramSystemPrompt | null;
169
169
  telegramPrefix?: string;
170
170
  localSystemPromptSuffix: string;
171
171
  telegramTurnSystemPromptSuffix: string;
172
172
  }): TelegramBeforeAgentStartResult {
173
+ const basePrompt = options.systemPrompt ?? "";
173
174
  const telegramPrefix = options.telegramPrefix ?? TELEGRAM_PREFIX;
174
175
  const telegramHead = telegramPrefix.endsWith("]")
175
176
  ? telegramPrefix.slice(0, -1)
@@ -182,12 +183,12 @@ export function buildTelegramBridgeSystemPrompt(options: {
182
183
  ? `${options.telegramTurnSystemPromptSuffix}\n- The current user message came from Telegram.`
183
184
  : "";
184
185
  return {
185
- systemPrompt: Array.isArray(options.systemPrompt)
186
+ systemPrompt: Array.isArray(basePrompt)
186
187
  ? [
187
- ...options.systemPrompt,
188
+ ...basePrompt,
188
189
  options.localSystemPromptSuffix + telegramSuffix,
189
190
  ]
190
- : options.systemPrompt +
191
+ : basePrompt +
191
192
  options.localSystemPromptSuffix +
192
193
  telegramSuffix,
193
194
  };
@@ -221,8 +222,9 @@ function stripTelegramToolMetadataFromString(systemPrompt: string): string {
221
222
  }
222
223
 
223
224
  function stripTelegramToolMetadataFromSystemPrompt(
224
- systemPrompt: TelegramSystemPrompt,
225
+ systemPrompt: TelegramSystemPrompt | null | undefined,
225
226
  ): TelegramSystemPrompt {
227
+ if (!systemPrompt) return "";
226
228
  return Array.isArray(systemPrompt)
227
229
  ? systemPrompt.map(stripTelegramToolMetadataFromString)
228
230
  : stripTelegramToolMetadataFromString(systemPrompt);