@llblab/pi-telegram 0.27.12 → 0.28.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/polling.ts CHANGED
@@ -22,11 +22,11 @@ const TELEGRAM_LONG_POLL_LIMIT = 10;
22
22
  const TELEGRAM_LONG_POLL_TIMEOUT_SECONDS = 30;
23
23
  const TELEGRAM_THREAD_CAPABILITY_MONITOR_INTERVAL_MS = 2_500;
24
24
  const TELEGRAM_THREAD_CAPABILITY_DISABLED_CONFIRMATION_PROBES = 2;
25
- const TELEGRAM_POLLING_DEFAULT_MAX_UPDATE_FAILURES = 3;
26
25
  const TELEGRAM_GET_UPDATES_CONFLICT_FAST_RETRY_LIMIT = 3;
27
26
  const TELEGRAM_GET_UPDATES_CONFLICT_FAST_RETRY_MS = 1_000;
28
27
  const TELEGRAM_GET_UPDATES_CONFLICT_SLOW_RETRY_MS = 3_000;
29
28
  const TELEGRAM_POLLING_RETRY_MS = 3_000;
29
+ export const TELEGRAM_GET_UPDATES_GRACE_MS = 10_000;
30
30
 
31
31
  // Standard Telegram DM polling does not expose ordinary message-deletion events,
32
32
  // so queue removal stays reaction-driven while delete-like business updates remain defensive-only.
@@ -65,11 +65,38 @@ export function buildTelegramLongPollRequest(lastUpdateId?: number): {
65
65
  }
66
66
 
67
67
  export function getLatestTelegramUpdateId(
68
- updates: TelegramUpdate[],
68
+ updates: readonly TelegramUpdate[],
69
69
  ): number | undefined {
70
70
  return updates.at(-1)?.update_id;
71
71
  }
72
72
 
