@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/routing.ts CHANGED
@@ -17,11 +17,377 @@ import * as PromptTemplates from "./prompt-templates.ts";
17
17
  import * as Queue from "./queue.ts";
18
18
  import type { TelegramBridgeRuntime } from "./runtime.ts";
19
19
  import * as TextGroups from "./text-groups.ts";
20
+ import * as ThreadReconciler from "./thread-reconciler.ts";
20
21
  import * as Turns from "./turns.ts";
22
+
23
+ function getContextCwd(ctx: unknown): string | undefined {
24
+ if (!ctx || typeof ctx !== "object") return undefined;
25
+ const cwd = (ctx as { cwd?: unknown }).cwd;
26
+ return typeof cwd === "string" && cwd.length > 0 ? cwd : undefined;
27
+ }
28
+
29
+ function getLeaderTopicProfileKey(
30
+ ctx: unknown,
31
+ instanceId: string | undefined,
32
+ ): string | undefined {
33
+ const cwd = getContextCwd(ctx);
34
+ if (cwd) return `cwd:${cwd}`;
35
+ return instanceId ? `leader:${instanceId}` : undefined;
36
+ }
37
+
38
+ function isCurrentLeaderTopicRecord(
39
+ record: Threads.TelegramTopicTargetRecord,
40
+ profileKey: string | undefined,
41
+ instanceId: string | undefined,
42
+ ): boolean {
43
+ if (instanceId && record.instanceId === instanceId) return true;
44
+ return !!profileKey && record.profileKey === profileKey;
45
+ }
46
+
47
+ function hasActiveLeaderTopic(
48
+ records: Threads.TelegramTopicTargetRecord[],
49
+ profileKey: string | undefined,
50
+ instanceId: string | undefined,
51
+ ): boolean {
52
+ return records.some((record) => {
53
+ if (record.status !== "active") return false;
54
+ return isCurrentLeaderTopicRecord(record, profileKey, instanceId);
55
+ });
56
+ }
57
+
58
+ function escapeHtml(text: string): string {
59
+ return text
60
+ .replace(/&/g, "&")
61
+ .replace(/</g, "&lt;")
62
+ .replace(/>/g, "&gt;");
63
+ }
64
+
65
+ interface TelegramAllTabPendingCommand {
66
+ command: Commands.ParsedTelegramCommand;
67
+ text: string;
68
+ createdAtMs: number;
69
+ }
70
+
71
+ const TELEGRAM_ALL_TAB_MENU_CALLBACK_PREFIX = "allmenu:";
72
+ const TELEGRAM_UNBOUND_REROUTE_CALLBACK_PREFIX = "reroute:";
73
+ const TELEGRAM_UNBOUND_REROUTE_RESTORE_MENU_CALLBACK_PREFIX = "rerouterestore:";
74
+ const TELEGRAM_UNBOUND_REROUTE_NEW_SLOT_CALLBACK_PREFIX = "reroutenew:";
75
+
76
+ function formatTelegramAllTabMenuCallbackData(
77
+ commandId: string,
78
+ threadId: number,
79
+ ): string {
80
+ return `${TELEGRAM_ALL_TAB_MENU_CALLBACK_PREFIX}${commandId}:${threadId}`;
81
+ }
82
+
83
+ function parseTelegramAllTabMenuCallbackData(
84
+ data: string | undefined,
85
+ ): { commandId: string; threadId: number } | undefined {
86
+ const match = data?.match(/^allmenu:([a-z0-9]+):(\d+)$/);
87
+ const commandId = match?.[1];
88
+ const threadId = Number(match?.[2]);
89
+ if (!commandId || !Number.isSafeInteger(threadId)) return undefined;
90
+ return { commandId, threadId };
91
+ }
92
+
93
+ function formatTelegramUnboundRerouteCallbackData(
94
+ rerouteId: string,
95
+ threadId: number,
96
+ ): string {
97
+ return `${TELEGRAM_UNBOUND_REROUTE_CALLBACK_PREFIX}${rerouteId}:${threadId}`;
98
+ }
99
+
100
+ function formatTelegramUnboundRerouteRestoreMenuCallbackData(
101
+ rerouteId: string,
102
+ ): string {
103
+ return `${TELEGRAM_UNBOUND_REROUTE_RESTORE_MENU_CALLBACK_PREFIX}${rerouteId}`;
104
+ }
105
+
106
+ function formatTelegramUnboundRerouteNewSlotCallbackData(
107
+ rerouteId: string,
108
+ threadId: number,
109
+ ): string {
110
+ return `${TELEGRAM_UNBOUND_REROUTE_NEW_SLOT_CALLBACK_PREFIX}${rerouteId}:${threadId}`;
111
+ }
112
+
113
+ function parseTelegramUnboundRerouteRestoreMenuCallbackData(
114
+ data: string | undefined,
115
+ ): { rerouteId: string } | undefined {
116
+ const match = data?.match(/^rerouterestore:([a-z0-9]+)$/);
117
+ const rerouteId = match?.[1];
118
+ return rerouteId ? { rerouteId } : undefined;
119
+ }
120
+
121
+ function parseTelegramUnboundRerouteCallbackData(
122
+ data: string | undefined,
123
+ ): { rerouteId: string; threadId: number; useNewSlot: boolean } | undefined {
124
+ const match = data?.match(/^(reroute|reroutenew):([a-z0-9]+):(\d+)$/);
125
+ const prefix = match?.[1];
126
+ const rerouteId = match?.[2];
127
+ const threadId = Number(match?.[3]);
128
+ if (!prefix || !rerouteId || !Number.isSafeInteger(threadId))
129
+ return undefined;
130
+ return { rerouteId, threadId, useNewSlot: prefix === "reroutenew" };
131
+ }
132
+
133
+ function getTelegramThreadRecordLabel(
134
+ record: Threads.TelegramTopicTargetRecord,
135
+ ): string {
136
+ return getRestoredThreadName(record, record.slot ?? "");
137
+ }
138
+
139
+ function getTelegramRouteThreadButtonLabel(
140
+ record: Threads.TelegramTopicTargetRecord,
141
+ ): string {
142
+ return `↪️ ${getTelegramThreadRecordLabel(record)}`;
143
+ }
144
+
145
+ function getTelegramReplaceThreadButtonLabel(
146
+ record: Threads.TelegramTopicTargetRecord,
147
+ ): string {
148
+ return `➡️ ${getTelegramThreadRecordLabel(record)}`;
149
+ }
150
+
151
+ function getNextTelegramSlotPreference(
152
+ slot: string | undefined,
153
+ ): string | undefined {
154
+ if (!slot || !/^[A-Z]$/.test(slot)) return undefined;
155
+ const index = slot.charCodeAt(0) - "A".charCodeAt(0);
156
+ return String.fromCharCode("A".charCodeAt(0) + ((index + 1) % 26));
157
+ }
158
+
159
+ function getRestoredThreadName(
160
+ record: Threads.TelegramTopicTargetRecord,
161
+ slot: string,
162
+ ): string {
163
+ return record.threadName &&
164
+ Threads.isTelegramTopicThreadNameValidForSlot(record.threadName, slot)
165
+ ? record.threadName
166
+ : (Threads.chooseTelegramThreadName({ slot }) ?? "Pi");
167
+ }
168
+
169
+ function isTelegramLiveThreadTarget(
170
+ record: Threads.TelegramTopicTargetRecord,
171
+ liveTargets: readonly Queue.TelegramQueueTarget[] | undefined,
172
+ ): boolean {
173
+ if (!liveTargets) return record.status === "active";
174
+ return liveTargets.some(
175
+ (target) =>
176
+ target.chatId === record.target.chatId &&
177
+ target.threadId === record.target.threadId,
178
+ );
179
+ }
180
+
181
+ function getTelegramRoutableThreadRecords(
182
+ records: readonly Threads.TelegramTopicTargetRecord[],
183
+ liveTargets: readonly Queue.TelegramQueueTarget[] | undefined,
184
+ ): Threads.TelegramTopicTargetRecord[] {
185
+ return records.filter(
186
+ (record) =>
187
+ record.status === "active" &&
188
+ isTelegramLiveThreadTarget(record, liveTargets),
189
+ );
190
+ }
191
+
192
+ function buildTelegramAllTabMenuChooserMarkup(
193
+ commandId: string,
194
+ records: readonly Threads.TelegramTopicTargetRecord[],
195
+ ): Menu.TelegramReplyMarkup {
196
+ const rows = records.map((record) => [
197
+ {
198
+ text: getTelegramRouteThreadButtonLabel(record),
199
+ callback_data: formatTelegramAllTabMenuCallbackData(
200
+ commandId,
201
+ record.target.threadId,
202
+ ),
203
+ },
204
+ ]);
205
+ return { inline_keyboard: rows };
206
+ }
207
+
208
+ function formatTelegramAllTabMenuChooserText(command: string): string {
209
+ return [
210
+ "<b>🧵 Choose target thread</b>",
211
+ "",
212
+ `You used <code>/${escapeHtml(command)}</code> from the <b>All</b> tab.`,
213
+ "Select the Pi thread that should handle it:",
214
+ ].join("\n");
215
+ }
216
+
217
+ function buildTelegramUnboundRerouteChooserMarkup(
218
+ rerouteId: string,
219
+ records: readonly Threads.TelegramTopicTargetRecord[],
220
+ options: {
221
+ currentLeaderProfileKey?: string;
222
+ currentInstanceId?: string;
223
+ } = {},
224
+ ): Menu.TelegramReplyMarkup {
225
+ const activeRecords = records.filter((record) => record.status === "active");
226
+ const canRestoreCurrentLeader = activeRecords.some(
227
+ (record) =>
228
+ typeof record.rerouteConfirmedAtMs === "number" &&
229
+ isCurrentLeaderTopicRecord(
230
+ record,
231
+ options.currentLeaderProfileKey,
232
+ options.currentInstanceId,
233
+ ),
234
+ );
235
+ const rows = activeRecords.map((record) => [
236
+ {
237
+ text: getTelegramRouteThreadButtonLabel(record),
238
+ callback_data: formatTelegramUnboundRerouteCallbackData(
239
+ rerouteId,
240
+ record.target.threadId,
241
+ ),
242
+ },
243
+ ]);
244
+ return {
245
+ inline_keyboard: canRestoreCurrentLeader
246
+ ? [
247
+ ...rows,
248
+ [
249
+ {
250
+ text: "🔁 Replace/restore thread…",
251
+ callback_data:
252
+ formatTelegramUnboundRerouteRestoreMenuCallbackData(rerouteId),
253
+ },
254
+ ],
255
+ ]
256
+ : rows,
257
+ };
258
+ }
259
+
260
+ function buildTelegramUnboundRerouteRestoreChooserMarkup(
261
+ rerouteId: string,
262
+ records: readonly Threads.TelegramTopicTargetRecord[],
263
+ ): Menu.TelegramReplyMarkup {
264
+ return {
265
+ inline_keyboard: records
266
+ .filter((record) => record.status === "active")
267
+ .map((record) => [
268
+ {
269
+ text: getTelegramReplaceThreadButtonLabel(record),
270
+ callback_data: formatTelegramUnboundRerouteNewSlotCallbackData(
271
+ rerouteId,
272
+ record.target.threadId,
273
+ ),
274
+ },
275
+ ]),
276
+ };
277
+ }
278
+
279
+ function formatTelegramUnboundRerouteRestoreChooserText(): string {
280
+ return [
281
+ "<b>🧵 Replace/restore Telegram thread</b>",
282
+ "",
283
+ "Choose the Pi instance to move to this new Telegram thread:",
284
+ ].join("\n");
285
+ }
286
+
287
+ function formatTelegramUnboundTopicGuidance(): string {
288
+ return [
289
+ "⚠️ <b>New thread is not a Pi instance</b>",
290
+ "",
291
+ "To create a bound Telegram tab:",
292
+ "<code>1.</code> Start another Pi instance in your terminal.",
293
+ "<code>2.</code> Run <code>/telegram-connect</code> in that instance.",
294
+ "<code>3.</code> The bridge will create and bind a fresh Telegram tab for it.",
295
+ ].join("\n");
296
+ }
297
+
298
+ function formatTelegramTargetKey(target: Queue.TelegramQueueTarget): string {
299
+ return `${target.chatId}:${target.threadId ?? "all"}`;
300
+ }
301
+
302
+ function formatTelegramUnboundRerouteChooserText(
303
+ _records: readonly Threads.TelegramTopicTargetRecord[],
304
+ options: { includeGuidance?: boolean } = {},
305
+ ): string {
306
+ const rerouteText = [
307
+ "🧵 <b>Choose target thread</b>",
308
+ "",
309
+ "Your message is still in this Telegram thread.",
310
+ "Select the Pi thread that should handle it:",
311
+ ].join("\n");
312
+ return options.includeGuidance === false
313
+ ? rerouteText
314
+ : [formatTelegramUnboundTopicGuidance(), "", rerouteText].join("\n");
315
+ }
316
+
21
317
  import { getTelegramVoiceReplyMode } from "./voice.ts";
