@llblab/pi-telegram 0.21.1 → 0.22.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.
package/lib/polling.ts CHANGED
@@ -346,10 +346,36 @@ export interface TelegramThreadCapabilityMonitor<TContext> {
346
346
  stop: () => void;
347
347
  }
348
348
 
349
+ export interface TelegramThreadCapabilityStateRuntime {
350
+ isBusPollingStarted(): boolean;
351
+ setBusPollingStarted(started: boolean): void;
352
+ isTopicModeUnavailable(): boolean;
353
+ setTopicModeUnavailable(unavailable: boolean): void;
354
+ shouldForceFreshLeaderThread(): boolean;
355
+ setForceFreshLeaderThread(forceFresh: boolean): void;
356
+ }
357
+
349
358
  export type TelegramThreadTargetObservationHandler<TContext> = (
350
359
  ctx: TContext,
351
360
  ) => Promise<void>;
352
361
 
362
+ export interface TelegramThreadTargetObservationBinding<TContext> {
363
+ handle: TelegramThreadTargetObservationHandler<TContext>;
364
+ set(handler: TelegramThreadTargetObservationHandler<TContext>): void;
365
+ }
366
+
367
+ export function createTelegramThreadTargetObservationBinding<TContext>(): TelegramThreadTargetObservationBinding<TContext> {
368
+ let handler: TelegramThreadTargetObservationHandler<TContext> | undefined;
369
+ return {
370
+ async handle(ctx) {
371
+ await handler?.(ctx);
372
+ },
373
+ set(nextHandler) {
374
+ handler = nextHandler;
375
+ },
376
+ };
377
+ }
378
+
353
379
  export interface TelegramThreadAwarePollingPorts<TContext, TOwner> {
354
380
  startPolling: (
355
381
  ctx: TContext,
@@ -386,6 +412,112 @@ export interface TelegramThreadAwarePollingDeps<
386
412
  stopFollowerRegistration: () => void;
387
413
  }
388
414
 
415
+ export interface TelegramThreadCapabilityOrchestrationDeps<
416
+ TContext,
417
+ TOwner,
418
+ > extends TelegramThreadCapabilityReaderDeps {
419
+ state: TelegramThreadCapabilityStateRuntime;
420
+ topicTargetStore: TelegramThreadCapabilityStore;
421
+ isBusConfigured: () => boolean;
422
+ isBusRuntimeEnabled: () => boolean;
423
+ ownsLock: (ctx: TContext) => boolean;
424
+ startClassicPolling: (ctx: TContext) => MaybePromise<void>;
425
+ stopClassicPolling: () => Promise<void>;
426
+ startBusLeaderPolling: (ctx: TContext) => Promise<void>;
427
+ stopBusLeaderPolling: () => Promise<void>;
428
+ startLeaderHealth: () => void;
429
+ stopLeaderHealth: () => void;
430
+ registerFollowerWithLeader: (
431
+ ctx: TContext,
432
+ owner: TOwner,
433
+ ) => Promise<boolean | undefined>;
434
+ stopFollowerRegistration: () => void;
435
+ isTopicModeUnavailableError: (error: unknown) => boolean;
436
+ updateStatus: (ctx: TContext) => void;
437
+ recordEvent: (
438
+ category: string,
439
+ message: unknown,
440
+ details?: Record<string, unknown>,
441
+ ) => void;
442
+ }
443
+
444
+ export interface TelegramThreadCapabilityOrchestration<TContext, TOwner> {
445
+ monitor: TelegramThreadCapabilityMonitor<TContext>;
446
+ observeTarget: TelegramThreadTargetObservationHandler<TContext>;
447
+ pollingPorts: TelegramThreadAwarePollingPorts<TContext, TOwner>;
448
+ }
449
+
450
+ export function createTelegramThreadCapabilityStateRuntime(): TelegramThreadCapabilityStateRuntime {
451
+ let busPollingStarted = false;
452
+ let topicModeUnavailable = false;
453
+ let forceFreshLeaderThread = false;
454
+ return {
455
+ isBusPollingStarted: () => busPollingStarted,
456
+ setBusPollingStarted(started) {
457
+ busPollingStarted = started;
458
+ },
459
+ isTopicModeUnavailable: () => topicModeUnavailable,
460
+ setTopicModeUnavailable(unavailable) {
461
+ topicModeUnavailable = unavailable;
462
+ },
463
+ shouldForceFreshLeaderThread: () => forceFreshLeaderThread,
464
+ setForceFreshLeaderThread(forceFresh) {
465
+ forceFreshLeaderThread = forceFresh;
466
+ },
467
+ };
468
+ }
469
+
470
+ export function createTelegramThreadCapabilityOrchestration<TContext, TOwner>(
471
+ deps: TelegramThreadCapabilityOrchestrationDeps<TContext, TOwner>,
472
+ ): TelegramThreadCapabilityOrchestration<TContext, TOwner> {
473
+ const capabilityDeps: TelegramThreadCapabilityRuntimeDeps<TContext> = {
474
+ getAllowedUserId: deps.getAllowedUserId,
475
+ callApi: deps.callApi,
476
+ topicTargetStore: deps.topicTargetStore,
477
+ isBusConfigured: deps.isBusConfigured,
478
+ ownsLock: deps.ownsLock,
479
+ getPollingStartedWithTelegramBus: deps.state.isBusPollingStarted,
480
+ setPollingStartedWithTelegramBus: deps.state.setBusPollingStarted,
481
+ setTopicModeUnavailable: deps.state.setTopicModeUnavailable,
482
+ stopFollowerRegistration: deps.stopFollowerRegistration,
483
+ startClassicPolling: deps.startClassicPolling,
484
+ stopClassicPolling: deps.stopClassicPolling,
485
+ startBusPolling: deps.startBusLeaderPolling,
486
+ stopBusPolling: deps.stopBusLeaderPolling,
487
+ startLeaderHealth: deps.startLeaderHealth,
488
+ stopLeaderHealth: deps.stopLeaderHealth,
489
+ isTopicModeUnavailableError: deps.isTopicModeUnavailableError,
490
+ updateStatus: deps.updateStatus,
491
+ recordEvent: deps.recordEvent,
492
+ };
493
+ return {
494
+ monitor: createTelegramThreadCapabilityMonitor(capabilityDeps),
495
+ observeTarget: createTelegramThreadTargetObservationHandler(capabilityDeps),
496
+ pollingPorts: createTelegramThreadAwarePollingPorts({
497
+ getAllowedUserId: deps.getAllowedUserId,
498
+ callApi: deps.callApi,
499
+ topicTargetStore: deps.topicTargetStore,
500
+ isBusConfigured: deps.isBusConfigured,
501
+ isBusRuntimeEnabled: deps.isBusRuntimeEnabled,
502
+ isTopicModeUnavailableError: deps.isTopicModeUnavailableError,
503
+ getPollingStartedWithTelegramBus: deps.state.isBusPollingStarted,
504
+ setPollingStartedWithTelegramBus: deps.state.setBusPollingStarted,
505
+ setForceFreshLeaderThreadOnNextStart:
506
+ deps.state.setForceFreshLeaderThread,
507
+ startClassicPolling: deps.startClassicPolling,
508
+ stopClassicPolling: deps.stopClassicPolling,
509
+ startBusLeaderPolling: deps.startBusLeaderPolling,
510
+ stopBusLeaderPolling: deps.stopBusLeaderPolling,
511
+ startLeaderHealth: deps.startLeaderHealth,
512
+ stopLeaderHealth: deps.stopLeaderHealth,
513
+ registerFollowerWithLeader: deps.registerFollowerWithLeader,
514
+ stopFollowerRegistration: deps.stopFollowerRegistration,
515
+ recordEvent: deps.recordEvent,
516
+ setTopicModeUnavailable: deps.state.setTopicModeUnavailable,
517
+ }),
518
+ };
519
+ }
520
+
389
521
  export async function readTelegramThreadCapability(
390
522
  deps: TelegramThreadCapabilityReaderDeps,
391
523
  ): Promise<boolean | undefined> {
@@ -429,7 +561,9 @@ export async function probeTelegramStartupThreadCapability(
429
561
  function hasTelegramClassicRestoreFailure(
430
562
  state: TelegramThreadCapabilityState,
431
563
  ): boolean {
432
- return state.lastReconcileAction?.endsWith("-classic-restore-failed") ?? false;
564
+ return (
565
+ state.lastReconcileAction?.endsWith("-classic-restore-failed") ?? false
566
+ );
433
567
  }
434
568
 
435
569
  function hasTelegramThreadCapabilityBindings(
@@ -698,7 +832,10 @@ export function createTelegramThreadCapabilityMonitor<TContext>(
698
832
  const current = botState.threadMode;
699
833
  if (threadModeEnabled && current === "enabled") return;
700
834
  if (!threadModeEnabled && current === "disabled") {
701
- if (!deps.ownsLock(ctx) || !hasTelegramClassicRestoreFailure(botState)) {
835
+ if (
836
+ !deps.ownsLock(ctx) ||
837
+ !hasTelegramClassicRestoreFailure(botState)
838
+ ) {
702
839
  return;
703
840
  }
704
841
  await applyTelegramThreadCapability(
@@ -770,7 +907,7 @@ export interface TelegramPollLoopDeps<
770
907
  body: Record<string, unknown>,
771
908
  signal: AbortSignal,
772
909
  ) => Promise<TUpdate[]>;
773
- persistConfig: () => Promise<void>;
910
+ persistConfig: (config: TelegramPollingConfig) => Promise<void>;
774
911
  handleUpdate: (update: TUpdate, ctx: TContext) => Promise<void>;
775
912
  onErrorStatus: (message: string) => void;
776
913
  onStatusReset: () => void;
@@ -788,7 +925,7 @@ export interface TelegramPollLoopRunnerDeps<
788
925
  body: Record<string, unknown>,
789
926
  signal: AbortSignal,
790
927
  ) => Promise<TUpdate[]>;
791
- persistConfig: () => Promise<void>;
928
+ persistConfig: (config: TelegramPollingConfig) => Promise<void>;
792
929
  handleUpdate: (update: TUpdate, ctx: TContext) => Promise<void>;
793
930
  updateStatus: (ctx: TContext, message?: string) => void;
794
931
  sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
@@ -882,7 +1019,7 @@ export async function runTelegramPollLoop<
882
1019
  const lastUpdateId = getLatestTelegramUpdateId(updates);
883
1020
  if (lastUpdateId !== undefined) {
884
1021
  deps.config.lastUpdateId = lastUpdateId;
885
- await deps.persistConfig();
1022
+ await deps.persistConfig(deps.config);
886
1023
  }
887
1024
  } catch {
888
1025
  // ignore
@@ -893,6 +1030,7 @@ export async function runTelegramPollLoop<
893
1030
  deps.maxUpdateFailures ?? TELEGRAM_POLLING_DEFAULT_MAX_UPDATE_FAILURES,
894
1031
  );
895
1032
  const updateFailures = new Map<number, number>();
1033
+ const admittedUpdates = new Set<number>();
896
1034
  let handledUpdateFailureRethrown = false;
897
1035
  let consecutiveGetUpdatesConflicts = 0;
898
1036
  while (!deps.signal.aborted) {
@@ -903,12 +1041,21 @@ export async function runTelegramPollLoop<
903
1041
  );
904
1042
  consecutiveGetUpdatesConflicts = 0;
905
1043
  for (const update of updates) {
1044
+ if (admittedUpdates.has(update.update_id)) {
1045
+ deps.config.lastUpdateId = update.update_id;
1046
+ await deps.persistConfig(deps.config);
1047
+ admittedUpdates.delete(update.update_id);
1048
+ continue;
1049
+ }
906
1050
  try {
907
1051
  await deps.handleUpdate(update, deps.ctx);
1052
+ admittedUpdates.add(update.update_id);
908
1053
  deps.config.lastUpdateId = update.update_id;
909
1054
  updateFailures.delete(update.update_id);
910
- await deps.persistConfig();
1055
+ await deps.persistConfig(deps.config);
1056
+ admittedUpdates.delete(update.update_id);
911
1057
  } catch (error) {
1058
+ if (admittedUpdates.has(update.update_id)) throw error;
912
1059
  const failureCount = (updateFailures.get(update.update_id) ?? 0) + 1;
913
1060
  updateFailures.set(update.update_id, failureCount);
914
1061
  deps.recordRuntimeEvent?.("polling", error, {
@@ -924,9 +1071,11 @@ export async function runTelegramPollLoop<
924
1071
  deps.onErrorStatus(
925
1072
  `skipping Telegram update ${update.update_id} after ${failureCount} failures: ${message}`,
926
1073
  );
1074
+ admittedUpdates.add(update.update_id);
927
1075
  deps.config.lastUpdateId = update.update_id;
928
1076
  updateFailures.delete(update.update_id);
929
- await deps.persistConfig();
1077
+ await deps.persistConfig(deps.config);
1078
+ admittedUpdates.delete(update.update_id);
930
1079
  }
931
1080
  }
932
1081
  } catch (error) {
package/lib/preview.ts CHANGED
@@ -145,6 +145,7 @@ export interface TelegramPreviewController {
145
145
  setPendingText: (text: string) => void;
146
146
  createState: () => TelegramPreviewRuntimeState;
147
147
  resetState: () => void;
148
+ invalidate: () => void;
148
149
  clear: (
149
150
  chatId: number,
150
151
  options?: { awaitFlush?: boolean; target?: TelegramTarget },
@@ -175,6 +176,7 @@ export function createTelegramPreviewControllerRuntime(
175
176
  maxMessageLength: deps.maxMessageLength,
176
177
  initialDraftSupport: deps.initialDraftSupport,
177
178
  sendDraft: deps.sendDraft,
179
+ canSend: deps.canSend,
178
180
  maxDraftId: deps.maxDraftId,
179
181
  recordRuntimeEvent: deps.recordRuntimeEvent,
180
182
  });
@@ -233,9 +235,10 @@ export function createTelegramNativeMarkdownPreviewFinalizer<
233
235
  const state = deps.getState();
234
236
  if (state?.flushPromise) {
235
237
  await state.flushPromise.catch(() => {});
238
+ if (deps.getState() !== state) return false;
236
239
  }
237
240
  await deps.sendMarkdownReply(chatId, replyToMessageId, markdown, options);
238
- deps.discard?.();
241
+ if (deps.getState() === state) deps.discard?.();
239
242
  return true;
240
243
  };
241
244
  }
@@ -275,12 +278,15 @@ export function createTelegramPreviewController(
275
278
  deps: TelegramPreviewControllerDeps,
276
279
  ): TelegramPreviewController {
277
280
  let state: TelegramPreviewRuntimeState | undefined;
281
+ let generation = 0;
278
282
  const maxDraftId = deps.maxDraftId ?? TELEGRAM_DRAFT_ID_MAX;
279
283
  const maxMessageLength =
280
284
  deps.maxMessageLength ?? TELEGRAM_DRAFT_PREVIEW_MAX_CHARS;
281
285
  let draftSupport = deps.initialDraftSupport ?? "unknown";
282
286
  let nextDraftId = 0;
283
- const getRuntimeDeps = (): TelegramPreviewRuntimeDeps => ({
287
+ const getRuntimeDeps = (
288
+ operationGeneration = generation,
289
+ ): TelegramPreviewRuntimeDeps => ({
284
290
  getState: () => state,
285
291
  setState: (nextState) => {
286
292
  state = nextState;
@@ -295,7 +301,8 @@ export function createTelegramPreviewController(
295
301
  return nextDraftId;
296
302
  },
297
303
  sendDraft: deps.sendDraft,
298
- canSend: deps.canSend,
304
+ canSend: () =>
305
+ operationGeneration === generation && (deps.canSend?.() ?? true),
299
306
  recordRuntimeEvent: deps.recordRuntimeEvent,
300
307
  });
301
308
  return {
@@ -308,8 +315,13 @@ export function createTelegramPreviewController(
308
315
  },
309
316
  createState: () => createTelegramPreviewRuntimeState(),
310
317
  resetState: () => {
318
+ generation += 1;
311
319
  state = createTelegramPreviewRuntimeState();
312
320
  },
321
+ invalidate: () => {
322
+ generation += 1;
323
+ state = undefined;
324
+ },
313
325
  clear: (chatId, options) =>
314
326
  clearTelegramPreview(chatId, getRuntimeDeps(), options),
315
327
  flush: (chatId, options) =>