@llblab/pi-telegram 0.17.4 → 0.18.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.
Files changed (62) hide show
  1. package/AGENTS.md +67 -32
  2. package/BACKLOG.md +59 -14
  3. package/CHANGELOG.md +40 -15
  4. package/README.md +63 -35
  5. package/docs/README.md +3 -1
  6. package/docs/architecture.md +55 -23
  7. package/docs/callback-namespaces.md +1 -1
  8. package/docs/inbound.md +1 -1
  9. package/docs/locks.md +0 -2
  10. package/docs/multi-instance-bus.md +483 -0
  11. package/docs/outbound.md +4 -3
  12. package/docs/public-api.md +12 -10
  13. package/docs/sections.md +2 -2
  14. package/docs/ui-style.md +76 -0
  15. package/index.ts +789 -32
  16. package/lib/bindings.ts +68 -12
  17. package/lib/bus-api.ts +314 -0
  18. package/lib/bus-follower.ts +853 -0
  19. package/lib/bus-leader.ts +915 -0
  20. package/lib/bus.ts +866 -0
  21. package/lib/command-templates.ts +9 -11
  22. package/lib/commands.ts +133 -47
  23. package/lib/config.ts +53 -5
  24. package/lib/lifecycle.ts +23 -7
  25. package/lib/locks.ts +230 -66
  26. package/lib/media.ts +30 -2
  27. package/lib/menu-model.ts +48 -17
  28. package/lib/menu-queue.ts +51 -20
  29. package/lib/menu-settings.ts +9 -5
  30. package/lib/menu-status.ts +3 -0
  31. package/lib/menu-thinking.ts +3 -0
  32. package/lib/menu.ts +67 -26
  33. package/lib/outbound-attachments.ts +102 -17
  34. package/lib/outbound-buttons.ts +6 -2
  35. package/lib/outbound-voice.ts +31 -11
  36. package/lib/outbound.ts +6 -4
  37. package/lib/ownership.ts +119 -0
  38. package/lib/pi.ts +26 -3
  39. package/lib/polling.ts +477 -7
  40. package/lib/preview.ts +141 -88
  41. package/lib/prompt-templates.ts +3 -3
  42. package/lib/prompts.ts +80 -30
  43. package/lib/queue.ts +193 -91
  44. package/lib/rendering.ts +0 -25
  45. package/lib/replies.ts +187 -55
  46. package/lib/routing.ts +1673 -9
  47. package/lib/runtime-log.ts +123 -0
  48. package/lib/runtime.ts +84 -12
  49. package/lib/sections.ts +28 -21
  50. package/lib/setup.ts +1 -1
  51. package/lib/status.ts +532 -9
  52. package/lib/sync.ts +618 -0
  53. package/lib/target.ts +49 -0
  54. package/lib/telegram-api.ts +405 -40
  55. package/lib/text-groups.ts +5 -1
  56. package/lib/thread-reconciler.ts +915 -0
  57. package/lib/threads.ts +2205 -0
  58. package/lib/turns.ts +48 -3
  59. package/lib/updates.ts +355 -32
  60. package/package.json +24 -2
  61. package/screenshot.png +0 -0
  62. package/docs/telegram-bot-api-rich-messages.md +0 -890
package/lib/polling.ts CHANGED
@@ -4,6 +4,8 @@
4
4
  * Owns polling request builders, stop conditions, and the long-poll loop runtime for Telegram updates
5
5
  */
6
6
 
