@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/sync.ts CHANGED
@@ -30,8 +30,7 @@ export interface TelegramLeaderThreadSyncDeps {
30
30
  getRandom?: () => number;
31
31
  getCurrentLeaderEpoch?: () => number | string | undefined;
32
32
  getThreadReconciliationMachineState?: () =>
33
- | ThreadReconciler.ThreadReconciliationMachineState
34
- | undefined;
33
+ ThreadReconciler.ThreadReconciliationMachineState | undefined;
35
34
  recordThreadReconciliationPlan?: (
36
35
  plan: ThreadReconciler.ThreadReconciliationPlan,
37
36
  ) => void;
@@ -67,8 +66,7 @@ export interface TelegramTopicLifecycleSyncDeps {
67
66
  isTopicProvisioningActive?: () => boolean;
68
67
  getCurrentLeaderEpoch?: () => number | string | undefined;
69
68
  getThreadReconciliationMachineState?: () =>
70
- | ThreadReconciler.ThreadReconciliationMachineState
71
- | undefined;
69
+ ThreadReconciler.ThreadReconciliationMachineState | undefined;
72
70
  recordThreadReconciliationPlan?: (
73
71
  plan: ThreadReconciler.ThreadReconciliationPlan,
74
72
  ) => void;
@@ -83,8 +81,9 @@ export type TelegramTopicLifecycleSyncHandler<TMessage = unknown> = (
83
81
  lifecycle: TelegramTopicLifecycleSyncUpdate<TMessage>,
84
82
  ) => Promise<void>;
85
83
 
86
- export interface TelegramObservedTopicLifecycleSyncDeps<TSyncState>
87
- extends TelegramTopicLifecycleSyncDeps {
84
+ export interface TelegramObservedTopicLifecycleSyncDeps<
85
+ TSyncState,
86
+ > extends TelegramTopicLifecycleSyncDeps {
88
87
  getSyncState: () => TSyncState;
89
88
  setSyncState: (state: TSyncState) => void;
90
89
  getNowMs?: () => number;
@@ -122,6 +121,7 @@ export interface TelegramManualThreadDisconnectDeps<TSyncState> {
122
121
  body: Record<string, unknown>,
123
122
  ) => Promise<TResponse>;
124
123
  getLeaderTarget: () => TelegramTarget | undefined;
124
+ getCurrentLeaderEpoch?: () => number | string | undefined;
125
125
  clearLeaderTarget: () => void;
126
126
  getSyncState: () => TSyncState;
127
127
  setSyncState: (state: TSyncState) => void;
@@ -134,11 +134,9 @@ export interface TelegramManualThreadDisconnectDeps<TSyncState> {
134
134
  getNowMs?: () => number;
135
135
  }
136
136
 
137
- export function markTelegramConfigSyncChange<TSyncState extends TelegramSyncState>(
138
- state: TSyncState,
139
- action: string,
140
- options?: { nowMs?: number },
141
- ): TSyncState {
137
+ export function markTelegramConfigSyncChange<
138
+ TSyncState extends TelegramSyncState,
139
+ >(state: TSyncState, action: string, options?: { nowMs?: number }): TSyncState {
142
140
  const nowMs = options?.nowMs ?? Date.now();
143
141
  let nextState = markTelegramSyncSliceFresh(state, "pairing", {
144
142
  nowMs,
@@ -163,10 +161,18 @@ export function createTelegramManualThreadDisconnectHandler<
163
161
  if (currentRecord?.target.threadId) {
164
162
  const isManualFollower = currentRecord.owner?.kind === "manual-follower";
165
163
  if (!isManualFollower) {
164
+ const leaderEpoch = deps.getCurrentLeaderEpoch?.();
165
+ const stillOwnsLeaderEpoch = () =>
166
+ !deps.getCurrentLeaderEpoch ||
167
+ (leaderEpoch !== undefined &&
168
+ deps.getCurrentLeaderEpoch() === leaderEpoch);
166
169
  await ThreadReconciler.applyThreadReconciliationPlan(
167
170
  ThreadReconciler.planDisconnectedInstanceThreadCleanup({
168
- target: currentRecord.target as TelegramTarget & { threadId: number },
171
+ target: currentRecord.target as TelegramTarget & {
172
+ threadId: number;
173
+ },
169
174
  instanceId: deps.instanceId,
175
+ leaderEpoch,
170
176
  }),
171
177
  {
172
178
  callApi(method, body) {
@@ -175,11 +181,17 @@ export function createTelegramManualThreadDisconnectHandler<
175
181
  persist() {
176
182
  return deps.topicTargetStore.persist();
177
183
  },
184
+ getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
178
185
  recordRuntimeEvent: deps.recordRuntimeEvent,
179
186
  },
180
187
  );
181
- if (deps.topicTargetStore.markOfflineByInstanceId(deps.instanceId) > 0) {
188
+ if (!stillOwnsLeaderEpoch()) return deps.stopPolling();
189
+ const offlineChanged =
190
+ deps.topicTargetStore.markOfflineByInstanceId(deps.instanceId) > 0;
191
+ if (!stillOwnsLeaderEpoch()) return deps.stopPolling();
192
+ if (offlineChanged) {
182
193
  await deps.topicTargetStore.persist();
194
+ if (!stillOwnsLeaderEpoch()) return deps.stopPolling();
183
195
  }
184
196
  }
185
197
  const leaderTarget = deps.getLeaderTarget();
@@ -305,15 +317,27 @@ export async function recoverStaleTelegramTopicApiError<
305
317
  export async function ensureTelegramLeaderThreadBinding(
306
318
  deps: TelegramLeaderThreadSyncDeps,
307
319
  ): Promise<TelegramOwnTopicProvisionResult | undefined> {
308
- await deps.topicTargetStore.load();
309
- const priorTargets = deps.topicTargetStore
310
- .list()
311
- .filter((record) => {
312
- return (
313
- record.instanceId === deps.instanceId &&
314
- (record.status === "active" || record.status === "starting")
320
+ const leaderEpoch = deps.getCurrentLeaderEpoch?.();
321
+ const assertLeaderEpoch = (phase: string): void => {
322
+ if (
323
+ deps.getCurrentLeaderEpoch &&
324
+ (leaderEpoch === undefined ||
325
+ deps.getCurrentLeaderEpoch() !== leaderEpoch)
326
+ ) {
327
+ throw new Error(
328
+ `Telegram leader thread binding lost ownership (${phase}).`,
315
329
  );
316
- });
330
+ }
331
+ };
332
+ assertLeaderEpoch("start");
333
+ await deps.topicTargetStore.load();
334
+ assertLeaderEpoch("after-load");
335
+ const priorTargets = deps.topicTargetStore.list().filter((record) => {
336
+ return (
337
+ record.instanceId === deps.instanceId &&
338
+ (record.status === "active" || record.status === "starting")
339
+ );
340
+ });
317
341
  // Short-circuit: when the instance already has an active thread and we are not
318
342
  // force-freshing, reuse it without re-provisioning. A thread belongs to the
319
343
  // live instance binding, not to one transient Pi session lifecycle.
@@ -330,6 +354,7 @@ export async function ensureTelegramLeaderThreadBinding(
330
354
  slot: record.slot,
331
355
  },
332
356
  );
357
+ assertLeaderEpoch("before-reuse");
333
358
  return {
334
359
  target: record.target,
335
360
  slot: record.slot ?? "A",
@@ -360,6 +385,7 @@ export async function ensureTelegramLeaderThreadBinding(
360
385
  }
361
386
  if (forcedUnnamedStale) await deps.topicTargetStore.persist();
362
387
  }
388
+ assertLeaderEpoch("before-provision");
363
389
  const ownTarget = await provisionOwnBusTopic({
364
390
  getAllowedUserId: deps.getAllowedUserId,
365
391
  instanceId: deps.instanceId,
@@ -375,6 +401,7 @@ export async function ensureTelegramLeaderThreadBinding(
375
401
  getRandom: deps.getRandom,
376
402
  recordEvent: deps.recordEvent,
377
403
  });
404
+ assertLeaderEpoch("after-provision");
378
405
  if (!ownTarget) return undefined;
379
406
  const replacementPlan = ThreadReconciler.planThreadReconciliation({
380
407
  nowMs: Date.now(),
@@ -404,7 +431,9 @@ export async function ensureTelegramLeaderThreadBinding(
404
431
  getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
405
432
  recordRuntimeEvent: deps.recordEvent,
406
433
  });
434
+ assertLeaderEpoch("before-final-persist");
407
435
  await deps.topicTargetStore.persist();
436
+ assertLeaderEpoch("after-final-persist");
408
437
  return ownTarget;
409
438
  }
410
439
 
@@ -454,6 +483,53 @@ export function createUnknownTelegramSyncState(): TelegramSyncState {
454
483
  ) as TelegramSyncState;
455
484
  }
456
485
 
486
+ export interface TelegramSyncStateRuntime {
487
+ getState(): TelegramSyncState;
488
+ setState(state: TelegramSyncState): void;
489
+ markConfigChange(action: string): void;
490
+ markSliceFresh(
491
+ slice: TelegramSyncSlice,
492
+ options: { nowMs: number; action: string },
493
+ ): void;
494
+ }
495
+
496
+ export function createTelegramSyncStateRuntime(
497
+ initialState = createUnknownTelegramSyncState(),
498
+ ): TelegramSyncStateRuntime {
499
+ let state = initialState;
500
+ return {
501
+ getState: () => state,
502
+ setState(nextState) {
503
+ state = nextState;
504
+ },
505
+ markConfigChange(action) {
506
+ state = markTelegramConfigSyncChange(state, action);
507
+ },
508
+ markSliceFresh(slice, options) {
509
+ state = markTelegramSyncSliceFresh(state, slice, options);
510
+ },
511
+ };
512
+ }
513
+
514
+ export interface TelegramProvisioningActivityRuntime {
515
+ isActive(): boolean;
516
+ start(): void;
517
+ end(): void;
518
+ }
519
+
520
+ export function createTelegramProvisioningActivityRuntime(): TelegramProvisioningActivityRuntime {
521
+ let activeCount = 0;
522
+ return {
523
+ isActive: () => activeCount > 0,
524
+ start() {
525
+ activeCount += 1;
526
+ },
527
+ end() {
528
+ activeCount = Math.max(0, activeCount - 1);
529
+ },
530
+ };
531
+ }
532
+
457
533
  const RECONCILE_TRIGGERS = new Set<TelegramSyncTrigger>([
458
534
  "startup",
459
535
  "reload",
@@ -518,9 +594,8 @@ export function createTelegramObservedTopicLifecycleSyncHandler<
518
594
  >(
519
595
  deps: TelegramObservedTopicLifecycleSyncDeps<TSyncState>,
520
596
  ): TelegramTopicLifecycleSyncHandler<TMessage> {
521
- const syncTopicLifecycle = createTelegramTopicLifecycleSyncHandler<TMessage>(
522
- deps,
523
- );
597
+ const syncTopicLifecycle =
598
+ createTelegramTopicLifecycleSyncHandler<TMessage>(deps);
524
599
  return async (lifecycle) => {
525
600
  const nowMs = deps.getNowMs ?? Date.now;
526
601
  deps.setSyncState(
@@ -580,14 +655,12 @@ export function createTelegramTopicLifecycleSyncHandler<TMessage = unknown>(
580
655
  const changed = result.changed;
581
656
  if (lifecycle.kind === "created" && deps.isBusEnabled()) {
582
657
  const target = lifecycle.target;
583
- const isKnownInRecords = deps.topicTargetStore
584
- .list()
585
- .some((record) => {
586
- return (
587
- record.target.chatId === target.chatId &&
588
- record.target.threadId === target.threadId
589
- );
590
- });
658
+ const isKnownInRecords = deps.topicTargetStore.list().some((record) => {
659
+ return (
660
+ record.target.chatId === target.chatId &&
661
+ record.target.threadId === target.threadId
662
+ );
663
+ });
591
664
  const isKnownInReservations = deps.topicTargetStore
592
665
  .listReservations()
593
666
  .some((reservation) => {
@@ -273,6 +273,7 @@ interface TelegramApiResponse<T> {
273
273
  export interface TelegramApiCallOptions {
274
274
  signal?: AbortSignal;
275
275
  maxAttempts?: number;
276
+ retrySafety?: "safe" | "non-idempotent";
276
277
  retryBaseDelayMs?: number;
277
278
  sleep?: (ms: number) => Promise<void>;
278
279
  }
@@ -449,6 +450,32 @@ function sanitizeFileName(name: string): string {
449
450
  return name.replace(/[^a-zA-Z0-9._-]+/g, "_");
450
451
  }
451
452
 
453
+ class TelegramApiMalformedSuccessError extends Error {
454
+ constructor(method: string, detail: string) {
455
+ super(`Telegram API ${method} ${detail}`);
456
+ this.name = "TelegramApiMalformedSuccessError";
457
+ }
458
+ }
459
+
460
+ export class TelegramApiCommitUnknownError extends Error {
461
+ readonly kind = "commit-unknown" as const;
462
+ readonly method: string;
463
+ override readonly cause: unknown;
464
+
465
+ constructor(method: string, cause: unknown) {
466
+ super(`Telegram API ${method} may have committed before transport failed.`);
467
+ this.name = "TelegramApiCommitUnknownError";
468
+ this.method = method;
469
+ this.cause = cause;
470
+ }
471
+ }
472
+
473
+ export function isTelegramApiCommitUnknownError(
474
+ error: unknown,
475
+ ): error is TelegramApiCommitUnknownError {
476
+ return error instanceof TelegramApiCommitUnknownError;
477
+ }
478
+
452
479
  class TelegramApiHttpError extends Error {
453
480
  readonly status: number | undefined;
454
481
  readonly retryAfterSeconds: number | undefined;
@@ -469,6 +496,30 @@ export function isTelegramMessageNotModifiedError(error: unknown): boolean {
469
496
  );
470
497
  }
471
498
 
499
+ const TELEGRAM_RETRY_SAFE_METHODS = new Set([
500
+ "answerCallbackQuery",
501
+ "closeForumTopic",
502
+ "deleteForumTopic",
503
+ "deleteMessage",
504
+ "deleteWebhook",
505
+ "editForumTopic",
506
+ "editMessageCaption",
507
+ "editMessageReplyMarkup",
508
+ "editMessageText",
509
+ "getChat",
510
+ "getFile",
511
+ "getMe",
512
+ "getUpdates",
513
+ "sendChatAction",
514
+ "sendMessageDraft",
515
+ "sendRichMessageDraft",
516
+ "setMyCommands",
517
+ ]);
518
+
519
+ export function isTelegramApiMethodRetrySafe(method: string): boolean {
520
+ return TELEGRAM_RETRY_SAFE_METHODS.has(method);
521
+ }
522
+
472
523
  function isRetryableTelegramApiError(error: unknown): boolean {
473
524
  return (
474
525
  error instanceof TelegramApiHttpError &&
@@ -579,22 +630,23 @@ async function parseTelegramApiResponse<TResponse>(
579
630
  Number.isFinite(retryAfterSeconds) ? retryAfterSeconds : undefined,
580
631
  );
581
632
  }
582
- return (
583
- data ?? {
584
- ok: false,
585
- description: `Telegram API ${method} returned invalid JSON`,
586
- }
587
- );
633
+ if (!data) {
634
+ throw new TelegramApiMalformedSuccessError(method, "returned invalid JSON");
635
+ }
636
+ return data;
588
637
  }
589
638
 
590
639
  function unwrapTelegramApiResult<TResponse>(
591
640
  method: string,
592
641
  data: TelegramApiResponse<TResponse>,
593
642
  ): TResponse {
594
- if (!data.ok || data.result === undefined) {
643
+ if (data.ok && data.result === undefined) {
644
+ throw new TelegramApiMalformedSuccessError(method, "returned no result");
645
+ }
646
+ if (!data.ok) {
595
647
  throw new Error(data.description || `Telegram API ${method} failed`);
596
648
  }
597
- return data.result;
649
+ return data.result as TResponse;
598
650
  }
599
651
 
600
652
  function getTelegramNetworkFamilyPolicy(
@@ -754,11 +806,13 @@ async function telegramFetch(
754
806
 
755
807
  async function callTelegramTransportRequest(
756
808
  request: (family?: TelegramNetworkFamily) => Promise<Response>,
809
+ allowFallback = true,
757
810
  ): Promise<Response> {
758
811
  const policy = getTelegramNetworkFamilyPolicy();
759
812
  if (policy === "auto") return request();
760
813
  const family = getTelegramNetworkFamily(policy);
761
814
  if (family) return request(family);
815
+ if (!allowFallback) return request();
762
816
  try {
763
817
  return await request();
764
818
  } catch (error) {
@@ -864,6 +918,10 @@ async function callTelegramWithRetry<TResponse>(
864
918
  request: (family?: TelegramNetworkFamily) => Promise<Response>,
865
919
  options: TelegramApiCallOptions | undefined,
866
920
  ): Promise<TResponse> {
921
+ const retrySafe =
922
+ options?.retrySafety === "safe" ||
923
+ (options?.retrySafety !== "non-idempotent" &&
924
+ isTelegramApiMethodRetrySafe(method));
867
925
  const maxAttempts = Math.max(1, options?.maxAttempts ?? 3);
868
926
  const retryBaseDelayMs = options?.retryBaseDelayMs ?? 500;
869
927
  const sleep = options?.sleep ?? sleepTelegramRetry;
@@ -872,14 +930,32 @@ async function callTelegramWithRetry<TResponse>(
872
930
  return unwrapTelegramApiResult(
873
931
  method,
874
932
  await parseTelegramApiResponse<TResponse>(
875
- await callTelegramTransportRequest(request),
933
+ await callTelegramTransportRequest(request, retrySafe),
876
934
  method,
877
935
  ),
878
936
  );
879
937
  } catch (error) {
880
- if (attempt >= maxAttempts - 1 || !isRetryableTelegramApiError(error)) {
938
+ const retryable = isRetryableTelegramApiError(error);
939
+ if (!retrySafe) {
940
+ if (error instanceof TelegramApiHttpError && error.status === 429) {
941
+ if (attempt >= maxAttempts - 1) throw error;
942
+ await sleep(
943
+ getTelegramRetryDelayMs(error, attempt, retryBaseDelayMs),
944
+ );
945
+ continue;
946
+ }
947
+ if (
948
+ error instanceof TelegramApiMalformedSuccessError ||
949
+ isTelegramTransportFailure(error) ||
950
+ (error instanceof TelegramApiHttpError &&
951
+ error.status !== undefined &&
952
+ error.status >= 500)
953
+ ) {
954
+ throw new TelegramApiCommitUnknownError(method, error);
955
+ }
881
956
  throw error;
882
957
  }
958
+ if (attempt >= maxAttempts - 1 || !retryable) throw error;
883
959
  await sleep(getTelegramRetryDelayMs(error, attempt, retryBaseDelayMs));
884
960
  }
885
961
  }
@@ -22,14 +22,22 @@ export interface TelegramTextGroupState<TMessage, TContext = unknown> {
22
22
  messages: TMessage[];
23
23
  context?: TContext;
24
24
  flushTimer?: ReturnType<typeof setTimeout>;
25
+ dispatching?: boolean;
26
+ suspended?: boolean;
27
+ reschedule?: () => void;
25
28
  }
26
29
 
27
30
  export interface TelegramTextGroupController<TMessage, TContext = unknown> {
28
31
  queueMessage: (options: {
29
32
  message: TMessage;
30
33
  context: TContext;
31
- dispatchMessages: (messages: TMessage[], ctx: TContext) => void;
34
+ dispatchMessages: (
35
+ messages: TMessage[],
36
+ ctx: TContext,
37
+ ) => unknown | Promise<unknown>;
32
38
  }) => boolean;
39
+ suspend: () => void;
40
+ resume: (context: TContext) => void;
33
41
  clear: () => void;
34
42
  }
35
43
 
@@ -71,9 +79,10 @@ function getTelegramTextGroupKey(
71
79
  if (message.media_group_id) return undefined;
72
80
  if (!message.from || message.from.is_bot) return undefined;
73
81
  if (typeof message.text !== "string") return undefined;
74
- const threadKey = typeof message.message_thread_id === "number"
75
- ? `thread:${message.message_thread_id}`
76
- : "private";
82
+ const threadKey =
83
+ typeof message.message_thread_id === "number"
84
+ ? `thread:${message.message_thread_id}`
85
+ : "private";
77
86
  return `${message.chat.id}:${threadKey}:${message.from.id}`;
78
87
  }
79
88
 
@@ -114,7 +123,10 @@ export function queueTelegramTextGroupMessage<
114
123
  minSplitLength: number;
115
124
  setTimer: (callback: () => void, ms: number) => ReturnType<typeof setTimeout>;
116
125
  clearTimer: (timer: ReturnType<typeof setTimeout>) => void;
117
- dispatchMessages: (messages: TMessage[], ctx: TContext) => void;
126
+ dispatchMessages: (
127
+ messages: TMessage[],
128
+ ctx: TContext,
129
+ ) => unknown | Promise<unknown>;
118
130
  }): boolean {
119
131
  const key = getTelegramTextGroupKey(options.message);
120
132
  if (!key) return false;
@@ -129,14 +141,45 @@ export function queueTelegramTextGroupMessage<
129
141
  const state = existing ?? { messages: [] };
130
142
  state.messages.push(options.message);
131
143
  state.context = options.context;
144
+ const scheduleDispatch = (): void => {
145
+ if (state.suspended) return;
146
+ state.flushTimer = options.setTimer(() => {
147
+ state.flushTimer = undefined;
148
+ const queued = options.groups.get(key);
149
+ if (!queued || queued.context === undefined) return;
150
+ if (queued.dispatching) {
151
+ scheduleDispatch();
152
+ return;
153
+ }
154
+ const dispatchedMessages = [...queued.messages];
155
+ const dispatchedIds = new Set(
156
+ dispatchedMessages.map((message) => message.message_id),
157
+ );
158
+ queued.dispatching = true;
159
+ void Promise.resolve(
160
+ options.dispatchMessages(dispatchedMessages, queued.context),
161
+ ).then(
162
+ () => {
163
+ if (options.groups.get(key) !== queued) return;
164
+ queued.messages = queued.messages.filter(
165
+ (message) => !dispatchedIds.has(message.message_id),
166
+ );
167
+ queued.dispatching = false;
168
+ if (queued.messages.length === 0) options.groups.delete(key);
169
+ else if (!queued.flushTimer) scheduleDispatch();
170
+ },
171
+ () => {
172
+ if (options.groups.get(key) !== queued) return;
173
+ queued.dispatching = false;
174
+ if (!queued.flushTimer) scheduleDispatch();
175
+ },
176
+ );
177
+ }, options.debounceMs);
178
+ state.flushTimer.unref?.();
179
+ };
180
+ state.reschedule = scheduleDispatch;
132
181
  if (state.flushTimer) options.clearTimer(state.flushTimer);
133
- state.flushTimer = options.setTimer(() => {
134
- const queued = options.groups.get(key);
135
- options.groups.delete(key);
136
- if (!queued || queued.context === undefined) return;
137
- options.dispatchMessages(queued.messages, queued.context);
138
- }, options.debounceMs);
139
- state.flushTimer.unref?.();
182
+ scheduleDispatch();
140
183
  options.groups.set(key, state);
141
184
  return true;
142
185
  }
@@ -168,6 +211,20 @@ export function createTelegramTextGroupController<
168
211
  clearTimer,
169
212
  dispatchMessages,
170
213
  }),
214
+ suspend: () => {
215
+ for (const state of groups.values()) {
216
+ state.suspended = true;
217
+ if (state.flushTimer) clearTimer(state.flushTimer);
218
+ state.flushTimer = undefined;
219
+ }
220
+ },
221
+ resume: (context) => {
222
+ for (const state of groups.values()) {
223
+ state.context = context;
224
+ state.suspended = false;
225
+ if (!state.dispatching && !state.flushTimer) state.reschedule?.();
226
+ }
227
+ },
171
228
  clear: () => {
172
229
  for (const state of groups.values()) {
173
230
  if (state.flushTimer) clearTimer(state.flushTimer);
@@ -190,9 +247,8 @@ export function createTelegramTextGroupDispatchRuntime<
190
247
  const queuedTextGroup = deps.textGroups.queueMessage({
191
248
  message,
192
249
  context: ctx,
193
- dispatchMessages: (messages, queuedCtx) => {
194
- void deps.dispatchMessages(messages, queuedCtx);
195
- },
250
+ dispatchMessages: (messages, queuedCtx) =>
251
+ deps.dispatchMessages(messages, queuedCtx),
196
252
  });
197
253
  if (queuedTextGroup) return;
198
254
  await deps.dispatchSingleMessage(message, ctx);