@llblab/pi-telegram 0.43.2 → 0.44.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/outbound.ts CHANGED
@@ -11,6 +11,7 @@ import { join } from "node:path";
11
11
  import type { TelegramAssistantSegmentEvent } from "./activity.ts";
12
12
  import { resolveTelegramTempDir } from "./paths.ts";
13
13
  import * as Replies from "./replies.ts";
14
+ import type { TelegramPreparedPreviewDelivery } from "./preview.ts";
14
15
  import { isTelegramApiCommitUnknownError } from "./telegram-api.ts";
15
16
  import type {
16
17
  TelegramEditMessageTextBody,
@@ -249,12 +250,8 @@ export interface TelegramOutboundTextPreviewRuntimeDeps<
249
250
  > {
250
251
  execCommand: TelegramVoiceReplySenderDeps["execCommand"];
251
252
  getHandlers?: () => TelegramOutboundHandlerConfig[] | undefined;
252
- finalizeMarkdownPreview: (
253
- chatId: number,
254
- markdown: string,
255
- replyToMessageId: number,
256
- options?: { replyMarkup?: TReplyMarkup },
257
- ) => Promise<boolean>;
253
+ finalizeMarkdownPreview: TelegramPreparedPreviewDelivery<TReplyMarkup>["finalizeMarkdownPreview"];
254
+ preparePreviewDelivery?: (isDeliveryActive: () => boolean) => TelegramPreparedPreviewDelivery<TReplyMarkup>;
258
255
  cwd?: string;
259
256
  recordRuntimeEvent?: TelegramVoiceReplySenderDeps["recordRuntimeEvent"];
260
257
  }
@@ -729,12 +726,13 @@ export function createTelegramOutboundTextPreviewRuntime<
729
726
  TReplyMarkup = unknown,
730
727
  >(
731
728
  deps: TelegramOutboundTextPreviewRuntimeDeps<TReplyMarkup>,
732
- ): Pick<
733
- TelegramOutboundTextPreviewRuntimeDeps<TReplyMarkup>,
734
- "finalizeMarkdownPreview"
735
- > {
736
- return {
737
- finalizeMarkdownPreview: async (
729
+ ): {
730
+ finalizeMarkdownPreview: TelegramPreparedPreviewDelivery<TReplyMarkup>["finalizeMarkdownPreview"];
731
+ preparePreviewDelivery: (isDeliveryActive: () => boolean) => TelegramPreparedPreviewDelivery<TReplyMarkup> | undefined;
732
+ } {
733
+ const wrap = (
734
+ finalize: TelegramPreparedPreviewDelivery<TReplyMarkup>["finalizeMarkdownPreview"],
735
+ ): TelegramPreparedPreviewDelivery<TReplyMarkup>["finalizeMarkdownPreview"] => async (
738
736
  chatId,
739
737
  markdown,
740
738
  replyToMessageId,
@@ -747,7 +745,7 @@ export function createTelegramOutboundTextPreviewRuntime<
747
745
  recordRuntimeEvent: deps.recordRuntimeEvent,
748
746
  replyMarkup: options?.replyMarkup,
749
747
  });
750
- return deps.finalizeMarkdownPreview(
748
+ return finalize(
751
749
  chatId,
752
750
  transformed.text,
753
751
  replyToMessageId,
@@ -758,6 +756,12 @@ export function createTelegramOutboundTextPreviewRuntime<
758
756
  : {}),
759
757
  },
760
758
  );
759
+ };
760
+ return {
761
+ finalizeMarkdownPreview: wrap(deps.finalizeMarkdownPreview),
762
+ preparePreviewDelivery(isDeliveryActive) {
763
+ const prepared = deps.preparePreviewDelivery?.(isDeliveryActive);
764
+ return prepared ? { ...prepared, finalizeMarkdownPreview: wrap(prepared.finalizeMarkdownPreview) } : undefined;
761
765
  },
762
766
  };
763
767
  }
@@ -1018,7 +1022,7 @@ export function createTelegramAssistantOutputSender<
1018
1022
  };