73
+ export class TelegramGetUpdatesTimeoutError extends Error {
74
+ readonly timeoutMs: number;
75
+
76
+ constructor(timeoutMs: number) {
77
+ super(`Telegram getUpdates timed out after ${timeoutMs} ms.`);
78
+ this.name = "TelegramGetUpdatesTimeoutError";
79
+ this.timeoutMs = timeoutMs;
80
+ }
81
+ }
82
+
83
+ export function getTelegramGetUpdatesRequestBudgetMs(
84
+ body: Record<string, unknown>,
85
+ graceMs = TELEGRAM_GET_UPDATES_GRACE_MS,
86
+ ): number {
87
+ const timeoutSeconds =
88
+ typeof body.timeout === "number" &&
89
+ Number.isFinite(body.timeout) &&
90
+ body.timeout >= 0
91
+ ? body.timeout
92
+ : 0;
93
+ const normalizedGraceMs =
94
+ Number.isFinite(graceMs) && graceMs > 0
95
+ ? Math.floor(graceMs)
96
+ : TELEGRAM_GET_UPDATES_GRACE_MS;
97
+ return Math.floor(timeoutSeconds * 1_000) + normalizedGraceMs;
98
+ }
99
+
73
100
  export function shouldStopTelegramPolling(
74
101
  signalAborted: boolean,
75
102
  error: unknown,
@@ -85,13 +112,67 @@ export interface TelegramPollingStartState {
85
112
  hasPollingPromise: boolean;
86
113
  }
87
114
 
88
- export interface TelegramPollingControllerState {
115
+ export type TelegramPollingWorkPhase =
116
+ | "long-poll"
117
+ | "persisting-journal"
118
+ | "persisting-offset"
119
+ | "retrying";
120
+
121
+ export type TelegramPollingPhase =
122
+ | "stopped"
123
+ | "starting"
124
+ | TelegramPollingWorkPhase;
125
+
126
+ export type TelegramPollingStopReason =
127
+ | "not-started"
128
+ | "requested"
129
+ | "completed"
130
+ | "failed";
131
+
132
+ export interface TelegramPollingStateSnapshot {
133
+ phase: TelegramPollingPhase;
134
+ phaseStartedAtMs?: number;
135
+ currentUpdateId?: number;
136
+ startedAtMs?: number;
137
+ stoppedAtMs?: number;
138
+ lastSuccessfulResponseAtMs?: number;
139
+ lastSuccessfulResponseUpdateCount?: number;
140
+ stopReason?: TelegramPollingStopReason;
141
+ }
142
+
143
+ export interface TelegramPollingControllerState
144
+ extends TelegramPollingStateSnapshot {
89
145
  pollingPromise?: Promise<void>;
90
146
  pollingController?: AbortController;
91
147
  }
92
148
 
93
149
  export function createTelegramPollingControllerState(): TelegramPollingControllerState {
94
- return {};
150
+ return {
151
+ phase: "stopped",
152
+ stopReason: "not-started",
153
+ };
154
+ }
155
+
156
+ export function getTelegramPollingStateSnapshot(
157
+ state: TelegramPollingControllerState,
158
+ ): TelegramPollingStateSnapshot {
159
+ return {
160
+ phase: state.phase,
161
+ phaseStartedAtMs: state.phaseStartedAtMs,
162
+ currentUpdateId: state.currentUpdateId,
163
+ startedAtMs: state.startedAtMs,
164
+ stoppedAtMs: state.stoppedAtMs,
165
+ lastSuccessfulResponseAtMs: state.lastSuccessfulResponseAtMs,
166
+ lastSuccessfulResponseUpdateCount:
167
+ state.lastSuccessfulResponseUpdateCount,
168
+ stopReason: state.stopReason,
169
+ };
170
+ }
171
+
172
+ export function createTelegramPollingStateReader(
173
+ state: TelegramPollingControllerState,
174
+ ): () => TelegramPollingStateSnapshot {
175
+ return () => getTelegramPollingStateSnapshot(state);
95
176
  }
96
177
 
97
178
  export function isTelegramPollingControllerActive(
@@ -118,6 +199,10 @@ export interface TelegramPollingRuntimeDeps<
118
199
  runPollLoop: (ctx: TContext, signal: AbortSignal) => Promise<void>;
119
200
  updateStatus: (ctx: TContext, message?: string) => void;
120
201
  createAbortController?: () => AbortController;
202
+ getNowMs?: () => number;
203
+ onPollingStateChange?: () => void;
204
+ onPollingStarted?: () => void;
205
+ onPollingStopped?: (reason: TelegramPollingStopReason) => void;
121
206
  }
122
207
 
123
208
  export type TelegramPollingControllerDeps<TContext> = Omit<
@@ -134,14 +219,117 @@ export interface TelegramPollingController<TContext> {
134
219
  stop: () => Promise<void>;
135
220
  }
136
221
 
137
- export interface TelegramPollingControllerRuntimeDeps<
222
+ export interface TelegramPollingAdmissionRuntime<TContext> {
223
+ isActive: () => boolean;
224
+ start: (ctx: TContext) => Promise<void>;
225
+ stop: () => Promise<void>;
226
+ }
227
+
228
+ export function createTelegramPollingAdmissionRuntime<TContext>(deps: {
229
+ polling: TelegramPollingController<TContext>;
230
+ validateStart?: () => void;
231
+ worker: {
232
+ onSessionStart: (ctx: TContext) => Promise<void>;
233
+ };
234
+ }): TelegramPollingAdmissionRuntime<TContext> {
235
+ return {
236
+ isActive: deps.polling.isActive,
237
+ async start(ctx) {
238
+ deps.validateStart?.();
239
+ await deps.worker.onSessionStart(ctx);
240
+ await deps.polling.start(ctx);
241
+ },
242
+ stop: deps.polling.stop,
243
+ };
244
+ }
245
+
246
+ export interface TelegramDurablePollingRuntimeAssembly<TContext> {
247
+ controller: TelegramPollingController<TContext>;
248
+ admission: TelegramPollingAdmissionRuntime<TContext>;
249
+ }
250
+
251
+ export type TelegramDurablePollingRuntimeAssemblyDeps<
252
+ TUpdate extends TelegramUpdate,
253
+ TContext,
254
+ > = Omit<
255
+ TelegramPollingControllerRuntimeDeps<TUpdate, TContext>,
256
+ "appendUpdateBatch" | "getJournalEntryCount" | "signalUpdateWorker"
257
+ > & {
258
+ journal: {
259
+ appendBatch: (updates: readonly TUpdate[]) => MaybePromise<unknown>;
260
+ getEntryCount: () => number;
261
+ signalWorker: () => void;
262
+ getBootstrapEntryCount: () => number;
263
+ onSessionStart: (ctx: TContext) => Promise<void>;
264
+ };
265
+ };
266
+
267
+ /** Own journal-first polling assembly and cursor bootstrap validation. */
268
+ export function createTelegramDurablePollingRuntimeAssembly<
269
+ TUpdate extends TelegramUpdate,
270
+ TContext,
271
+ >(
272
+ deps: TelegramDurablePollingRuntimeAssemblyDeps<TUpdate, TContext>,
273
+ ): TelegramDurablePollingRuntimeAssembly<TContext> {
274
+ const controller = createTelegramPollingControllerRuntime({
275
+ ...deps,
276
+ appendUpdateBatch: deps.journal.appendBatch,
277
+ getJournalEntryCount: deps.journal.getEntryCount,
278
+ signalUpdateWorker: deps.journal.signalWorker,
279
+ });
280
+ const admission = createTelegramPollingAdmissionRuntime({
281
+ polling: controller,
282
+ validateStart() {
283
+ if (deps.getConfig().lastUpdateId !== undefined) return;
284
+ if (deps.journal.getBootstrapEntryCount() === 0) return;
285
+ throw new TelegramPollingCursorBootstrapError(
286
+ "Telegram polling cursor is missing while the durable update journal is non-empty.",
287
+ );
288
+ },
289
+ worker: deps.journal,
290
+ });
291
+ return { controller, admission };
292
+ }
293
+
294
+ export type TelegramPollingControllerRuntimeDeps<
138
295
  TUpdate extends TelegramUpdate,
139
296
  TContext = unknown,
140
- > extends TelegramPollLoopRunnerDeps<TUpdate, TContext> {
297
+ > = Omit<
298
+ TelegramPollLoopRunnerDeps<TUpdate, TContext>,
299
+ "onPhaseChange" | "onSuccessfulResponse"
300
+ > & {
141
301
  state?: TelegramPollingControllerState;
142
302
  hasBotToken: () => boolean;
143
303
  stopTypingLoop: () => unknown;
144
304
  createAbortController?: () => AbortController;
305
+ getNowMs?: () => number;
306
+ onPollingStateChange?: () => void;
307
+ };
308
+
309
+ function notifyTelegramPollingStateChange(
310
+ deps: Pick<
311
+ TelegramPollingRuntimeDeps<unknown>,
312
+ "onPollingStateChange" | "recordRuntimeEvent"
313
+ >,
314
+ ): void {
315
+ try {
316
+ deps.onPollingStateChange?.();
317
+ } catch (error) {
318
+ deps.recordRuntimeEvent?.("polling", error, {
319
+ phase: "state-observer",
320
+ });
321
+ }
322
+ }
323
+
324
+ function transitionTelegramPollingState(
325
+ state: TelegramPollingControllerState,
326
+ phase: TelegramPollingPhase,
327
+ nowMs: number,
328
+ currentUpdateId?: number,
329
+ ): void {
330
+ state.phase = phase;
331
+ state.phaseStartedAtMs = nowMs;
332
+ state.currentUpdateId = currentUpdateId;
145
333
  }
146
334
 
147
335
  export function createTelegramPollingControllerRuntime<
@@ -150,24 +338,49 @@ export function createTelegramPollingControllerRuntime<
150
338
  >(
151
339
  deps: TelegramPollingControllerRuntimeDeps<TUpdate, TContext>,
152
340
  ): TelegramPollingController<TContext> {
341
+ const state = deps.state ?? createTelegramPollingControllerState();
342
+ const getNowMs = deps.getNowMs ?? Date.now;
343
+ const notifyStateChange = () =>
344
+ notifyTelegramPollingStateChange({
345
+ onPollingStateChange: deps.onPollingStateChange,
346
+ recordRuntimeEvent: deps.recordRuntimeEvent,
347
+ });
153
348
  return createTelegramPollingController({
154
- state: deps.state,
349
+ state,
155
350
  hasBotToken: deps.hasBotToken,
156
351
  stopTypingLoop: deps.stopTypingLoop,
157
352
  runPollLoop: createTelegramPollLoopRunner<TUpdate, TContext>({
158
353
  getConfig: deps.getConfig,
159
354
  deleteWebhook: deps.deleteWebhook,
160
355
  getUpdates: deps.getUpdates,
356
+ getUpdatesRequestBudgetMs: deps.getUpdatesRequestBudgetMs,
161
357
  persistConfig: deps.persistConfig,
162
- handleUpdate: deps.handleUpdate,
358
+ appendUpdateBatch: deps.appendUpdateBatch,
359
+ getJournalEntryCount: deps.getJournalEntryCount,
360
+ signalUpdateWorker: deps.signalUpdateWorker,
163
361
  prepareUpdateBatch: deps.prepareUpdateBatch,
164
362
  updateStatus: deps.updateStatus,
165
363
  sleep: deps.sleep,
166
- maxUpdateFailures: deps.maxUpdateFailures,
364
+ onPhaseChange(phase, currentUpdateId) {
365
+ transitionTelegramPollingState(
366
+ state,
367
+ phase,
368
+ getNowMs(),
369
+ currentUpdateId,
370
+ );
371
+ notifyStateChange();
372
+ },
373
+ onSuccessfulResponse(updateCount) {
374
+ state.lastSuccessfulResponseAtMs = getNowMs();
375
+ state.lastSuccessfulResponseUpdateCount = updateCount;
376
+ notifyStateChange();
377
+ },
167
378
  recordRuntimeEvent: deps.recordRuntimeEvent,
168
379
  }),
169
380
  updateStatus: deps.updateStatus,
170
381
  createAbortController: deps.createAbortController,
382
+ getNowMs,
383
+ onPollingStateChange: deps.onPollingStateChange,
171
384
  recordRuntimeEvent: deps.recordRuntimeEvent,
172
385
  });
173
386
  }
@@ -176,6 +389,12 @@ export function createTelegramPollingController<TContext>(
176
389
  deps: TelegramPollingControllerDeps<TContext>,
177
390
  ): TelegramPollingController<TContext> {
178
391
  const state = deps.state ?? createTelegramPollingControllerState();
392
+ const getNowMs = deps.getNowMs ?? Date.now;
393
+ const notifyStateChange = () =>
394
+ notifyTelegramPollingStateChange({
395
+ onPollingStateChange: deps.onPollingStateChange,
396
+ recordRuntimeEvent: deps.recordRuntimeEvent,
397
+ });
179
398
  const runtimeDeps: TelegramPollingRuntimeDeps<TContext> = {
180
399
  ...deps,
181
400
  getPollingPromise: () => state.pollingPromise,
@@ -186,6 +405,25 @@ export function createTelegramPollingController<TContext>(
186
405
  setPollingController: (controller) => {
187
406
  state.pollingController = controller;
188
407
  },
408
+ onPollingStarted: () => {
409
+ const nowMs = getNowMs();
410
+ transitionTelegramPollingState(state, "starting", nowMs);
411
+ state.startedAtMs = nowMs;
412
+ state.stoppedAtMs = undefined;
413
+ state.lastSuccessfulResponseAtMs = undefined;
414
+ state.lastSuccessfulResponseUpdateCount = undefined;
415
+ state.stopReason = undefined;
416
+ notifyStateChange();
417
+ deps.onPollingStarted?.();
418
+ },
419
+ onPollingStopped: (reason) => {
420
+ const nowMs = getNowMs();
421
+ transitionTelegramPollingState(state, "stopped", nowMs);
422
+ state.stoppedAtMs = nowMs;
423
+ state.stopReason = reason;
424
+ notifyStateChange();
425
+ deps.onPollingStopped?.(reason);
426
+ },
189
427
  };
190
428
  return {
191
429
  isActive: () => isTelegramPollingControllerActive(state),
@@ -212,12 +450,16 @@ export async function stopTelegramPollingRuntime<TContext>(
212
450
  }
213
451
  pollingController?.abort();
214
452
  await pollingPromise?.catch(() => undefined);
453
+ let cleared = false;
215
454
  if (deps.getPollingPromise() === pollingPromise) {
216
455
  deps.setPollingPromise(undefined);
456
+ cleared = pollingPromise !== undefined;
217
457
  }
218
458
  if (deps.getPollingController() === pollingController) {
219
459
  deps.setPollingController(undefined);
460
+ cleared = cleared || pollingController !== undefined;
220
461
  }
462
+ if (cleared) deps.onPollingStopped?.("requested");
221
463
  }
222
464
 
223
465
  function updateTelegramPollingStatusSafely<TContext>(
@@ -250,16 +492,41 @@ export function startTelegramPollingRuntime<TContext>(
250
492
  }
251
493
  const controller = deps.createAbortController?.() ?? new AbortController();
252
494
  deps.setPollingController(controller);
495
+ deps.onPollingStarted?.();
496
+ let failed = false;
497
+ let runPromise: Promise<void>;
498
+ try {
499
+ runPromise = deps.runPollLoop(ctx, controller.signal);
500
+ } catch (error) {
501
+ runPromise = Promise.reject(error);
502
+ }
253
503
  let promise: Promise<void>;
254
- promise = deps.runPollLoop(ctx, controller.signal).finally(() => {
255
- if (deps.getPollingPromise() === promise) deps.setPollingPromise(undefined);
256
- if (deps.getPollingController() === controller) {
257
- deps.setPollingController(undefined);
258
- }
259
- updateTelegramPollingStatusSafely(deps.updateStatus, ctx, {
260
- recordRuntimeEvent: deps.recordRuntimeEvent,
504
+ promise = runPromise
505
+ .catch((error) => {
506
+ if (shouldStopTelegramPolling(controller.signal.aborted, error)) return;
507
+ failed = true;
508
+ deps.recordRuntimeEvent?.("polling", error, {
509
+ phase: "controller",
510
+ });
511
+ })
512
+ .finally(() => {
513
+ const ownsPromise = deps.getPollingPromise() === promise;
514
+ const ownsController = deps.getPollingController() === controller;
515
+ if (ownsPromise) deps.setPollingPromise(undefined);
516
+ if (ownsController) deps.setPollingController(undefined);
517
+ if (ownsPromise || ownsController) {
518
+ deps.onPollingStopped?.(
519
+ failed
520
+ ? "failed"
521
+ : controller.signal.aborted
522
+ ? "requested"
523
+ : "completed",
524
+ );
525
+ }
526
+ updateTelegramPollingStatusSafely(deps.updateStatus, ctx, {
527
+ recordRuntimeEvent: deps.recordRuntimeEvent,
528
+ });
261
529
  });
262
- });
263
530
  deps.setPollingPromise(promise);
264
531
  updateTelegramPollingStatusSafely(deps.updateStatus, ctx, {
265
532
  recordRuntimeEvent: deps.recordRuntimeEvent,
@@ -290,6 +557,7 @@ export interface TelegramThreadCapabilityRecordView {
290
557
 
291
558
  export interface TelegramThreadCapabilityStore {
292
559
  load: () => Promise<void>;
560
+ refresh?: () => Promise<void>;
293
561
  persist: () => Promise<void>;
294
562
  getBotState: () => TelegramThreadCapabilityState;
295
563
  setBotState: (state: TelegramThreadCapabilityState) => void;
@@ -320,6 +588,7 @@ export interface TelegramThreadCapabilityRuntimeDeps<
320
588
  > extends TelegramThreadCapabilityReaderDeps {
321
589
  topicTargetStore: TelegramThreadCapabilityStore;
322
590
  ownsLock: (ctx: TContext) => boolean;
591
+ isFollowerRegistered?: () => boolean;
323
592
  getPollingStartedWithTelegramBus: () => boolean;
324
593
  setPollingStartedWithTelegramBus: (started: boolean) => void;
325
594
  setTopicModeUnavailable: (unavailable: boolean) => void;
@@ -420,6 +689,7 @@ export interface TelegramThreadCapabilityOrchestrationDeps<
420
689
  topicTargetStore: TelegramThreadCapabilityStore;
421
690
  isBusRuntimeEnabled: () => boolean;
422
691
  ownsLock: (ctx: TContext) => boolean;
692
+ isFollowerRegistered?: () => boolean;
423
693
  startClassicPolling: (ctx: TContext) => MaybePromise<void>;
424
694
  stopClassicPolling: () => Promise<void>;
425
695
  startBusLeaderPolling: (ctx: TContext) => Promise<void>;
@@ -475,6 +745,7 @@ export function createTelegramThreadCapabilityOrchestration<TContext, TOwner>(
475
745
  callApi: deps.callApi,
476
746
  topicTargetStore: deps.topicTargetStore,
477
747
  ownsLock: deps.ownsLock,
748
+ isFollowerRegistered: deps.isFollowerRegistered,
478
749
  getPollingStartedWithTelegramBus: deps.state.isBusPollingStarted,
479
750
  setPollingStartedWithTelegramBus: deps.state.setBusPollingStarted,
480
751
  setTopicModeUnavailable: deps.state.setTopicModeUnavailable,
@@ -737,24 +1008,14 @@ export function createTelegramThreadAwarePollingPorts<TContext, TOwner>(
737
1008
  ctx: TContext,
738
1009
  owner: TOwner,
739
1010
  ): Promise<boolean | undefined> => {
740
- await deps.topicTargetStore.load();
1011
+ if (deps.topicTargetStore.refresh) {
1012
+ await deps.topicTargetStore.refresh();
1013
+ } else {
1014
+ await deps.topicTargetStore.load();
1015
+ }
741
1016
  if (deps.topicTargetStore.getBotState().threadMode !== "enabled") {
742
- if (hasTelegramThreadCapabilityBindings(deps.topicTargetStore)) {
743
- deps.recordEvent(
744
- "bus",
745
- "Telegram Threaded Mode disabled; follower takeover blocked",
746
- {
747
- phase: "follower-register-thread-mode-disabled",
748
- reason: "active-thread-bindings-present",
749
- },
750
- );
751
- throw new Error(
752
- "Telegram Threaded Mode is disabled; the current leader remains the classic polling owner.",
753
- );
754
- }
755
1017
  return undefined;
756
1018
  }
757
- if (!deps.isBusRuntimeEnabled()) return undefined;
758
1019
  return deps.registerFollowerWithLeader(ctx, owner);
759
1020
  };
760
1021
  return {
@@ -788,24 +1049,40 @@ export function createTelegramThreadTargetObservationHandler<TContext>(
788
1049
  };
789
1050
  }
790
1051
 
1052
+ export function canProbeTelegramThreadCapability<TContext>(
1053
+ ctx: TContext,
1054
+ deps: Pick<
1055
+ TelegramThreadCapabilityRuntimeDeps<TContext>,
1056
+ "ownsLock" | "isFollowerRegistered"
1057
+ >,
1058
+ ): boolean {
1059
+ return deps.ownsLock(ctx) || deps.isFollowerRegistered?.() === true;
1060
+ }
1061
+
791
1062
  export function createTelegramThreadCapabilityMonitor<TContext>(
792
1063
  deps: TelegramThreadCapabilityRuntimeDeps<TContext>,
793
1064
  ): TelegramThreadCapabilityMonitor<TContext> {
794
1065
  const intervalMs =
795
1066
  deps.intervalMs ?? TELEGRAM_THREAD_CAPABILITY_MONITOR_INTERVAL_MS;
796
1067
  let interval: ReturnType<typeof setInterval> | undefined;
797
- let transitionPending = false;
1068
+ let generation = 0;
1069
+ let transitionPromise: Promise<void> | undefined;
798
1070
  let consecutiveDisabledProbes = 0;
799
1071
  const stop = (): void => {
800
- if (!interval) return;
801
- clearInterval(interval);
1072
+ generation += 1;
1073
+ if (interval) clearInterval(interval);
802
1074
  interval = undefined;
803
1075
  };
804
1076
  const check = (ctx: TContext): void => {
805
- if (transitionPending) return;
806
- transitionPending = true;
807
- void readTelegramThreadCapability(deps)
1077
+ if (transitionPromise || !canProbeTelegramThreadCapability(ctx, deps)) {
1078
+ return;
1079
+ }
1080
+ const expectedGeneration = generation;
1081
+ const isCurrent = (): boolean => generation === expectedGeneration;
1082
+ let tracked: Promise<void>;
1083
+ tracked = readTelegramThreadCapability(deps)
808
1084
  .then(async (threadModeEnabled) => {
1085
+ if (!isCurrent()) return;
809
1086
  if (threadModeEnabled === undefined) {
810
1087
  if (
811
1088
  deps.topicTargetStore.getBotState().threadMode !== "enabled" &&
@@ -870,11 +1147,17 @@ export function createTelegramThreadCapabilityMonitor<TContext>(
870
1147
  );
871
1148
  })
872
1149
  .catch((error) => {
873
- deps.recordEvent("bus", error, { phase: "capability-monitor" });
1150
+ if (!isCurrent()) return;
1151
+ try {
1152
+ deps.recordEvent("bus", error, { phase: "capability-monitor" });
1153
+ } catch {
1154
+ // Monitor diagnostics cannot create an unhandled interval rejection.
1155
+ }
874
1156
  })
875
1157
  .finally(() => {
876
- transitionPending = false;
1158
+ if (transitionPromise === tracked) transitionPromise = undefined;
877
1159
  });
1160
+ transitionPromise = tracked;
878
1161
  };
879
1162
  return {
880
1163
  start(ctx) {
@@ -888,6 +1171,96 @@ export function createTelegramThreadCapabilityMonitor<TContext>(
888
1171
  };
889
1172
  }
890
1173
 
1174
+ export class TelegramPollingBatchValidationError extends Error {
1175
+ constructor(message: string) {
1176
+ super(message);
1177
+ this.name = "TelegramPollingBatchValidationError";
1178
+ }
1179
+ }
1180
+
1181
+ export class TelegramPollingCursorBootstrapError extends Error {
1182
+ constructor(message: string) {
1183
+ super(message);
1184
+ this.name = "TelegramPollingCursorBootstrapError";
1185
+ }
1186
+ }
1187
+
1188
+ export interface TelegramPollingBatchAdmissionResult {
1189
+ updateCount: number;
1190
+ latestUpdateId?: number;
1191
+ }
1192
+
1193
+ export interface TelegramPollingBatchAdmissionDeps<
1194
+ TUpdate extends TelegramUpdate,
1195
+ > extends TelegramRuntimeEventRecorderPort {
1196
+ updates: readonly TUpdate[];
1197
+ config: TelegramPollingConfig;
1198
+ appendBatch: (updates: readonly TUpdate[]) => MaybePromise<unknown>;
1199
+ persistConfig: (config: TelegramPollingConfig) => Promise<void>;
1200
+ signalWorker: () => void;
1201
+ onPhaseChange?: (
1202
+ phase: TelegramPollingWorkPhase,
1203
+ currentUpdateId?: number,
1204
+ ) => void;
1205
+ }
1206
+
1207
+ function validateTelegramPollingBatch(
1208
+ updates: readonly TelegramUpdate[],
1209
+ lastUpdateId?: number,
1210
+ ): void {
1211
+ let previousUpdateId = lastUpdateId;
1212
+ for (const update of updates) {
1213
+ if (
1214
+ !Number.isSafeInteger(update.update_id) ||
1215
+ update.update_id < 0 ||
1216
+ (previousUpdateId !== undefined && update.update_id <= previousUpdateId)
1217
+ ) {
1218
+ throw new TelegramPollingBatchValidationError(
1219
+ `Telegram getUpdates returned non-monotonic update id ${String(update.update_id)} after ${String(previousUpdateId)}`,
1220
+ );
1221
+ }
1222
+ previousUpdateId = update.update_id;
1223
+ }
1224
+ }
1225
+
1226
+ export async function admitTelegramPollingUpdateBatch<
1227
+ TUpdate extends TelegramUpdate,
1228
+ >(
1229
+ deps: TelegramPollingBatchAdmissionDeps<TUpdate>,
1230
+ ): Promise<TelegramPollingBatchAdmissionResult> {
1231
+ if (deps.updates.length === 0) return { updateCount: 0 };
1232
+ validateTelegramPollingBatch(deps.updates, deps.config.lastUpdateId);
1233
+ const latestUpdateId = getLatestTelegramUpdateId(deps.updates);
1234
+ if (latestUpdateId === undefined) return { updateCount: 0 };
1235
+ reportTelegramPollingPhase(
1236
+ deps,
1237
+ "persisting-journal",
1238
+ deps.updates[0]?.update_id,
1239
+ );
1240
+ await deps.appendBatch(deps.updates);
1241
+ reportTelegramPollingPhase(deps, "persisting-offset", latestUpdateId);
1242
+ const previousUpdateId = deps.config.lastUpdateId;
1243
+ deps.config.lastUpdateId = latestUpdateId;
1244
+ try {
1245
+ await deps.persistConfig(deps.config);
1246
+ } catch (error) {
1247
+ if (deps.config.lastUpdateId === latestUpdateId) {
1248
+ deps.config.lastUpdateId = previousUpdateId;
1249
+ }
1250
+ throw error;
1251
+ }
1252
+ try {
1253
+ deps.signalWorker();
1254
+ } catch (error) {
1255
+ deps.recordRuntimeEvent?.("polling", error, {
1256
+ phase: "worker-signal",
1257
+ updateCount: deps.updates.length,
1258
+ latestUpdateId,
1259
+ });
1260
+ }
1261
+ return { updateCount: deps.updates.length, latestUpdateId };
1262
+ }
1263
+
891
1264
  export interface TelegramPollLoopDeps<
892
1265
  TUpdate extends TelegramUpdate,
893
1266
  TContext = unknown,
@@ -900,13 +1273,20 @@ export interface TelegramPollLoopDeps<
900
1273
  body: Record<string, unknown>,
901
1274
  signal: AbortSignal,
902
1275
  ) => Promise<TUpdate[]>;
1276
+ getUpdatesRequestBudgetMs?: (body: Record<string, unknown>) => number;
903
1277
  persistConfig: (config: TelegramPollingConfig) => Promise<void>;
904
- handleUpdate: (update: TUpdate, ctx: TContext) => Promise<void>;
1278
+ appendUpdateBatch: (updates: readonly TUpdate[]) => MaybePromise<unknown>;
1279
+ getJournalEntryCount: () => number;
1280
+ signalUpdateWorker: () => void;
905
1281
  prepareUpdateBatch?: (updates: readonly TUpdate[]) => void;
906
1282
  onErrorStatus: (message: string) => void;
907
1283
  onStatusReset: () => void;
908
1284
  sleep: (ms: number, signal?: AbortSignal) => Promise<void>;
909
- maxUpdateFailures?: number;
1285
+ onPhaseChange?: (
1286
+ phase: TelegramPollingWorkPhase,
1287
+ currentUpdateId?: number,
1288
+ ) => void;
1289
+ onSuccessfulResponse?: (updateCount: number) => void;
910
1290
  }
911
1291
 
912
1292
  export interface TelegramPollLoopRunnerDeps<
@@ -919,12 +1299,19 @@ export interface TelegramPollLoopRunnerDeps<
919
1299
  body: Record<string, unknown>,
920
1300
  signal: AbortSignal,
921
1301
  ) => Promise<TUpdate[]>;
1302
+ getUpdatesRequestBudgetMs?: (body: Record<string, unknown>) => number;
922
1303
  persistConfig: (config: TelegramPollingConfig) => Promise<void>;
923
- handleUpdate: (update: TUpdate, ctx: TContext) => Promise<void>;
1304
+ appendUpdateBatch: (updates: readonly TUpdate[]) => MaybePromise<unknown>;
1305
+ getJournalEntryCount: () => number;
1306
+ signalUpdateWorker: () => void;
924
1307
  prepareUpdateBatch?: (updates: readonly TUpdate[]) => void;
925
1308
  updateStatus: (ctx: TContext, message?: string) => void;
926
1309
  sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
927
- maxUpdateFailures?: number;
1310
+ onPhaseChange?: (
1311
+ phase: TelegramPollingWorkPhase,
1312
+ currentUpdateId?: number,
1313
+ ) => void;
1314
+ onSuccessfulResponse?: (updateCount: number) => void;
928
1315
  }
929
1316
 
930
1317
  export function sleepTelegramPollingRetry(
@@ -966,8 +1353,11 @@ export function createTelegramPollLoopRunner<
966
1353
  config: deps.getConfig(),
967
1354
  deleteWebhook: deps.deleteWebhook,
968
1355
  getUpdates: deps.getUpdates,
1356
+ getUpdatesRequestBudgetMs: deps.getUpdatesRequestBudgetMs,
969
1357
  persistConfig: deps.persistConfig,
970
- handleUpdate: deps.handleUpdate,
1358
+ appendUpdateBatch: deps.appendUpdateBatch,
1359
+ getJournalEntryCount: deps.getJournalEntryCount,
1360
+ signalUpdateWorker: deps.signalUpdateWorker,
971
1361
  prepareUpdateBatch: deps.prepareUpdateBatch,
972
1362
  onErrorStatus: (message) => {
973
1363
  updateTelegramPollingStatusSafely(deps.updateStatus, ctx, {
@@ -981,7 +1371,8 @@ export function createTelegramPollLoopRunner<
981
1371
  });
982
1372
  },
983
1373
  sleep,
984
- maxUpdateFailures: deps.maxUpdateFailures,
1374
+ onPhaseChange: deps.onPhaseChange,
1375
+ onSuccessfulResponse: deps.onSuccessfulResponse,
985
1376
  recordRuntimeEvent: deps.recordRuntimeEvent,
986
1377
  });
987
1378
  }
@@ -996,6 +1387,102 @@ export function isTelegramGetUpdatesConflictError(error: unknown): boolean {
996
1387
  );
997
1388
  }
998
1389
 
1390
+ function reportTelegramPollingPhase(
1391
+ deps: TelegramRuntimeEventRecorderPort & {
1392
+ onPhaseChange?: (
1393
+ phase: TelegramPollingWorkPhase,
1394
+ currentUpdateId?: number,
1395
+ ) => void;
1396
+ },
1397
+ phase: TelegramPollingWorkPhase,
1398
+ currentUpdateId?: number,
1399
+ ): void {
1400
+ try {
1401
+ deps.onPhaseChange?.(phase, currentUpdateId);
1402
+ } catch (error) {
1403
+ deps.recordRuntimeEvent?.("polling", error, {
1404
+ phase: "phase-observer",
1405
+ });
1406
+ }
1407
+ }
1408
+
1409
+ function reportTelegramPollingResponse<
1410
+ TUpdate extends TelegramUpdate,
1411
+ TContext,
1412
+ >(
1413
+ deps: TelegramPollLoopDeps<TUpdate, TContext>,
1414
+ updateCount: number,
1415
+ ): void {
1416
+ try {
1417
+ deps.onSuccessfulResponse?.(updateCount);
1418
+ } catch (error) {
1419
+ deps.recordRuntimeEvent?.("polling", error, {
1420
+ phase: "response-observer",
1421
+ });
1422
+ }
1423
+ }
1424
+
1425
+ function getTelegramPollingAbortReason(signal: AbortSignal): unknown {
1426
+ return signal.reason ?? new DOMException("Aborted", "AbortError");
1427
+ }
1428
+
1429
+ async function requestTelegramUpdatesWithinBudget<
1430
+ TUpdate extends TelegramUpdate,
1431
+ TContext,
1432
+ >(
1433
+ deps: TelegramPollLoopDeps<TUpdate, TContext>,
1434
+ body: Record<string, unknown>,
1435
+ ): Promise<TUpdate[]> {
1436
+ if (deps.signal.aborted) throw getTelegramPollingAbortReason(deps.signal);
1437
+ const configuredBudgetMs = deps.getUpdatesRequestBudgetMs?.(body);
1438
+ const timeoutMs =
1439
+ typeof configuredBudgetMs === "number" &&
1440
+ Number.isFinite(configuredBudgetMs) &&
1441
+ configuredBudgetMs > 0
1442
+ ? Math.floor(configuredBudgetMs)
1443
+ : getTelegramGetUpdatesRequestBudgetMs(body);
1444
+ const controller = new AbortController();
1445
+ const abortFromOwner = () => {
1446
+ controller.abort(getTelegramPollingAbortReason(deps.signal));
1447
+ };
1448
+ deps.signal.addEventListener("abort", abortFromOwner, { once: true });
1449
+ if (deps.signal.aborted) abortFromOwner();
1450
+ const timeout = setTimeout(() => {
1451
+ controller.abort(new TelegramGetUpdatesTimeoutError(timeoutMs));
1452
+ }, timeoutMs);
1453
+ let onRequestAbort: (() => void) | undefined;
1454
+ const aborted = new Promise<never>((_resolve, reject) => {
1455
+ onRequestAbort = () =>
1456
+ reject(getTelegramPollingAbortReason(controller.signal));
1457
+ controller.signal.addEventListener("abort", onRequestAbort, {
1458
+ once: true,
1459
+ });
1460
+ if (controller.signal.aborted) onRequestAbort();
1461
+ });
1462
+ const operation = Promise.resolve()
1463
+ .then(() => {
1464
+ if (controller.signal.aborted) {
1465
+ throw getTelegramPollingAbortReason(controller.signal);
1466
+ }
1467
+ return deps.getUpdates(body, controller.signal);
1468
+ })
1469
+ .catch((error) => {
1470
+ if (controller.signal.aborted) {
1471
+ throw getTelegramPollingAbortReason(controller.signal);
1472
+ }
1473
+ throw error;
1474
+ });
1475
+ try {
1476
+ return await Promise.race([operation, aborted]);
1477
+ } finally {
1478
+ clearTimeout(timeout);
1479
+ deps.signal.removeEventListener("abort", abortFromOwner);
1480
+ if (onRequestAbort) {
1481
+ controller.signal.removeEventListener("abort", onRequestAbort);
1482
+ }
1483
+ }
1484
+ }
1485
+
999
1486
  export async function runTelegramPollLoop<
1000
1487
  TUpdate extends TelegramUpdate,
1001
1488
  TContext = unknown,
@@ -1006,82 +1493,80 @@ export async function runTelegramPollLoop<
1006
1493
  } catch {
1007
1494
  // ignore
1008
1495
  }
1496
+ if (
1497
+ deps.config.lastUpdateId === undefined &&
1498
+ deps.getJournalEntryCount() > 0
1499
+ ) {
1500
+ throw new TelegramPollingCursorBootstrapError(
1501
+ "Telegram polling cursor is missing while the durable update journal is non-empty.",
1502
+ );
1503
+ }
1009
1504
  if (deps.config.lastUpdateId === undefined) {
1010
1505
  try {
1011
- const updates = await deps.getUpdates(
1012
- buildTelegramInitialSyncRequest(),
1013
- deps.signal,
1014
- );
1506
+ const request = buildTelegramInitialSyncRequest();
1507
+ reportTelegramPollingPhase(deps, "long-poll");
1508
+ const updates = await requestTelegramUpdatesWithinBudget(deps, request);
1509
+ reportTelegramPollingResponse(deps, updates.length);
1015
1510
  const lastUpdateId = getLatestTelegramUpdateId(updates);
1016
1511
  if (lastUpdateId !== undefined) {
1512
+ reportTelegramPollingPhase(
1513
+ deps,
1514
+ "persisting-offset",
1515
+ lastUpdateId,
1516
+ );
1017
1517
  deps.config.lastUpdateId = lastUpdateId;
1018
1518
  await deps.persistConfig(deps.config);
1519
+ deps.recordRuntimeEvent?.(
1520
+ "polling",
1521
+ new Error("Initialized Telegram cursor without executing history."),
1522
+ { phase: "cursor-bootstrap", lastUpdateId },
1523
+ );
1019
1524
  }
1020
- } catch {
1021
- // ignore
1525
+ } catch (error) {
1526
+ if (shouldStopTelegramPolling(deps.signal.aborted, error)) return;
1527
+ reportTelegramPollingPhase(deps, "retrying");
1528
+ deps.recordRuntimeEvent?.("polling", error, {
1529
+ phase: "initial-sync",
1530
+ ...(error instanceof TelegramGetUpdatesTimeoutError
1531
+ ? { timeoutMs: error.timeoutMs }
1532
+ : {}),
1533
+ });
1022
1534
  }
1023
1535
  }
1024
- const maxUpdateFailures = Math.max(
1025
- 1,
1026
- deps.maxUpdateFailures ?? TELEGRAM_POLLING_DEFAULT_MAX_UPDATE_FAILURES,
1027
- );
1028
- const updateFailures = new Map<number, number>();
1029
- const admittedUpdates = new Set<number>();
1030
- let handledUpdateFailureRethrown = false;
1031
1536
  let consecutiveGetUpdatesConflicts = 0;
1537
+ let currentUpdateId: number | undefined;
1032
1538
  while (!deps.signal.aborted) {
1033
1539
  try {
1034
- const updates = await deps.getUpdates(
1035
- buildTelegramLongPollRequest(deps.config.lastUpdateId),
1036
- deps.signal,
1037
- );
1540
+ currentUpdateId = undefined;
1541
+ const request = buildTelegramLongPollRequest(deps.config.lastUpdateId);
1542
+ reportTelegramPollingPhase(deps, "long-poll");
1543
+ const updates = await requestTelegramUpdatesWithinBudget(deps, request);
1544
+ reportTelegramPollingResponse(deps, updates.length);
1038
1545
  deps.prepareUpdateBatch?.(updates);
1039
1546
  consecutiveGetUpdatesConflicts = 0;
1040
- for (const update of updates) {
1041
- if (admittedUpdates.has(update.update_id)) {
1042
- deps.config.lastUpdateId = update.update_id;
1043
- await deps.persistConfig(deps.config);
1044
- admittedUpdates.delete(update.update_id);
1045
- continue;
1046
- }
1047
- try {
1048
- await deps.handleUpdate(update, deps.ctx);
1049
- admittedUpdates.add(update.update_id);
1050
- deps.config.lastUpdateId = update.update_id;
1051
- updateFailures.delete(update.update_id);
1052
- await deps.persistConfig(deps.config);
1053
- admittedUpdates.delete(update.update_id);
1054
- } catch (error) {
1055
- if (admittedUpdates.has(update.update_id)) throw error;
1056
- const failureCount = (updateFailures.get(update.update_id) ?? 0) + 1;
1057
- updateFailures.set(update.update_id, failureCount);
1058
- deps.recordRuntimeEvent?.("polling", error, {
1059
- phase: "handleUpdate",
1060
- updateId: update.update_id,
1061
- failureCount,
1062
- });
1063
- if (failureCount < maxUpdateFailures) {
1064
- handledUpdateFailureRethrown = true;
1065
- throw error;
1066
- }
1067
- const message = getTelegramPollingErrorMessage(error);
1068
- deps.onErrorStatus(
1069
- `skipping Telegram update ${update.update_id} after ${failureCount} failures: ${message}`,
1070
- );
1071
- admittedUpdates.add(update.update_id);
1072
- deps.config.lastUpdateId = update.update_id;
1073
- updateFailures.delete(update.update_id);
1074
- await deps.persistConfig(deps.config);
1075
- admittedUpdates.delete(update.update_id);
1076
- }
1077
- }
1547
+ currentUpdateId = updates[0]?.update_id;
1548
+ await admitTelegramPollingUpdateBatch({
1549
+ updates,
1550
+ config: deps.config,
1551
+ appendBatch: deps.appendUpdateBatch,
1552
+ persistConfig: deps.persistConfig,
1553
+ signalWorker: deps.signalUpdateWorker,
1554
+ onPhaseChange: deps.onPhaseChange,
1555
+ recordRuntimeEvent: deps.recordRuntimeEvent,
1556
+ });
1557
+ currentUpdateId = undefined;
1078
1558
  } catch (error) {
1079
1559
  if (shouldStopTelegramPolling(deps.signal.aborted, error)) return;
1080
- if (handledUpdateFailureRethrown) {
1081
- handledUpdateFailureRethrown = false;
1082
- } else {
1083
- deps.recordRuntimeEvent?.("polling", error, { phase: "loop" });
1084
- }
1560
+ reportTelegramPollingPhase(deps, "retrying", currentUpdateId);
1561
+ deps.recordRuntimeEvent?.("polling", error, {
1562
+ phase:
1563
+ error instanceof TelegramGetUpdatesTimeoutError
1564
+ ? "long-poll"
1565
+ : "loop",
1566
+ ...(error instanceof TelegramGetUpdatesTimeoutError
1567
+ ? { timeoutMs: error.timeoutMs }
1568
+ : {}),
1569
+ });
1085
1570
  if (isTelegramGetUpdatesConflictError(error)) {
1086
1571
  consecutiveGetUpdatesConflicts += 1;
1087
1572
  await deps.sleep(