22
318
  import type { TelegramUser } from "./updates.ts";
319
+ import * as Threads from "./threads.ts";
23
320
  import * as Updates from "./updates.ts";
24
321
 
322
+ async function deleteReservedTelegramTopicThroughReconciler(
323
+ deps: {
324
+ callApi?: <TResponse>(
325
+ method: string,
326
+ body: Record<string, unknown>,
327
+ ) => Promise<TResponse>;
328
+ threadStore?: Pick<
329
+ Threads.TelegramTopicTargetStore,
330
+ | "list"
331
+ | "listReservations"
332
+ | "listSyncObservations"
333
+ | "markStaleByTarget"
334
+ | "persist"
335
+ >;
336
+ getCurrentLeaderEpoch?: () => number | string | undefined;
337
+ getThreadReconciliationMachineState?: () =>
338
+ | ThreadReconciler.ThreadReconciliationMachineState
339
+ | undefined;
340
+ recordThreadReconciliationPlan?: (
341
+ plan: ThreadReconciler.ThreadReconciliationPlan,
342
+ ) => void;
343
+ recordRuntimeEvent?: (
344
+ category: string,
345
+ error: unknown,
346
+ details?: Record<string, unknown>,
347
+ ) => void;
348
+ },
349
+ target: { chatId: number; threadId: number },
350
+ messageId: number,
351
+ ): Promise<boolean> {
352
+ if (!deps.threadStore) return false;
353
+ const nowMs = Date.now();
354
+ const currentLeaderEpoch = deps.getCurrentLeaderEpoch?.();
355
+ const plan = ThreadReconciler.planThreadReconciliation({
356
+ nowMs,
357
+ currentLeaderEpoch,
358
+ previousState: deps.getThreadReconciliationMachineState?.(),
359
+ records: deps.threadStore.list(),
360
+ reservations: deps.threadStore.listReservations(),
361
+ observations: deps.threadStore.listSyncObservations(),
362
+ reservedMessages: [
363
+ {
364
+ target,
365
+ observedAtMs: nowMs,
366
+ messageId,
367
+ ...(currentLeaderEpoch !== undefined
368
+ ? { leaderEpoch: currentLeaderEpoch }
369
+ : {}),
370
+ },
371
+ ],
372
+ });
373
+ deps.recordThreadReconciliationPlan?.(plan);
374
+ await ThreadReconciler.applyThreadReconciliationPlan(plan, {
375
+ callApi: deps.callApi,
376
+ markStaleByTarget: (staleTarget, syncStatus, lastSyncError) =>
377
+ deps.threadStore?.markStaleByTarget(
378
+ staleTarget,
379
+ syncStatus,
380
+ lastSyncError,
381
+ ) ?? false,
382
+ persist: () => deps.threadStore?.persist() ?? Promise.resolve(),
383
+ getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
384
+ recordRuntimeEvent: deps.recordRuntimeEvent,
385
+ });
386
+ return plan.actions.some(
387
+ (action) => action.kind === "close-delete-reserved-topic",
388
+ );
389
+ }
390
+
25
391
  export type TelegramRoutedMessage = Updates.TelegramUpdateMessage &
26
392
  Media.TelegramMediaMessage &
27
393
  Media.TelegramMediaGroupMessage &
@@ -40,7 +406,41 @@ export interface TelegramInboundRouteRuntimeDeps<
40
406
  configStore: Pick<
41
407
  TelegramConfigStore,
42
408
  "get" | "getAllowedUserId" | "setAllowedUserId" | "persist"
409
+ > & { set?: TelegramConfigStore["set"] };
410
+ callApi?: <TResponse>(
411
+ method: string,
412
+ body: Record<string, unknown>,
413
+ ) => Promise<TResponse>;
414
+ getCurrentInstanceId?: () => string | undefined;
415
+ getMessageOwnership?: Updates.TelegramMessageOwnershipLookup;
416
+ getTargetOwnership?: Updates.TelegramTargetOwnershipLookup;
417
+ getLiveThreadTargets?: () => Queue.TelegramQueueTarget[];
418
+ getCurrentLeaderEpoch?: () => number | string | undefined;
419
+ getThreadReconciliationMachineState?: () =>
420
+ | ThreadReconciler.ThreadReconciliationMachineState
421
+ | undefined;
422
+ recordThreadReconciliationPlan?: (
423
+ plan: ThreadReconciler.ThreadReconciliationPlan,
424
+ ) => void;
425
+ handleTelegramTopicLifecycleUpdate?: (
426
+ lifecycle: Updates.TelegramTopicLifecycleUpdate<TMessage>,
427
+ ctx: TContext,
428
+ ) => Promise<void> | void;
429
+ handleTelegramThreadTargetObserved?: (
430
+ target: Threads.TelegramTopicTargetRecord["target"],
431
+ ctx: TContext,
432
+ ) => Promise<void> | void;
433
+ foreignOwnedUpdateForwarder?: Updates.TelegramForeignOwnedUpdateForwarder<
434
+ TContext,
435
+ Updates.TelegramMessageReactionUpdated,
436
+ TCallbackQuery,
437
+ TMessage
43
438
  >;
439
+ replaceFollowerThreadTarget?: (input: {
440
+ record: Threads.TelegramTopicTargetRecord;
441
+ target: Threads.TelegramTopicTargetRecord["target"];
442
+ oldTarget: Threads.TelegramTopicTargetRecord["target"];
443
+ }) => Promise<boolean>;
44
444
  bridgeRuntime: TelegramBridgeRuntime;
45
445
  activeTurnRuntime: Queue.TelegramActiveTurnStore;
46
446
  mediaGroupRuntime: Media.TelegramMediaGroupController<TMessage, TContext>;
@@ -78,12 +478,17 @@ export interface TelegramInboundRouteRuntimeDeps<
78
478
  ) => Promise<boolean>;
79
479
  buttonActionStore?: OutboundHandlers.TelegramButtonActionStore;
80
480
  inboundHandlerRuntime: TelegramInboundHandlerRuntime<TContext>;
481
+ threadStore?: Threads.TelegramTopicTargetStore;
81
482
  updateStatus: (ctx: TContext, error?: string) => void;
82
483
  dispatchNextQueuedTelegramTurn: (ctx: TContext) => void;
83
484
  requestDeferredDispatchNextQueuedTelegramTurn?: (
84
485
  dispatch: (ctx: TContext) => void,
85
486
  ) => void;
86
- startTypingLoop?: (ctx: TContext, chatId?: number) => void;
487
+ startTypingLoop?: (
488
+ ctx: TContext,
489
+ chatId?: number,
490
+ options?: { target?: { chatId: number; threadId?: number } },
491
+ ) => void;
87
492
  stopTypingLoop?: () => void;
88
493
  answerCallbackQuery: (
89
494
  callbackQueryId: string,
@@ -101,6 +506,7 @@ export interface TelegramInboundRouteRuntimeDeps<
101
506
  text: string,
102
507
  mode: "markdown" | "html" | "plain",
103
508
  replyMarkup: Menu.TelegramReplyMarkup,
509
+ options?: { target?: Queue.TelegramQueueTarget; replyToMessageId?: number },
104
510
  ) => Promise<number | undefined>;
105
511
  deleteMessage?: (chatId: number, messageId: number) => Promise<void>;
