@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
@@ -579,15 +579,13 @@ function evaluateCommandTemplateExpression(
579
579
  ): number {
580
580
  let index = 0;
581
581
  const source = expression.replace(/\s+/g, "");
582
- function peek(): string | undefined {
583
- return source[index];
584
- }
585
- function consume(char: string): boolean {
582
+ const peek = (): string | undefined => source[index];
583
+ const consume = (char: string): boolean => {
586
584
  if (peek() !== char) return false;
587
585
  index += 1;
588
586
  return true;
589
- }
590
- function parsePrimary(): number {
587
+ };
588
+ const parsePrimary = (): number => {
591
589
  if (consume("(")) {
592
590
  const value = parseExpression();
593
591
  if (!consume(")"))
@@ -610,8 +608,8 @@ function evaluateCommandTemplateExpression(
610
608
  return Number(value);
611
609
  }
612
610
  throw new Error(`Invalid command template expression: ${expression}`);
613
- }
614
- function parseTerm(): number {
611
+ };
612
+ const parseTerm = (): number => {
615
613
  let value = parsePrimary();
616
614
  while (true) {
617
615
  if (consume("*")) value *= parsePrimary();
@@ -619,15 +617,15 @@ function evaluateCommandTemplateExpression(
619
617
  else if (consume("%")) value %= parsePrimary();
620
618
  else return value;
621
619
  }
622
- }
623
- function parseExpression(): number {
620
+ };
621
+ const parseExpression = (): number => {
624
622
  let value = parseTerm();
625
623
  while (true) {
626
624
  if (consume("+")) value += parseTerm();
627
625
  else if (consume("-")) value -= parseTerm();
628
626
  else return value;
629
627
  }
630
- }
628
+ };
631
629
  const value = parseExpression();
632
630
  if (index !== source.length)
633
631
  throw new Error(`Invalid command template expression: ${expression}`);
package/lib/commands.ts CHANGED
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { pairTelegramUserIfNeeded } from "./config.ts";
8
8
  import type { ExtensionAPI, ExtensionCommandContext } from "./pi.ts";
9
+ import type { TelegramBridgeStatusLineOptions } from "./status.ts";
9
10
  import {
10
11
  createTelegramControlItemBuilder,
11
12
  createTelegramControlQueueController,
@@ -158,6 +159,7 @@ export const TELEGRAM_COMMAND_EMOJI = {
158
159
  thinking: "🧠",
159
160
  compact: "🗜",
160
161
  queue: "🔢",
162
+ thread: "🧵",
161
163
  next: "⏩",
162
164
  continue: "▶️",
163
165
  abort: "⏹️",
@@ -217,13 +219,13 @@ export const TELEGRAM_BUILTIN_BOT_COMMANDS: readonly TelegramBotCommandDefinitio
217
219
  },
218
220
  {
219
221
  command: "abort",
220
- description: formatTelegramBotCommandDescription("abort", "Abort π"),
222
+ description: formatTelegramBotCommandDescription("abort", "Abort Pi"),
221
223
  },
222
224
  {
223
225
  command: "stop",
224
226
  description: formatTelegramBotCommandDescription(
225
227
  "stop",
226
- "Abort π & Clear queue",
228
+ "Abort Pi & Clear queue",
227
229
  ),
228
230
  },
229
231
  ];
@@ -282,6 +284,7 @@ export function createTelegramBotCommandRegistrar(
282
284
 
283
285
  export interface TelegramBridgeCommandStartPollingOptions {
284
286
  force?: boolean;
287
+ forceFreshLeaderThread?: boolean;
285
288
  }
286
289
 
287
290
  export interface TelegramBridgeCommandStartPollingResult {
@@ -293,7 +296,7 @@ export interface TelegramBridgeCommandStartPollingResult {
293
296
 
294
297
  export interface TelegramBridgeCommandRegistrationDeps {
295
298
  promptForConfig: (ctx: ExtensionCommandContext) => Promise<void>;
296
- getStatusLines: () => string[];
299
+ getStatusLines: (options?: TelegramBridgeStatusLineOptions) => string[];
297
300
  reloadConfig: () => Promise<void>;
298
301
  hasBotToken: () => boolean;
299
302
  startPolling: (
@@ -319,7 +322,7 @@ function formatTelegramTakeoverPrompt(
319
322
  const action = theme.fg("warning", "move singleton lock here?");
320
323
  const from = theme.fg("muted", "from:");
321
324
  const to = theme.fg("muted", "to:");
322
- const source = owner ?? "another π instance";
325
+ const source = owner ?? "another Pi instance";
323
326
  return `${action}\n\n${from} ${source}\n${to} ${ctx.cwd}`;
324
327
  }
325
328
 
@@ -335,19 +338,24 @@ export function registerTelegramBridgeCommands(
335
338
  });
336
339
  pi.registerCommand("telegram-status", {
337
340
  description: "Show Telegram bridge status",
338
- handler: async (_args, ctx) => {
339
- ctx.ui.notify(deps.getStatusLines().join("\n"), "info");
341
+ handler: async (args, ctx) => {
342
+ const verbose = /(^|\s)(--debug|debug|--verbose|verbose)(\s|$)/i.test(
343
+ args,
344
+ );
345
+ ctx.ui.notify(deps.getStatusLines({ verbose }).join("\n"), "info");
340
346
  },
341
347
  });
342
348
  pi.registerCommand("telegram-connect", {
343
- description: "Start the Telegram bridge in this π session",
349
+ description: "Start the Telegram bridge in this Pi session",
344
350
  handler: async (_args, ctx) => {
345
351
  await deps.reloadConfig();
346
352
  if (!deps.hasBotToken()) {
347
353
  await deps.promptForConfig(ctx);
348
354
  return;
349
355
  }
350
- let result = await deps.startPolling(ctx);
356
+ let result = await deps.startPolling(ctx, {
357
+ forceFreshLeaderThread: true,
358
+ });
351
359
  if (result && !result.ok && result.canTakeover) {
352
360
  const confirmed = await ctx.ui.confirm(
353
361
  formatTelegramTakeoverTitle(ctx),
@@ -358,7 +366,10 @@ export function registerTelegramBridgeCommands(
358
366
  deps.updateStatus(ctx);
359
367
  return;
360
368
  }
361
- result = await deps.startPolling(ctx, { force: true });
369
+ result = await deps.startPolling(ctx, {
370
+ force: true,
371
+ forceFreshLeaderThread: true,
372
+ });
362
373
  }
363
374
  if (result?.message) {
364
375
  ctx.ui.notify(result.message, result.ok ? "info" : "warning");
@@ -367,7 +378,7 @@ export function registerTelegramBridgeCommands(
367
378
  },
368
379
  });
369
380
  pi.registerCommand("telegram-disconnect", {
370
- description: "Stop the Telegram bridge in this π session",
381
+ description: "Stop the Telegram bridge in this Pi session",
371
382
  handler: async (_args, ctx) => {
372
383
  const message = await deps.stopPolling();
373
384
  if (message) ctx.ui.notify(message, "info");
@@ -496,13 +507,18 @@ export interface TelegramCompactConfirmationDeps {
496
507
  text: string,
497
508
  mode: "markdown" | "html" | "plain",
498
509
  replyMarkup: TelegramCompactConfirmationReplyMarkup,
510
+ options?: { target?: { chatId: number; threadId?: number } },
499
511
  ) => Promise<number | undefined>;
500
512
  }
501
513
 
502
514
  export interface TelegramCompactConfirmationCallbackQuery {
503
515
  id: string;
504
516
  data?: string;
505
- message?: { chat?: { id?: number }; message_id?: number };
517
+ message?: {
518
+ chat?: { id?: number };
519
+ message_id?: number;
520
+ message_thread_id?: number;
521
+ };
506
522
  }
507
523
 
508
524
  export interface TelegramCompactConfirmationCallbackDeps<TContext> {
@@ -522,6 +538,7 @@ export interface TelegramCompactConfirmationCallbackDeps<TContext> {
522
538
  ctx: TContext,
523
539
  chatId: number,
524
540
  replyToMessageId: number,
541
+ target?: { chatId: number; threadId?: number },
525
542
  ) => Promise<void>;
526
543
  }
527
544
 
@@ -529,16 +546,24 @@ export type TelegramControlCommandType =
529
546
  PendingTelegramControlItem<unknown>["controlType"];
530
547
 
531
548
  export interface TelegramCommandRuntimeMessage {
532
- chat: { id: number };
549
+ chat: { id: number; type?: string; title?: string };
533
550
  message_id: number;
551
+ message_thread_id?: number;
534
552
  from?: { id?: number };
535
553
  }
536
554
 
537
555
  export interface TelegramCommandMessageTarget {
538
556
  chatId: number;
557
+ threadId?: number;
539
558
  replyToMessageId: number;
540
559
  }
541
560
 
561
+ function canPairTelegramUserFromCommandMessage(
562
+ message: TelegramCommandRuntimeMessage,
563
+ ): boolean {
564
+ return message.chat.type === undefined || message.chat.type === "private";
565
+ }
566
+
542
567
  export interface TelegramCommandTargetRuntimeDeps<TContext> {
543
568
  enqueueControlItem: (
544
569
  target: TelegramCommandMessageTarget,
@@ -551,21 +576,25 @@ export interface TelegramCommandTargetRuntimeDeps<TContext> {
551
576
  chatId: number,
552
577
  replyToMessageId: number,
553
578
  ctx: TContext,
579
+ threadId?: number,
554
580
  ) => Promise<void>;
555
581
  openModelMenu: (
556
582
  chatId: number,
557
583
  replyToMessageId: number,
558
584
  ctx: TContext,
585
+ threadId?: number,
559
586
  ) => Promise<void>;
560
587
  openSettingsMenu?: (
561
588
  chatId: number,
562
589
  replyToMessageId: number,
563
590
  ctx: TContext,
591
+ threadId?: number,
564
592
  ) => Promise<void>;
565
593
  sendTextReply: (
566
594
  chatId: number,
567
595
  replyToMessageId: number,
568
596
  text: string,
597
+ options?: { target?: { chatId: number; threadId?: number } },
569
598
  ) => Promise<unknown>;
570
599
  }
571
600
 
@@ -591,6 +620,10 @@ export function getTelegramCommandMessageTarget(
591
620
  ): TelegramCommandMessageTarget {
592
621
  return {
593
622
  chatId: message.chat.id,
623
+ threadId:
624
+ typeof message.message_thread_id === "number"
625
+ ? message.message_thread_id
626
+ : undefined,
594
627
  replyToMessageId: message.message_id,
595
628
  };
596
629
  }
@@ -598,6 +631,7 @@ export function getTelegramCommandMessageTarget(
598
631
  export interface TelegramCommandControlQueueRuntimeDeps<TContext> {
599
632
  createControlItem: (options: {
600
633
  chatId: number;
634
+ target?: { chatId: number; threadId?: number };
601
635
  replyToMessageId: number;
602
636
  controlType: TelegramControlCommandType;
603
637
  statusSummary: string;
@@ -626,6 +660,7 @@ export function createTelegramCommandControlQueueRuntime<TContext>(
626
660
  export function createTelegramCommandControlEnqueueAdapter<TContext>(deps: {
627
661
  createControlItem: (options: {
628
662
  chatId: number;
663
+ target?: { chatId: number; threadId?: number };
629
664
  replyToMessageId: number;
630
665
  controlType: TelegramControlCommandType;
631
666
  statusSummary: string;
@@ -690,11 +725,21 @@ export function createTelegramCommandTargetRuntime<
690
725
  },
691
726
  showStatus: (message, ctx) => {
692
727
  const target = getTelegramCommandMessageTarget(message);
693
- return deps.showStatus(target.chatId, target.replyToMessageId, ctx);
728
+ return deps.showStatus(
729
+ target.chatId,
730
+ target.replyToMessageId,
731
+ ctx,
732
+ target.threadId,
733
+ );
694
734
  },
695
735
  openModelMenu: (message, ctx) => {
696
736
  const target = getTelegramCommandMessageTarget(message);
697
- return deps.openModelMenu(target.chatId, target.replyToMessageId, ctx);
737
+ return deps.openModelMenu(
738
+ target.chatId,
739
+ target.replyToMessageId,
740
+ ctx,
741
+ target.threadId,
742
+ );
698
743
  },
699
744
  openSettingsMenu: async (message, ctx) => {
700
745
  const target = getTelegramCommandMessageTarget(message);
@@ -703,20 +748,29 @@ export function createTelegramCommandTargetRuntime<
703
748
  target.chatId,
704
749
  target.replyToMessageId,
705
750
  "Settings menu is unavailable.",
751
+ { target },
706
752
  );
707
753
  return;
708
754
  }
709
- await deps.openSettingsMenu(target.chatId, target.replyToMessageId, ctx);
755
+ await deps.openSettingsMenu(
756
+ target.chatId,
757
+ target.replyToMessageId,
758
+ ctx,
759
+ target.threadId,
760
+ );
710
761
  },
711
762
  sendTextReply: async (message, text) => {
712
763
  const target = getTelegramCommandMessageTarget(message);
713
- await deps.sendTextReply(target.chatId, target.replyToMessageId, text);
764
+ await deps.sendTextReply(target.chatId, target.replyToMessageId, text, {
765
+ target,
766
+ });
714
767
  },
715
768
  };
716
769
  }
717
770
 
718
771
  export interface TelegramCommandOrPromptRuntimeDeps<TMessage, TContext> {
719
772
  extractRawText: (messages: TMessage[]) => string;
773
+ shouldIgnoreMessages?: (messages: TMessage[]) => boolean;
720
774
  handleCommand: (
721
775
  commandName: string | undefined,
722
776
  message: TMessage,
@@ -756,7 +810,11 @@ export interface TelegramCommandRuntimeDeps<
756
810
  requestDeferredDispatchNextQueuedTelegramTurn?: (
757
811
  dispatch: (ctx: TContext) => void,
758
812
  ) => void;
759
- startTypingLoop?: (ctx: TContext, chatId?: number) => void;
813
+ startTypingLoop?: (
814
+ ctx: TContext,
815
+ chatId?: number,
816
+ options?: { target?: { chatId: number; threadId?: number } },
817
+ ) => void;
760
818
  stopTypingLoop?: () => void;
761
819
  enqueueContinueTurn: (message: TMessage, ctx: TContext) => Promise<void>;
762
820
  compact: (
@@ -771,6 +829,10 @@ export interface TelegramCommandRuntimeDeps<
771
829
  execute: (ctx: TContext) => Promise<void>,
772
830
  ) => void;
773
831
  showStatus: (message: TMessage, ctx: TContext) => Promise<void>;
832
+ handleForumBootstrap?: (
833
+ message: TMessage,
834
+ ctx: TContext,
835
+ ) => Promise<string | undefined>;
774
836
  openModelMenu: (message: TMessage, ctx: TContext) => Promise<void>;
775
837
  openThinkingMenu: (message: TMessage, ctx: TContext) => Promise<void>;
776
838
  openQueueMenu: (message: TMessage, ctx: TContext) => Promise<void>;
@@ -785,14 +847,14 @@ export interface TelegramCommandRuntimeDeps<
785
847
  }
786
848
 
787
849
  export const TELEGRAM_APP_MENU_INTRO_HTML = [
788
- "<b Telegram</b>",
850
+ "<b>Pi Telegram</b>",
789
851
  "",
790
852
  `${formatTelegramCommandEmojiPrefix("start")}/start — Open menu / Pair bridge`,
791
853
  `${formatTelegramCommandEmojiPrefix("compact")}/compact — Compact current session`,
792
854
  `${formatTelegramCommandEmojiPrefix("next")}/next — Force next turn`,
793
855
  `${formatTelegramCommandEmojiPrefix("continue")}/continue — Queue continue prompt`,
794
- `${formatTelegramCommandEmojiPrefix("abort")}/abort — Abort π`,
795
- `${formatTelegramCommandEmojiPrefix("stop")}/stop — Abort π & Clear queue`,
856
+ `${formatTelegramCommandEmojiPrefix("abort")}/abort — Abort Pi`,
857
+ `${formatTelegramCommandEmojiPrefix("stop")}/stop — Abort Pi & Clear queue`,
796
858
  ].join("\n");
797
859
 
798
860
  function escapeTelegramCommandMenuHtml(text: string): string {
@@ -825,15 +887,15 @@ function buildTelegramAppMenuIntroHtml(): string {
825
887
  const extensionLines = buildTelegramExtensionCommandMenuLines();
826
888
  if (extensionLines.length === 0) return TELEGRAM_APP_MENU_INTRO_HTML;
827
889
  return [
828
- "<b Telegram</b>",
890
+ "<b>Pi Telegram</b>",
829
891
  "",
830
892
  `${formatTelegramCommandEmojiPrefix("start")}/start — Open menu / Pair bridge`,
831
893
  `${formatTelegramCommandEmojiPrefix("compact")}/compact — Compact current session`,
832
894
  ...extensionLines,
833
895
  `${formatTelegramCommandEmojiPrefix("next")}/next — Force next turn`,
834
896
  `${formatTelegramCommandEmojiPrefix("continue")}/continue — Queue continue prompt`,
835
- `${formatTelegramCommandEmojiPrefix("abort")}/abort — Abort π`,
836
- `${formatTelegramCommandEmojiPrefix("stop")}/stop — Abort π & Clear queue`,
897
+ `${formatTelegramCommandEmojiPrefix("abort")}/abort — Abort Pi`,
898
+ `${formatTelegramCommandEmojiPrefix("stop")}/stop — Abort Pi & Clear queue`,
837
899
  ].join("\n");
838
900
  }
839
901
 
@@ -852,7 +914,7 @@ export function createTelegramAppMenuHtmlBuilder<TContext>(deps: {
852
914
  buildStatusHtml: (ctx: TContext) => string;
853
915
  getPromptTemplateCommands?: () => readonly TelegramPromptTemplateMenuCommand[];
854
916
  }): (ctx: TContext) => string {
855
- return function buildTelegramAppMenuHtmlForContext(ctx) {
917
+ return (ctx) => {
856
918
  return buildTelegramAppMenuHtml(
857
919
  deps.buildStatusHtml(ctx),
858
920
  deps.getPromptTemplateCommands?.(),
@@ -979,7 +1041,7 @@ export async function handleTelegramNextCommand(deps: {
979
1041
  return;
980
1042
  }
981
1043
  if (!deps.isIdle()) {
982
- await deps.sendTextReply("π is busy. Send /abort or /stop first.");
1044
+ await deps.sendTextReply("Pi is busy. Send /abort or /stop first.");
983
1045
  return;
984
1046
  }
985
1047
  deps.dispatchNextQueuedTurn();
@@ -1029,14 +1091,17 @@ export function getTelegramCompactConfirmationHtml(): string {
1029
1091
  }
1030
1092
 
1031
1093
  export async function openTelegramCompactConfirmation(
1032
- chatId: number,
1094
+ target: TelegramCommandMessageTarget,
1033
1095
  deps: TelegramCompactConfirmationDeps,
1034
1096
  ): Promise<void> {
1035
1097
  await deps.sendInteractiveMessage(
1036
- chatId,
1098
+ target.chatId,
1037
1099
  getTelegramCompactConfirmationHtml(),
1038
1100
  "html",
1039
1101
  buildTelegramCompactConfirmationReplyMarkup(),
1102
+ target.threadId !== undefined
1103
+ ? { target: { chatId: target.chatId, threadId: target.threadId } }
1104
+ : undefined,
1040
1105
  );
1041
1106
  }
1042
1107
 
@@ -1047,8 +1112,9 @@ export async function handleTelegramCompactConfirmationCallback<TContext>(
1047
1112
  if (query.data !== "compact:confirm" && query.data !== "compact:cancel") {
1048
1113
  return false;
1049
1114
  }
1050
- const chatId = query.message?.chat?.id;
1051
- const messageId = query.message?.message_id;
1115
+ const callbackMessage = query.message;
1116
+ const chatId = callbackMessage?.chat?.id;
1117
+ const messageId = callbackMessage?.message_id;
1052
1118
  if (typeof chatId !== "number" || typeof messageId !== "number") {
1053
1119
  await deps.answerCallbackQuery(query.id, "Interactive message expired.");
1054
1120
  return true;
@@ -1067,12 +1133,18 @@ export async function handleTelegramCompactConfirmationCallback<TContext>(
1067
1133
  await deps.editInteractiveMessage(
1068
1134
  chatId,
1069
1135
  messageId,
1070
- "Compaction started.",
1136
+ `${formatTelegramCommandEmojiPrefix("compact")}Compaction started.`,
1071
1137
  "plain",
1072
1138
  { inline_keyboard: [] },
1073
1139
  );
1074
1140
  await deps.answerCallbackQuery(query.id);
1075
- await deps.runCompact(deps.ctx, chatId, messageId);
1141
+ const threadId = callbackMessage?.message_thread_id;
1142
+ await deps.runCompact(
1143
+ deps.ctx,
1144
+ chatId,
1145
+ messageId,
1146
+ typeof threadId === "number" ? { chatId, threadId } : { chatId },
1147
+ );
1076
1148
  return true;
1077
1149
  }
1078
1150
 
@@ -1088,25 +1160,23 @@ export async function handleTelegramCompactCommand(
1088
1160
  deps.isCompactionInProgress()
1089
1161
  ) {
1090
1162
  await deps.sendTextReply(
1091
- "Cannot compact while π or the Telegram queue is busy. Wait for queued turns to finish or send /abort first.",
1163
+ "Cannot compact while Pi or the Telegram queue is busy. Wait for queued turns to finish or send /abort first.",
1092
1164
  );
1093
1165
  return;
1094
1166
  }
1095
1167
  deps.setCompactionInProgress(true);
1096
1168
  deps.updateStatus();
1097
- let compactionStillInProgress = true;
1169
+ deps.startTypingLoop?.();
1098
1170
  try {
1099
1171
  deps.compact({
1100
1172
  onComplete: () => {
1101
- compactionStillInProgress = false;
1102
1173
  deps.stopTypingLoop?.();
1103
1174
  deps.setCompactionInProgress(false);
1104
1175
  deps.updateStatus();
1105
1176
  dispatchNextQueuedTelegramTurnAfterCompact(deps);
1106
- void deps.sendTextReply("Compaction completed.");
1177
+ void deps.sendTextReply("Compaction completed.");
1107
1178
  },
1108
1179
  onError: (error) => {
1109
- compactionStillInProgress = false;
1110
1180
  deps.stopTypingLoop?.();
1111
1181
  deps.setCompactionInProgress(false);
1112
1182
  deps.updateStatus();
@@ -1117,7 +1187,6 @@ export async function handleTelegramCompactCommand(
1117
1187
  },
1118
1188
  });
1119
1189
  } catch (error) {
1120
- compactionStillInProgress = false;
1121
1190
  deps.stopTypingLoop?.();
1122
1191
  deps.setCompactionInProgress(false);
1123
1192
  deps.updateStatus();
@@ -1127,9 +1196,10 @@ export async function handleTelegramCompactCommand(
1127
1196
  return;
1128
1197
  }
1129
1198
  if (!deps.suppressStartNotice) {
1130
- await deps.sendTextReply("Compaction started.");
1199
+ await deps.sendTextReply(
1200
+ `${formatTelegramCommandEmojiPrefix("compact")}Compaction started.`,
1201
+ );
1131
1202
  }
1132
- if (compactionStillInProgress) deps.startTypingLoop?.();
1133
1203
  }
1134
1204
 
1135
1205
  function isTelegramStaleContextError(error: unknown): boolean {
@@ -1279,6 +1349,7 @@ export function createTelegramCommandHandlerTargetRuntime<
1279
1349
  openThinkingMenu: deps.openThinkingMenu,
1280
1350
  openQueueMenu: deps.openQueueMenu,
1281
1351
  openSettingsMenu: commandTargetRuntime.openSettingsMenu,
1352
+ handleForumBootstrap: deps.handleForumBootstrap,
1282
1353
  getAllowedUserId: deps.getAllowedUserId,
1283
1354
  setAllowedUserId: deps.setAllowedUserId,
1284
1355
  registerBotCommands: createTelegramBotCommandRegistrar({
@@ -1294,11 +1365,11 @@ export function createTelegramCommandHandler<
1294
1365
  TMessage extends TelegramCommandRuntimeMessage,
1295
1366
  TContext,
1296
1367
  >(deps: TelegramCommandRuntimeDeps<TMessage, TContext>) {
1297
- return async function handleTelegramCommand(
1368
+ return async (
1298
1369
  commandName: string | undefined,
1299
1370
  message: TMessage,
1300
1371
  ctx: TContext,
1301
- ): Promise<boolean> {
1372
+ ): Promise<boolean> => {
1302
1373
  return handleTelegramCommandRuntime(commandName, message, ctx, deps);
1303
1374
  };
1304
1375
  }
@@ -1313,6 +1384,7 @@ export function createTelegramCommandOrPromptRuntime<TMessage, TContext>(
1313
1384
  ): Promise<void> => {
1314
1385
  const firstMessage = messages[0];
1315
1386
  if (!firstMessage) return;
1387
+ if (deps.shouldIgnoreMessages?.(messages)) return;
1316
1388
  const command = parseTelegramCommand(deps.extractRawText(messages));
1317
1389
  const handled = await deps.handleCommand(
1318
1390
  command?.name,
@@ -1415,9 +1487,10 @@ async function handleTelegramCommandRuntime<
1415
1487
  },
1416
1488
  handleCompact: async (nextMessage, commandCtx) => {
1417
1489
  if (deps.sendInteractiveMessage) {
1418
- await openTelegramCompactConfirmation(nextMessage.chat.id, {
1419
- sendInteractiveMessage: deps.sendInteractiveMessage,
1420
- });
1490
+ await openTelegramCompactConfirmation(
1491
+ getTelegramCommandMessageTarget(nextMessage),
1492
+ { sendInteractiveMessage: deps.sendInteractiveMessage },
1493
+ );
1421
1494
  return;
1422
1495
  }
1423
1496
  await handleTelegramCompactCommand({
@@ -1440,7 +1513,10 @@ async function handleTelegramCommandRuntime<
1440
1513
  : undefined,
1441
1514
  compact: (callbacks) => deps.compact(commandCtx, callbacks),
1442
1515
  startTypingLoop: deps.startTypingLoop
1443
- ? () => deps.startTypingLoop?.(commandCtx, nextMessage.chat.id)
1516
+ ? () =>
1517
+ deps.startTypingLoop?.(commandCtx, nextMessage.chat.id, {
1518
+ target: getTelegramCommandMessageTarget(nextMessage),
1519
+ })
1444
1520
  : undefined,
1445
1521
  stopTypingLoop: deps.stopTypingLoop,
1446
1522
  sendTextReply: sendReplyFor(nextMessage),
@@ -1465,7 +1541,7 @@ async function handleTelegramCommandRuntime<
1465
1541
  await deps.openSettingsMenu?.(nextMessage, commandCtx);
1466
1542
  }
1467
1543
  : undefined,
1468
- handleHelp: async (nextMessage, _nextCommandName, commandCtx) => {
1544
+ handleHelp: async (nextMessage, nextCommandName, commandCtx) => {
1469
1545
  try {
1470
1546
  await deps.registerBotCommands();
1471
1547
  } catch (error) {
@@ -1475,7 +1551,10 @@ async function handleTelegramCommandRuntime<
1475
1551
  `Warning: failed to register bot commands menu: ${errorMessage}`,
1476
1552
  );
1477
1553
  }
1478
- if (nextMessage.from?.id !== undefined) {
1554
+ if (
1555
+ nextMessage.from?.id !== undefined &&
1556
+ canPairTelegramUserFromCommandMessage(nextMessage)
1557
+ ) {
1479
1558
  await pairTelegramUserIfNeeded(nextMessage.from.id, {
1480
1559
  allowedUserId: deps.getAllowedUserId(),
1481
1560
  ctx: undefined,
@@ -1484,6 +1563,13 @@ async function handleTelegramCommandRuntime<
1484
1563
  updateStatus: updateStatusFor(commandCtx),
1485
1564
  });
1486
1565
  }
1566
+ const forumBootstrapMessage =
1567
+ nextCommandName === "start"
1568
+ ? await deps.handleForumBootstrap?.(nextMessage, commandCtx)
1569
+ : undefined;
1570
+ if (forumBootstrapMessage) {
1571
+ await deps.sendTextReply(nextMessage, forumBootstrapMessage);
1572
+ }
1487
1573
  await deps.showStatus(nextMessage, commandCtx);
1488
1574
  },
1489
1575
  },
package/lib/config.ts CHANGED
@@ -120,8 +120,33 @@ export function updateTelegramVoiceConfig(
120
120
  return true;
121
121
  }
122
122
 
123
+ type TelegramMutableConfigStore = Pick<
124
+ TelegramConfigStore,
125
+ "get" | "set" | "persist"
126
+ > & {
127
+ load?: () => Promise<void>;
128
+ };
129
+
130
+ function isEmptyTelegramConfig(config: TelegramConfig): boolean {
131
+ return Object.keys(config).length === 0;
132
+ }
133
+
134
+ async function loadLatestTelegramConfig(
135
+ configStore: TelegramMutableConfigStore,
136
+ ): Promise<void> {
137
+ if (!configStore.load) return;
138
+ const before = configStore.get();
139
+ await configStore.load();
140
+ if (
141
+ !isEmptyTelegramConfig(before) &&
142
+ isEmptyTelegramConfig(configStore.get())
143
+ ) {
144
+ configStore.set(before);
145
+ }
146
+ }
147
+
123
148
  export function bindGlobalTelegramConfigRuntime(
124
- configStore: Pick<TelegramConfigStore, "get" | "set" | "persist">,
149
+ configStore: TelegramMutableConfigStore,
125
150
  ): void {
126
151
  setGlobalTelegramConfigRuntime({
127
152
  updateVoiceConfig(voice) {
@@ -224,9 +249,10 @@ export function createTelegramProactivePushChecker(
224
249
  }
225
250
 
226
251
  export function createTelegramProactivePushSetter(
227
- configStore: Pick<TelegramConfigStore, "get" | "set" | "persist">,
252
+ configStore: TelegramMutableConfigStore,
228
253
  ): (enabled: boolean) => Promise<void> {
229
254
  return async (enabled) => {
255
+ await loadLatestTelegramConfig(configStore);
230
256
  const config = { ...configStore.get(), proactivePush: enabled };
231
257
  configStore.set(config);
232
258
  await configStore.persist(config);
@@ -254,9 +280,10 @@ export function createTelegramVoiceReplyModeConfiguredChecker(
254
280
  }
255
281
 
256
282
  export function createTelegramVoiceReplyModeSetter(
257
- configStore: Pick<TelegramConfigStore, "get" | "set" | "persist">,
283
+ configStore: TelegramMutableConfigStore,
258
284
  ): (replyMode: "manual" | "mirror" | "always" | undefined) => Promise<void> {
259
285
  return async (replyMode) => {
286
+ await loadLatestTelegramConfig(configStore);
260
287
  const current = configStore.get();
261
288
  if (replyMode === undefined) {
262
289
  const { replyMode: _replyMode, ...remainingVoice } = current.voice ?? {};
@@ -310,9 +337,10 @@ export function createTelegramTimeInjectionModeGetter(
310
337
  }
311
338
 
312
339
  export function createTelegramTimeInjectionModeSetter(
313
- configStore: Pick<TelegramConfigStore, "get" | "set" | "persist">,
340
+ configStore: TelegramMutableConfigStore,
314
341
  ): (injectionMode: TelegramTimeMode) => Promise<void> {
315
342
  return async (injectionMode) => {
343
+ await loadLatestTelegramConfig(configStore);
316
344
  const current = configStore.get();
317
345
  if (injectionMode === "hidden") {
318
346
  const { injectionMode: _injectionMode, ...remainingTime } =
@@ -333,6 +361,11 @@ export function createTelegramTimeInjectionModeSetter(
333
361
  };
334
362
  }
335
363
 
364
+ export interface TelegramProactivePushTarget {
365
+ chatId: number;
366
+ threadId?: number;
367
+ }
368
+
336
369
  export function createTelegramProactivePushChatIdGetter(deps: {
337
370
  getActiveTurnChatId: () => number | undefined;
338
371
  getAllowedUserId: () => number | undefined;
@@ -340,8 +373,23 @@ export function createTelegramProactivePushChatIdGetter(deps: {
340
373
  return () => deps.getActiveTurnChatId() ?? deps.getAllowedUserId();
341
374
  }
342
375
 
376
+ export function createTelegramProactivePushTargetGetter(deps: {
377
+ getActiveTurnTarget: () => TelegramProactivePushTarget | undefined;
378
+ getAssignedTarget: () => TelegramProactivePushTarget | undefined;
379
+ getAllowedUserId: () => number | undefined;
380
+ }): () => TelegramProactivePushTarget | undefined {
381
+ return () => {
382
+ const activeTarget = deps.getActiveTurnTarget();
383
+ if (activeTarget) return activeTarget;
384
+ const assignedTarget = deps.getAssignedTarget();
385
+ if (assignedTarget) return assignedTarget;
386
+ const chatId = deps.getAllowedUserId();
387
+ return typeof chatId === "number" ? { chatId } : undefined;
388
+ };
389
+ }
390
+
343
391
  export function createTelegramConfigControls(
344
- configStore: Pick<TelegramConfigStore, "get" | "set" | "persist">,
392
+ configStore: TelegramMutableConfigStore,
345
393
  ) {
346
394
  return {
347
395
  isProactivePushEnabled: createTelegramProactivePushChecker(configStore),