7
+ type MaybePromise<T> = T | Promise<T>;
8
+
7
9
  export interface TelegramPollingConfig {
8
10
  botToken?: string;
9
11
  lastUpdateId?: number;
@@ -13,6 +15,19 @@ export interface TelegramUpdate {
13
15
  update_id: number;
14
16
  }
15
17
 
18
+ const TELEGRAM_INITIAL_SYNC_OFFSET = -1;
19
+ const TELEGRAM_INITIAL_SYNC_LIMIT = 1;
20
+ const TELEGRAM_INITIAL_SYNC_TIMEOUT_SECONDS = 0;
21
+ const TELEGRAM_LONG_POLL_LIMIT = 10;
22
+ const TELEGRAM_LONG_POLL_TIMEOUT_SECONDS = 30;
23
+ const TELEGRAM_THREAD_CAPABILITY_MONITOR_INTERVAL_MS = 5_000;
24
+ const TELEGRAM_THREAD_CAPABILITY_DISABLED_CONFIRMATION_PROBES = 2;
25
+ const TELEGRAM_POLLING_DEFAULT_MAX_UPDATE_FAILURES = 3;
26
+ const TELEGRAM_GET_UPDATES_CONFLICT_FAST_RETRY_LIMIT = 3;
27
+ const TELEGRAM_GET_UPDATES_CONFLICT_FAST_RETRY_MS = 1_000;
28
+ const TELEGRAM_GET_UPDATES_CONFLICT_SLOW_RETRY_MS = 3_000;
29
+ const TELEGRAM_POLLING_RETRY_MS = 3_000;
30
+
16
31
  // Standard Telegram DM polling does not expose ordinary message-deletion events,
17
32
  // so queue removal stays reaction-driven while delete-like business updates remain defensive-only.
18
33
  export const TELEGRAM_ALLOWED_UPDATES = [
@@ -29,9 +44,9 @@ export function buildTelegramInitialSyncRequest(): {
29
44
  timeout: number;
30
45
  } {
31
46
  return {
32
- offset: -1,
33
- limit: 1,
34
- timeout: 0,
47
+ offset: TELEGRAM_INITIAL_SYNC_OFFSET,
48
+ limit: TELEGRAM_INITIAL_SYNC_LIMIT,
49
+ timeout: TELEGRAM_INITIAL_SYNC_TIMEOUT_SECONDS,
35
50
  };
36
51
  }
37
52
 
@@ -43,8 +58,8 @@ export function buildTelegramLongPollRequest(lastUpdateId?: number): {
43
58
  } {
44
59
  return {
45
60
  offset: lastUpdateId !== undefined ? lastUpdateId + 1 : undefined,
46
- limit: 10,
47
- timeout: 30,
61
+ limit: TELEGRAM_LONG_POLL_LIMIT,
62
+ timeout: TELEGRAM_LONG_POLL_TIMEOUT_SECONDS,
48
63
  allowed_updates: TELEGRAM_ALLOWED_UPDATES,
49
64
  };
50
65
  }
@@ -258,6 +273,438 @@ export interface TelegramRuntimeEventRecorderPort {
258
273
  ) => void;
259
274
  }
260
275
 
276
+ export type TelegramThreadCapabilityMode = "enabled" | "disabled" | "unknown";
277
+
278
+ export interface TelegramThreadCapabilityState {
279
+ threadMode?: TelegramThreadCapabilityMode;
280
+ updatedAtMs?: number;
281
+ lastSlot?: string;
282
+ lastReconcileAction?: string;
283
+ }
284
+
285
+ export interface TelegramThreadCapabilityRecordView {
286
+ status?: string;
287
+ target?: { chatId?: number; threadId?: number };
288
+ }
289
+
290
+ export interface TelegramThreadCapabilityStore {
291
+ load: () => Promise<void>;
292
+ persist: () => Promise<void>;
293
+ getBotState: () => TelegramThreadCapabilityState;
294
+ setBotState: (state: TelegramThreadCapabilityState) => void;
295
+ list?: () => TelegramThreadCapabilityRecordView[];
296
+ }
297
+
298
+ export interface TelegramThreadCapabilityReaderDeps {
299
+ getAllowedUserId: () => number | undefined;
300
+ callApi: <TResponse>(
301
+ method: string,
302
+ body: Record<string, unknown>,
303
+ ) => Promise<TResponse>;
304
+ }
305
+
306
+ export interface TelegramStartupThreadCapabilityProbeDeps extends TelegramThreadCapabilityReaderDeps {
307
+ topicTargetStore: TelegramThreadCapabilityStore;
308
+ recordEvent: (
309
+ category: string,
310
+ message: unknown,
311
+ details?: Record<string, unknown>,
312
+ ) => void;
313
+ setTopicModeUnavailable: (unavailable: boolean) => void;
314
+ getNowMs?: () => number;
315
+ }
316
+
317
+ export interface TelegramThreadCapabilityRuntimeDeps<
318
+ TContext,
319
+ > extends TelegramThreadCapabilityReaderDeps {
320
+ topicTargetStore: TelegramThreadCapabilityStore;
321
+ isBusConfigured: () => boolean;
322
+ ownsLock: (ctx: TContext) => boolean;
323
+ getPollingStartedWithTelegramBus: () => boolean;
324
+ setPollingStartedWithTelegramBus: (started: boolean) => void;
325
+ setTopicModeUnavailable: (unavailable: boolean) => void;
326
+ stopFollowerRegistration: () => void;
327
+ startClassicPolling: (ctx: TContext) => MaybePromise<void>;
328
+ stopClassicPolling: () => MaybePromise<void>;
329
+ startBusPolling: (ctx: TContext) => MaybePromise<void>;
330
+ stopBusPolling: () => MaybePromise<void>;
331
+ startLeaderHealth: () => void;
332
+ stopLeaderHealth: () => void;
333
+ isTopicModeUnavailableError?: (error: unknown) => boolean;
334
+ updateStatus: (ctx: TContext) => void;
335
+ recordEvent: (
336
+ category: string,
337
+ message: unknown,
338
+ details?: Record<string, unknown>,
339
+ ) => void;
340
+ getNowMs?: () => number;
341
+ intervalMs?: number;
342
+ }
343
+
344
+ export interface TelegramThreadCapabilityMonitor<TContext> {
345
+ start: (ctx: TContext) => void;
346
+ stop: () => void;
347
+ }
348
+
349
+ export type TelegramThreadTargetObservationHandler<TContext> = (
350
+ ctx: TContext,
351
+ ) => Promise<void>;
352
+
353
+ export interface TelegramThreadAwarePollingPorts<TContext, TOwner> {
354
+ startPolling: (
355
+ ctx: TContext,
356
+ options?: { forceFreshLeaderThread?: boolean },
357
+ ) => Promise<void>;
358
+ stopPolling: () => Promise<void>;
359
+ registerFollowerWithOwner: (
360
+ ctx: TContext,
361
+ owner: TOwner,
362
+ ) => Promise<boolean | undefined>;
363
+ stopFollowerRegistration: () => void;
364
+ }
365
+
366
+ export interface TelegramThreadAwarePollingDeps<
367
+ TContext,
368
+ TOwner,
369
+ > extends TelegramStartupThreadCapabilityProbeDeps {
370
+ isBusConfigured: () => boolean;
371
+ isBusRuntimeEnabled: () => boolean;
372
+ isTopicModeUnavailableError: (error: unknown) => boolean;
373
+ getPollingStartedWithTelegramBus: () => boolean;
374
+ setPollingStartedWithTelegramBus: (started: boolean) => void;
375
+ setForceFreshLeaderThreadOnNextStart: (forceFresh: boolean) => void;
376
+ startClassicPolling: (ctx: TContext) => MaybePromise<void>;
377
+ stopClassicPolling: () => Promise<void>;
378
+ startBusLeaderPolling: (ctx: TContext) => Promise<void>;
379
+ stopBusLeaderPolling: () => Promise<void>;
380
+ startLeaderHealth: () => void;
381
+ stopLeaderHealth: () => void;
382
+ registerFollowerWithLeader: (
383
+ ctx: TContext,
384
+ owner: TOwner,
385
+ ) => Promise<boolean | undefined>;
386
+ stopFollowerRegistration: () => void;
387
+ }
388
+
389
+ export async function readTelegramThreadCapability(
390
+ deps: TelegramThreadCapabilityReaderDeps,
391
+ ): Promise<boolean | undefined> {
392
+ const bot = await deps.callApi<{ has_topics_enabled?: boolean }>("getMe", {});
393
+ if (bot.has_topics_enabled === true) return true;
394
+ if (bot.has_topics_enabled === false) return false;
395
+ return undefined;
396
+ }
397
+
398
+ export async function probeTelegramStartupThreadCapability(
399
+ deps: TelegramStartupThreadCapabilityProbeDeps,
400
+ ): Promise<boolean | undefined> {
401
+ const threadModeEnabled = await readTelegramThreadCapability(deps);
402
+ const nowMs = (deps.getNowMs ?? Date.now)();
403
+ if (threadModeEnabled === false) {
404
+ deps.topicTargetStore.setBotState({
405
+ threadMode: "disabled",
406
+ updatedAtMs: nowMs,
407
+ lastReconcileAction: "startup-bot-topics-disabled",
408
+ });
409
+ await deps.topicTargetStore.persist();
410
+ deps.recordEvent("bus", "Telegram Threaded Mode unavailable on startup", {
411
+ phase: "startup-bot-topics-disabled",
412
+ });
413
+ deps.setTopicModeUnavailable(true);
414
+ return threadModeEnabled;
415
+ }
416
+ if (threadModeEnabled === true) {
417
+ deps.topicTargetStore.setBotState({
418
+ ...deps.topicTargetStore.getBotState(),
419
+ threadMode: "enabled",
420
+ updatedAtMs: nowMs,
421
+ lastReconcileAction: "startup-bot-topics-enabled",
422
+ });
423
+ await deps.topicTargetStore.persist();
424
+ deps.setTopicModeUnavailable(false);
425
+ }
426
+ return threadModeEnabled;
427
+ }
428
+
429
+ function hasTelegramThreadCapabilityBindings(
430
+ store: TelegramThreadCapabilityStore,
431
+ ): boolean {
432
+ return (
433
+ store.list?.().some((record) => {
434
+ return (
435
+ typeof record.target?.chatId === "number" &&
436
+ typeof record.target.threadId === "number" &&
437
+ record.status !== "deleted" &&
438
+ record.status !== "offline" &&
439
+ record.status !== "stale"
440
+ );
441
+ }) ?? false
442
+ );
443
+ }
444
+
445
+ export async function applyTelegramThreadCapability<TContext>(
446
+ ctx: TContext,
447
+ threadModeEnabled: boolean,
448
+ phase: string,
449
+ deps: TelegramThreadCapabilityRuntimeDeps<TContext>,
450
+ ): Promise<void> {
451
+ await deps.topicTargetStore.load();
452
+ if (!deps.isBusConfigured()) return;
453
+ const nowMs = (deps.getNowMs ?? Date.now)();
454
+ if (!threadModeEnabled) {
455
+ if (
456
+ hasTelegramThreadCapabilityBindings(deps.topicTargetStore) &&
457
+ !phase.endsWith("-confirmed")
458
+ ) {
459
+ deps.recordEvent("bus", "Telegram Threaded Mode probe deferred", {
460
+ phase,
461
+ reason: "active-thread-bindings-present",
462
+ });
463
+ return;
464
+ }
465
+ deps.topicTargetStore.setBotState({
466
+ threadMode: "disabled",
467
+ updatedAtMs: nowMs,
468
+ lastReconcileAction: phase,
469
+ });
470
+ await deps.topicTargetStore.persist();
471
+ deps.setTopicModeUnavailable(true);
472
+ deps.stopFollowerRegistration();
473
+ if (deps.getPollingStartedWithTelegramBus()) {
474
+ deps.stopLeaderHealth();
475
+ await deps.stopBusPolling();
476
+ deps.setPollingStartedWithTelegramBus(false);
477
+ await deps.startClassicPolling(ctx);
478
+ }
479
+ deps.updateStatus(ctx);
480
+ return;
481
+ }
482
+ deps.topicTargetStore.setBotState({
483
+ ...deps.topicTargetStore.getBotState(),
484
+ threadMode: "enabled",
485
+ updatedAtMs: nowMs,
486
+ lastReconcileAction: phase,
487
+ });
488
+ await deps.topicTargetStore.persist();
489
+ deps.setTopicModeUnavailable(false);
490
+ if (!deps.getPollingStartedWithTelegramBus() && deps.ownsLock(ctx)) {
491
+ await deps.stopClassicPolling();
492
+ deps.setPollingStartedWithTelegramBus(true);
493
+ try {
494
+ await deps.startBusPolling(ctx);
495
+ deps.startLeaderHealth();
496
+ } catch (error) {
497
+ deps.setPollingStartedWithTelegramBus(false);
498
+ const threadModeUnavailable =
499
+ deps.isTopicModeUnavailableError?.(error) === true;
500
+ if (threadModeUnavailable) {
501
+ deps.topicTargetStore.setBotState({
502
+ threadMode: "disabled",
503
+ updatedAtMs: nowMs,
504
+ lastReconcileAction: `${phase}-unavailable`,
505
+ });
506
+ await deps.topicTargetStore.persist();
507
+ deps.setTopicModeUnavailable(true);
508
+ }
509
+ try {
510
+ await deps.startClassicPolling(ctx);
511
+ } catch (classicError) {
512
+ deps.recordEvent("bus", classicError, {
513
+ phase: `${phase}-classic-restore`,
514
+ });
515
+ }
516
+ deps.updateStatus(ctx);
517
+ if (threadModeUnavailable) return;
518
+ throw error;
519
+ }
520
+ }
521
+ deps.updateStatus(ctx);
522
+ }
523
+
524
+ export function createTelegramThreadAwarePollingPorts<TContext, TOwner>(
525
+ deps: TelegramThreadAwarePollingDeps<TContext, TOwner>,
526
+ ): TelegramThreadAwarePollingPorts<TContext, TOwner> {
527
+ const startPolling = async (
528
+ ctx: TContext,
529
+ options?: { forceFreshLeaderThread?: boolean },
530
+ ): Promise<void> => {
531
+ if (deps.isBusConfigured()) {
532
+ await deps.topicTargetStore.load();
533
+ let startupThreadCapability: boolean | undefined;
534
+ try {
535
+ startupThreadCapability =
536
+ await probeTelegramStartupThreadCapability(deps);
537
+ } catch (error) {
538
+ deps.recordEvent("bus", error, { phase: "startup-thread-mode-probe" });
539
+ }
540
+ deps.setTopicModeUnavailable(startupThreadCapability !== true);
541
+ }
542
+ if (deps.isBusRuntimeEnabled()) {
543
+ deps.setTopicModeUnavailable(false);
544
+ try {
545
+ deps.setPollingStartedWithTelegramBus(true);
546
+ deps.setForceFreshLeaderThreadOnNextStart(
547
+ !!options?.forceFreshLeaderThread,
548
+ );
549
+ await deps.startBusLeaderPolling(ctx);
550
+ deps.startLeaderHealth();
551
+ return;
552
+ } catch (error) {
553
+ deps.setPollingStartedWithTelegramBus(false);
554
+ if (!deps.isTopicModeUnavailableError(error)) throw error;
555
+ deps.setTopicModeUnavailable(true);
556
+ await deps.topicTargetStore.load();
557
+ deps.topicTargetStore.setBotState({
558
+ threadMode: "disabled",
559
+ updatedAtMs: Date.now(),
560
+ lastReconcileAction: "thread-mode-unavailable",
561
+ });
562
+ await deps.topicTargetStore.persist();
563
+ deps.recordEvent("bus", error, { phase: "thread-mode-unavailable" });
564
+ } finally {
565
+ deps.setForceFreshLeaderThreadOnNextStart(false);
566
+ }
567
+ }
568
+ deps.setPollingStartedWithTelegramBus(false);
569
+ await deps.startClassicPolling(ctx);
570
+ };
571
+ const stopPolling = async (): Promise<void> => {
572
+ if (deps.getPollingStartedWithTelegramBus()) {
573
+ deps.stopLeaderHealth();
574
+ await deps.stopBusLeaderPolling();
575
+ deps.setPollingStartedWithTelegramBus(false);
576
+ return;
577
+ }
578
+ await deps.stopClassicPolling();
579
+ };
580
+ const registerFollowerWithOwner = async (
581
+ ctx: TContext,
582
+ owner: TOwner,
583
+ ): Promise<boolean | undefined> => {
584
+ await deps.topicTargetStore.load();
585
+ if (deps.topicTargetStore.getBotState().threadMode !== "enabled") {
586
+ return undefined;
587
+ }
588
+ if (!deps.isBusRuntimeEnabled()) return undefined;
589
+ return deps.registerFollowerWithLeader(ctx, owner);
590
+ };
591
+ return {
592
+ startPolling,
593
+ stopPolling,
594
+ registerFollowerWithOwner,
595
+ stopFollowerRegistration: deps.stopFollowerRegistration,
596
+ };
597
+ }
598
+
599
+ export function createTelegramThreadTargetObservationHandler<TContext>(
600
+ deps: TelegramThreadCapabilityRuntimeDeps<TContext>,
601
+ ): TelegramThreadTargetObservationHandler<TContext> {
602
+ let transitionPending = false;
603
+ return async (ctx) => {
604
+ if (transitionPending) return;
605
+ if (deps.topicTargetStore.getBotState().threadMode === "enabled") return;
606
+ transitionPending = true;
607
+ try {
608
+ await applyTelegramThreadCapability(
609
+ ctx,
610
+ true,
611
+ "thread-target-observed",
612
+ deps,
613
+ );
614
+ } catch (error) {
615
+ deps.recordEvent("bus", error, { phase: "thread-target-observed" });
616
+ } finally {
617
+ transitionPending = false;
618
+ }
619
+ };
620
+ }
621
+
622
+ export function createTelegramThreadCapabilityMonitor<TContext>(
623
+ deps: TelegramThreadCapabilityRuntimeDeps<TContext>,
624
+ ): TelegramThreadCapabilityMonitor<TContext> {
625
+ const intervalMs =
626
+ deps.intervalMs ?? TELEGRAM_THREAD_CAPABILITY_MONITOR_INTERVAL_MS;
627
+ let interval: ReturnType<typeof setInterval> | undefined;
628
+ let transitionPending = false;
629
+ let consecutiveDisabledProbes = 0;
630
+ const stop = (): void => {
631
+ if (!interval) return;
632
+ clearInterval(interval);
633
+ interval = undefined;
634
+ };
635
+ const check = (ctx: TContext): void => {
636
+ if (transitionPending) return;
637
+ transitionPending = true;
638
+ void readTelegramThreadCapability(deps)
639
+ .then(async (threadModeEnabled) => {
640
+ if (threadModeEnabled === undefined) {
641
+ if (
642
+ deps.topicTargetStore.getBotState().threadMode !== "enabled" &&
643
+ !deps.getPollingStartedWithTelegramBus() &&
644
+ deps.ownsLock(ctx)
645
+ ) {
646
+ await applyTelegramThreadCapability(
647
+ ctx,
648
+ true,
649
+ "capability-monitor-retry",
650
+ deps,
651
+ );
652
+ }
653
+ return;
654
+ }
655
+ if (threadModeEnabled) consecutiveDisabledProbes = 0;
656
+ const current = deps.topicTargetStore.getBotState().threadMode;
657
+ if (threadModeEnabled && current === "enabled") return;
658
+ if (!threadModeEnabled && current === "disabled") return;
659
+ if (
660
+ !threadModeEnabled &&
661
+ hasTelegramThreadCapabilityBindings(deps.topicTargetStore)
662
+ ) {
663
+ consecutiveDisabledProbes += 1;
664
+ if (
665
+ consecutiveDisabledProbes <
666
+ TELEGRAM_THREAD_CAPABILITY_DISABLED_CONFIRMATION_PROBES
667
+ ) {
668
+ deps.recordEvent("bus", "Telegram Threaded Mode probe deferred", {
669
+ phase: "capability-monitor-disabled",
670
+ reason: "active-thread-bindings-present",
671
+ consecutiveDisabledProbes,
672
+ });
673
+ return;
674
+ }
675
+ }
676
+ await applyTelegramThreadCapability(
677
+ ctx,
678
+ threadModeEnabled,
679
+ threadModeEnabled
680
+ ? "capability-monitor-enabled"
681
+ : consecutiveDisabledProbes >=
682
+ TELEGRAM_THREAD_CAPABILITY_DISABLED_CONFIRMATION_PROBES
683
+ ? "capability-monitor-disabled-confirmed"
684
+ : "capability-monitor-disabled",
685
+ deps,
686
+ );
687
+ })
688
+ .catch((error) => {
689
+ deps.recordEvent("bus", error, { phase: "capability-monitor" });
690
+ })
691
+ .finally(() => {
692
+ transitionPending = false;
693
+ });
694
+ };
695
+ return {
696
+ start(ctx) {
697
+ stop();
698
+ if (!deps.isBusConfigured()) return;
699
+ interval = setInterval(() => {
700
+ check(ctx);
701
+ }, intervalMs);
702
+ interval.unref?.();
703
+ },
704
+ stop,
705
+ };
706
+ }
707
+
261
708
  export interface TelegramPollLoopDeps<
262
709
  TUpdate extends TelegramUpdate,
263
710
  TContext = unknown,
@@ -357,6 +804,12 @@ function getTelegramPollingErrorMessage(error: unknown): string {
357
804
  return error instanceof Error ? error.message : String(error);
358
805
  }
359
806
 
807
+ export function isTelegramGetUpdatesConflictError(error: unknown): boolean {
808
+ return getTelegramPollingErrorMessage(error).includes(
809
+ "Conflict: terminated by other getUpdates request",
810
+ );
811
+ }
812
+
360
813
  export async function runTelegramPollLoop<
361
814
  TUpdate extends TelegramUpdate,
362
815
  TContext = unknown,
@@ -382,15 +835,20 @@ export async function runTelegramPollLoop<
382
835
  // ignore
383
836
  }
