@llblab/pi-telegram 0.17.5 → 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 (61) hide show
  1. package/AGENTS.md +67 -32
  2. package/BACKLOG.md +59 -19
  3. package/CHANGELOG.md +36 -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/docs/telegram-bot-api-rich-messages.md +0 -890
package/lib/replies.ts CHANGED
@@ -5,6 +5,10 @@
5
5
  */
6
6
 
7
7
  import { assertTelegramInlineKeyboardCallbackData } from "./keyboard.ts";
8
+ import {
9
+ getTelegramTargetThreadParams,
10
+ type TelegramTarget,
11
+ } from "./target.ts";
8
12
  import type {
9
13
  TelegramInputRichMessage,
10
14
  TelegramReplyParameters,
@@ -29,7 +33,7 @@ export const TELEGRAM_RICH_MESSAGE_MAX_BLOCKS = 500;
29
33
  // --- Reply Dedup ---
30
34
 
31
35
  /** Non-persistent reply deduplication for a single agent turn.
32
- * First reply to a prompt gets `reply_parameters.reply_to_message_id`;
36
+ * First reply to a prompt gets `reply_parameters.message_id`;
33
37
  * subsequent replies in the same turn skip it to avoid stacking
34
38
  * duplicate reply headers in the chat viewport. */
35
39
  export interface ReplyDedupRuntime {
@@ -56,29 +60,45 @@ export function createReplyDedupRuntime(): ReplyDedupRuntime {
56
60
 
57
61
  // --- Transport-level dedup ---
58
62
 
59
- const lastRepliedToMessageIdByChat = new Map<number, number>();
63
+ const lastRepliedToMessageIdByTarget = new Map<string, number>();
64
+
65
+ function getReplyDedupTargetKey(
66
+ chatId: number,
67
+ target?: TelegramTarget,
68
+ ): string {
69
+ const threadId = target?.threadId;
70
+ return typeof threadId === "number"
71
+ ? `${chatId}:thread:${threadId}`
72
+ : `${chatId}:private`;
73
+ }
60
74
 
61
75
  export function resetTransportReplyDedup(): void {
62
- lastRepliedToMessageIdByChat.clear();
76
+ lastRepliedToMessageIdByTarget.clear();
63
77
  }
64
78
 
65
79
  export function buildTelegramReplyParameters(
66
80
  chatId: number,
67
81
  messageId: number | undefined,
82
+ target?: TelegramTarget,
68
83
  ): TelegramReplyParameters | undefined {
69
- if (messageId === undefined) return undefined;
70
- if (lastRepliedToMessageIdByChat.get(chatId) === messageId) {
84
+ if (messageId === undefined || messageId <= 0) return undefined;
85
+ const key = getReplyDedupTargetKey(chatId, target);
86
+ if (lastRepliedToMessageIdByTarget.get(key) === messageId) {
71
87
  return undefined;
72
88
  }
73
- lastRepliedToMessageIdByChat.set(chatId, messageId);
74
- return { message_id: messageId, allow_sending_without_reply: true };
89
+ lastRepliedToMessageIdByTarget.set(key, messageId);
90
+ return {
91
+ message_id: messageId,
92
+ allow_sending_without_reply: true,
93
+ };
75
94
  }
76
95
 
77
96
  export function buildTelegramMultipartReplyParameters(
78
97
  chatId: number,
79
98
  messageId: number | undefined,
99
+ target?: TelegramTarget,
80
100
  ): string | undefined {
81
- const parameters = buildTelegramReplyParameters(chatId, messageId);
101
+ const parameters = buildTelegramReplyParameters(chatId, messageId, target);
82
102
  return parameters ? JSON.stringify(parameters) : undefined;
83
103
  }
84
104
 
@@ -132,13 +152,24 @@ export function extractLatestAssistantMessageText(
132
152
  return {};
133
153
  }
134
154
 
155
+ export interface TelegramReplyOwnershipRecorder {
156
+ record: (input: {
157
+ chatId: number;
158
+ messageId: number;
159
+ target?: TelegramTarget;
160
+ }) => void;
161
+ }
162
+
135
163
  export interface TelegramReplyDeliveryDeps<TReplyMarkup> {
164
+ recordOwnership?: TelegramReplyOwnershipRecorder["record"];
136
165
  sendMessage: (body: {
137
166
  chat_id: number;
138
167
  text: string;
139
168
  parse_mode?: "HTML";
140
169
  reply_markup?: TReplyMarkup;
141
170
  reply_parameters?: TelegramReplyParameters;
171
+ reply_to_message_id?: number;
172
+ message_thread_id?: number;
142
173
  }) => Promise<TelegramSentMessage>;
143
174
  editMessage: (body: {
144
175
  chat_id: number;
@@ -147,20 +178,28 @@ export interface TelegramReplyDeliveryDeps<TReplyMarkup> {
147
178
  rich_message?: TelegramInputRichMessage;
148
179
  parse_mode?: "HTML";
149
180
  reply_markup?: TReplyMarkup;
181
+ message_thread_id?: number;
150
182
  }) => Promise<unknown>;
151
183
  }
152
184
 
185
+ export interface TelegramReplyTargetOptions {
186
+ target?: TelegramTarget;
187
+ replyToMessageId?: number;
188
+ }
189
+
153
190
  export interface TelegramReplyTransport<TReplyMarkup> {
154
191
  sendRenderedChunks: (
155
192
  chatId: number,
156
193
  chunks: TelegramRenderedChunk[],
157
- options?: { replyMarkup?: TReplyMarkup; replyToMessageId?: number },
194
+ options?: TelegramReplyTargetOptions & {
195
+ replyMarkup?: TReplyMarkup;
196
+ },
158
197
  ) => Promise<number | undefined>;
159
198
  editRenderedMessage: (
160
199
  chatId: number,
161
200
  messageId: number,
162
201
  chunks: TelegramRenderedChunk[],
163
- options?: { replyMarkup?: TReplyMarkup },
202
+ options?: TelegramReplyTargetOptions & { replyMarkup?: TReplyMarkup },
164
203
  ) => Promise<number | undefined>;
165
204
  }
166
205
 
@@ -187,24 +226,37 @@ export async function sendTelegramRenderedChunks<TReplyMarkup>(
187
226
  chatId: number,
188
227
  chunks: TelegramRenderedChunk[],
189
228
  deps: TelegramReplyDeliveryDeps<TReplyMarkup>,
190
- options?: { replyMarkup?: TReplyMarkup; replyToMessageId?: number },
229
+ options?: TelegramReplyTargetOptions & {
230
+ replyMarkup?: TReplyMarkup;
231
+ },
191
232
  ): Promise<number | undefined> {
192
233
  assertTelegramInlineKeyboardCallbackData(options?.replyMarkup);
193
234
  let lastMessageId: number | undefined;
194
235
  for (const [index, chunk] of chunks.entries()) {
195
236
  const replyParameters =
196
237
  index === 0
197
- ? buildTelegramReplyParameters(chatId, options?.replyToMessageId)
238
+ ? buildTelegramReplyParameters(
239
+ chatId,
240
+ options?.replyToMessageId,
241
+ options?.target,
242
+ )
198
243
  : undefined;
199
- const sent = await deps.sendMessage({
244
+ const body = {
200
245
  chat_id: chatId,
201
246
  text: chunk.text,
202
247
  parse_mode: chunk.parseMode,
203
248
  reply_markup:
204
249
  index === chunks.length - 1 ? options?.replyMarkup : undefined,
205
250
  ...(replyParameters ? { reply_parameters: replyParameters } : {}),
206
- });
251
+ ...(options?.target ? getTelegramTargetThreadParams(options.target) : {}),
252
+ };
253
+ const sent = await deps.sendMessage(body);
207
254
  lastMessageId = sent.message_id;
255
+ deps.recordOwnership?.({
256
+ chatId,
257
+ messageId: sent.message_id,
258
+ target: options?.target,
259
+ });
208
260
  }
209
261
  return lastMessageId;
210
262
  }
@@ -214,11 +266,12 @@ export async function editTelegramRenderedMessage<TReplyMarkup>(
214
266
  messageId: number,
215
267
  chunks: TelegramRenderedChunk[],
216
268
  deps: TelegramReplyDeliveryDeps<TReplyMarkup>,
217
- options?: { replyMarkup?: TReplyMarkup },
269
+ options?: TelegramReplyTargetOptions & { replyMarkup?: TReplyMarkup },
218
270
  ): Promise<number | undefined> {
219
271
  assertTelegramInlineKeyboardCallbackData(options?.replyMarkup);
220
272
  if (chunks.length === 0) return messageId;
221
273
  const [firstChunk, ...remainingChunks] = chunks;
274
+ deps.recordOwnership?.({ chatId, messageId, target: options?.target });
222
275
  await deps.editMessage({
223
276
  chat_id: chatId,
224
277
  message_id: messageId,
@@ -226,15 +279,21 @@ export async function editTelegramRenderedMessage<TReplyMarkup>(
226
279
  parse_mode: firstChunk.parseMode,
227
280
  reply_markup:
228
281
  remainingChunks.length === 0 ? options?.replyMarkup : undefined,
282
+ ...(options?.target ? getTelegramTargetThreadParams(options.target) : {}),
229
283
  });
230
284
  if (remainingChunks.length > 0) {
231
285
  return sendTelegramRenderedChunks(chatId, remainingChunks, deps, {
232
286
  replyMarkup: options?.replyMarkup,
287
+ target: options?.target,
233
288
  });
234
289
  }
235
290
  return messageId;
236
291
  }
237
292
 
293
+ export interface TelegramTextReplyOptions extends TelegramReplyTargetOptions {
294
+ parseMode?: "HTML";
295
+ }
296
+
238
297
  export interface TelegramReplyRuntimeDeps<TReplyMarkup = unknown> {
239
298
  renderTelegramMessage: (
240
299
  text: string,
@@ -242,19 +301,22 @@ export interface TelegramReplyRuntimeDeps<TReplyMarkup = unknown> {
242
301
  ) => TelegramRenderedChunk[];
243
302
  sendRenderedChunks: (
244
303
  chunks: TelegramRenderedChunk[],
245
- options?: { replyMarkup?: TReplyMarkup },
304
+ options?: { replyMarkup?: TReplyMarkup } & TelegramReplyTargetOptions,
246
305
  ) => Promise<number | undefined>;
247
306
  }
248
307
 
249
308
  export async function sendTelegramPlainReply(
250
309
  text: string,
251
310
  deps: TelegramReplyRuntimeDeps,
252
- options?: { parseMode?: "HTML" },
311
+ options?: TelegramTextReplyOptions,
253
312
  ): Promise<number | undefined> {
254
313
  const chunks = deps.renderTelegramMessage(text, {
255
314
  mode: options?.parseMode === "HTML" ? "html" : "plain",
256
315
  });
257
- return deps.sendRenderedChunks(chunks);
316
+ return deps.sendRenderedChunks(chunks, {
317
+ target: options?.target,
318
+ replyToMessageId: options?.replyToMessageId,
319
+ });
258
320
  }
259
321
 
260
322
  function normalizeIndentedTelegramNativeMarkdownList(line: string): string {
@@ -283,10 +345,16 @@ function normalizeTelegramNativeMarkdownLine(line: string): string {
283
345
  /(^|[^\\$])\$([A-Z][A-Z0-9]{1,})(?!\$)(?=\b|[.,;:)/-])/g,
284
346
  (_match, prefix: string, ticker: string) => `${prefix}\\$${ticker}`,
285
347
  );
286
- return result.replace(/\u0000(\d+)\u0000/g, (_match, index) => codeSpans[Number(index)] ?? "");
348
+ return result.replace(
349
+ /\u0000(\d+)\u0000/g,
350
+ (_match, index) => codeSpans[Number(index)] ?? "",
351
+ );
287
352
  }
288
353
 
289
- function hasClosingDisplayMathDelimiter(lines: readonly string[], startIndex: number): boolean {
354
+ function hasClosingDisplayMathDelimiter(
355
+ lines: readonly string[],
356
+ startIndex: number,
357
+ ): boolean {
290
358
  let fence: { marker: "`" | "~"; length: number } | undefined;
291
359
  for (let index = startIndex + 1; index < lines.length; index += 1) {
292
360
  const line = lines[index] ?? "";
@@ -328,7 +396,10 @@ export function normalizeTelegramNativeMarkdown(markdown: string): string {
328
396
  if (displayMath) return line;
329
397
  if (!inFence && fenceMatch) {
330
398
  const markerText = fenceMatch[1] ?? "```";
331
- fence = { marker: markerText[0] as "`" | "~", length: markerText.length };
399
+ fence = {
400
+ marker: markerText[0] as "`" | "~",
401
+ length: markerText.length,
402
+ };
332
403
  return line;
333
404
  }
334
405
  if (
@@ -348,19 +419,23 @@ export function splitTelegramNativeMarkdown(markdown: string): string[] {
348
419
  const normalizedMarkdown = normalizeTelegramNativeMarkdown(markdown);
349
420
  if (
350
421
  normalizedMarkdown.length <= TELEGRAM_RICH_MESSAGE_MAX_CHARS &&
351
- countTelegramNativeMarkdownBlocks(normalizedMarkdown) <= TELEGRAM_RICH_MESSAGE_MAX_BLOCKS
422
+ countTelegramNativeMarkdownBlocks(normalizedMarkdown) <=
423
+ TELEGRAM_RICH_MESSAGE_MAX_BLOCKS
352
424
  ) {
353
425
  return [normalizedMarkdown];
354
426
  }
355
427
  const chunks: string[] = [];
356
428
  let current = "";
357
429
  let currentBlockCount = 0;
358
- for (const rawBlock of splitTelegramNativeMarkdownBlocks(normalizedMarkdown)) {
430
+ for (const rawBlock of splitTelegramNativeMarkdownBlocks(
431
+ normalizedMarkdown,
432
+ )) {
359
433
  for (const block of splitTelegramNativeMarkdownCountedBlocks(rawBlock)) {
360
434
  const blockCount = countTelegramNativeMarkdownBlocks(block);
361
435
  const candidate = current ? `${current}\n\n${block}` : block;
362
436
  const exceedsChars = candidate.length > TELEGRAM_RICH_MESSAGE_MAX_CHARS;
363
- const exceedsBlocks = currentBlockCount + blockCount > TELEGRAM_RICH_MESSAGE_MAX_BLOCKS;
437
+ const exceedsBlocks =
438
+ currentBlockCount + blockCount > TELEGRAM_RICH_MESSAGE_MAX_BLOCKS;
364
439
  if (!exceedsChars && !exceedsBlocks) {
365
440
  current = candidate;
366
441
  currentBlockCount += blockCount;
@@ -417,7 +492,9 @@ function splitTelegramNativeMarkdownBlocks(markdown: string): string[] {
417
492
  }
418
493
 
419
494
  function splitTelegramNativeMarkdownCountedBlocks(block: string): string[] {
420
- if (countTelegramNativeMarkdownBlocks(block) <= TELEGRAM_RICH_MESSAGE_MAX_BLOCKS) {
495
+ if (
496
+ countTelegramNativeMarkdownBlocks(block) <= TELEGRAM_RICH_MESSAGE_MAX_BLOCKS
497
+ ) {
421
498
  return [block];
422
499
  }
423
500
  const chunks: string[] = [];
@@ -456,9 +533,11 @@ function countTelegramNativeMarkdownBlocks(block: string): number {
456
533
  }
457
534
 
458
535
  function splitTelegramNativeMarkdownLongBlock(block: string): string[] {
459
- return splitTelegramNativeMarkdownLongFenceBlock(block) ??
536
+ return (
537
+ splitTelegramNativeMarkdownLongFenceBlock(block) ??
460
538
  splitTelegramNativeMarkdownLongWrappedInlineBlock(block) ??
461
- splitTelegramNativeMarkdownLongPlainBlock(block);
539
+ splitTelegramNativeMarkdownLongPlainBlock(block)
540
+ );
462
541
  }
463
542
 
464
543
  function splitTelegramNativeMarkdownLongPlainBlock(block: string): string[] {
@@ -474,7 +553,9 @@ function splitTelegramNativeMarkdownLongPlainBlock(block: string): string[] {
474
553
  return chunks;
475
554
  }
476
555
 
477
- function splitTelegramNativeMarkdownLongFenceBlock(block: string): string[] | undefined {
556
+ function splitTelegramNativeMarkdownLongFenceBlock(
557
+ block: string,
558
+ ): string[] | undefined {
478
559
  const lines = block.split("\n");
479
560
  const opening = lines[0] ?? "";
480
561
  const closing = lines[lines.length - 1] ?? "";
@@ -482,33 +563,35 @@ function splitTelegramNativeMarkdownLongFenceBlock(block: string): string[] | un
482
563
  if (!openingMatch || !closing || lines.length < 2) return undefined;
483
564
  const markerText = openingMatch[1] ?? "```";
484
565
  const marker = markerText[0] as "`" | "~";
485
- if (!new RegExp(`^ {0,3}${marker}{${markerText.length},}\\s*$`).test(closing)) {
566
+ if (
567
+ !new RegExp(`^ {0,3}${marker}{${markerText.length},}\\s*$`).test(closing)
568
+ ) {
486
569
  return undefined;
487
570
  }
488
- const maxContentLength = TELEGRAM_RICH_MESSAGE_MAX_CHARS -
489
- opening.length -
490
- closing.length -
491
- 2;
571
+ const maxContentLength =
572
+ TELEGRAM_RICH_MESSAGE_MAX_CHARS - opening.length - closing.length - 2;
492
573
  if (maxContentLength <= 0) return undefined;
493
574
  const content = lines.slice(1, -1).join("\n");
494
575
  return splitTelegramNativeMarkdownWrappedContent(
495
576
  content,
496
577
  maxContentLength,
497
- (chunk) => `${opening}\n${chunk}${chunk.endsWith("\n") ? "" : "\n"}${closing}`,
578
+ (chunk) =>
579
+ `${opening}\n${chunk}${chunk.endsWith("\n") ? "" : "\n"}${closing}`,
498
580
  );
499
581
  }
500
582
 
501
583
  function splitTelegramNativeMarkdownLongWrappedInlineBlock(
502
584
  block: string,
503
585
  ): string[] | undefined {
504
- const delimiter = ["**", "__", "~~", "`", "*", "_"]
505
- .find((candidate) =>
586
+ const delimiter = ["**", "__", "~~", "`", "*", "_"].find(
587
+ (candidate) =>
506
588
  block.startsWith(candidate) &&
507
589
  block.endsWith(candidate) &&
508
- block.length > candidate.length * 2
509
- );
590
+ block.length > candidate.length * 2,
591
+ );
510
592
  if (!delimiter) return undefined;
511
- const maxContentLength = TELEGRAM_RICH_MESSAGE_MAX_CHARS - delimiter.length * 2;
593
+ const maxContentLength =
594
+ TELEGRAM_RICH_MESSAGE_MAX_CHARS - delimiter.length * 2;
512
595
  if (maxContentLength <= 0) return undefined;
513
596
  return splitTelegramNativeMarkdownWrappedContent(
514
597
  block.slice(delimiter.length, -delimiter.length),
@@ -526,7 +609,10 @@ function splitTelegramNativeMarkdownWrappedContent(
526
609
  let remaining = content;
527
610
  while (remaining.length > maxContentLength) {
528
611
  const window = remaining.slice(0, maxContentLength + 1);
529
- const splitIndex = findTelegramNativeMarkdownSplitIndex(window, maxContentLength);
612
+ const splitIndex = findTelegramNativeMarkdownSplitIndex(
613
+ window,
614
+ maxContentLength,
615
+ );
530
616
  chunks.push(wrap(remaining.slice(0, splitIndex)));
531
617
  remaining = remaining.slice(splitIndex);
532
618
  }
@@ -552,11 +638,12 @@ export async function sendTelegramNativeMarkdownReply<TReplyMarkup = unknown>(
552
638
  replyToMessageId: number | undefined,
553
639
  markdown: string,
554
640
  deps: {
641
+ recordOwnership?: TelegramReplyOwnershipRecorder["record"];
555
642
  sendRichMessage: (
556
643
  body: TelegramSendRichMessageBody,
557
644
  ) => Promise<TelegramSentMessage>;
558
645
  },
559
- options?: { replyMarkup?: TReplyMarkup },
646
+ options?: TelegramReplyTargetOptions & { replyMarkup?: TReplyMarkup },
560
647
  ): Promise<number | undefined> {
561
648
  assertTelegramInlineKeyboardCallbackData(options?.replyMarkup);
562
649
  let lastMessageId: number | undefined;
@@ -564,15 +651,26 @@ export async function sendTelegramNativeMarkdownReply<TReplyMarkup = unknown>(
564
651
  for (const [index, chunk] of chunks.entries()) {
565
652
  const replyParameters =
566
653
  index === 0
567
- ? buildTelegramReplyParameters(chatId, replyToMessageId)
654
+ ? buildTelegramReplyParameters(
655
+ chatId,
656
+ replyToMessageId,
657
+ options?.target,
658
+ )
568
659
  : undefined;
569
660
  const sent = await deps.sendRichMessage({
570
661
  chat_id: chatId,
571
662
  rich_message: { markdown: chunk, skip_entity_detection: true },
572
- reply_markup: index === chunks.length - 1 ? options?.replyMarkup : undefined,
663
+ reply_markup:
664
+ index === chunks.length - 1 ? options?.replyMarkup : undefined,
573
665
  ...(replyParameters ? { reply_parameters: replyParameters } : {}),
666
+ ...(options?.target ? getTelegramTargetThreadParams(options.target) : {}),
574
667
  });
575
668
  lastMessageId = sent.message_id;
669
+ deps.recordOwnership?.({
670
+ chatId,
671
+ messageId: sent.message_id,
672
+ target: options?.target,
673
+ });
576
674
  }
577
675
  return lastMessageId;
578
676
  }
@@ -586,7 +684,10 @@ export interface TelegramRenderedMessageRuntimeDeps<TReplyMarkup> {
586
684
  options?: { mode?: TelegramRenderMode },
587
685
  ) => TelegramRenderedChunk[];
588
686
  replyTransport: TelegramReplyTransport<TReplyMarkup>;
589
- sendRichMessage: (body: TelegramSendRichMessageBody) => Promise<TelegramSentMessage>;
687
+ recordOwnership?: TelegramReplyOwnershipRecorder["record"];
688
+ sendRichMessage: (
689
+ body: TelegramSendRichMessageBody,
690
+ ) => Promise<TelegramSentMessage>;
590
691
  }
591
692
 
592
693
  export interface TelegramRenderedMessageRuntime<TReplyMarkup> {
@@ -594,13 +695,13 @@ export interface TelegramRenderedMessageRuntime<TReplyMarkup> {
594
695
  chatId: number,
595
696
  replyToMessageId: number | undefined,
596
697
  text: string,
597
- options?: { parseMode?: "HTML" },
698
+ options?: TelegramTextReplyOptions,
598
699
  ) => Promise<number | undefined>;
599
700
  sendMarkdownReply: (
600
701
  chatId: number,
601
702
  replyToMessageId: number | undefined,
602
703
  markdown: string,
603
- options?: { replyMarkup?: unknown },
704
+ options?: TelegramReplyTargetOptions & { replyMarkup?: TReplyMarkup },
604
705
  ) => Promise<number | undefined>;
605
706
  editInteractiveMessage: (
606
707
  chatId: number,
@@ -614,6 +715,7 @@ export interface TelegramRenderedMessageRuntime<TReplyMarkup> {
614
715
  text: string,
615
716
  mode: TelegramRenderMode,
616
717
  replyMarkup: TReplyMarkup,
718
+ options?: TelegramReplyTargetOptions,
617
719
  ) => Promise<number | undefined>;
618
720
  }
619
721
 
@@ -630,13 +732,16 @@ export interface TelegramRenderedMessageDeliveryRuntimeDeps<
630
732
  text: string,
631
733
  options?: { mode?: TelegramRenderMode },
632
734
  ) => TelegramRenderedChunk[];
633
- sendRichMessage: (body: TelegramSendRichMessageBody) => Promise<TelegramSentMessage>;
735
+ sendRichMessage: (
736
+ body: TelegramSendRichMessageBody,
737
+ ) => Promise<TelegramSentMessage>;
634
738
  }
635
739
 
636
740
  export function createTelegramRenderedMessageDeliveryRuntime<TReplyMarkup>(
637
741
  deps: TelegramRenderedMessageDeliveryRuntimeDeps<TReplyMarkup>,
638
742
  ): TelegramRenderedMessageDeliveryRuntime<TReplyMarkup> {
639
743
  const replyTransport = buildTelegramReplyTransport({
744
+ recordOwnership: deps.recordOwnership,
640
745
  sendMessage: deps.sendMessage,
641
746
  editMessage: deps.editMessage,
642
747
  });
@@ -646,6 +751,7 @@ export function createTelegramRenderedMessageDeliveryRuntime<TReplyMarkup>(
646
751
  renderTelegramMessage:
647
752
  deps.renderTelegramMessage ?? renderTelegramMessage,
648
753
  replyTransport,
754
+ recordOwnership: deps.recordOwnership,
649
755
  sendRichMessage: deps.sendRichMessage,
650
756
  }),
651
757
  };
@@ -660,20 +766,36 @@ export function createTelegramRenderedMessageRuntime<TReplyMarkup>(
660
766
  text,
661
767
  {
662
768
  renderTelegramMessage: deps.renderTelegramMessage,
663
- sendRenderedChunks: (chunks) =>
769
+ sendRenderedChunks: (chunks, chunkOptions) =>
664
770
  deps.replyTransport.sendRenderedChunks(chatId, chunks, {
665
- replyToMessageId,
771
+ target: chunkOptions?.target,
772
+ replyToMessageId:
773
+ chunkOptions?.replyToMessageId ?? replyToMessageId,
666
774
  }),
667
775
  },
668
776
  options,
669
777
  );
670
778
  },
671
779
  sendMarkdownReply: async (chatId, replyToMessageId, markdown, options) => {
780
+ if (typeof options?.target?.threadId === "number" && replyToMessageId && replyToMessageId > 0) {
781
+ return deps.replyTransport.sendRenderedChunks(
782
+ chatId,
783
+ deps.renderTelegramMessage(markdown, { mode: "markdown" }),
784
+ {
785
+ replyMarkup: options.replyMarkup,
786
+ target: options.target,
787
+ replyToMessageId,
788
+ },
789
+ );
790
+ }
672
791
  return sendTelegramNativeMarkdownReply(
673
792
  chatId,
674
793
  replyToMessageId,
675
794
  markdown,
676
- { sendRichMessage: deps.sendRichMessage },
795
+ {
796
+ recordOwnership: deps.recordOwnership,
797
+ sendRichMessage: deps.sendRichMessage,
798
+ },
677
799
  options,
678
800
  );
679
801
  },
@@ -691,11 +813,21 @@ export function createTelegramRenderedMessageRuntime<TReplyMarkup>(
691
813
  { replyMarkup },
692
814
  );
693
815
  },
694
- sendInteractiveMessage: async (chatId, text, mode, replyMarkup) => {
816
+ sendInteractiveMessage: async (
817
+ chatId,
818
+ text,
819
+ mode,
820
+ replyMarkup,
821
+ options,
822
+ ) => {
695
823
  return deps.replyTransport.sendRenderedChunks(
696
824
  chatId,
697
825
  deps.renderTelegramMessage(text, { mode }),
698
- { replyMarkup },
826
+ {
827
+ replyMarkup,
828
+ target: options?.target,
829
+ replyToMessageId: options?.replyToMessageId,
830
+ },
699
831
  );
700
832
  },
701
833
  };
@@ -704,20 +836,20 @@ export function createTelegramRenderedMessageRuntime<TReplyMarkup>(
704
836
  // --- Dedup-wrapped Reply Wrappers ---
705
837
 
706
838
  /** Wrap a sendTextReply with reply dedup so only the first message
707
- * in a turn carries `reply_to_message_id`. */
839
+ * in a turn carries reply metadata. */
708
840
  export function dedupSendTextReply(
709
841
  dedup: ReplyDedupRuntime,
710
842
  inner: (
711
843
  chatId: number,
712
844
  replyToMessageId: number | undefined,
713
845
  text: string,
714
- options?: { parseMode?: "HTML" },
846
+ options?: TelegramTextReplyOptions,
715
847
  ) => Promise<number | undefined>,
716
848
  ): (
717
849
  chatId: number,
718
850
  replyToMessageId: number,
719
851
  text: string,
720
- options?: { parseMode?: "HTML" },
852
+ options?: TelegramTextReplyOptions,
721
853
  ) => Promise<number | undefined> {
722
854
  return async (chatId, replyToMessageId, text, options) => {
723
855
  const effectiveReplyTo = dedup.shouldReply(replyToMessageId)