@llblab/pi-telegram 0.17.4 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/AGENTS.md +67 -32
  2. package/BACKLOG.md +59 -14
  3. package/CHANGELOG.md +40 -15
  4. package/README.md +63 -35
  5. package/docs/README.md +3 -1
  6. package/docs/architecture.md +55 -23
  7. package/docs/callback-namespaces.md +1 -1
  8. package/docs/inbound.md +1 -1
  9. package/docs/locks.md +0 -2
  10. package/docs/multi-instance-bus.md +483 -0
  11. package/docs/outbound.md +4 -3
  12. package/docs/public-api.md +12 -10
  13. package/docs/sections.md +2 -2
  14. package/docs/ui-style.md +76 -0
  15. package/index.ts +789 -32
  16. package/lib/bindings.ts +68 -12
  17. package/lib/bus-api.ts +314 -0
  18. package/lib/bus-follower.ts +853 -0
  19. package/lib/bus-leader.ts +915 -0
  20. package/lib/bus.ts +866 -0
  21. package/lib/command-templates.ts +9 -11
  22. package/lib/commands.ts +133 -47
  23. package/lib/config.ts +53 -5
  24. package/lib/lifecycle.ts +23 -7
  25. package/lib/locks.ts +230 -66
  26. package/lib/media.ts +30 -2
  27. package/lib/menu-model.ts +48 -17
  28. package/lib/menu-queue.ts +51 -20
  29. package/lib/menu-settings.ts +9 -5
  30. package/lib/menu-status.ts +3 -0
  31. package/lib/menu-thinking.ts +3 -0
  32. package/lib/menu.ts +67 -26
  33. package/lib/outbound-attachments.ts +102 -17
  34. package/lib/outbound-buttons.ts +6 -2
  35. package/lib/outbound-voice.ts +31 -11
  36. package/lib/outbound.ts +6 -4
  37. package/lib/ownership.ts +119 -0
  38. package/lib/pi.ts +26 -3
  39. package/lib/polling.ts +477 -7
  40. package/lib/preview.ts +141 -88
  41. package/lib/prompt-templates.ts +3 -3
  42. package/lib/prompts.ts +80 -30
  43. package/lib/queue.ts +193 -91
  44. package/lib/rendering.ts +0 -25
  45. package/lib/replies.ts +187 -55
  46. package/lib/routing.ts +1673 -9
  47. package/lib/runtime-log.ts +123 -0
  48. package/lib/runtime.ts +84 -12
  49. package/lib/sections.ts +28 -21
  50. package/lib/setup.ts +1 -1
  51. package/lib/status.ts +532 -9
  52. package/lib/sync.ts +618 -0
  53. package/lib/target.ts +49 -0
  54. package/lib/telegram-api.ts +405 -40
  55. package/lib/text-groups.ts +5 -1
  56. package/lib/thread-reconciler.ts +915 -0
  57. package/lib/threads.ts +2205 -0
  58. package/lib/turns.ts +48 -3
  59. package/lib/updates.ts +355 -32
  60. package/package.json +24 -2
  61. package/screenshot.png +0 -0
  62. package/docs/telegram-bot-api-rich-messages.md +0 -890
package/lib/menu.ts CHANGED
@@ -4,13 +4,6 @@
4
4
  * Owns app-menu/status state, inline UI text, and callback composition while model/thinking/queue menu details live in dedicated domains
5
5
  */
6
6
 