1019
1023
  await outboundRuntime.sendMarkdownReply(
1020
1024
  target.chatId,
1021
- undefined,
1025
+ event.source === "telegram" ? event.replyToMessageId : undefined,
1022
1026
  buttonReply.markdown,
1023
1027
  {
1024
1028
  target,
package/lib/preview.ts CHANGED
@@ -5,7 +5,8 @@
5
5
  */
6
6
 
7
7
  import { normalizeTelegramNativeMarkdown } from "./replies.ts";
8
- import { stripTelegramCommentMarkupForPreview } from "./outbound.ts";
8
+ import type { TelegramAssistantOutputPreparation } from "./activity.ts";
9
+ import { stripTelegramCommentMarkupForPreview } from "./outbound-markup.ts";
9
10
  import {
10
11
  getTelegramTargetThreadParams,
11
12
  type TelegramTarget,
@@ -14,6 +15,7 @@ import { shouldSuppressPreviewForVoice } from "./voice.ts";
14
15
 
15
16
  const TELEGRAM_DRAFT_ID_MAX = 2_147_483_647;
16
17
  const TELEGRAM_DRAFT_PREVIEW_MAX_CHARS = 4096;
18
+ const TELEGRAM_DRAFT_INTERVAL_MS = 2_000;
17
19
 
18
20
  export type TelegramDraftSupport = "unknown" | "supported";
19
21
 
@@ -27,6 +29,11 @@ export interface TelegramPreviewState {
27
29
  export interface TelegramPreviewRuntimeState extends TelegramPreviewState {
28
30
  flushPromise?: Promise<void>;
29
31
  flushRequested?: boolean;
32
+ precedingFlush?: Promise<void>;
33
+ publicationPromise?: Promise<void>;
34
+ sealed?: boolean;
35
+ nextDraftAt?: number;
36
+ flushTimer?: ReturnType<typeof setTimeout>;
30
37
  }
31
38
 
32
39
  export type TelegramPreviewReplyMarkup = unknown;
@@ -35,6 +42,7 @@ export interface TelegramPreviewRuntimeDeps {
35
42
  getState: () => TelegramPreviewRuntimeState | undefined;
36
43
  setState: (state: TelegramPreviewRuntimeState | undefined) => void;
37
44
  maxMessageLength: number;
45
+ minDraftIntervalMs?: number;
38
46
  getDraftSupport: () => TelegramDraftSupport;
39
47
  setDraftSupport: (support: TelegramDraftSupport) => void;
40
48
  allocateDraftId: () => number;
@@ -64,23 +72,13 @@ export interface TelegramPreviewActiveTurn {
64
72
  voiceReplyRequired?: boolean;
65
73
  }
66
74
 
67
- export interface TelegramAssistantMessagePreviewStartDeps<
68
- TMessage,
69
- TReplyMarkup = TelegramPreviewReplyMarkup,
70
- > {
75
+ export interface TelegramAssistantMessagePreviewStartDeps<TMessage> {
71
76
  getActiveTurn: () => TelegramPreviewActiveTurn | undefined;
72
77
  isAssistantMessage: (message: TMessage) => boolean;
73
78
  getState: () => TelegramPreviewRuntimeState | undefined;
74
79
  setState: (state: TelegramPreviewRuntimeState | undefined) => void;
75
80
  createPreviewState: () => TelegramPreviewRuntimeState;
76
81
  canSend?: () => boolean;
77
- finalizePreview: (chatId: number) => Promise<boolean>;
78
- finalizeMarkdownPreview: (
79
- chatId: number,
80
- markdown: string,
81
- replyToMessageId?: number,
82
- options?: { replyMarkup?: TReplyMarkup; target?: TelegramTarget },
83
- ) => Promise<boolean>;
84
82
  }
85
83
 
86
84
  export interface TelegramAssistantMessagePreviewUpdateDeps<TMessage> {
@@ -97,10 +95,7 @@ export interface TelegramAssistantMessagePreviewUpdateDeps<TMessage> {
97
95
  ) => void;
98
96
  }
99
97
 
100
- export type TelegramAssistantMessagePreviewHookDeps<
101
- TMessage,
102
- TReplyMarkup = TelegramPreviewReplyMarkup,
103
- > = TelegramAssistantMessagePreviewStartDeps<TMessage, TReplyMarkup> &
98
+ export type TelegramAssistantMessagePreviewHookDeps<TMessage> = TelegramAssistantMessagePreviewStartDeps<TMessage> &
104
99
  TelegramAssistantMessagePreviewUpdateDeps<TMessage>;
105
100
 
106
101
  export interface TelegramAssistantMessagePreviewHookEvent<TMessage> {
@@ -140,6 +135,8 @@ export interface TelegramPreviewControllerDeps {
140
135
  }
141
136
 
142
137
  export interface TelegramPreviewController {
138
+ seal: () => void;
139
+ preparePublication: () => TelegramAssistantOutputPreparation | undefined;
143
140
  prepareClear: (
144
141
  chatId: number,
145
142
  options?: { target?: TelegramTarget; isDeliveryActive?: () => boolean },
@@ -201,11 +198,18 @@ export interface TelegramAssistantPreviewRuntimeDeps<
201
198
  ) => Promise<number | undefined>;
202
199
  }
203
200
 
201
+ export interface TelegramPreparedPreviewDelivery<TReplyMarkup = unknown> {
202
+ clearPreview: TelegramPreviewController["clear"];
203
+ setPreviewPendingText: TelegramPreviewController["setPendingText"];
204
+ finalizeMarkdownPreview: ReturnType<typeof createTelegramNativeMarkdownPreviewFinalizer<TReplyMarkup>>;
205
+ }
206
+
204
207
  export type TelegramAssistantPreviewRuntime<
205
208
  TMessage,
206
209
  TReplyMarkup = TelegramPreviewReplyMarkup,
207
210
  > = TelegramPreviewController &
208
211
  TelegramAssistantMessagePreviewHooks<TMessage> & {
212
+ prepareDelivery: (isDeliveryActive: () => boolean) => TelegramPreparedPreviewDelivery<TReplyMarkup>;
209
213
  finalizeMarkdown: (
210
214
  chatId: number,
211
215
  markdown: string,
@@ -214,6 +218,14 @@ export type TelegramAssistantPreviewRuntime<
214
218
  ) => Promise<boolean>;
215
219
  };
216
220
 
221
+ function sealTelegramPreviewState(state: TelegramPreviewRuntimeState | undefined): void {
222
+ if (!state) return;
223
+ state.sealed = true;
224
+ state.flushRequested = false;
225
+ if (state.flushTimer) clearTimeout(state.flushTimer);
226
+ state.flushTimer = undefined;
227
+ }
228
+
217
229
  export function createTelegramNativeMarkdownPreviewFinalizer<
218
230
  TReplyMarkup,
219
231
  >(deps: {
@@ -223,6 +235,7 @@ export function createTelegramNativeMarkdownPreviewFinalizer<
223
235
  options?: { awaitFlush?: boolean; target?: TelegramTarget },
224
236
  ) => Promise<void>;
225
237
  discard?: () => void;
238
+ isDeliveryActive?: () => boolean;
226
239
  sendMarkdownReply: (
227
240
  chatId: number,
228
241
  replyToMessageId: number | undefined,
@@ -235,14 +248,21 @@ export function createTelegramNativeMarkdownPreviewFinalizer<
235
248
  replyToMessageId?: number,
236
249
  options?: { replyMarkup?: TReplyMarkup; target?: TelegramTarget },
237
250
  ) => Promise<boolean> {
251
+ return (...args) => prepareTelegramNativeMarkdownPreviewFinalizer(deps)(...args);
252
+ }
253
+
254
+ function prepareTelegramNativeMarkdownPreviewFinalizer<TReplyMarkup>(
255
+ deps: Parameters<typeof createTelegramNativeMarkdownPreviewFinalizer<TReplyMarkup>>[0],
256
+ ): ReturnType<typeof createTelegramNativeMarkdownPreviewFinalizer<TReplyMarkup>> {
257
+ const state = deps.getState();
258
+ sealTelegramPreviewState(state);
259
+ const inFlight = state?.flushPromise ?? state?.precedingFlush;
238
260
  return async (chatId, markdown, replyToMessageId, options) => {
239
- const state = deps.getState();
240
- if (state?.flushPromise) {
241
- await state.flushPromise.catch(() => {});
242
- if (deps.getState() !== state) return false;
243
- }
261
+ if (deps.isDeliveryActive?.() === false) return false;
262
+ await inFlight?.catch(() => {});
263
+ if (deps.getState() !== state || deps.isDeliveryActive?.() === false) return false;
244
264
  await deps.sendMarkdownReply(chatId, replyToMessageId, markdown, options);
245
- if (deps.getState() === state) deps.discard?.();
265
+ if (deps.getState() === state && deps.isDeliveryActive?.() !== false) deps.discard?.();
246
266
  return true;
247
267
  };
248
268
  }
@@ -254,15 +274,28 @@ export function createTelegramAssistantPreviewRuntime<
254
274
  deps: TelegramAssistantPreviewRuntimeDeps<TMessage, TReplyMarkup>,
255
275
  ): TelegramAssistantPreviewRuntime<TMessage, TReplyMarkup> {
256
276
  const controller = createTelegramPreviewControllerRuntime(deps);
257
- const finalizeMarkdownPreview = createTelegramNativeMarkdownPreviewFinalizer({
277
+ const finalizerDeps = {
258
278
  getState: controller.getState,
259
279
  clear: controller.clear,
260
280
  discard: () => controller.setState(undefined),
261
281
  sendMarkdownReply: deps.sendMarkdownReply,
262
- });
282
+ };
263
283
  return {
264
284
  ...controller,
265
- finalizeMarkdown: finalizeMarkdownPreview,
285
+ finalizeMarkdown: createTelegramNativeMarkdownPreviewFinalizer(finalizerDeps),
286
+ prepareDelivery(isDeliveryActive) {
287
+ const state = controller.getState();
288
+ return {
289
+ setPreviewPendingText(text) {
290
+ if (controller.getState() === state && isDeliveryActive()) controller.setPendingText(text);
291
+ },
292
+ clearPreview: async (chatId, options) => {
293
+ if (controller.getState() !== state || !isDeliveryActive()) return;
294
+ await controller.prepareClear(chatId, { ...options, isDeliveryActive })();
295
+ },
296
+ finalizeMarkdownPreview: prepareTelegramNativeMarkdownPreviewFinalizer({ ...finalizerDeps, isDeliveryActive }),
297
+ };
298
+ },
266
299
  ...createTelegramAssistantMessagePreviewHooks({
267
300
  getActiveTurn: deps.getActiveTurn,
268
301
  isAssistantMessage: deps.isAssistantMessage,
@@ -270,8 +303,6 @@ export function createTelegramAssistantPreviewRuntime<
270
303
  setState: controller.setState,
271
304
  createPreviewState: controller.createState,
272
305
  canSend: deps.canSend,
273
- finalizePreview: controller.finalize,
274
- finalizeMarkdownPreview,
275
306
  getMessageText: deps.getMessageText,
276
307
  schedulePreviewFlush: controller.scheduleFlush,
277
308
  }),
@@ -288,14 +319,17 @@ export function createTelegramPreviewController(
288
319
  deps.maxMessageLength ?? TELEGRAM_DRAFT_PREVIEW_MAX_CHARS;
289
320
  let draftSupport = deps.initialDraftSupport ?? "unknown";
290
321
  let nextDraftId = 0;
322
+ const setState = (nextState: TelegramPreviewRuntimeState | undefined): void => {
323
+ if (state !== nextState) sealTelegramPreviewState(state);
324
+ state = nextState;
325
+ };
291
326
  const getRuntimeDeps = (
292
327
  operationGeneration = generation,
293
328
  ): TelegramPreviewRuntimeDeps => ({
294
329
  getState: () => state,
295
- setState: (nextState) => {
296
- state = nextState;
297
- },
330
+ setState,
298
331
  maxMessageLength,
332
+ minDraftIntervalMs: TELEGRAM_DRAFT_INTERVAL_MS,
299
333
  getDraftSupport: () => draftSupport,
300
334
  setDraftSupport: (support) => {
301
335
  draftSupport = support;
@@ -311,20 +345,27 @@ export function createTelegramPreviewController(
311
345
  });
312
346
  return {
313
347
  getState: () => state,
314
- setState: (nextState) => {
315
- state = nextState;
316
- },
348
+ setState,
317
349
  setPendingText: (text) => {
318
350
  if (state) state.pendingText = text;
319
351
  },
320
352
  createState: () => createTelegramPreviewRuntimeState(),
321
353
  resetState: () => {
322
354
  generation += 1;
323
- state = createTelegramPreviewRuntimeState();
355
+ setState({ ...createTelegramPreviewRuntimeState(), nextDraftAt: state?.nextDraftAt });
324
356
  },
325
357
  invalidate: () => {
326
358
  generation += 1;
327
- state = undefined;
359
+ setState(undefined);
360
+ },
361
+ seal: () => sealTelegramPreviewState(state),
362
+ preparePublication: () => {
363
+ if (!state) return undefined;
364
+ sealTelegramPreviewState(state);
365
+ const prior = state.publicationPromise ?? state.flushPromise ?? state.precedingFlush;
366
+ let settle!: () => void;
367
+ state.publicationPromise = new Promise<void>((resolve) => { settle = resolve; });
368
+ return { wait: async () => { await prior?.catch(() => {}); }, settle };
328
369
  },
329
370
  prepareClear: (chatId, options) => {
330
371
  const admittedState = state;
@@ -350,11 +391,8 @@ export function createTelegramPreviewController(
350
391
  };
351
392
  }
352
393
 
353
- export function createTelegramAssistantMessagePreviewHooks<
354
- TMessage,
355
- TReplyMarkup = TelegramPreviewReplyMarkup,
356
- >(
357
- deps: TelegramAssistantMessagePreviewHookDeps<TMessage, TReplyMarkup>,
394
+ export function createTelegramAssistantMessagePreviewHooks<TMessage>(
395
+ deps: TelegramAssistantMessagePreviewHookDeps<TMessage>,
358
396
  ): TelegramAssistantMessagePreviewHooks<TMessage> {
359
397
  return {
360
398
  onMessageStart: async (
@@ -370,12 +408,9 @@ export function createTelegramAssistantMessagePreviewHooks<
370
408
  };
371
409
  }
372
410
 
373
- export async function handleTelegramAssistantMessagePreviewStart<
374
- TMessage,
375
- TReplyMarkup = TelegramPreviewReplyMarkup,
376
- >(
411
+ export async function handleTelegramAssistantMessagePreviewStart<TMessage>(
377
412
  message: TMessage,
378
- deps: TelegramAssistantMessagePreviewStartDeps<TMessage, TReplyMarkup>,
413
+ deps: TelegramAssistantMessagePreviewStartDeps<TMessage>,
379
414
  ): Promise<void> {
380
415
  const turn = deps.getActiveTurn();
381
416
  if (!turn || !deps.isAssistantMessage(message)) return;
@@ -388,26 +423,13 @@ export async function handleTelegramAssistantMessagePreviewStart<
388
423
  return;
389
424
  }
390
425
  const state = deps.getState();
391
- if (
392
- state &&
393
- (state.pendingText.trim().length > 0 ||
394
- state.lastSentText.trim().length > 0)
395
- ) {
396
- const previousText = state.pendingText.trim();
397
- if (previousText.length > 0) {
398
- await deps.finalizeMarkdownPreview(
399
- turn.chatId,
400
- previousText,
401
- turn.replyToMessageId,
402
- {
403
- target: turn.target,
404
- },
405
- );
406
- } else {
407
- await deps.finalizePreview(turn.chatId);
408
- }
409
- }
410
- deps.setState(deps.createPreviewState());
426
+ sealTelegramPreviewState(state);
427
+ const next = deps.createPreviewState();
428
+ // Carry the previous delivery boundary; permanent text remains with its publication owner.
429
+ next.draftId = state?.draftId;
430
+ next.nextDraftAt = state?.nextDraftAt;
431
+ next.precedingFlush = state?.publicationPromise ?? state?.flushPromise ?? state?.precedingFlush;
432
+ deps.setState(next);
411
433
  }
412
434
 
413
435
  export async function handleTelegramAssistantMessagePreviewUpdate<TMessage>(
@@ -426,6 +448,7 @@ export async function handleTelegramAssistantMessagePreviewUpdate<TMessage>(
426
448
  state = deps.createPreviewState();
427
449
  deps.setState(state);
428
450
  }
451
+ if (state.sealed) return;
429
452
  state.pendingText = stripTelegramCommentMarkupForPreview(
430
453
  deps.getMessageText(message),
431
454
  );
@@ -473,9 +496,11 @@ export async function clearTelegramPreview(
473
496
  ): Promise<void> {
474
497
  const state = deps.getState();
475
498
  if (!state || options.isDeliveryActive?.() === false) return;
476
- if (state.flushPromise && options.awaitFlush !== false) {
499
+ sealTelegramPreviewState(state);
500
+ const inFlight = state.flushPromise ?? state.precedingFlush;
501
+ if (inFlight && options.awaitFlush !== false) {
477
502
  state.flushRequested = false;
478
- await state.flushPromise.catch(() => {});
503
+ await inFlight.catch(() => {});
479
504
  if (deps.getState() !== state) return;
480
505
  }
481
506
  if (options.isDeliveryActive?.() === false) return;
@@ -782,13 +807,15 @@ async function performTelegramPreviewFlush(
782
807
  ) {
783
808
  const draftId = state.draftId ?? deps.allocateDraftId();
784
809
  state.draftId = draftId;
810
+ state.nextDraftAt = Date.now() + (deps.minDraftIntervalMs ?? 0);
785
811
  try {
786
- await deps.sendDraft(
812
+ const delivered = await deps.sendDraft(
787
813
  chatId,
788
814
  draftId,
789
815
  normalizeTelegramNativeMarkdown(snapshot.text),
790
816
  { ...getTelegramTargetThreadParams(options.target ?? { chatId }) },
791
817
  );
818
+ if (delivered === false || deps.getState() !== state || deps.canSend?.() === false) return;
792
819
  deps.setDraftSupport("supported");
793
820
  state.mode = "draft";
794
821
  state.lastSentText = snapshot.text;
@@ -810,15 +837,33 @@ export async function flushTelegramPreview(
810
837
  options: { target?: TelegramTarget } = {},
811
838
  ): Promise<void> {
812
839
  const state = deps.getState();
813
- if (!state) return;
840
+ if (!state || state.sealed) return;
814
841
  if (state.flushPromise) {
815
842
  state.flushRequested = true;
816
843
  await state.flushPromise;
817
844
  return;
818
845
  }
819
846
  state.flushPromise = (async () => {
847
+ if (state.precedingFlush) {
848
+ await state.precedingFlush.catch(() => {});
849
+ state.precedingFlush = undefined;
850
+ if (deps.getState() !== state || state.sealed) return;
851
+ }
820
852
  do {
821
853
  state.flushRequested = false;
854
+ const delay = (state.nextDraftAt ?? 0) - Date.now();
855
+ if (delay > 0) {
856
+ if (!state.flushTimer) {
857
+ state.flushTimer = setTimeout(() => {
858
+ state.flushTimer = undefined;
859
+ if (deps.getState() === state && !state.sealed) void flushTelegramPreview(chatId, deps, options);
860
+ }, delay);
861
+ state.flushTimer.unref?.();
862
+ }
863
+ break;
864
+ }
865
+ if (state.flushTimer) clearTimeout(state.flushTimer);
866
+ state.flushTimer = undefined;
822
867
  try {
823
868
  await performTelegramPreviewFlush(chatId, state, deps, options);
824
869
  } catch (error) {
@@ -829,7 +874,7 @@ export async function flushTelegramPreview(
829
874
  });
830
875
  break;
831
876
  }
832
- } while (deps.getState() === state && state.flushRequested);
877
+ } while (deps.getState() === state && !state.sealed && state.flushRequested);
833
878
  })();
834
879
  try {
835
880
  await state.flushPromise;
package/lib/queue.ts CHANGED
@@ -1498,6 +1498,10 @@ export interface TelegramAgentEndRuntimeDeps<
1498
1498
  updateStatus: () => void;
1499
1499
  dispatchNextQueuedTelegramTurn: () => void;
1500
1500
  scheduleActiveTurnDelivery?: (task: () => Promise<void>) => void;
1501
+ preparePreviewDelivery?: (isDeliveryActive: () => boolean) => Pick<
1502
+ TelegramAgentEndRuntimeDeps<TTurn, TReplyMarkup>,
1503
+ "clearPreview" | "setPreviewPendingText" | "finalizeMarkdownPreview"
1504
+ > | undefined;
1501
1505
  preparePreviewClear?: (
1502
1506
  chatId: number,
1503
1507
  options?: { target?: TelegramQueueTarget; isDeliveryActive?: () => boolean },
@@ -1595,6 +1599,7 @@ export interface TelegramAgentEndHookRuntimeDeps<
1595
1599
  schedule: (task: () => Promise<void>) => void;
1596
1600
  cancel: () => void;
1597
1601
  };
1602
+ preparePreviewDelivery?: TelegramAgentEndRuntimeDeps<TTurn, TReplyMarkup>["preparePreviewDelivery"];
1598
1603
  preparePreviewClear?: TelegramAgentEndRuntimeDeps<TTurn, TReplyMarkup>["preparePreviewClear"];
1599
1604
  clearPreview: TelegramAgentEndRuntimeDeps<
1600
1605
  TTurn,
@@ -1746,6 +1751,7 @@ export function createTelegramAgentEndHook<
1746
1751
  await task();
1747
1752
  })
1748
1753
  : undefined,
1754
+ preparePreviewDelivery: deps.preparePreviewDelivery,
1749
1755
  preparePreviewClear: deps.preparePreviewClear,
1750
1756
  clearPreview: deps.clearPreview,
1751
1757
  setPreviewPendingText: deps.setPreviewPendingText,
@@ -1807,9 +1813,13 @@ export async function handleTelegramAgentEndRuntime<
1807
1813
  const isDeliveryActive = (): boolean =>
1808
1814
  deps.isSessionActive?.() !== false &&
1809
1815
  (!turn || deps.isTurnTransportActive?.(turn) !== false);
1816
+ const preview = turn && !turn.guestQueryId ? deps.preparePreviewDelivery?.(isDeliveryActive) : undefined;
1817
+ const setPreviewPendingText = preview?.setPreviewPendingText ?? deps.setPreviewPendingText;
1818
+ const finalizeMarkdownPreview = preview?.finalizeMarkdownPreview ?? deps.finalizeMarkdownPreview;
1810
1819
  const clearPreview = turn
1811
- ? deps.preparePreviewClear?.(turn.chatId, { target: turn.target, isDeliveryActive })
1812
- ?? (() => deps.clearPreview(turn.chatId, { target: turn.target }))
1820
+ ? preview ? () => preview.clearPreview(turn.chatId, { target: turn.target })
1821
+ : deps.preparePreviewClear?.(turn.chatId, { target: turn.target, isDeliveryActive })
1822
+ ?? (() => deps.clearPreview(turn.chatId, { target: turn.target }))
1813
1823
  : undefined;
1814
1824
  const updateStatusIgnoringStaleContext = (): void => {
1815
1825
  try {
@@ -1914,7 +1924,7 @@ export async function handleTelegramAgentEndRuntime<
1914
1924
  if (endPlan.shouldDispatchNext) deps.dispatchNextQueuedTelegramTurn();
1915
1925
  return;
1916
1926
  }
1917
- if (finalText) deps.setPreviewPendingText(finalText);
1927
+ if (finalText) setPreviewPendingText(finalText);
1918
1928
 
1919
1929
  if (!isDeliveryActive()) return;
1920
1930
  let richAttachmentDelivered = false;
@@ -1932,9 +1942,9 @@ export async function handleTelegramAgentEndRuntime<
1932
1942
  );
1933
1943
  if (!isDeliveryActive()) return;
1934
1944
  if (richAttachmentDelivered) {
1935
- await deps.clearPreview(turn.chatId, { target: turn.target });
1945
+ await clearPreview?.();
1936
1946
  if (!isDeliveryActive()) return;
1937
- deps.setPreviewPendingText("");
1947
+ setPreviewPendingText("");
1938
1948
  }
1939
1949
  } catch (error) {
1940
1950
  if (!isDeliveryActive()) return;
@@ -1949,7 +1959,7 @@ export async function handleTelegramAgentEndRuntime<
1949
1959
  if (!isDeliveryActive()) return;
1950
1960
  if (!richAttachmentDelivered && endPlan.kind === "text" && finalText) {
1951
1961
  try {
1952
- const finalized = await deps.finalizeMarkdownPreview(
1962
+ const finalized = await finalizeMarkdownPreview(
1953
1963
  turn.chatId,
1954
1964
  finalText,
1955
1965
  turn.replyToMessageId,
@@ -1957,7 +1967,7 @@ export async function handleTelegramAgentEndRuntime<
1957
1967
  );
1958
1968
  if (!isDeliveryActive()) return;
1959
1969
  if (!finalized) {
1960
- await deps.clearPreview(turn.chatId, { target: turn.target });
1970
+ await clearPreview?.();
1961
1971
  if (!isDeliveryActive()) return;
1962
1972
  await deps.sendMarkdownReply(
1963
1973
  turn.chatId,
@@ -1967,7 +1977,7 @@ export async function handleTelegramAgentEndRuntime<
1967
1977
  );
1968
1978
  }
1969
1979
  if (!isDeliveryActive()) return;
1970
- deps.setPreviewPendingText("");
1980
+ setPreviewPendingText("");
1971
1981
  } catch (error) {
1972
1982
  deps.recordRuntimeEvent?.("delivery", error, {
1973
1983
  phase: "final-text",
package/lib/replies.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  getTelegramTargetThreadParams,
10
10
  type TelegramTarget,
11
11
  } from "./target.ts";
12
+ import { isTelegramApiCommitUnknownError } from "./telegram-api.ts";
12
13
  import type {
13
14
  TelegramInputRichMessage,
14
15
  TelegramReplyParameters,
@@ -67,6 +68,7 @@ export function createReplyDedupRuntime(): ReplyDedupRuntime {
67
68
  // --- Transport-level dedup ---
68
69
 
69
70
  const lastRepliedToMessageIdByTarget = new Map<string, number>();
71
+ let replyDedupGeneration = 0;
70
72
 
71
73
  function getReplyDedupTargetKey(
72
74
  chatId: number,
@@ -79,6 +81,7 @@ function getReplyDedupTargetKey(
79
81
  }
80
82
 
81
83
  export function resetTransportReplyDedup(): void {
84
+ replyDedupGeneration += 1;
82
85
  lastRepliedToMessageIdByTarget.clear();
83
86
  }
84
87
 
@@ -99,13 +102,29 @@ export function buildTelegramReplyParameters(
99
102
  };
100
103
  }
101
104
 
102
- export function buildTelegramMultipartReplyParameters(
105
+ // Answer publications are caller-serialized. A rejected send releases its anchor;
106
+ // an uncertain ACK retains it because Telegram may already have delivered it.
107
+ export async function withTelegramReplyParameters<T>(
103
108
  chatId: number,
104
109
  messageId: number | undefined,
105
- target?: TelegramTarget,
106
- ): string | undefined {
110
+ target: TelegramTarget | undefined,
111
+ send: (parameters: TelegramReplyParameters | undefined) => Promise<T>,
112
+ ): Promise<T> {
113
+ const key = getReplyDedupTargetKey(chatId, target);
114
+ const generation = replyDedupGeneration;
115
+ const previous = lastRepliedToMessageIdByTarget.get(key);
107
116
  const parameters = buildTelegramReplyParameters(chatId, messageId, target);
108
- return parameters ? JSON.stringify(parameters) : undefined;
117
+ try {
118
+ return await send(parameters);
119
+ } catch (error) {
120
+ if (parameters && !isTelegramApiCommitUnknownError(error)
121
+ && generation === replyDedupGeneration
122
+ && lastRepliedToMessageIdByTarget.get(key) === messageId) {
123
+ if (previous === undefined) lastRepliedToMessageIdByTarget.delete(key);
124
+ else lastRepliedToMessageIdByTarget.set(key, previous);
125
+ }
126
+ throw error;
127
+ }
109
128
  }
110
129
 
111
130
  function getAgentMessageField(message: unknown, field: string): unknown {
@@ -239,24 +258,18 @@ export async function sendTelegramRenderedChunks<TReplyMarkup>(
239
258
  assertTelegramInlineKeyboardCallbackData(options?.replyMarkup);
240
259
  let lastMessageId: number | undefined;
241
260
  for (const [index, chunk] of chunks.entries()) {
242
- const replyParameters =
243
- index === 0
244
- ? buildTelegramReplyParameters(
245
- chatId,
246
- options?.replyToMessageId,
247
- options?.target,
248
- )
249
- : undefined;
250
- const body = {
251
- chat_id: chatId,
252
- text: chunk.text,
253
- parse_mode: chunk.parseMode,
254
- reply_markup:
255
- index === chunks.length - 1 ? options?.replyMarkup : undefined,
256
- ...(replyParameters ? { reply_parameters: replyParameters } : {}),
257
- ...(options?.target ? getTelegramTargetThreadParams(options.target) : {}),
258
- };
259
- const sent = await deps.sendMessage(body);
261
+ const sent = await withTelegramReplyParameters(
262
+ chatId, index === 0 ? options?.replyToMessageId : undefined, options?.target,
263
+ (replyParameters) => deps.sendMessage({
264
+ chat_id: chatId,
265
+ text: chunk.text,
266
+ parse_mode: chunk.parseMode,
267
+ reply_markup:
268
+ index === chunks.length - 1 ? options?.replyMarkup : undefined,
269
+ ...(replyParameters ? { reply_parameters: replyParameters } : {}),
270
+ ...(options?.target ? getTelegramTargetThreadParams(options.target) : {}),
271
+ }),
272
+ );
260
273
  lastMessageId = sent.message_id;
261
274
  deps.recordOwnership?.({
262
275
  chatId,
@@ -655,22 +668,17 @@ export async function sendTelegramNativeMarkdownReply<TReplyMarkup = unknown>(
655
668
  let lastMessageId: number | undefined;
656
669
  const chunks = splitTelegramNativeMarkdown(markdown);
657
670
  for (const [index, chunk] of chunks.entries()) {
658
- const replyParameters =
659
- index === 0
660
- ? buildTelegramReplyParameters(
661
- chatId,
662
- replyToMessageId,
663
- options?.target,
664
- )
665
- : undefined;
666
- const sent = await deps.sendRichMessage({
667
- chat_id: chatId,
668
- rich_message: { markdown: chunk, skip_entity_detection: true },
669
- reply_markup:
670
- index === chunks.length - 1 ? options?.replyMarkup : undefined,
671
- ...(replyParameters ? { reply_parameters: replyParameters } : {}),
672
- ...(options?.target ? getTelegramTargetThreadParams(options.target) : {}),
673
- });
671
+ const sent = await withTelegramReplyParameters(
672
+ chatId, index === 0 ? replyToMessageId : undefined, options?.target,
673
+ (replyParameters) => deps.sendRichMessage({
674
+ chat_id: chatId,
675
+ rich_message: { markdown: chunk, skip_entity_detection: true },
676
+ reply_markup:
677
+ index === chunks.length - 1 ? options?.replyMarkup : undefined,
678
+ ...(replyParameters ? { reply_parameters: replyParameters } : {}),
679
+ ...(options?.target ? getTelegramTargetThreadParams(options.target) : {}),
680
+ }),
681
+ );
674
682
  lastMessageId = sent.message_id;
675
683
  deps.recordOwnership?.({
676
684
  chatId,