384
837
  }
385
- const maxUpdateFailures = Math.max(1, deps.maxUpdateFailures ?? 3);
838
+ const maxUpdateFailures = Math.max(
839
+ 1,
840
+ deps.maxUpdateFailures ?? TELEGRAM_POLLING_DEFAULT_MAX_UPDATE_FAILURES,
841
+ );
386
842
  const updateFailures = new Map<number, number>();
387
843
  let handledUpdateFailureRethrown = false;
844
+ let consecutiveGetUpdatesConflicts = 0;
388
845
  while (!deps.signal.aborted) {
389
846
  try {
390
847
  const updates = await deps.getUpdates(
391
848
  buildTelegramLongPollRequest(deps.config.lastUpdateId),
392
849
  deps.signal,
393
850
  );
851
+ consecutiveGetUpdatesConflicts = 0;
394
852
  for (const update of updates) {
395
853
  try {
396
854
  await deps.handleUpdate(update, deps.ctx);
@@ -425,8 +883,20 @@ export async function runTelegramPollLoop<
425
883
  } else {
426
884
  deps.recordRuntimeEvent?.("polling", error, { phase: "loop" });
427
885
  }
886
+ if (isTelegramGetUpdatesConflictError(error)) {
887
+ consecutiveGetUpdatesConflicts += 1;
888
+ await deps.sleep(
889
+ consecutiveGetUpdatesConflicts <
890
+ TELEGRAM_GET_UPDATES_CONFLICT_FAST_RETRY_LIMIT
891
+ ? TELEGRAM_GET_UPDATES_CONFLICT_FAST_RETRY_MS
892
+ : TELEGRAM_GET_UPDATES_CONFLICT_SLOW_RETRY_MS,
893
+ deps.signal,
894
+ );
895
+ continue;
896
+ }
897
+ consecutiveGetUpdatesConflicts = 0;
428
898
  deps.onErrorStatus(getTelegramPollingErrorMessage(error));
429
- await deps.sleep(3000, deps.signal);
899
+ await deps.sleep(TELEGRAM_POLLING_RETRY_MS, deps.signal);
430
900
  if (deps.signal.aborted) return;
431
901
  deps.onStatusReset();
432
902
  }