7
- import {
8
- handleTelegramSectionCallback,
9
- handleTelegramSectionOpen,
10
- handleTelegramSectionSettingsOpen,
11
- parseTelegramSectionCallback,
12
- type TelegramSectionRegistry,
13
- } from "./sections.ts";
14
7
  import {
15
8
  createTelegramModelMenuStateBuilder,
16
9
  handleTelegramModelMenuCallbackAction,
@@ -39,6 +32,13 @@ import {
39
32
  type ScopedTelegramModel,
40
33
  type ThinkingLevel,
41
34
  } from "./model.ts";
35
+ import {
36
+ handleTelegramSectionCallback,
37
+ handleTelegramSectionOpen,
38
+ handleTelegramSectionSettingsOpen,
39
+ parseTelegramSectionCallback,
40
+ type TelegramSectionRegistry,
41
+ } from "./sections.ts";
42
42
 
43
43
  export {
44
44
  applyTelegramModelPageSelection,
@@ -150,7 +150,11 @@ export interface TelegramMenuCallbackEntryDeps {
150
150
  export interface MenuCallbackQuery {
151
151
  id: string;
152
152
  data?: string;
153
- message?: { message_id?: number };
153
+ message?: {
154
+ message_id?: number;
155
+ message_thread_id?: number;
156
+ chat?: { id?: number };
157
+ };
154
158
  }
155
159
 
156
160
  export interface StoredTelegramMenuCallbackDeps<
@@ -158,6 +162,7 @@ export interface StoredTelegramMenuCallbackDeps<
158
162
  > {
159
163
  getStoredModelMenuState: (
160
164
  messageId: number | undefined,
165
+ chatId?: number,
161
166
  ) => TelegramModelMenuState<TModel> | undefined;
162
167
  handleStatusAction: (
163
168
  state: TelegramModelMenuState<TModel>,
@@ -180,6 +185,7 @@ export interface TelegramMenuCallbackRuntimeDeps<
180
185
  > {
181
186
  getStoredModelMenuState: (
182
187
  messageId: number | undefined,
188
+ chatId?: number,
183
189
  ) => TelegramModelMenuState<TModel> | undefined;
184
190
  getActiveModel: (ctx: TContext) => TModel | undefined;
185
191
  getThinkingLevel: () => ThinkingLevel;
@@ -236,8 +242,13 @@ export interface TelegramMenuCallbackRuntimeDeps<
236
242
  text: string,
237
243
  mode: "markdown" | "html" | "plain",
238
244
  replyMarkup: TelegramReplyMarkup,
245
+ options?: { target?: { chatId: number; threadId?: number } },
239
246
  ) => Promise<number | undefined>;
240
- enqueueSectionPrompt?: (prompt: string, ctx: TContext) => Promise<void>;
247
+ enqueueSectionPrompt?: (
248
+ prompt: string,
249
+ ctx: TContext,
250
+ target?: { chatId: number; threadId?: number },
251
+ ) => Promise<void>;
241
252
  deleteMessage?: (chatId: number, messageId: number) => Promise<void>;
242
253
  isVoiceReplyActive?: () => boolean;
243
254
  }
@@ -249,6 +260,7 @@ export interface TelegramMenuActionRuntimeDeps<
249
260
  getModelMenuState: (
250
261
  chatId: number,
251
262
  ctx: TContext,
263
+ threadId?: number,
252
264
  ) => Promise<TelegramModelMenuState<TModel>>;
253
265
  getActiveModel: (ctx: TContext) => TModel | undefined;
254
266
  getThinkingLevel: () => ThinkingLevel;
@@ -261,6 +273,7 @@ export interface TelegramMenuActionRuntimeDeps<
261
273
  chatId: number,
262
274
  replyToMessageId: number,
263
275
  text: string,
276
+ options?: { target?: { chatId: number; threadId?: number } },
264
277
  ) => Promise<unknown>;
265
278
  sectionRegistry?: TelegramSectionRegistry;
266
279
  isVoiceReplyActive?: () => boolean;
@@ -286,11 +299,13 @@ export interface TelegramMenuActionRuntime<
286
299
  chatId: number,
287
300
  replyToMessageId: number,
288
301
  ctx: TContext,
302
+ threadId?: number,
289
303
  ) => Promise<void>;
290
304
  openModelMenu: (
291
305
  chatId: number,
292
306
  replyToMessageId: number,
293
307
  ctx: TContext,
308
+ threadId?: number,
294
309
  ) => Promise<void>;
295
310
  openThinkingMenu: (
296
311
  chatId: number,
@@ -392,7 +407,10 @@ export async function handleStoredTelegramMenuCallback<
392
407
  query: MenuCallbackQuery,
393
408
  deps: StoredTelegramMenuCallbackDeps<TModel>,
394
409
  ): Promise<void> {
395
- const state = deps.getStoredModelMenuState(query.message?.message_id);
410
+ const state = deps.getStoredModelMenuState(
411
+ query.message?.message_id,
412
+ query.message?.chat?.id,
413
+ );
396
414
  await handleTelegramMenuCallbackEntry(query.id, query.data, state, {
397
415
  handleStatusAction: async () => {
398
416
  if (!state) return false;
@@ -416,6 +434,7 @@ export interface TelegramMenuCallbackRuntimeAdapterDeps<
416
434
  > {
417
435
  getStoredModelMenuState: (
418
436
  messageId: number | undefined,
437
+ chatId?: number,
419
438
  ) => TelegramModelMenuState<TModel> | undefined;
420
439
  getActiveModel: (ctx: TContext) => TModel | undefined;
421
440
  getThinkingLevel: () => ThinkingLevel;
@@ -472,8 +491,13 @@ export interface TelegramMenuCallbackRuntimeAdapterDeps<
472
491
  text: string,
473
492
  mode: "markdown" | "html" | "plain",
474
493
  replyMarkup: TelegramReplyMarkup,
494
+ options?: { target?: { chatId: number; threadId?: number } },
475
495
  ) => Promise<number | undefined>;
476
- enqueueSectionPrompt?: (prompt: string, ctx: TContext) => Promise<void>;
496
+ enqueueSectionPrompt?: (
497
+ prompt: string,
498
+ ctx: TContext,
499
+ target?: { chatId: number; threadId?: number },
500
+ ) => Promise<void>;
477
501
  deleteMessage?: (chatId: number, messageId: number) => Promise<void>;
478
502
  }
479
503
 
@@ -532,7 +556,10 @@ export async function handleTelegramMenuCallbackRuntime<
532
556
  deps: TelegramMenuCallbackRuntimeDeps<TContext, TModel>,
533
557
  ): Promise<void> {
534
558
  if (query.data === "menu:back") {
535
- const state = deps.getStoredModelMenuState(query.message?.message_id);
559
+ const state = deps.getStoredModelMenuState(
560
+ query.message?.message_id,
561
+ query.message?.chat?.id,
562
+ );
536
563
  if (!state) {
537
564
  await deps.answerCallbackQuery(query.id, "Interactive message expired.");
538
565
  return;
@@ -545,14 +572,19 @@ export async function handleTelegramMenuCallbackRuntime<
545
572
  if (deps.sectionRegistry && query.data?.startsWith("section:")) {
546
573
  const parsed = parseTelegramSectionCallback(query.data);
547
574
  if (parsed) {
548
- const chatId = (query as { message?: { chat?: { id?: number } } }).message
549
- ?.chat?.id;
550
- const messageId = (query as { message?: { message_id?: number } }).message
551
- ?.message_id;
575
+ const message = query.message;
576
+ const chatId = message?.chat?.id;
577
+ const messageId = message?.message_id;
578
+ const target =
579
+ typeof chatId === "number"
580
+ ? typeof message?.message_thread_id === "number"
581
+ ? { chatId, threadId: message.message_thread_id }
582
+ : { chatId }
583
+ : undefined;
552
584
  if (typeof chatId === "number" && typeof messageId === "number") {
553
585
  const { token, action, payload } = parsed;
554
586
  if (action === "open") {
555
- const state = deps.getStoredModelMenuState(messageId);
587
+ const state = deps.getStoredModelMenuState(messageId, chatId);
556
588
  if (!state) {
557
589
  await deps.answerCallbackQuery(
558
590
  query.id,
@@ -568,12 +600,14 @@ export async function handleTelegramMenuCallbackRuntime<
568
600
  query.id,
569
601
  {
570
602
  answerCallbackQuery: deps.answerCallbackQuery,
603
+ target,
571
604
  editInteractiveMessage:
572
605
  deps.editInteractiveMessage ?? (async () => {}),
573
606
  sendInteractiveMessage:
574
607
  deps.sendInteractiveMessage ?? (async () => undefined),
575
608
  enqueuePrompt: deps.enqueueSectionPrompt
576
- ? (prompt: string) => deps.enqueueSectionPrompt!(prompt, ctx)
609
+ ? (prompt: string) =>
610
+ deps.enqueueSectionPrompt!(prompt, ctx, target)
577
611
  : async () => {},
578
612
  deleteMessage: deps.deleteMessage ?? (async () => {}),
579
613
  },
@@ -589,12 +623,14 @@ export async function handleTelegramMenuCallbackRuntime<
589
623
  query.id,
590
624
  {
591
625
  answerCallbackQuery: deps.answerCallbackQuery,
626
+ target,
592
627
  editInteractiveMessage:
593
628
  deps.editInteractiveMessage ?? (async () => {}),
594
629
  sendInteractiveMessage:
595
630
  deps.sendInteractiveMessage ?? (async () => undefined),
596
631
  enqueuePrompt: deps.enqueueSectionPrompt
597
- ? (prompt: string) => deps.enqueueSectionPrompt!(prompt, ctx)
632
+ ? (prompt: string) =>
633
+ deps.enqueueSectionPrompt!(prompt, ctx, target)
598
634
  : async () => {},
599
635
  deleteMessage: deps.deleteMessage ?? (async () => {}),
600
636
  },
@@ -612,12 +648,14 @@ export async function handleTelegramMenuCallbackRuntime<
612
648
  query.id,
613
649
  {
614
650
  answerCallbackQuery: deps.answerCallbackQuery,
651
+ target,
615
652
  editInteractiveMessage:
616
653
  deps.editInteractiveMessage ?? (async () => {}),
617
654
  sendInteractiveMessage:
618
655
  deps.sendInteractiveMessage ?? (async () => undefined),
619
656
  enqueuePrompt: deps.enqueueSectionPrompt
620
- ? (prompt: string) => deps.enqueueSectionPrompt!(prompt, ctx)
657
+ ? (prompt: string) =>
658
+ deps.enqueueSectionPrompt!(prompt, ctx, target)
621
659
  : async () => {},
622
660
  deleteMessage: deps.deleteMessage ?? (async () => {}),
623
661
  },
@@ -770,17 +808,18 @@ export function createTelegramMenuActionRuntime<
770
808
  deps.sectionRegistry,
771
809
  deps.isVoiceReplyActive?.(),
772
810
  ),
773
- sendStatusMessage: (chatId, replyToMessageId, ctx) =>
811
+ sendStatusMessage: (chatId, replyToMessageId, ctx, threadId) =>
774
812
  openTelegramStatusMenu({
775
813
  isIdle: () => deps.isIdle(ctx),
776
814
  sendBusyMessage: async () => {
777
815
  await deps.sendTextReply(
778
816
  chatId,
779
817
  replyToMessageId,
780
- "Cannot open status while π is busy. Send /abort, /next, or /stop.",
818
+ "Cannot open status while Pi is busy. Send /abort, /next, or /stop.",
819
+ { target: { chatId, threadId } },
781
820
  );
782
821
  },
783
- getModelMenuState: () => deps.getModelMenuState(chatId, ctx),
822
+ getModelMenuState: () => deps.getModelMenuState(chatId, ctx, threadId),
784
823
  buildStatusHtml: () => deps.buildStatusHtml(ctx),
785
824
  getActiveModel: () => deps.getActiveModel(ctx),
786
825
  getThinkingLevel: deps.getThinkingLevel,
@@ -804,7 +843,7 @@ export function createTelegramMenuActionRuntime<
804
843
  ),
805
844
  storeModelMenuState: deps.storeModelMenuState,
806
845
  }),
807
- openModelMenu: (chatId, replyToMessageId, ctx) =>
846
+ openModelMenu: (chatId, replyToMessageId, ctx, threadId) =>
808
847
  openTelegramModelMenu({
809
848
  isIdle: () => deps.isIdle(ctx),
810
849
  canOfferInFlightModelSwitch: () =>
@@ -813,7 +852,8 @@ export function createTelegramMenuActionRuntime<
813
852
  await deps.sendTextReply(
814
853
  chatId,
815
854
  replyToMessageId,
816
- "Cannot switch model while π is busy. Send /abort, /next, or /stop.",
855
+ "Cannot switch model while Pi is busy. Send /abort, /next, or /stop.",
856
+ { target: { chatId, threadId } },
817
857
  );
818
858
  },
819
859
  sendNoModelsMessage: async () => {
@@ -821,9 +861,10 @@ export function createTelegramMenuActionRuntime<
821
861
  chatId,
822
862
  replyToMessageId,
823
863
  "No available models with configured auth.",
864
+ { target: { chatId, threadId } },
824
865
  );
825
866
  },
826
- getModelMenuState: () => deps.getModelMenuState(chatId, ctx),
867
+ getModelMenuState: () => deps.getModelMenuState(chatId, ctx, threadId),
827
868
  getActiveModel: () => deps.getActiveModel(ctx),
828
869
  sendModelMenu: (state, activeModel) =>
829
870
  sendTelegramModelMenuMessage(state, activeModel, deps),
@@ -11,6 +11,10 @@ import { Type } from "@sinclair/typebox";
11
11
 
12
12
  import type { ExtensionAPI } from "./pi.ts";
13
13
  import { buildTelegramMultipartReplyParameters } from "./replies.ts";
14
+ import {
15
+ getTelegramTargetThreadParams,
16
+ type TelegramTarget,
17
+ } from "./target.ts";
14
18
 
15
19
  const MAX_ATTACHMENTS_PER_TURN = 10;
16
20
 
@@ -54,6 +58,7 @@ export interface TelegramOutboundAttachmentToolRegistrationDeps extends Telegram
54
58
  maxAttachmentSizeBytes?: number;
55
59
  getActiveTurn: () => TelegramOutboundAttachmentQueueTargetView | undefined;
56
60
  getDefaultChatId?: () => number | undefined;
61
+ getDefaultTarget?: () => TelegramTarget | undefined;
57
62
  canSendDirect?: () => boolean;
58
63
  sendMultipart?: TelegramQueuedOutboundAttachmentDeliveryDeps["sendMultipart"];
59
64
  statPath?: (path: string) => Promise<{ isFile(): boolean; size?: number }>;
@@ -66,12 +71,13 @@ export interface TelegramOutboundMessagePlan {
66
71
 
67
72
  export interface TelegramOutboundMessageToolRegistrationDeps extends TelegramOutboundAttachmentRuntimeEventRecorderPort {
68
73
  getDefaultChatId: () => number | undefined;
74
+ getDefaultTarget?: () => TelegramTarget | undefined;
69
75
  canSendDirect: () => boolean;
70
76
  planMessage: (markdown: string) => TelegramOutboundMessagePlan;
71
77
  sendMarkdownMessage: (
72
78
  chatId: number,
73
79
  markdown: string,
74
- options?: { replyMarkup?: unknown },
80
+ options?: { replyMarkup?: unknown; target?: TelegramTarget },
75
81
  ) => Promise<number | undefined>;
76
82
  }
77
83
 
@@ -87,6 +93,7 @@ export interface TelegramOutboundAttachmentQueueTargetView {
87
93
  export interface TelegramQueuedOutboundAttachmentTurnView extends TelegramOutboundAttachmentQueueTargetView {
88
94
  chatId: number;
89
95
  replyToMessageId: number;
96
+ target?: TelegramTarget;
90
97
  }
91
98
 
92
99
  function isTelegramOutboundPhotoAttachmentPath(path: string): boolean {
@@ -113,7 +120,7 @@ function formatTelegramOutboundAttachmentToolResultText(
113
120
  count: number,
114
121
  mode: "queued" | "sent" = "queued",
115
122
  ): string {
116
- // Pi's compact tool rows need an empty first line to visually separate header and result
123
+ // Pi's compact tool rows need one leading newline to visually separate header and result.
117
124
  const verb = mode === "queued" ? "Queued" : "Sent";
118
125
  return ["", `${verb} ${count} Telegram attachment(s).`].join("\n");
119
126
  }
@@ -122,26 +129,55 @@ function formatTelegramOutboundMessageToolResultText(chatId: number): string {
122
129
  return ["", `Sent Telegram message to ${chatId}.`].join("\n");
123
130
  }
124
131
 
132
+ function getTelegramMultipartTargetFields(
133
+ target: TelegramTarget | undefined,
134
+ ): Record<string, string> {
135
+ if (!target) return {};
136
+ return Object.fromEntries(
137
+ Object.entries(getTelegramTargetThreadParams(target)).map(
138
+ ([key, value]) => [key, String(value)],
139
+ ),
140
+ );
141
+ }
142
+
125
143
  function assertTelegramDirectDeliveryAllowed(
126
144
  canSendDirect: (() => boolean) | undefined,
127
145
  ): void {
128
146
  if (canSendDirect?.()) return;
129
147
  throw new Error(
130
- "Telegram direct delivery requires this π instance to own /telegram-connect",
148
+ "Telegram direct delivery requires this Pi instance to own /telegram-connect or be registered with the Telegram multi-instance bus",
131
149
  );
132
150
  }
133
151
 
134
- function resolveTelegramOutboundChatId(options: {
152
+ function resolveTelegramOutboundTarget(options: {
135
153
  chatId?: number;
154
+ threadId?: number;
155
+ target?: TelegramTarget;
136
156
  getDefaultChatId?: () => number | undefined;
137
- }): number {
138
- const chatId = options.chatId ?? options.getDefaultChatId?.();
139
- if (chatId === undefined) {
157
+ getDefaultTarget?: () => TelegramTarget | undefined;
158
+ }): { chatId: number; target?: TelegramTarget } {
159
+ if (options.target)
160
+ return { chatId: options.target.chatId, target: options.target };
161
+ if (options.chatId !== undefined) {
162
+ return {
163
+ chatId: options.chatId,
164
+ target:
165
+ options.threadId !== undefined
166
+ ? { chatId: options.chatId, threadId: options.threadId }
167
+ : undefined,
168
+ };
169
+ }
170
+ const defaultTarget = options.getDefaultTarget?.();
171
+ if (defaultTarget) {
172
+ return { chatId: defaultTarget.chatId, target: defaultTarget };
173
+ }
174
+ const defaultChatId = options.getDefaultChatId?.();
175
+ if (defaultChatId === undefined) {
140
176
  throw new Error(
141
177
  "Telegram chat_id is required when no paired/default Telegram chat is available",
142
178
  );
143
179
  }
144
- return chatId;
180
+ return { chatId: defaultChatId };
145
181
  }
146
182
 
147
183
  async function buildTelegramOutboundAttachmentViews(options: {
@@ -194,6 +230,7 @@ export function registerTelegramOutboundAttachmentTool(
194
230
  promptGuidelines: [
195
231
  "When handling a [telegram] message and the user asked for a file or generated artifact, call telegram_attach with the local path instead of only mentioning the path in text.",
196
232
  "When a local/TUI user explicitly asks to send a generated file to Telegram, telegram_attach can deliver it to the paired/default Telegram chat even without an active Telegram turn.",
233
+ "For an explicit thread target, provide chat_id plus thread_id; registered multi-instance followers default to their assigned thread target.",
197
234
  ],
198
235
  parameters: Type.Object({
199
236
  paths: Type.Array(
@@ -206,6 +243,12 @@ export function registerTelegramOutboundAttachmentTool(
206
243
  "Optional Telegram chat id for immediate delivery when no Telegram turn is active",
207
244
  }),
208
245
  ),
246
+ thread_id: Type.Optional(
247
+ Type.Number({
248
+ description:
249
+ "Optional Telegram topic thread id for immediate delivery with chat_id",
250
+ }),
251
+ ),
209
252
  caption: Type.Optional(
210
253
  Type.String({
211
254
  description:
@@ -219,11 +262,13 @@ export function registerTelegramOutboundAttachmentTool(
219
262
  activeTurn: deps.getActiveTurn(),
220
263
  paths: params.paths,
221
264
  chatId: params.chat_id,
265
+ threadId: params.thread_id,
222
266
  caption: params.caption,
223
267
  maxAttachmentsPerTurn,
224
268
  maxAttachmentSizeBytes,
225
269
  sendMultipart: deps.sendMultipart,
226
270
  getDefaultChatId: deps.getDefaultChatId,
271
+ getDefaultTarget: deps.getDefaultTarget,
227
272
  canSendDirect: deps.canSendDirect,
228
273
  statPath: deps.statPath,
229
274
  });
@@ -251,6 +296,7 @@ export function registerTelegramOutboundMessageTool(
251
296
  "Send direct Telegram Markdown text when the user explicitly asks for Telegram delivery outside the normal reply flow.",
252
297
  promptGuidelines: [
253
298
  "Use telegram_message only when the user explicitly asks to send a message to Telegram from the local/TUI side, or names a concrete Telegram delivery target.",
299
+ "For an explicit thread target, provide chat_id plus thread_id; registered multi-instance followers default to their assigned thread target.",
254
300
  "Add buttons by embedding the same top-level telegram_button HTML comments used in normal Telegram replies; Telegram does not support standalone buttons.",
255
301
  "Do not use this tool for ordinary Telegram-originated replies; answer normally so the bridge can deliver the active turn reply.",
256
302
  ],
@@ -259,13 +305,20 @@ export function registerTelegramOutboundMessageTool(
259
305
  chat_id: Type.Optional(
260
306
  Type.Number({ description: "Optional Telegram chat id" }),
261
307
  ),
308
+ thread_id: Type.Optional(
309
+ Type.Number({
310
+ description: "Optional Telegram topic thread id with chat_id",
311
+ }),
312
+ ),
262
313
  }),
263
314
  async execute(_toolCallId, params) {
264
315
  try {
265
316
  return await sendTelegramOutboundMessage({
266
317
  text: params.text,
267
318
  chatId: params.chat_id,
319
+ threadId: params.thread_id,
268
320
  getDefaultChatId: deps.getDefaultChatId,
321
+ getDefaultTarget: deps.getDefaultTarget,
269
322
  canSendDirect: deps.canSendDirect,
270
323
  planMessage: deps.planMessage,
271
324
  sendMarkdownMessage: deps.sendMarkdownMessage,
@@ -290,6 +343,7 @@ export interface TelegramQueuedOutboundAttachmentDeliveryDeps {
290
343
  chatId: number,
291
344
  replyToMessageId: number,
292
345
  text: string,
346
+ options?: { target?: TelegramTarget },
293
347
  ) => Promise<unknown>;
294
348
  recordRuntimeEvent?: (
295
349
  category: string,
@@ -304,11 +358,13 @@ export async function queueTelegramOutboundAttachments(options: {
304
358
  activeTurn: TelegramOutboundAttachmentQueueTargetView | undefined;
305
359
  paths: string[];
306
360
  chatId?: number;
361
+ threadId?: number;
307
362
  caption?: string;
308
363
  maxAttachmentsPerTurn: number;
309
364
  maxAttachmentSizeBytes?: number;
310
365
  sendMultipart?: TelegramQueuedOutboundAttachmentDeliveryDeps["sendMultipart"];
311
366
  getDefaultChatId?: () => number | undefined;
367
+ getDefaultTarget?: () => TelegramTarget | undefined;
312
368
  canSendDirect?: () => boolean;
313
369
  statPath?: (path: string) => Promise<{ isFile(): boolean; size?: number }>;
314
370
  }): Promise<TelegramOutboundAttachmentToolResult> {
@@ -321,11 +377,13 @@ export async function queueTelegramOutboundAttachments(options: {
321
377
  return sendTelegramOutboundFiles({
322
378
  paths: options.paths,
323
379
  chatId: options.chatId,
380
+ threadId: options.threadId,
324
381
  caption: options.caption,
325
382
  maxAttachmentsPerTurn: options.maxAttachmentsPerTurn,
326
383
  maxAttachmentSizeBytes: options.maxAttachmentSizeBytes,
327
384
  sendMultipart: options.sendMultipart,
328
385
  getDefaultChatId: options.getDefaultChatId,
386
+ getDefaultTarget: options.getDefaultTarget,
329
387
  canSendDirect: options.canSendDirect,
330
388
  statPath: options.statPath,
331
389
  });
@@ -359,29 +417,41 @@ export async function queueTelegramOutboundAttachments(options: {
359
417
  export async function sendTelegramOutboundMessage(options: {
360
418
  text: string;
361
419
  chatId?: number;
420
+ threadId?: number;
421
+ target?: TelegramTarget;
362
422
  getDefaultChatId?: () => number | undefined;
423
+ getDefaultTarget?: () => TelegramTarget | undefined;
363
424
  canSendDirect: () => boolean;
364
425
  planMessage: (markdown: string) => TelegramOutboundMessagePlan;
365
426
  sendMarkdownMessage: (
366
427
  chatId: number,
367
428
  markdown: string,
368
- options?: { replyMarkup?: unknown },
429
+ options?: { replyMarkup?: unknown; target?: TelegramTarget },
369
430
  ) => Promise<number | undefined>;
370
431
  }): Promise<{
371
432
  content: Array<{ type: "text"; text: string }>;
372
433
  details: { chatId: number; messageId?: number };
373
434
  }> {
374
435
  assertTelegramDirectDeliveryAllowed(options.canSendDirect);
375
- const chatId = resolveTelegramOutboundChatId({
436
+ const { chatId, target } = resolveTelegramOutboundTarget({
376
437
  chatId: options.chatId,
438
+ threadId: options.threadId,
439
+ target: options.target,
377
440
  getDefaultChatId: options.getDefaultChatId,
441
+ getDefaultTarget: options.getDefaultTarget,
378
442
  });
379
443
  const plan = options.planMessage(options.text);
380
444
  const messageId = await options.sendMarkdownMessage(chatId, plan.markdown, {
381
445
  replyMarkup: plan.replyMarkup,
446
+ target,
382
447
  });
383
448
  return {
384
- content: [{ type: "text", text: formatTelegramOutboundMessageToolResultText(chatId) }],
449
+ content: [
450
+ {
451
+ type: "text",
452
+ text: formatTelegramOutboundMessageToolResultText(chatId),
453
+ },
454
+ ],
385
455
  details: { chatId, messageId },
386
456
  };
387
457
  }
@@ -389,23 +459,33 @@ export async function sendTelegramOutboundMessage(options: {
389
459
  export async function sendTelegramOutboundFiles(options: {
390
460
  paths: string[];
391
461
  chatId?: number;
462
+ threadId?: number;
463
+ target?: TelegramTarget;
392
464
  caption?: string;
393
465
  maxAttachmentsPerTurn: number;
394
466
  maxAttachmentSizeBytes?: number;
395
467
  sendMultipart: TelegramQueuedOutboundAttachmentDeliveryDeps["sendMultipart"];
396
468
  getDefaultChatId?: () => number | undefined;
469
+ getDefaultTarget?: () => TelegramTarget | undefined;
397
470
  canSendDirect?: () => boolean;
398
471
  statPath?: (path: string) => Promise<{ isFile(): boolean; size?: number }>;
399
- }): Promise<TelegramOutboundAttachmentToolResult & { details: { paths: string[]; chatId: number } }> {
472
+ }): Promise<
473
+ TelegramOutboundAttachmentToolResult & {
474
+ details: { paths: string[]; chatId: number };
475
+ }
476
+ > {
400
477
  assertTelegramDirectDeliveryAllowed(options.canSendDirect);
401
478
  if (options.paths.length > options.maxAttachmentsPerTurn) {
402
479
  throw new Error(
403
480
  `Attachment limit reached (${options.maxAttachmentsPerTurn})`,
404
481
  );
405
482
  }
406
- const chatId = resolveTelegramOutboundChatId({
483
+ const { chatId, target } = resolveTelegramOutboundTarget({
407
484
  chatId: options.chatId,
485
+ threadId: options.threadId,
486
+ target: options.target,
408
487
  getDefaultChatId: options.getDefaultChatId,
488
+ getDefaultTarget: options.getDefaultTarget,
409
489
  });
410
490
  const pendingAttachments = await buildTelegramOutboundAttachmentViews({
411
491
  paths: options.paths,
@@ -421,6 +501,7 @@ export async function sendTelegramOutboundFiles(options: {
421
501
  {
422
502
  chat_id: String(chatId),
423
503
  ...(options.caption && index === 0 ? { caption: options.caption } : {}),
504
+ ...getTelegramMultipartTargetFields(target),
424
505
  },
425
506
  fieldName,
426
507
  attachment.path,
@@ -432,7 +513,10 @@ export async function sendTelegramOutboundFiles(options: {
432
513
  content: [
433
514
  {
434
515
  type: "text",
435
- text: formatTelegramOutboundAttachmentToolResultText(added.length, "sent"),
516
+ text: formatTelegramOutboundAttachmentToolResultText(
517
+ added.length,
518
+ "sent",
519
+ ),
436
520
  },
437
521
  ],
438
522
  details: { paths: added, chatId },
@@ -442,9 +526,7 @@ export async function sendTelegramOutboundFiles(options: {
442
526
  export function createTelegramQueuedOutboundAttachmentSender(
443
527
  deps: TelegramQueuedOutboundAttachmentDeliveryDeps,
444
528
  ) {
445
- return async function sendQueuedAttachments(
446
- turn: TelegramQueuedOutboundAttachmentTurnView,
447
- ): Promise<void> {
529
+ return async (turn: TelegramQueuedOutboundAttachmentTurnView): Promise<void> => {
448
530
  await sendQueuedTelegramOutboundAttachments(turn, {
449
531
  ...deps,
450
532
  maxAttachmentSizeBytes:
@@ -476,12 +558,14 @@ export async function sendQueuedTelegramOutboundAttachments(
476
558
  const replyParameters = buildTelegramMultipartReplyParameters(
477
559
  turn.chatId,
478
560
  turn.replyToMessageId,
561
+ turn.target,
479
562
  );
480
563
  await deps.sendMultipart(
481
564
  method,
482
565
  {
483
566
  chat_id: String(turn.chatId),
484
567
  ...(replyParameters ? { reply_parameters: replyParameters } : {}),
568
+ ...getTelegramMultipartTargetFields(turn.target),
485
569
  },
486
570
  fieldName,
487
571
  attachment.path,
@@ -496,6 +580,7 @@ export async function sendQueuedTelegramOutboundAttachments(
496
580
  turn.chatId,
497
581
  turn.replyToMessageId,
498
582
  `Failed to send attachment ${attachment.fileName}: ${message}`,
583
+ { target: turn.target },
499
584
  );
500
585
  }
501
586
  }
@@ -14,6 +14,7 @@ import {
14
14
  } from "./outbound-markup.ts";
15
15
  import {
16
16
  type PendingTelegramTurn,
17
+ type TelegramQueueTarget,
17
18
  truncateTelegramQueueSummary,
18
19
  } from "./queue.ts";
19
20
 
@@ -48,6 +49,7 @@ export interface TelegramButtonCallbackQuery {
48
49
  data?: string;
49
50
  message?: {
50
51
  message_id?: number;
52
+ message_thread_id?: number;
51
53
  chat?: { id?: number };
52
54
  };
53
55
  }
@@ -114,11 +116,11 @@ export function createTelegramButtonActionStore(
114
116
  ): TelegramButtonActionStore {
115
117
  const ttlMs = options.ttlMs ?? TELEGRAM_BUTTON_ACTION_TTL_MS;
116
118
  const actions = new Map<string, TelegramOutboundButtonStoredAction>();
117
- function cleanup(currentTime: number): void {
119
+ const cleanup = (currentTime: number): void => {
118
120
  for (const [key, action] of actions) {
119
121
  if (currentTime - action.createdAt > ttlMs) actions.delete(key);
120
122
  }
121
- }
123
+ };
122
124
  return {
123
125
  register: (action) => {
124
126
  const currentTime = nowMs();
@@ -180,11 +182,13 @@ export function createTelegramButtonPromptTurn(options: {
180
182
  replyToMessageId: number;
181
183
  queueOrder: number;
182
184
  action: TelegramOutboundButtonAction;
185
+ target?: TelegramQueueTarget;
183
186
  }): PendingTelegramTurn {
184
187
  const prompt = `[telegram] ${options.action.prompt}`;
185
188
  return {
186
189
  kind: "prompt",
187
190
  chatId: options.chatId,
191
+ ...(options.target ? { target: options.target } : {}),
188
192
  replyToMessageId: options.replyToMessageId,
189
193
  sourceMessageIds: [options.replyToMessageId],
190
194
  queueOrder: options.queueOrder,