106
512
  answerGuestQuery: (guestQueryId: string, text?: string) => Promise<void>;
@@ -108,6 +514,7 @@ export interface TelegramInboundRouteRuntimeDeps<
108
514
  chatId: number,
109
515
  replyToMessageId: number,
110
516
  text: string,
517
+ options?: { parseMode?: "HTML"; target?: Queue.TelegramQueueTarget },
111
518
  ) => Promise<number | undefined>;
112
519
  setMyCommands: Commands.TelegramBotCommandRegistrationDeps["setMyCommands"];
113
520
  getCommands: () => Parameters<
@@ -141,6 +548,8 @@ export interface TelegramInboundRouteRuntimeDeps<
141
548
  }
142
549
 
143
550
  const TELEGRAM_OWNED_CALLBACK_PREFIXES = [
551
+ TELEGRAM_ALL_TAB_MENU_CALLBACK_PREFIX,
552
+ TELEGRAM_UNBOUND_REROUTE_CALLBACK_PREFIX,
144
553
  "compact:",
145
554
  "menu:",
146
555
  "model:",
@@ -176,6 +585,74 @@ export function createTelegramInboundRouteRuntime<
176
585
  TModel
177
586
  >,
178
587
  ): Updates.TelegramUpdateRuntimeController<TContext, TUpdate> {
588
+ const pendingUnboundReroutes = new Map<
589
+ string,
590
+ { messages: TMessage[]; createdAtMs: number }
591
+ >();
592
+ const pendingAllTabCommands = new Map<string, TelegramAllTabPendingCommand>();
593
+ const guidedUnboundTopicKeys = new Set<string>();
594
+ let nextUnboundRerouteId = 0;
595
+ let nextAllTabCommandId = 0;
596
+ const prunePendingUnboundReroutes = () => {
597
+ const nowMs = Date.now();
598
+ for (const [id, entry] of pendingUnboundReroutes) {
599
+ if (nowMs - entry.createdAtMs > 30 * 60_000) {
600
+ pendingUnboundReroutes.delete(id);
601
+ }
602
+ }
603
+ while (pendingUnboundReroutes.size > 100) {
604
+ const oldest = pendingUnboundReroutes.keys().next().value;
605
+ if (!oldest) break;
606
+ pendingUnboundReroutes.delete(oldest);
607
+ }
608
+ };
609
+ const storePendingUnboundReroute = (messages: TMessage[]): string => {
610
+ prunePendingUnboundReroutes();
611
+ nextUnboundRerouteId += 1;
612
+ const id = nextUnboundRerouteId.toString(36);
613
+ pendingUnboundReroutes.set(id, { messages, createdAtMs: Date.now() });
614
+ return id;
615
+ };
616
+ const pendingUnboundRerouteMediaGroups = new Map<
617
+ string,
618
+ {
619
+ messages: TMessage[];
620
+ timer: ReturnType<typeof setTimeout>;
621
+ }
622
+ >();
623
+ const prunePendingAllTabCommands = () => {
624
+ const nowMs = Date.now();
625
+ for (const [id, entry] of pendingAllTabCommands) {
626
+ if (nowMs - entry.createdAtMs > 30 * 60_000) {
627
+ pendingAllTabCommands.delete(id);
628
+ }
629
+ }
630
+ while (pendingAllTabCommands.size > 100) {
631
+ const oldest = pendingAllTabCommands.keys().next().value;
632
+ if (!oldest) break;
633
+ pendingAllTabCommands.delete(oldest);
634
+ }
635
+ };
636
+ const storePendingAllTabCommand = (
637
+ command: Commands.ParsedTelegramCommand,
638
+ text: string,
639
+ ): string => {
640
+ prunePendingAllTabCommands();
641
+ const bareCommandText = `/${command.name}`;
642
+ const id = text.trim() === bareCommandText ? command.name : undefined;
643
+ if (id) {
644
+ pendingAllTabCommands.set(id, { command, text, createdAtMs: Date.now() });
645
+ return id;
646
+ }
647
+ nextAllTabCommandId += 1;
648
+ const generatedId = nextAllTabCommandId.toString(36);
649
+ pendingAllTabCommands.set(generatedId, {
650
+ command,
651
+ text,
652
+ createdAtMs: Date.now(),
653
+ });
654
+ return generatedId;
655
+ };
179
656
  const menuCallbackHandler = Menu.createTelegramMenuCallbackHandlerForContext<
180
657
  TCallbackQuery,
181
658
  TContext,
@@ -206,13 +683,18 @@ export function createTelegramInboundRouteRuntime<
206
683
  editInteractiveMessage: deps.editInteractiveMessage,
207
684
  sendInteractiveMessage: deps.sendInteractiveMessage,
208
685
  deleteMessage: deps.deleteMessage,
209
- enqueueSectionPrompt: async (prompt: string, ctx: TContext) => {
210
- const chatId = deps.configStore.getAllowedUserId();
686
+ enqueueSectionPrompt: async (
687
+ prompt: string,
688
+ ctx: TContext,
689
+ target?: Queue.TelegramQueueTarget,
690
+ ) => {
691
+ const chatId = target?.chatId ?? deps.configStore.getAllowedUserId();
211
692
  if (typeof chatId !== "number") return;
212
693
  const order = deps.bridgeRuntime.queue.allocateItemOrder();
213
694
  const turn: Queue.PendingTelegramTurn = {
214
695
  kind: "prompt",
215
696
  chatId,
697
+ ...(target ? { target } : {}),
216
698
  replyToMessageId: 0,
217
699
  sourceMessageIds: [],
218
700
  queueOrder: order,
@@ -233,10 +715,518 @@ export function createTelegramInboundRouteRuntime<
233
715
  deps.dispatchNextQueuedTelegramTurn(ctx);
234
716
  },
235
717
  });
718
+ const cloneTelegramMessagesForThread = (
719
+ messages: TMessage[],
720
+ threadId: number,
721
+ ): TMessage[] => {
722
+ return messages.map(
723
+ (message) =>
724
+ ({
725
+ ...message,
726
+ message_id: 0,
727
+ message_thread_id: threadId,
728
+ reply_to_message: undefined,
729
+ }) as TMessage,
730
+ );
731
+ };
732
+ const applyThreadCleanupPlan = async (
733
+ plan: ThreadReconciler.ThreadReconciliationPlan,
734
+ ): Promise<void> => {
735
+ deps.recordThreadReconciliationPlan?.(plan);
736
+ await ThreadReconciler.applyThreadReconciliationPlan(plan, {
737
+ callApi: deps.callApi,
738
+ markStaleByTarget: (staleTarget, syncStatus, lastSyncError) =>
739
+ deps.threadStore?.markStaleByTarget(
740
+ staleTarget,
741
+ syncStatus,
742
+ lastSyncError,
743
+ ) ?? false,
744
+ persist: () => deps.threadStore?.persist() ?? Promise.resolve(),
745
+ removePendingProvisionById: (id) =>
746
+ deps.threadStore?.removePendingProvision(id) ?? false,
747
+ getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
748
+ recordRuntimeEvent: deps.recordRuntimeEvent,
749
+ });
750
+ };
751
+ const dismissRerouteChooserMessage = async (
752
+ query: TCallbackQuery,
753
+ ): Promise<void> => {
754
+ const chatId = query.message?.chat?.id;
755
+ const messageId = query.message?.message_id;
756
+ if (
757
+ typeof chatId !== "number" ||
758
+ typeof messageId !== "number" ||
759
+ !deps.deleteMessage
760
+ ) {
761
+ return;
762
+ }
763
+ try {
764
+ await deps.deleteMessage(chatId, messageId);
765
+ } catch (error) {
766
+ deps.recordRuntimeEvent?.("telegram", error, {
767
+ phase: "reroute-chooser-delete",
768
+ chatId,
769
+ messageId,
770
+ threadId: query.message?.message_thread_id,
771
+ });
772
+ }
773
+ };
774
+ const closeReroutedUnboundTopic = async (
775
+ target: { chatId: number; threadId: number } | undefined,
776
+ messageId: number | undefined,
777
+ ): Promise<void> => {
778
+ if (!target || !deps.threadStore) return;
779
+ const nowMs = Date.now();
780
+ const currentLeaderEpoch = deps.getCurrentLeaderEpoch?.();
781
+ const plan = ThreadReconciler.planThreadReconciliation({
782
+ nowMs,
783
+ currentLeaderEpoch,
784
+ previousState: deps.getThreadReconciliationMachineState?.(),
785
+ records: deps.threadStore.list(),
786
+ reservations: deps.threadStore.listReservations(),
787
+ pendingProvisions: deps.threadStore.listPendingProvisions(),
788
+ unboundMessages: [
789
+ {
790
+ target,
791
+ observedAtMs: nowMs,
792
+ ...(typeof messageId === "number" ? { messageId } : {}),
793
+ ...(currentLeaderEpoch !== undefined
794
+ ? { leaderEpoch: currentLeaderEpoch }
795
+ : {}),
796
+ },
797
+ ],
798
+ });
799
+ await applyThreadCleanupPlan(plan);
800
+ };
801
+ const closePreviousLeaderThread = async (
802
+ target: { chatId: number; threadId: number } | undefined,
803
+ ): Promise<void> => {
804
+ if (!target || !deps.threadStore) return;
805
+ const currentLeaderEpoch = deps.getCurrentLeaderEpoch?.();
806
+ await applyThreadCleanupPlan({
807
+ actions: [
808
+ {
809
+ kind: "close-delete-previous-leader-topic",
810
+ target,
811
+ reason: "previous-leader",
812
+ instanceId: deps.getCurrentInstanceId?.(),
813
+ ...(currentLeaderEpoch !== undefined
814
+ ? { leaderEpoch: currentLeaderEpoch }
815
+ : {}),
816
+ },
817
+ ],
818
+ });
819
+ };
820
+ const closeReplacedFollowerThread = async (
821
+ target: { chatId: number; threadId: number } | undefined,
822
+ instanceId: string | undefined,
823
+ ): Promise<void> => {
824
+ if (!target || !deps.threadStore) return;
825
+ const currentLeaderEpoch = deps.getCurrentLeaderEpoch?.();
826
+ await applyThreadCleanupPlan({
827
+ actions: [
828
+ {
829
+ kind: "close-delete-replaced-follower-topic",
830
+ target,
831
+ reason: "replaced-follower",
832
+ instanceId,
833
+ ...(currentLeaderEpoch !== undefined
834
+ ? { leaderEpoch: currentLeaderEpoch }
835
+ : {}),
836
+ },
837
+ ],
838
+ });
839
+ };
840
+ const handleUnboundRerouteRestoreMenuCallback = async (
841
+ query: TCallbackQuery,
842
+ _ctx: TContext,
843
+ ): Promise<boolean> => {
844
+ const parsed = parseTelegramUnboundRerouteRestoreMenuCallbackData(
845
+ query.data,
846
+ );
847
+ if (!parsed) return false;
848
+ const chatId = query.message?.chat?.id;
849
+ const messageId = query.message?.message_id;
850
+ const pending = pendingUnboundReroutes.get(parsed.rerouteId);
851
+ if (
852
+ typeof chatId !== "number" ||
853
+ typeof messageId !== "number" ||
854
+ !deps.threadStore ||
855
+ !pending
856
+ ) {
857
+ await deps.answerCallbackQuery(query.id, "Message route expired.");
858
+ return true;
859
+ }
860
+ await deps.threadStore.load();
861
+ const activeRecords = getTelegramRoutableThreadRecords(
862
+ deps.threadStore.list(),
863
+ deps.getLiveThreadTargets?.(),
864
+ );
865
+ const replyMarkup = buildTelegramUnboundRerouteRestoreChooserMarkup(
866
+ parsed.rerouteId,
867
+ activeRecords,
868
+ );
869
+ if (deps.editInteractiveMessage) {
870
+ await deps.editInteractiveMessage(
871
+ chatId,
872
+ messageId,
873
+ formatTelegramUnboundRerouteRestoreChooserText(),
874
+ "html",
875
+ replyMarkup,
876
+ );
877
+ } else if (deps.sendInteractiveMessage) {
878
+ await deps.sendInteractiveMessage(
879
+ chatId,
880
+ formatTelegramUnboundRerouteRestoreChooserText(),
881
+ "html",
882
+ replyMarkup,
883
+ typeof query.message?.message_thread_id === "number"
884
+ ? {
885
+ target: { chatId, threadId: query.message.message_thread_id },
886
+ replyToMessageId: messageId,
887
+ }
888
+ : undefined,
889
+ );
890
+ }
891
+ await deps.answerCallbackQuery(query.id, "Choose instance to restore.");
892
+ return true;
893
+ };
894
+ const handleUnboundRerouteCallback = async (
895
+ query: TCallbackQuery,
896
+ ctx: TContext,
897
+ ): Promise<boolean> => {
898
+ const parsed = parseTelegramUnboundRerouteCallbackData(query.data);
899
+ if (!parsed) return false;
900
+ const chatId = query.message?.chat?.id;
901
+ const pending = pendingUnboundReroutes.get(parsed.rerouteId);
902
+ if (typeof chatId !== "number" || !deps.threadStore || !pending) {
903
+ await deps.answerCallbackQuery(query.id, "Message route expired.");
904
+ return true;
905
+ }
906
+ await deps.threadStore.load();
907
+ const record = getTelegramRoutableThreadRecords(
908
+ deps.threadStore.list(),
909
+ deps.getLiveThreadTargets?.(),
910
+ ).find(
911
+ (candidate) =>
912
+ candidate.target.chatId === chatId &&
913
+ candidate.target.threadId === parsed.threadId,
914
+ );
915
+ if (!record) {
916
+ await deps.answerCallbackQuery(query.id, "Thread is not active yet.");
917
+ return true;
918
+ }
919
+ const reroutedMessages = cloneTelegramMessagesForThread(
920
+ pending.messages,
921
+ parsed.threadId,
922
+ );
923
+ const sourceTarget =
924
+ typeof query.message?.message_thread_id === "number"
925
+ ? { chatId, threadId: query.message.message_thread_id }
926
+ : undefined;
927
+ const sourceMessageId = query.message?.message_id;
928
+ const currentInstanceId = deps.getCurrentInstanceId?.();
929
+ const leaderProfileKey = getLeaderTopicProfileKey(ctx, currentInstanceId);
930
+ const isCurrentLeaderRecord = isCurrentLeaderTopicRecord(
931
+ record,
932
+ leaderProfileKey,
933
+ currentInstanceId,
934
+ );
935
+ if (parsed.useNewSlot && !isCurrentLeaderRecord) {
936
+ if (
937
+ !sourceTarget ||
938
+ !deps.replaceFollowerThreadTarget ||
939
+ !deps.foreignOwnedUpdateForwarder?.forwardMessage
940
+ ) {
941
+ await deps.answerCallbackQuery(
942
+ query.id,
943
+ "Follower thread restore is not available yet.",
944
+ );
945
+ return true;
946
+ }
947
+ const replaced = await deps.replaceFollowerThreadTarget({
948
+ record,
949
+ target: sourceTarget,
950
+ oldTarget: record.target,
951
+ });
952
+ if (!replaced) {
953
+ await deps.answerCallbackQuery(
954
+ query.id,
955
+ "Follower thread is unavailable.",
956
+ );
957
+ return true;
958
+ }
959
+ const nowMs = Date.now();
960
+ const slot = record.slot ?? "?";
961
+ const threadName = getRestoredThreadName(record, slot);
962
+ deps.threadStore.markStaleByTarget(
963
+ record.target,
964
+ "deleted",
965
+ "Follower thread was replaced by restore source.",
966
+ );
967
+ deps.threadStore.upsert({
968
+ ...record,
969
+ target: sourceTarget,
970
+ status: "active",
971
+ syncStatus: "open",
972
+ updatedAtMs: nowMs,
973
+ threadName,
974
+ lastSyncObservedAtMs: nowMs,
975
+ lastReconcileAction: "follower-thread-restore",
976
+ rerouteConfirmedAtMs: nowMs,
977
+ });
978
+ await deps.threadStore.persist();
979
+ if (deps.callApi) {
980
+ try {
981
+ await deps.callApi("editForumTopic", {
982
+ chat_id: sourceTarget.chatId,
983
+ message_thread_id: sourceTarget.threadId,
984
+ name: Threads.getTelegramTopicTitleForThreadName(threadName, slot),
985
+ });
986
+ } catch (renameError) {
987
+ deps.recordRuntimeEvent?.("telegram", renameError, {
988
+ phase: "follower-topic-reroute-restore-rename",
989
+ chatId: sourceTarget.chatId,
990
+ threadId: sourceTarget.threadId,
991
+ slot: record.slot,
992
+ });
993
+ }
994
+ }
995
+ await closeReplacedFollowerThread(record.target, record.instanceId);
996
+ const forwarded = await Promise.all(
997
+ cloneTelegramMessagesForThread(
998
+ pending.messages,
999
+ sourceTarget.threadId,
1000
+ ).map((message) =>
1001
+ deps.foreignOwnedUpdateForwarder!.forwardMessage!({
1002
+ message,
1003
+ ownership: { instanceId: record.instanceId! },
1004
+ ctx,
1005
+ }),
1006
+ ),
1007
+ );
1008
+ const allForwarded = forwarded.every(Boolean);
1009
+ if (allForwarded) {
1010
+ pendingUnboundReroutes.delete(parsed.rerouteId);
1011
+ await dismissRerouteChooserMessage(query);
1012
+ }
1013
+ await deps.answerCallbackQuery(
1014
+ query.id,
1015
+ allForwarded ? "Message routed." : "Target thread is unavailable.",
1016
+ );
1017
+ return true;
1018
+ }
1019
+ if (
1020
+ record.instanceId &&
1021
+ record.instanceId !== currentInstanceId &&
1022
+ !isCurrentLeaderRecord
1023
+ ) {
1024
+ if (!deps.foreignOwnedUpdateForwarder?.forwardMessage) {
1025
+ await deps.answerCallbackQuery(
1026
+ query.id,
1027
+ "Open that thread and resend the message there.",
1028
+ );
1029
+ return true;
1030
+ }
1031
+ const forwarded = await Promise.all(
1032
+ reroutedMessages.map((message) =>
1033
+ deps.foreignOwnedUpdateForwarder!.forwardMessage!({
1034
+ message,
1035
+ ownership: { instanceId: record.instanceId! },
1036
+ ctx,
1037
+ }),
1038
+ ),
1039
+ );
1040
+ const allForwarded = forwarded.every(Boolean);
1041
+ if (allForwarded) {
1042
+ pendingUnboundReroutes.delete(parsed.rerouteId);
1043
+ await dismissRerouteChooserMessage(query);
1044
+ await closeReroutedUnboundTopic(sourceTarget, sourceMessageId);
1045
+ }
1046
+ await deps.answerCallbackQuery(
1047
+ query.id,
1048
+ allForwarded ? "Message routed." : "Target thread is unavailable.",
1049
+ );
1050
+ return true;
1051
+ }
1052
+ if (
1053
+ sourceTarget &&
1054
+ isCurrentLeaderRecord &&
1055
+ (parsed.useNewSlot || typeof record.rerouteConfirmedAtMs !== "number") &&
1056
+ (record.target.chatId !== sourceTarget.chatId ||
1057
+ record.target.threadId !== sourceTarget.threadId)
1058
+ ) {
1059
+ deps.threadStore.markStaleByTarget(
1060
+ record.target,
1061
+ "deleted",
1062
+ parsed.useNewSlot
1063
+ ? "Current leader thread was replaced by a new-slot reroute source."
1064
+ : "Current leader thread was replaced by reroute source.",
1065
+ );
1066
+ const slot = parsed.useNewSlot
1067
+ ? (deps.threadStore.allocateSlot(
1068
+ leaderProfileKey ?? record.profileKey,
1069
+ getNextTelegramSlotPreference(record.slot),
1070
+ ) ??
1071
+ record.slot ??
1072
+ "?")
1073
+ : (record.slot ?? "?");
1074
+ const nowMs = Date.now();
1075
+ const threadName = getRestoredThreadName(record, slot);
1076
+ deps.threadStore.upsert({
1077
+ ...record,
1078
+ target: sourceTarget,
1079
+ status: "active",
1080
+ updatedAtMs: nowMs,
1081
+ threadName,
1082
+ instanceId: currentInstanceId,
1083
+ slot,
1084
+ lastReconcileAction: parsed.useNewSlot
1085
+ ? "reroute-new-slot"
1086
+ : "reroute-reclaim",
1087
+ rerouteConfirmedAtMs: nowMs,
1088
+ });
1089
+ await deps.threadStore.persist();
1090
+ if (deps.callApi) {
1091
+ try {
1092
+ await deps.callApi("editForumTopic", {
1093
+ chat_id: sourceTarget.chatId,
1094
+ message_thread_id: sourceTarget.threadId,
1095
+ name: Threads.getTelegramTopicTitleForThreadName(threadName, slot),
1096
+ });
1097
+ } catch (renameError) {
1098
+ deps.recordRuntimeEvent?.("telegram", renameError, {
1099
+ phase: "leader-topic-reroute-reclaim-rename",
1100
+ chatId: sourceTarget.chatId,
1101
+ threadId: sourceTarget.threadId,
1102
+ slot,
1103
+ });
1104
+ }
1105
+ }
1106
+ deps.recordRuntimeEvent?.(
1107
+ "bus",
1108
+ "Bus leader reclaimed reroute source thread",
1109
+ {
1110
+ phase: "leader-topic-reroute-reclaim",
1111
+ chatId: sourceTarget.chatId,
1112
+ threadId: sourceTarget.threadId,
1113
+ staleThreadId: record.target.threadId,
1114
+ slot,
1115
+ },
1116
+ );
1117
+ if (parsed.useNewSlot) {
1118
+ await closePreviousLeaderThread(record.target);
1119
+ }
1120
+ await promptEnqueue(
1121
+ cloneTelegramMessagesForThread(pending.messages, sourceTarget.threadId),
1122
+ ctx,
1123
+ );
1124
+ pendingUnboundReroutes.delete(parsed.rerouteId);
1125
+ await dismissRerouteChooserMessage(query);
1126
+ await deps.answerCallbackQuery(query.id, "Message routed.");
1127
+ return true;
1128
+ }
1129
+ await promptEnqueue(reroutedMessages, ctx);
1130
+ pendingUnboundReroutes.delete(parsed.rerouteId);
1131
+ await dismissRerouteChooserMessage(query);
1132
+ await closeReroutedUnboundTopic(sourceTarget, sourceMessageId);
1133
+ await deps.answerCallbackQuery(query.id, "Message routed.");
1134
+ return true;
1135
+ };
1136
+ let dispatchAllTabCommandToTarget:
1137
+ | ((
1138
+ commandText: string,
1139
+ query: TCallbackQuery,
1140
+ threadId: number,
1141
+ ctx: TContext,
1142
+ ) => Promise<void>)
1143
+ | undefined;
1144
+ const handleAllTabMenuCallback = async (
1145
+ query: TCallbackQuery,
1146
+ ctx: TContext,
1147
+ ): Promise<boolean> => {
1148
+ const parsed = parseTelegramAllTabMenuCallbackData(query.data);
1149
+ if (!parsed) return false;
1150
+ const chatId = query.message?.chat?.id;
1151
+ const pending = pendingAllTabCommands.get(parsed.commandId);
1152
+ if (typeof chatId !== "number" || !deps.threadStore || !pending) {
1153
+ await deps.answerCallbackQuery(query.id, "Thread menu expired.");
1154
+ return true;
1155
+ }
1156
+ await deps.threadStore.load();
1157
+ const record = getTelegramRoutableThreadRecords(
1158
+ deps.threadStore.list(),
1159
+ deps.getLiveThreadTargets?.(),
1160
+ ).find(
1161
+ (candidate) =>
1162
+ candidate.target.chatId === chatId &&
1163
+ candidate.target.threadId === parsed.threadId,
1164
+ );
1165
+ if (!record) {
1166
+ await deps.answerCallbackQuery(query.id, "Thread is not active yet.");
1167
+ return true;
1168
+ }
1169
+ const currentInstanceId = deps.getCurrentInstanceId?.();
1170
+ const leaderProfileKey = getLeaderTopicProfileKey(ctx, currentInstanceId);
1171
+ const isCurrentLeaderRecord = isCurrentLeaderTopicRecord(
1172
+ record,
1173
+ leaderProfileKey,
1174
+ currentInstanceId,
1175
+ );
1176
+ if (
1177
+ record.instanceId &&
1178
+ record.instanceId !== currentInstanceId &&
1179
+ !isCurrentLeaderRecord
1180
+ ) {
1181
+ if (!deps.foreignOwnedUpdateForwarder?.forwardMessage) {
1182
+ await deps.answerCallbackQuery(
1183
+ query.id,
1184
+ "Open that thread and run /start there.",
1185
+ );
1186
+ return true;
1187
+ }
1188
+ const forwarded = await deps.foreignOwnedUpdateForwarder.forwardMessage({
1189
+ message: {
1190
+ ...(query.message ?? {}),
1191
+ message_id: query.message?.message_id ?? 0,
1192
+ chat: { id: chatId, type: "private" },
1193
+ from: query.from,
1194
+ message_thread_id: parsed.threadId,
1195
+ text: pending.text,
1196
+ } as TMessage,
1197
+ ownership: { instanceId: record.instanceId },
1198
+ ctx,
1199
+ });
1200
+ if (forwarded) pendingAllTabCommands.delete(parsed.commandId);
1201
+ await deps.answerCallbackQuery(
1202
+ query.id,
1203
+ forwarded
1204
+ ? "Opening in target thread."
1205
+ : "Target thread is unavailable.",
1206
+ );
1207
+ return true;
1208
+ }
1209
+ if (!dispatchAllTabCommandToTarget) {
1210
+ await deps.answerCallbackQuery(query.id, "Thread menu expired.");
1211
+ return true;
1212
+ }
1213
+ await dispatchAllTabCommandToTarget(
1214
+ pending.text,
1215
+ query,
1216
+ parsed.threadId,
1217
+ ctx,
1218
+ );
1219
+ pendingAllTabCommands.delete(parsed.commandId);
1220
+ await deps.answerCallbackQuery(query.id, "Opening in target thread.");
1221
+ return true;
1222
+ };
236
1223
  const callbackHandler = async (
237
1224
  query: TCallbackQuery,
238
1225
  ctx: TContext,
239
1226
  ): Promise<void> => {
1227
+ if (await handleUnboundRerouteRestoreMenuCallback(query, ctx)) return;
1228
+ if (await handleUnboundRerouteCallback(query, ctx)) return;
1229
+ if (await handleAllTabMenuCallback(query, ctx)) return;
240
1230
  if (deps.buttonActionStore) {
241
1231
  const handled = await OutboundHandlers.handleTelegramButtonCallbackQuery(
242
1232
  query,
@@ -253,6 +1243,13 @@ export function createTelegramInboundRouteRuntime<
253
1243
  deps.queueMutationRuntime.append(
254
1244
  OutboundHandlers.createTelegramButtonPromptTurn({
255
1245
  chatId,
1246
+ target:
1247
+ typeof buttonQuery.message?.message_thread_id === "number"
1248
+ ? {
1249
+ chatId,
1250
+ threadId: buttonQuery.message.message_thread_id,
1251
+ }
1252
+ : { chatId },
256
1253
  replyToMessageId: messageId,
257
1254
  queueOrder,
258
1255
  action,
@@ -271,7 +1268,7 @@ export function createTelegramInboundRouteRuntime<
271
1268
  ctx,
272
1269
  answerCallbackQuery: deps.answerCallbackQuery,
273
1270
  editInteractiveMessage: deps.editInteractiveMessage ?? (async () => {}),
274
- runCompact: async (compactCtx, chatId, replyToMessageId) => {
1271
+ runCompact: async (compactCtx, chatId, replyToMessageId, target) => {
275
1272
  await Commands.handleTelegramCompactCommand({
276
1273
  isIdle: () => deps.isIdle(compactCtx),
277
1274
  hasPendingMessages: () => deps.hasPendingMessages(compactCtx),
@@ -294,11 +1291,16 @@ export function createTelegramInboundRouteRuntime<
294
1291
  : undefined,
295
1292
  compact: (callbacks) => deps.compact(compactCtx, callbacks),
296
1293
  startTypingLoop: deps.startTypingLoop
297
- ? () => deps.startTypingLoop?.(compactCtx, chatId)
1294
+ ? () =>
1295
+ deps.startTypingLoop?.(compactCtx, chatId, {
1296
+ target,
1297
+ })
298
1298
  : undefined,
299
1299
  stopTypingLoop: deps.stopTypingLoop,
300
1300
  sendTextReply: (text) =>
301
- deps.sendTextReply(chatId, replyToMessageId, text).then(() => {}),
1301
+ deps
1302
+ .sendTextReply(chatId, replyToMessageId, text, { target })
1303
+ .then(() => {}),
302
1304
  suppressStartNotice: true,
303
1305
  recordRuntimeEvent: deps.recordRuntimeEvent,
304
1306
  });
@@ -343,6 +1345,22 @@ export function createTelegramInboundRouteRuntime<
343
1345
  const mode = deps.configStore.get().voice?.replyMode;
344
1346
  return mode === "manual" || mode === "mirror" || mode === "always";
345
1347
  },
1348
+ getTelegramThreadLabel(message) {
1349
+ if (!deps.threadStore) return undefined;
1350
+ const chatId = message.chat.id;
1351
+ const threadId = message.message_thread_id;
1352
+ if (!threadId) return undefined;
1353
+ const records = deps.threadStore.list();
1354
+ for (const r of records) {
1355
+ if (r.target.chatId !== chatId || r.target.threadId !== threadId)
1356
+ continue;
1357
+ return r.threadName &&
1358
+ Threads.isTelegramTopicThreadNameValidForSlot(r.threadName, r.slot)
1359
+ ? r.threadName
1360
+ : getRestoredThreadName(r, r.slot ?? "");
1361
+ }
1362
+ return undefined;
1363
+ },
346
1364
  });
347
1365
  const enqueueContinueTurn = async (
348
1366
  message: TMessage,
@@ -429,28 +1447,207 @@ export function createTelegramInboundRouteRuntime<
429
1447
  deps.bridgeRuntime.lifecycle.shouldFoldQueuedPromptsIntoHistory,
430
1448
  setFoldQueuedPromptsIntoHistory:
431
1449
  deps.bridgeRuntime.lifecycle.setFoldQueuedPromptsIntoHistory,
432
- createTurn: promptTurnBuilder,
1450
+ createTurn: async (messages, historyTurns, turnCtx) => {
1451
+ const turn = await promptTurnBuilder(messages, historyTurns, turnCtx);
1452
+ return turn.replyToMessageId > 0
1453
+ ? turn
1454
+ : { ...turn, replyToMessageId: 0 };
1455
+ },
433
1456
  updateStatus: deps.updateStatus,
434
1457
  dispatchNextQueuedTelegramTurn: deps.dispatchNextQueuedTelegramTurn,
435
1458
  }).enqueue;
1459
+ const sendUnboundRerouteChooserNow = async (
1460
+ messages: TMessage[],
1461
+ ctx: TContext,
1462
+ ): Promise<void> => {
1463
+ const message = messages[0];
1464
+ if (!message || !deps.threadStore) return;
1465
+ const records = deps.threadStore.list();
1466
+ const activeRecords = getTelegramRoutableThreadRecords(
1467
+ records,
1468
+ deps.getLiveThreadTargets?.(),
1469
+ );
1470
+ const sourceTarget =
1471
+ typeof message.message_thread_id === "number"
1472
+ ? { chatId: message.chat.id, threadId: message.message_thread_id }
1473
+ : undefined;
1474
+ const sourceKey = sourceTarget
1475
+ ? formatTelegramTargetKey(sourceTarget)
1476
+ : undefined;
1477
+ const includeGuidance = sourceKey
1478
+ ? !guidedUnboundTopicKeys.has(sourceKey)
1479
+ : true;
1480
+ if (sourceKey) guidedUnboundTopicKeys.add(sourceKey);
1481
+ if (activeRecords.length === 0) {
1482
+ await deps.sendTextReply(
1483
+ message.chat.id,
1484
+ message.message_id,
1485
+ [
1486
+ includeGuidance ? formatTelegramUnboundTopicGuidance() : undefined,
1487
+ "This thread is not bound to a Pi instance. Open an active Pi thread or run /telegram-connect from a Pi session to bind one.",
1488
+ ]
1489
+ .filter((line): line is string => typeof line === "string")
1490
+ .join("\n\n"),
1491
+ { parseMode: "HTML", target: sourceTarget },
1492
+ );
1493
+ return;
1494
+ }
1495
+ const rerouteId = storePendingUnboundReroute(messages);
1496
+ const text = formatTelegramUnboundRerouteChooserText(activeRecords, {
1497
+ includeGuidance,
1498
+ });
1499
+ const currentInstanceId = deps.getCurrentInstanceId?.();
1500
+ const replyMarkup = buildTelegramUnboundRerouteChooserMarkup(
1501
+ rerouteId,
1502
+ activeRecords,
1503
+ {
1504
+ currentLeaderProfileKey: getLeaderTopicProfileKey(
1505
+ ctx,
1506
+ currentInstanceId,
1507
+ ),
1508
+ currentInstanceId,
1509
+ },
1510
+ );
1511
+ if (deps.sendInteractiveMessage) {
1512
+ await deps.sendInteractiveMessage(
1513
+ message.chat.id,
1514
+ text,
1515
+ "html",
1516
+ replyMarkup,
1517
+ sourceTarget
1518
+ ? { target: sourceTarget, replyToMessageId: message.message_id }
1519
+ : { replyToMessageId: message.message_id },
1520
+ );
1521
+ return;
1522
+ }
1523
+ await deps.sendTextReply(message.chat.id, message.message_id, text, {
1524
+ parseMode: "HTML",
1525
+ target: sourceTarget,
1526
+ });
1527
+ };
1528
+ const sendUnboundRerouteChooser = async (
1529
+ message: TMessage,
1530
+ ctx: TContext,
1531
+ ): Promise<void> => {
1532
+ const groupKey = Media.getTelegramMediaGroupKey(message);
1533
+ if (!groupKey) {
1534
+ await sendUnboundRerouteChooserNow([message], ctx);
1535
+ return;
1536
+ }
1537
+ const existing = pendingUnboundRerouteMediaGroups.get(groupKey);
1538
+ if (existing) clearTimeout(existing.timer);
1539
+ const messages = [...(existing?.messages ?? []), message];
1540
+ const timer = setTimeout(() => {
1541
+ pendingUnboundRerouteMediaGroups.delete(groupKey);
1542
+ void sendUnboundRerouteChooserNow(messages, ctx);
1543
+ }, 1200);
1544
+ timer.unref?.();
1545
+ pendingUnboundRerouteMediaGroups.set(groupKey, { messages, timer });
1546
+ };
1547
+ const getKnownTelegramAllTabCommand = (
1548
+ text: string,
1549
+ ): Commands.ParsedTelegramCommand | undefined => {
1550
+ const command = Commands.parseTelegramCommand(text);
1551
+ if (!command) return undefined;
1552
+ if (reservedCommandNames().has(command.name)) return command;
1553
+ if (Commands.findTelegramExtensionCommand(command.name)) return command;
1554
+ if (
1555
+ getPromptTemplateCommands().some(
1556
+ (template) => template.command === command.name,
1557
+ )
1558
+ ) {
1559
+ return command;
1560
+ }
1561
+ return undefined;
1562
+ };
1563
+ const sendAllTabCommandChooser = async (
1564
+ command: Commands.ParsedTelegramCommand,
1565
+ commandText: string,
1566
+ message: TMessage,
1567
+ options: {
1568
+ replyToSource?: boolean;
1569
+ target?: Queue.TelegramQueueTarget;
1570
+ } = {},
1571
+ ): Promise<boolean> => {
1572
+ if (!deps.threadStore) return false;
1573
+ const records = deps.threadStore.list();
1574
+ const activeRecords = getTelegramRoutableThreadRecords(
1575
+ records,
1576
+ deps.getLiveThreadTargets?.(),
1577
+ );
1578
+ if (activeRecords.length === 0) return false;
1579
+ const commandId = storePendingAllTabCommand(command, commandText);
1580
+ const text = formatTelegramAllTabMenuChooserText(command.name);
1581
+ const replyMarkup = buildTelegramAllTabMenuChooserMarkup(
1582
+ commandId,
1583
+ activeRecords,
1584
+ );
1585
+ if (deps.sendInteractiveMessage) {
1586
+ await deps.sendInteractiveMessage(
1587
+ message.chat.id,
1588
+ text,
1589
+ "html",
1590
+ replyMarkup,
1591
+ options.target || options.replyToSource
1592
+ ? {
1593
+ ...(options.target ? { target: options.target } : {}),
1594
+ ...(options.replyToSource
1595
+ ? { replyToMessageId: message.message_id }
1596
+ : {}),
1597
+ }
1598
+ : undefined,
1599
+ );
1600
+ return true;
1601
+ }
1602
+ if (deps.callApi) {
1603
+ await deps.callApi("sendMessage", {
1604
+ chat_id: message.chat.id,
1605
+ text,
1606
+ parse_mode: "HTML",
1607
+ reply_markup: replyMarkup,
1608
+ ...(typeof options.target?.threadId === "number"
1609
+ ? { message_thread_id: options.target.threadId }
1610
+ : {}),
1611
+ ...(options.replyToSource
1612
+ ? {
1613
+ reply_parameters: {
1614
+ message_id: message.message_id,
1615
+ allow_sending_without_reply: true,
1616
+ },
1617
+ }
1618
+ : {}),
1619
+ });
1620
+ return true;
1621
+ }
1622
+ await deps.sendTextReply(message.chat.id, message.message_id, text, {
1623
+ parseMode: "HTML",
1624
+ target: options.target,
1625
+ });
1626
+ return true;
1627
+ };
436
1628
  const commandOrPrompt = Commands.createTelegramCommandOrPromptRuntime<
437
1629
  TMessage,
438
1630
  TContext
439
1631
  >({
440
1632
  extractRawText: Media.extractFirstTelegramMessageText,
1633
+ shouldIgnoreMessages: (messages) =>
1634
+ !Media.hasTelegramMessagesPromptContent(messages),
441
1635
  handleCommand: commandHandler,
442
1636
  executeExtensionCommand: async (command, message, ctx) => {
443
1637
  const extensionCommand = Commands.findTelegramExtensionCommand(
444
1638
  command.name,
445
1639
  );
446
1640
  if (!extensionCommand) return false;
1641
+ const sourceTarget = Updates.getTelegramMessageTarget(message);
447
1642
  try {
448
1643
  await extensionCommand.handler({
449
1644
  name: command.name,
450
1645
  args: command.args,
451
1646
  reply: (text) =>
452
1647
  deps
453
- .sendTextReply(message.chat.id, message.message_id, text)
1648
+ .sendTextReply(message.chat.id, message.message_id, text, {
1649
+ target: sourceTarget,
1650
+ })
454
1651
  .then(() => {}),
455
1652
  enqueuePrompt: (prompt) =>
456
1653
  promptEnqueue(
@@ -472,6 +1669,7 @@ export function createTelegramInboundRouteRuntime<
472
1669
  message.chat.id,
473
1670
  message.message_id,
474
1671
  "Command failed.",
1672
+ { target: sourceTarget },
475
1673
  );
476
1674
  }
477
1675
  return true;
@@ -486,6 +1684,24 @@ export function createTelegramInboundRouteRuntime<
486
1684
  ({ ...message, text, caption: undefined }) as TMessage,
487
1685
  enqueueTurn: promptEnqueue,
488
1686
  });
1687
+ dispatchAllTabCommandToTarget = async (commandText, query, threadId, ctx) => {
1688
+ const chatId = query.message?.chat?.id;
1689
+ if (typeof chatId !== "number") return;
1690
+ await commandOrPrompt.dispatchMessages(
1691
+ [
1692
+ {
1693
+ ...(query.message ?? {}),
1694
+ message_id: 0,
1695
+ chat: { id: chatId, type: "private" },
1696
+ from: query.from,
1697
+ message_thread_id: threadId,
1698
+ text: commandText,
1699
+ caption: undefined,
1700
+ } as TMessage,
1701
+ ],
1702
+ ctx,
1703
+ );
1704
+ };
489
1705
  const mediaDispatch = Media.createTelegramMediaGroupDispatchRuntime<
490
1706
  TMessage,
491
1707
  TContext
@@ -508,6 +1724,16 @@ export function createTelegramInboundRouteRuntime<
508
1724
  ...deps.telegramQueueStore,
509
1725
  updateStatus: deps.updateStatus,
510
1726
  });
1727
+ const handleTelegramTopicLifecycleUpdate = async (
1728
+ lifecycle: Updates.TelegramTopicLifecycleUpdate<TMessage>,
1729
+ ctx: TContext,
1730
+ ): Promise<void> => {
1731
+ await deps.handleTelegramTopicLifecycleUpdate?.(lifecycle, ctx);
1732
+ if (lifecycle.kind !== "created" || !deps.threadStore) {
1733
+ return;
1734
+ }
1735
+ await deps.threadStore.load();
1736
+ };
511
1737
  const handleAuthorizedTelegramGuestMessage = async (
512
1738
  guestMessage: Updates.TelegramGuestMessage & { from: TelegramUser },
513
1739
  ctx: TContext,
@@ -609,6 +1835,11 @@ export function createTelegramInboundRouteRuntime<
609
1835
  };
610
1836
  return Updates.createTelegramPairedUpdateRuntime<TContext, TUpdate>({
611
1837
  getAllowedUserId: deps.configStore.getAllowedUserId,
1838
+ getCurrentInstanceId: deps.getCurrentInstanceId,
1839
+ getMessageOwnership: deps.getMessageOwnership,
1840
+ getTargetOwnership: deps.getTargetOwnership,
1841
+ handleTelegramTopicLifecycleUpdate,
1842
+ foreignOwnedUpdateForwarder: deps.foreignOwnedUpdateForwarder,
612
1843
  setAllowedUserId: deps.configStore.setAllowedUserId,
613
1844
  persistConfig: deps.configStore.persist,
614
1845
  updateStatus: deps.updateStatus,
@@ -623,8 +1854,441 @@ export function createTelegramInboundRouteRuntime<
623
1854
  answerGuestQuery: deps.answerGuestQuery,
624
1855
  handleAuthorizedTelegramCallbackQuery: callbackHandler,
625
1856
  sendTextReply: deps.sendTextReply,
626
- handleAuthorizedTelegramMessage: textDispatch.handleMessage,
1857
+ handleAuthorizedTelegramMessage: async (message, ctx) => {
1858
+ if (typeof message.message_thread_id === "number") {
1859
+ await deps.handleTelegramThreadTargetObserved?.(
1860
+ {
1861
+ chatId: message.chat.id,
1862
+ threadId: message.message_thread_id,
1863
+ },
1864
+ ctx,
1865
+ );
1866
+ }
1867
+ const text = Media.extractFirstTelegramMessageText([
1868
+ message as TMessage,
1869
+ ]).trim();
1870
+ if (deps.threadStore && typeof message.message_thread_id !== "number") {
1871
+ await deps.threadStore.load();
1872
+ if (deps.threadStore.getBotState().threadMode === "disabled") {
1873
+ await textDispatch.handleMessage(message as TMessage, ctx);
1874
+ return;
1875
+ }
1876
+ const records = deps.threadStore.list();
1877
+ const bindings = getTelegramRoutableThreadRecords(
1878
+ records,
1879
+ deps.getLiveThreadTargets?.(),
1880
+ );
1881
+ const command = getKnownTelegramAllTabCommand(text);
1882
+ if (bindings.length > 0 && command && command.name !== "thread") {
1883
+ if (
1884
+ await sendAllTabCommandChooser(command, text, message as TMessage, {
1885
+ replyToSource: true,
1886
+ })
1887
+ ) {
1888
+ return;
1889
+ }
1890
+ }
1891
+ if (bindings.length > 0 && !text.startsWith("/")) {
1892
+ const probeTarget = bindings[0]?.target;
1893
+ if (probeTarget?.threadId && deps.callApi) {
1894
+ try {
1895
+ await deps.callApi("sendChatAction", {
1896
+ chat_id: probeTarget.chatId,
1897
+ message_thread_id: probeTarget.threadId,
1898
+ action: "typing",
1899
+ });
1900
+ } catch (error) {
1901
+ if (
1902
+ Threads.isTelegramTopicModeUnavailableError(error) ||
1903
+ Threads.isTelegramTopicTargetStaleError(error)
1904
+ ) {
1905
+ deps.threadStore.setBotState({
1906
+ threadMode: "disabled",
1907
+ updatedAtMs: Date.now(),
1908
+ lastReconcileAction:
1909
+ "thread-mode-unavailable-threadless-prompt",
1910
+ });
1911
+ await deps.threadStore.persist();
1912
+ await textDispatch.handleMessage(message as TMessage, ctx);
1913
+ return;
1914
+ }
1915
+ deps.recordRuntimeEvent?.("telegram", error, {
1916
+ phase: "threadless-topic-capability-check",
1917
+ chatId: probeTarget.chatId,
1918
+ threadId: probeTarget.threadId,
1919
+ });
1920
+ }
1921
+ }
1922
+ await deps.sendTextReply(
1923
+ message.chat.id,
1924
+ message.message_id,
1925
+ "This bot is in threaded multi-instance mode. Send prompts in a bound Pi thread tab so they route to the right instance.",
1926
+ );
1927
+ return;
1928
+ }
1929
+ }
1930
+ await textDispatch.handleMessage(message as TMessage, ctx);
1931
+ },
627
1932
  handleAuthorizedTelegramEditedMessage: editRuntime.updateFromEditedMessage,
628
1933
  handleAuthorizedTelegramGuestMessage,
1934
+ handleUnboundTelegramTopicMessage: async (message, ctx) => {
1935
+ if (!deps.threadStore) {
1936
+ await textDispatch.handleMessage(message as TMessage, ctx);
1937
+ return;
1938
+ }
1939
+ await deps.threadStore.load();
1940
+ if (deps.threadStore.getBotState().threadMode === "disabled") {
1941
+ await textDispatch.handleMessage(message as TMessage, ctx);
1942
+ return;
1943
+ }
1944
+ const target = Updates.getTelegramMessageTarget(message);
1945
+ if (!target?.threadId) {
1946
+ await textDispatch.handleMessage(message as TMessage, ctx);
1947
+ return;
1948
+ }
1949
+ const text = Media.extractFirstTelegramMessageText([
1950
+ message as TMessage,
1951
+ ]).trim();
1952
+ const instanceId = deps.getCurrentInstanceId?.();
1953
+ const leaderProfileKey = getLeaderTopicProfileKey(ctx, instanceId);
1954
+ const existing = deps.threadStore.list().find((r) => {
1955
+ return (
1956
+ r.target.chatId === target.chatId &&
1957
+ r.target.threadId === target.threadId
1958
+ );
1959
+ });
1960
+ if (existing) {
1961
+ const isLeaderTopic =
1962
+ (instanceId && existing.instanceId === instanceId) ||
1963
+ (!!leaderProfileKey && existing.profileKey === leaderProfileKey);
1964
+ if (existing.status === "active" && isLeaderTopic) {
1965
+ if (typeof existing.rerouteConfirmedAtMs !== "number") {
1966
+ const nowMs = Date.now();
1967
+ deps.threadStore.upsert({
1968
+ ...existing,
1969
+ updatedAtMs: nowMs,
1970
+ rerouteConfirmedAtMs: nowMs,
1971
+ });
1972
+ await deps.threadStore.persist();
1973
+ }
1974
+ await textDispatch.handleMessage(message as TMessage, ctx);
1975
+ return;
1976
+ }
1977
+ if (existing.status === "starting") {
1978
+ await deps.sendTextReply(
1979
+ target.chatId,
1980
+ message.message_id,
1981
+ "Instance " +
1982
+ getTelegramThreadRecordLabel(existing) +
1983
+ " is starting. Please wait…",
1984
+ { target },
1985
+ );
1986
+ return;
1987
+ }
1988
+ if (existing.status === "active") {
1989
+ await deps.sendTextReply(
1990
+ target.chatId,
1991
+ message.message_id,
1992
+ "Instance " +
1993
+ getTelegramThreadRecordLabel(existing) +
1994
+ " is not connected to the Telegram bus yet. Run /telegram-connect in that Pi instance; keeping this thread.",
1995
+ { target },
1996
+ );
1997
+ return;
1998
+ }
1999
+ if (
2000
+ (existing.status === "stale" || existing.status === "offline") &&
2001
+ leaderProfileKey &&
2002
+ !hasActiveLeaderTopic(
2003
+ deps.threadStore.list(),
2004
+ leaderProfileKey,
2005
+ instanceId,
2006
+ )
2007
+ ) {
2008
+ const priorLeaderRecord =
2009
+ deps.threadStore.getByProfileKey(leaderProfileKey);
2010
+ const slot =
2011
+ deps.threadStore.allocateSlot(
2012
+ leaderProfileKey,
2013
+ priorLeaderRecord?.slot ?? existing.slot,
2014
+ ) ??
2015
+ priorLeaderRecord?.slot ??
2016
+ existing.slot ??
2017
+ "A";
2018
+ const threadName =
2019
+ priorLeaderRecord?.threadName ??
2020
+ existing.threadName ??
2021
+ Threads.chooseTelegramThreadName({ slot }) ??
2022
+ "Pi";
2023
+ deps.threadStore.upsert({
2024
+ profileKey: leaderProfileKey,
2025
+ owner: {
2026
+ kind: "leader",
2027
+ cwd:
2028
+ typeof (ctx as { cwd?: unknown }).cwd === "string"
2029
+ ? (ctx as { cwd?: string }).cwd
2030
+ : undefined,
2031
+ instanceId,
2032
+ },
2033
+ target: { chatId: target.chatId, threadId: target.threadId },
2034
+ status: "active",
2035
+ createdAtMs: priorLeaderRecord?.createdAtMs ?? existing.createdAtMs,
2036
+ updatedAtMs: Date.now(),
2037
+ threadName,
2038
+ instanceId,
2039
+ slot,
2040
+ });
2041
+ await deps.threadStore.persist();
2042
+ if (deps.callApi) {
2043
+ try {
2044
+ await deps.callApi("editForumTopic", {
2045
+ chat_id: target.chatId,
2046
+ message_thread_id: target.threadId,
2047
+ name: Threads.getTelegramTopicTitleForThreadName(
2048
+ threadName,
2049
+ slot,
2050
+ ),
2051
+ });
2052
+ } catch (renameError) {
2053
+ deps.recordRuntimeEvent?.("telegram", renameError, {
2054
+ phase: "leader-topic-reclaim-rename",
2055
+ chatId: target.chatId,
2056
+ threadId: target.threadId,
2057
+ slot,
2058
+ });
2059
+ }
2060
+ }
2061
+ deps.recordRuntimeEvent?.(
2062
+ "bus",
2063
+ "Bus leader reclaimed unbound thread",
2064
+ {
2065
+ phase: "leader-topic-reclaim",
2066
+ chatId: target.chatId,
2067
+ threadId: target.threadId,
2068
+ slot,
2069
+ profileKey: leaderProfileKey,
2070
+ },
2071
+ );
2072
+ await textDispatch.handleMessage(message as TMessage, ctx);
2073
+ return;
2074
+ }
2075
+ await deps.sendTextReply(
2076
+ target.chatId,
2077
+ message.message_id,
2078
+ "Topic " +
2079
+ (existing.slot ?? "?") +
2080
+ " is " +
2081
+ existing.status +
2082
+ ". Start a Pi instance to claim it.",
2083
+ { target },
2084
+ );
2085
+ return;
2086
+ }
2087
+ const reservations = deps.threadStore.listReservations();
2088
+ const reservation = reservations.find(
2089
+ (reservation) =>
2090
+ reservation.target.chatId === target.chatId &&
2091
+ reservation.target.threadId === target.threadId,
2092
+ );
2093
+ if (reservation) {
2094
+ await deps.sendTextReply(
2095
+ target.chatId,
2096
+ message.message_id,
2097
+ "Previous leader thread (" +
2098
+ (reservation.slot ?? "?") +
2099
+ "). Closing and deleting this old topic. Use the current thread tab instead.",
2100
+ { target },
2101
+ );
2102
+ await deleteReservedTelegramTopicThroughReconciler(
2103
+ deps,
2104
+ { chatId: target.chatId, threadId: target.threadId },
2105
+ message.message_id,
2106
+ );
2107
+ return;
2108
+ }
2109
+ const records = deps.threadStore.list();
2110
+ const command = getKnownTelegramAllTabCommand(text);
2111
+ if (
2112
+ command &&
2113
+ getTelegramRoutableThreadRecords(records, deps.getLiveThreadTargets?.())
2114
+ .length > 0
2115
+ ) {
2116
+ if (
2117
+ await sendAllTabCommandChooser(command, text, message as TMessage, {
2118
+ target: { chatId: target.chatId, threadId: target.threadId },
2119
+ replyToSource: true,
2120
+ })
2121
+ ) {
2122
+ return;
2123
+ }
2124
+ }
2125
+ if (leaderProfileKey && deps.callApi) {
2126
+ const currentLeaderRecord = records.find((record) => {
2127
+ if (record.status !== "active") return false;
2128
+ if (instanceId && record.instanceId === instanceId) return true;
2129
+ return record.profileKey === leaderProfileKey;
2130
+ });
2131
+ if (
2132
+ currentLeaderRecord &&
2133
+ (currentLeaderRecord.target.chatId !== target.chatId ||
2134
+ currentLeaderRecord.target.threadId !== target.threadId)
2135
+ ) {
2136
+ let currentLeaderIsStale = false;
2137
+ try {
2138
+ await deps.callApi("sendChatAction", {
2139
+ chat_id: currentLeaderRecord.target.chatId,
2140
+ message_thread_id: currentLeaderRecord.target.threadId,
2141
+ action: "typing",
2142
+ });
2143
+ } catch (error) {
2144
+ currentLeaderIsStale =
2145
+ Threads.isTelegramTopicTargetStaleError(error);
2146
+ if (!currentLeaderIsStale) throw error;
2147
+ }
2148
+ if (currentLeaderIsStale) {
2149
+ deps.threadStore.markStaleByTarget(
2150
+ currentLeaderRecord.target,
2151
+ "deleted",
2152
+ "Current leader thread is stale during unbound prompt routing.",
2153
+ );
2154
+ const slot = currentLeaderRecord.slot ?? "A";
2155
+ const threadName = getRestoredThreadName(currentLeaderRecord, slot);
2156
+ deps.threadStore.upsert({
2157
+ ...currentLeaderRecord,
2158
+ profileKey: leaderProfileKey,
2159
+ owner: {
2160
+ kind: "leader",
2161
+ cwd:
2162
+ typeof (ctx as { cwd?: unknown }).cwd === "string"
2163
+ ? (ctx as { cwd?: string }).cwd
2164
+ : undefined,
2165
+ instanceId,
2166
+ },
2167
+ target: { chatId: target.chatId, threadId: target.threadId },
2168
+ status: "active",
2169
+ updatedAtMs: Date.now(),
2170
+ threadName,
2171
+ instanceId,
2172
+ slot,
2173
+ });
2174
+ await deps.threadStore.persist();
2175
+ try {
2176
+ await deps.callApi("editForumTopic", {
2177
+ chat_id: target.chatId,
2178
+ message_thread_id: target.threadId,
2179
+ name: Threads.getTelegramTopicTitleForThreadName(
2180
+ threadName,
2181
+ slot,
2182
+ ),
2183
+ });
2184
+ } catch (renameError) {
2185
+ deps.recordRuntimeEvent?.("telegram", renameError, {
2186
+ phase: "leader-topic-unbound-reclaim-rename",
2187
+ chatId: target.chatId,
2188
+ threadId: target.threadId,
2189
+ slot,
2190
+ });
2191
+ }
2192
+ deps.recordRuntimeEvent?.(
2193
+ "bus",
2194
+ "Bus leader reclaimed stale-current unbound thread",
2195
+ {
2196
+ phase: "leader-topic-unbound-stale-reclaim",
2197
+ chatId: target.chatId,
2198
+ threadId: target.threadId,
2199
+ staleThreadId: currentLeaderRecord.target.threadId,
2200
+ slot,
2201
+ profileKey: leaderProfileKey,
2202
+ },
2203
+ );
2204
+ await textDispatch.handleMessage(message as TMessage, ctx);
2205
+ return;
2206
+ }
2207
+ }
2208
+ }
2209
+ if (
2210
+ leaderProfileKey &&
2211
+ !hasActiveLeaderTopic(records, leaderProfileKey, instanceId)
2212
+ ) {
2213
+ const priorLeaderRecord =
2214
+ deps.threadStore.getByProfileKey(leaderProfileKey);
2215
+ const priorLeaderIdentity =
2216
+ deps.threadStore.getIdentityByProfileKey(leaderProfileKey);
2217
+ const slot =
2218
+ deps.threadStore.allocateSlot(
2219
+ leaderProfileKey,
2220
+ priorLeaderRecord?.slot ?? priorLeaderIdentity?.slot,
2221
+ ) ??
2222
+ priorLeaderRecord?.slot ??
2223
+ priorLeaderIdentity?.slot ??
2224
+ "A";
2225
+ const identityThreadName =
2226
+ priorLeaderIdentity?.threadName &&
2227
+ Threads.isTelegramTopicThreadNameValidForSlot(
2228
+ priorLeaderIdentity.threadName,
2229
+ slot,
2230
+ )
2231
+ ? priorLeaderIdentity.threadName
2232
+ : undefined;
2233
+ const threadName =
2234
+ priorLeaderRecord?.threadName ??
2235
+ identityThreadName ??
2236
+ Threads.chooseTelegramThreadName({ slot }) ??
2237
+ "Pi";
2238
+ deps.threadStore.upsert({
2239
+ profileKey: leaderProfileKey,
2240
+ owner: {
2241
+ kind: "leader",
2242
+ cwd:
2243
+ typeof (ctx as { cwd?: unknown }).cwd === "string"
2244
+ ? (ctx as { cwd?: string }).cwd
2245
+ : undefined,
2246
+ instanceId,
2247
+ },
2248
+ target: { chatId: target.chatId, threadId: target.threadId },
2249
+ status: "active",
2250
+ createdAtMs: priorLeaderRecord?.createdAtMs ?? Date.now(),
2251
+ updatedAtMs: Date.now(),
2252
+ threadName,
2253
+ instanceId,
2254
+ slot,
2255
+ });
2256
+ await deps.threadStore.persist();
2257
+ if (deps.callApi) {
2258
+ try {
2259
+ await deps.callApi("editForumTopic", {
2260
+ chat_id: target.chatId,
2261
+ message_thread_id: target.threadId,
2262
+ name: Threads.getTelegramTopicTitleForThreadName(
2263
+ threadName,
2264
+ slot,
2265
+ ),
2266
+ });
2267
+ } catch (renameError) {
2268
+ deps.recordRuntimeEvent?.("telegram", renameError, {
2269
+ phase: "leader-topic-reclaim-rename",
2270
+ chatId: target.chatId,
2271
+ threadId: target.threadId,
2272
+ slot,
2273
+ });
2274
+ }
2275
+ }
2276
+ deps.recordRuntimeEvent?.(
2277
+ "bus",
2278
+ "Bus leader reclaimed unbound thread",
2279
+ {
2280
+ phase: "leader-topic-reclaim",
2281
+ chatId: target.chatId,
2282
+ threadId: target.threadId,
2283
+ slot,
2284
+ profileKey: leaderProfileKey,
2285
+ },
2286
+ );
2287
+ await textDispatch.handleMessage(message as TMessage, ctx);
2288
+ return;
2289
+ }
2290
+ await sendUnboundRerouteChooser(message as TMessage, ctx);
2291
+ return;
2292
+ },
629
2293
  });
630
2294
  }