@llblab/pi-telegram 0.6.3 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/commands.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  /**
2
2
  * Telegram command routing helpers
3
+ * Zones: telegram controls, pi agent commands, queue controls
3
4
  * Owns Telegram slash-command normalization, bot command metadata, and pi-side command registration behind runtime ports
4
5
  */
5
6
 
@@ -21,33 +22,123 @@ export interface TelegramBotCommandDefinition {
21
22
  description: string;
22
23
  }
23
24
 
24
- export const TELEGRAM_BOT_COMMANDS: readonly TelegramBotCommandDefinition[] = [
25
+ export interface TelegramPromptTemplateMenuCommand {
26
+ command: string;
27
+ description?: string;
28
+ }
29
+
30
+ export const TELEGRAM_COMMAND_EMOJI = {
31
+ start: "đŸŸĸ",
32
+ status: "📊",
33
+ model: "🤖",
34
+ thinking: "🧠",
35
+ compact: "🗜",
36
+ queue: "đŸ”ĸ",
37
+ next: "⏊",
38
+ continue: "â–ļī¸",
39
+ abort: "âšī¸",
40
+ stop: "đŸŸĨ",
41
+ } as const;
42
+
43
+ export type TelegramCommandEmojiName = keyof typeof TELEGRAM_COMMAND_EMOJI;
44
+
45
+ export function getTelegramCommandEmoji(
46
+ command: TelegramCommandEmojiName,
47
+ ): string {
48
+ return TELEGRAM_COMMAND_EMOJI[command];
49
+ }
50
+
51
+ export function formatTelegramCommandEmojiPrefix(
52
+ command: TelegramCommandEmojiName,
53
+ ): string {
54
+ return `${getTelegramCommandEmoji(command)} `;
55
+ }
56
+
57
+ function formatTelegramBotCommandDescription(
58
+ command: TelegramCommandEmojiName,
59
+ description: string,
60
+ ): string {
61
+ return `${formatTelegramCommandEmojiPrefix(command)}${description}`;
62
+ }
63
+
64
+ export const TELEGRAM_BUILTIN_BOT_COMMANDS: readonly TelegramBotCommandDefinition[] = [
25
65
  {
26
66
  command: "start",
27
- description: "Show help and pair the Telegram bridge",
67
+ description: formatTelegramBotCommandDescription(
68
+ "start",
69
+ "Open menu / Pair bridge",
70
+ ),
71
+ },
72
+ {
73
+ command: "compact",
74
+ description: formatTelegramBotCommandDescription(
75
+ "compact",
76
+ "Compact current session",
77
+ ),
78
+ },
79
+ {
80
+ command: "next",
81
+ description: formatTelegramBotCommandDescription(
82
+ "next",
83
+ "Force next turn",
84
+ ),
28
85
  },
29
86
  {
30
- command: "status",
31
- description: "Show model, usage, cost, and context status",
87
+ command: "continue",
88
+ description: formatTelegramBotCommandDescription(
89
+ "continue",
90
+ "Queue continue prompt",
91
+ ),
92
+ },
93
+ {
94
+ command: "abort",
95
+ description: formatTelegramBotCommandDescription("abort", "Abort ΀"),
32
96
  },
33
- { command: "model", description: "Open the interactive model selector" },
34
- { command: "compact", description: "Compact the current pi session" },
35
97
  {
36
98
  command: "stop",
37
- description: "Abort the current pi task and clear queued turns",
99
+ description: formatTelegramBotCommandDescription(
100
+ "stop",
101
+ "Abort ΀ & Clear queue",
102
+ ),
38
103
  },
39
104
  ];
40
105
 
106
+ export const TELEGRAM_BOT_COMMANDS = TELEGRAM_BUILTIN_BOT_COMMANDS;
107
+
108
+ const TELEGRAM_MAX_BOT_COMMANDS = 100;
109
+ const TELEGRAM_BOT_COMMAND_DESCRIPTION_LIMIT = 256;
110
+
111
+ function truncateTelegramBotCommandDescription(description: string): string {
112
+ if (description.length <= TELEGRAM_BOT_COMMAND_DESCRIPTION_LIMIT) return description;
113
+ return `${description.slice(0, TELEGRAM_BOT_COMMAND_DESCRIPTION_LIMIT - 1)}â€Ļ`;
114
+ }
115
+
116
+ export function buildTelegramBotCommands(
117
+ promptTemplates: readonly TelegramPromptTemplateMenuCommand[] = [],
118
+ ): TelegramBotCommandDefinition[] {
119
+ const remainingSlots = TELEGRAM_MAX_BOT_COMMANDS - TELEGRAM_BUILTIN_BOT_COMMANDS.length;
120
+ const templateCommands = promptTemplates.slice(0, remainingSlots).map((template) => ({
121
+ command: template.command,
122
+ description: truncateTelegramBotCommandDescription(
123
+ `🧩 ${template.description?.trim() || "Prompt template"}`,
124
+ ),
125
+ }));
126
+ return [...TELEGRAM_BUILTIN_BOT_COMMANDS, ...templateCommands];
127
+ }
128
+
41
129
  export interface TelegramBotCommandRegistrationDeps {
42
130
  setMyCommands: (
43
131
  commands: readonly TelegramBotCommandDefinition[],
44
132
  ) => Promise<unknown>;
133
+ getPromptTemplateCommands?: () => readonly TelegramPromptTemplateMenuCommand[];
45
134
  }
46
135
 
47
136
  export async function registerTelegramBotCommands(
48
137
  deps: TelegramBotCommandRegistrationDeps,
49
138
  ): Promise<void> {
50
- await deps.setMyCommands(TELEGRAM_BOT_COMMANDS);
139
+ await deps.setMyCommands(
140
+ buildTelegramBotCommands(deps.getPromptTemplateCommands?.()),
141
+ );
51
142
  }
52
143
 
53
144
  export function createTelegramBotCommandRegistrar(
@@ -95,7 +186,7 @@ function formatTelegramTakeoverPrompt(
95
186
  const action = theme.fg("warning", "move singleton lock here?");
96
187
  const from = theme.fg("muted", "from:");
97
188
  const to = theme.fg("muted", "to:");
98
- const source = owner ?? "another pi instance";
189
+ const source = owner ?? "another ΀ instance";
99
190
  return `${action}\n\n${from} ${source}\n${to} ${ctx.cwd}`;
100
191
  }
101
192
 
@@ -116,7 +207,7 @@ export function registerTelegramBridgeCommands(
116
207
  },
117
208
  });
118
209
  pi.registerCommand("telegram-connect", {
119
- description: "Start the Telegram bridge in this pi session",
210
+ description: "Start the Telegram bridge in this ΀ session",
120
211
  handler: async (_args, ctx) => {
121
212
  await deps.reloadConfig();
122
213
  if (!deps.hasBotToken()) {
@@ -143,7 +234,7 @@ export function registerTelegramBridgeCommands(
143
234
  },
144
235
  });
145
236
  pi.registerCommand("telegram-disconnect", {
146
- description: "Stop the Telegram bridge in this pi session",
237
+ description: "Stop the Telegram bridge in this ΀ session",
147
238
  handler: async (_args, ctx) => {
148
239
  const message = await deps.stopPolling();
149
240
  if (message) ctx.ui.notify(message, "info");
@@ -152,12 +243,44 @@ export function registerTelegramBridgeCommands(
152
243
  });
153
244
  }
154
245
 
246
+ export const TELEGRAM_RESERVED_COMMAND_NAMES = [
247
+ "stop",
248
+ "abort",
249
+ "next",
250
+ "continue",
251
+ "status",
252
+ "queue",
253
+ "compact",
254
+ "model",
255
+ "thinking",
256
+ "help",
257
+ "start",
258
+ ] as const;
259
+
260
+ export type TelegramReservedCommandName =
261
+ (typeof TELEGRAM_RESERVED_COMMAND_NAMES)[number];
262
+
263
+ const TELEGRAM_RESERVED_COMMAND_NAME_SET = new Set<string>(
264
+ TELEGRAM_RESERVED_COMMAND_NAMES,
265
+ );
266
+
267
+ export function isTelegramReservedCommandName(
268
+ commandName: string | undefined,
269
+ ): commandName is TelegramReservedCommandName {
270
+ return commandName !== undefined && TELEGRAM_RESERVED_COMMAND_NAME_SET.has(commandName);
271
+ }
272
+
155
273
  export type TelegramCommandAction =
156
274
  | { kind: "ignore"; executionMode: "ignored" }
157
275
  | { kind: "stop"; executionMode: "immediate" }
276
+ | { kind: "abort"; executionMode: "immediate" }
277
+ | { kind: "next"; executionMode: "immediate" }
278
+ | { kind: "continue"; executionMode: "immediate" }
279
+ | { kind: "queue"; executionMode: "immediate" }
158
280
  | { kind: "compact"; executionMode: "immediate" }
159
281
  | { kind: "status"; executionMode: "immediate" }
160
282
  | { kind: "model"; executionMode: "immediate" }
283
+ | { kind: "thinking"; executionMode: "immediate" }
161
284
  | {
162
285
  kind: "help";
163
286
  commandName: "help" | "start";
@@ -168,9 +291,14 @@ export type TelegramCommandExecutionMode = "ignored" | "immediate";
168
291
 
169
292
  export interface TelegramCommandActionDeps<TMessage, TContext> {
170
293
  handleStop: (message: TMessage, ctx: TContext) => Promise<void>;
294
+ handleAbort: (message: TMessage, ctx: TContext) => Promise<void>;
295
+ handleNext: (message: TMessage, ctx: TContext) => Promise<void>;
296
+ handleContinue: (message: TMessage, ctx: TContext) => Promise<void>;
297
+ handleQueue: (message: TMessage, ctx: TContext) => Promise<void>;
171
298
  handleCompact: (message: TMessage, ctx: TContext) => Promise<void>;
172
299
  handleStatus: (message: TMessage, ctx: TContext) => Promise<void>;
173
300
  handleModel: (message: TMessage, ctx: TContext) => Promise<void>;
301
+ handleThinking: (message: TMessage, ctx: TContext) => Promise<void>;
174
302
  handleHelp: (
175
303
  message: TMessage,
176
304
  commandName: "help" | "start",
@@ -213,16 +341,6 @@ export interface TelegramCompactCommandDeps extends TelegramRuntimeEventRecorder
213
341
  sendTextReply: (text: string) => Promise<void>;
214
342
  }
215
343
 
216
- export interface TelegramHelpCommandDeps {
217
- senderUserId?: number;
218
- getAllowedUserId: () => number | undefined;
219
- setAllowedUserId: (userId: number) => void;
220
- registerBotCommands: () => Promise<void>;
221
- persistConfig: () => Promise<void>;
222
- updateStatus: () => void;
223
- sendTextReply: (text: string) => Promise<void>;
224
- }
225
-
226
344
  export type TelegramControlCommandType =
227
345
  PendingTelegramControlItem<unknown>["controlType"];
228
346
 
@@ -401,6 +519,11 @@ export interface TelegramCommandOrPromptRuntimeDeps<TMessage, TContext> {
401
519
  message: TMessage,
402
520
  ctx: TContext,
403
521
  ) => Promise<boolean>;
522
+ expandPromptTemplateCommand?: (
523
+ commandName: string,
524
+ args: string,
525
+ ) => string | undefined;
526
+ replaceMessageText: (message: TMessage, text: string) => TMessage;
404
527
  enqueueTurn: (messages: TMessage[], ctx: TContext) => Promise<void>;
405
528
  }
406
529
 
@@ -422,6 +545,7 @@ export interface TelegramCommandRuntimeDeps<
422
545
  setCompactionInProgress: (inProgress: boolean) => void;
423
546
  updateStatus: (ctx: TContext) => void;
424
547
  dispatchNextQueuedTelegramTurn: (ctx: TContext) => void;
548
+ enqueueContinueTurn: (message: TMessage, ctx: TContext) => Promise<void>;
425
549
  compact: (
426
550
  ctx: TContext,
427
551
  callbacks: { onComplete: () => void; onError: (error: unknown) => void },
@@ -435,15 +559,63 @@ export interface TelegramCommandRuntimeDeps<
435
559
  ) => void;
436
560
  showStatus: (message: TMessage, ctx: TContext) => Promise<void>;
437
561
  openModelMenu: (message: TMessage, ctx: TContext) => Promise<void>;
562
+ openThinkingMenu: (message: TMessage, ctx: TContext) => Promise<void>;
563
+ openQueueMenu: (message: TMessage, ctx: TContext) => Promise<void>;
438
564
  getAllowedUserId: () => number | undefined;
439
565
  setAllowedUserId: (userId: number) => void;
440
566
  registerBotCommands: () => Promise<void>;
567
+ getPromptTemplateCommands?: () => readonly TelegramPromptTemplateMenuCommand[];
441
568
  persistConfig: () => Promise<void>;
442
569
  sendTextReply: (message: TMessage, text: string) => Promise<void>;
443
570
  }
444
571
 
445
- export const TELEGRAM_HELP_TEXT =
446
- "Send me a message and I will forward it to pi. Commands: /status, /model, /compact, /stop. /stop aborts the current run and clears queued Telegram turns.";
572
+ export const TELEGRAM_APP_MENU_INTRO_HTML = [
573
+ "<b>Ī€ Telegram bridge</b>",
574
+ "",
575
+ `${formatTelegramCommandEmojiPrefix("start")}/start — Open menu / Pair bridge`,
576
+ `${formatTelegramCommandEmojiPrefix("compact")}/compact — Compact current session`,
577
+ `${formatTelegramCommandEmojiPrefix("next")}/next — Force next turn`,
578
+ `${formatTelegramCommandEmojiPrefix("continue")}/continue — Queue continue prompt`,
579
+ `${formatTelegramCommandEmojiPrefix("abort")}/abort — Abort Ī€`,
580
+ `${formatTelegramCommandEmojiPrefix("stop")}/stop — Abort Ī€ & Clear queue`,
581
+ ].join("\n");
582
+
583
+ function escapeTelegramCommandMenuHtml(text: string): string {
584
+ return text
585
+ .replace(/&/g, "&amp;")
586
+ .replace(/</g, "&lt;")
587
+ .replace(/>/g, "&gt;");
588
+ }
589
+
590
+ function buildTelegramPromptTemplateMenuHtml(
591
+ promptTemplates: readonly TelegramPromptTemplateMenuCommand[] = [],
592
+ ): string {
593
+ if (promptTemplates.length === 0) return "";
594
+ return promptTemplates
595
+ .map((template) => `🧩 /${escapeTelegramCommandMenuHtml(template.command)}`)
596
+ .join("\n");
597
+ }
598
+
599
+ export function buildTelegramAppMenuHtml(
600
+ statusHtml: string,
601
+ promptTemplates: readonly TelegramPromptTemplateMenuCommand[] = [],
602
+ ): string {
603
+ const promptTemplateHtml = buildTelegramPromptTemplateMenuHtml(promptTemplates);
604
+ if (!promptTemplateHtml) return `${TELEGRAM_APP_MENU_INTRO_HTML}\n\n${statusHtml}`;
605
+ return `${TELEGRAM_APP_MENU_INTRO_HTML}\n\n${promptTemplateHtml}\n\n${statusHtml}`;
606
+ }
607
+
608
+ export function createTelegramAppMenuHtmlBuilder<TContext>(deps: {
609
+ buildStatusHtml: (ctx: TContext) => string;
610
+ getPromptTemplateCommands?: () => readonly TelegramPromptTemplateMenuCommand[];
611
+ }): (ctx: TContext) => string {
612
+ return function buildTelegramAppMenuHtmlForContext(ctx) {
613
+ return buildTelegramAppMenuHtml(
614
+ deps.buildStatusHtml(ctx),
615
+ deps.getPromptTemplateCommands?.(),
616
+ );
617
+ };
618
+ }
447
619
 
448
620
  function getTelegramCommandErrorMessage(error: unknown): string {
449
621
  return error instanceof Error ? error.message : String(error);
@@ -460,24 +632,27 @@ export function parseTelegramCommand(
460
632
  return { name, args: tail.join(" ").trim() };
461
633
  }
462
634
 
635
+ export const TELEGRAM_COMMAND_ACTIONS = {
636
+ stop: { kind: "stop", executionMode: "immediate" },
637
+ abort: { kind: "abort", executionMode: "immediate" },
638
+ next: { kind: "next", executionMode: "immediate" },
639
+ continue: { kind: "continue", executionMode: "immediate" },
640
+ status: { kind: "status", executionMode: "immediate" },
641
+ queue: { kind: "queue", executionMode: "immediate" },
642
+ compact: { kind: "compact", executionMode: "immediate" },
643
+ model: { kind: "model", executionMode: "immediate" },
644
+ thinking: { kind: "thinking", executionMode: "immediate" },
645
+ help: { kind: "help", commandName: "help", executionMode: "immediate" },
646
+ start: { kind: "help", commandName: "start", executionMode: "immediate" },
647
+ } as const satisfies Record<TelegramReservedCommandName, TelegramCommandAction>;
648
+
463
649
  export function buildTelegramCommandAction(
464
650
  commandName: string | undefined,
465
651
  ): TelegramCommandAction {
466
- switch (commandName) {
467
- case "stop":
468
- return { kind: "stop", executionMode: "immediate" };
469
- case "compact":
470
- return { kind: "compact", executionMode: "immediate" };
471
- case "status":
472
- return { kind: "status", executionMode: "immediate" };
473
- case "model":
474
- return { kind: "model", executionMode: "immediate" };
475
- case "help":
476
- case "start":
477
- return { kind: "help", commandName, executionMode: "immediate" };
478
- default:
479
- return { kind: "ignore", executionMode: "ignored" };
652
+ if (!isTelegramReservedCommandName(commandName)) {
653
+ return { kind: "ignore", executionMode: "ignored" };
480
654
  }
655
+ return TELEGRAM_COMMAND_ACTIONS[commandName];
481
656
  }
482
657
 
483
658
  export function getTelegramCommandExecutionMode(
@@ -514,6 +689,69 @@ export async function handleTelegramStopCommand(
514
689
  await deps.sendTextReply(`Aborted current turn.${clearedSuffix}`);
515
690
  }
516
691
 
692
+ export async function handleTelegramAbortCommand(deps: {
693
+ hasAbortHandler: () => boolean;
694
+ clearPendingModelSwitch: () => void;
695
+ abortCurrentTurn: () => void;
696
+ setPreserveForIdle: () => void;
697
+ updateStatus: () => void;
698
+ sendTextReply: (text: string) => Promise<void>;
699
+ }): Promise<void> {
700
+ deps.clearPendingModelSwitch();
701
+ if (!deps.hasAbortHandler()) {
702
+ await deps.sendTextReply("No active turn.");
703
+ return;
704
+ }
705
+ deps.setPreserveForIdle();
706
+ deps.abortCurrentTurn();
707
+ deps.updateStatus();
708
+ await deps.sendTextReply("Aborted current turn.");
709
+ }
710
+
711
+ export async function handleTelegramNextCommand(deps: {
712
+ hasAbortHandler: () => boolean;
713
+ isIdle: () => boolean;
714
+ hasQueuedItems: () => boolean;
715
+ clearPendingModelSwitch: () => void;
716
+ abortCurrentTurn: () => void;
717
+ dispatchNextQueuedTurn: () => void;
718
+ setPreserveForDispatch: () => void;
719
+ updateStatus: () => void;
720
+ sendTextReply: (text: string) => Promise<void>;
721
+ }): Promise<void> {
722
+ deps.clearPendingModelSwitch();
723
+ if (!deps.hasQueuedItems()) {
724
+ await deps.sendTextReply("<b>Queue is empty.</b>");
725
+ return;
726
+ }
727
+ if (!deps.isIdle() && deps.hasAbortHandler()) {
728
+ deps.setPreserveForDispatch();
729
+ deps.abortCurrentTurn();
730
+ deps.updateStatus();
731
+ await deps.sendTextReply(
732
+ "Aborted current turn. Dispatching next queued turn.",
733
+ );
734
+ return;
735
+ }
736
+ if (!deps.isIdle()) {
737
+ await deps.sendTextReply("Ī€ is busy. Send /abort or /stop first.");
738
+ return;
739
+ }
740
+ deps.dispatchNextQueuedTurn();
741
+ deps.updateStatus();
742
+ await deps.sendTextReply("Dispatching next queued turn.");
743
+ }
744
+
745
+ export async function handleTelegramContinueCommand<TMessage, TContext>(
746
+ message: TMessage,
747
+ ctx: TContext,
748
+ deps: {
749
+ enqueueContinueTurn: (message: TMessage, ctx: TContext) => Promise<void>;
750
+ },
751
+ ): Promise<void> {
752
+ await deps.enqueueContinueTurn(message, ctx);
753
+ }
754
+
517
755
  export async function handleTelegramCompactCommand(
518
756
  deps: TelegramCompactCommandDeps,
519
757
  ): Promise<void> {
@@ -526,7 +764,7 @@ export async function handleTelegramCompactCommand(
526
764
  deps.isCompactionInProgress()
527
765
  ) {
528
766
  await deps.sendTextReply(
529
- "Cannot compact while pi or the Telegram queue is busy. Wait for queued turns to finish or send /stop first.",
767
+ "Cannot compact while ΀ or the Telegram queue is busy. Wait for queued turns to finish or send /stop first.",
530
768
  );
531
769
  return;
532
770
  }
@@ -560,30 +798,6 @@ export async function handleTelegramCompactCommand(
560
798
  await deps.sendTextReply("Compaction started.");
561
799
  }
562
800
 
563
- export async function handleTelegramHelpCommand(
564
- commandName: "help" | "start",
565
- deps: TelegramHelpCommandDeps,
566
- ): Promise<void> {
567
- let helpText = TELEGRAM_HELP_TEXT;
568
- if (commandName === "start") {
569
- try {
570
- await deps.registerBotCommands();
571
- } catch (error) {
572
- const errorMessage = getTelegramCommandErrorMessage(error);
573
- helpText += `\n\nWarning: failed to register bot commands menu: ${errorMessage}`;
574
- }
575
- }
576
- await deps.sendTextReply(helpText);
577
- if (deps.senderUserId === undefined) return;
578
- await pairTelegramUserIfNeeded(deps.senderUserId, {
579
- allowedUserId: deps.getAllowedUserId(),
580
- ctx: undefined,
581
- setAllowedUserId: deps.setAllowedUserId,
582
- persistConfig: deps.persistConfig,
583
- updateStatus: deps.updateStatus,
584
- });
585
- }
586
-
587
801
  export async function handleTelegramStatusCommand<TContext>(deps: {
588
802
  ctx: TContext;
589
803
  showStatus: (ctx: TContext) => Promise<void>;
@@ -610,6 +824,18 @@ export async function executeTelegramCommandAction<TMessage, TContext>(
610
824
  case "stop":
611
825
  await deps.handleStop(message, ctx);
612
826
  return true;
827
+ case "abort":
828
+ await deps.handleAbort(message, ctx);
829
+ return true;
830
+ case "next":
831
+ await deps.handleNext(message, ctx);
832
+ return true;
833
+ case "continue":
834
+ await deps.handleContinue(message, ctx);
835
+ return true;
836
+ case "queue":
837
+ await deps.handleQueue(message, ctx);
838
+ return true;
613
839
  case "compact":
614
840
  await deps.handleCompact(message, ctx);
615
841
  return true;
@@ -619,6 +845,9 @@ export async function executeTelegramCommandAction<TMessage, TContext>(
619
845
  case "model":
620
846
  await deps.handleModel(message, ctx);
621
847
  return true;
848
+ case "thinking":
849
+ await deps.handleThinking(message, ctx);
850
+ return true;
622
851
  case "help":
623
852
  await deps.handleHelp(message, action.commandName, ctx);
624
853
  return true;
@@ -683,14 +912,18 @@ export function createTelegramCommandHandlerTargetRuntime<
683
912
  setCompactionInProgress: deps.setCompactionInProgress,
684
913
  updateStatus: deps.updateStatus,
685
914
  dispatchNextQueuedTelegramTurn: deps.dispatchNextQueuedTelegramTurn,
915
+ enqueueContinueTurn: deps.enqueueContinueTurn,
686
916
  compact: deps.compact,
687
917
  enqueueControlItem: commandTargetRuntime.enqueueControlItem,
688
918
  showStatus: commandTargetRuntime.showStatus,
689
919
  openModelMenu: commandTargetRuntime.openModelMenu,
920
+ openThinkingMenu: deps.openThinkingMenu,
921
+ openQueueMenu: deps.openQueueMenu,
690
922
  getAllowedUserId: deps.getAllowedUserId,
691
923
  setAllowedUserId: deps.setAllowedUserId,
692
924
  registerBotCommands: createTelegramBotCommandRegistrar({
693
925
  setMyCommands: deps.setMyCommands,
926
+ getPromptTemplateCommands: deps.getPromptTemplateCommands,
694
927
  }),
695
928
  persistConfig: deps.persistConfig,
696
929
  sendTextReply: commandTargetRuntime.sendTextReply,
@@ -721,11 +954,22 @@ export function createTelegramCommandOrPromptRuntime<TMessage, TContext>(
721
954
  ): Promise<void> => {
722
955
  const firstMessage = messages[0];
723
956
  if (!firstMessage) return;
724
- const commandName = parseTelegramCommand(
725
- deps.extractRawText(messages),
726
- )?.name;
727
- const handled = await deps.handleCommand(commandName, firstMessage, ctx);
957
+ const command = parseTelegramCommand(deps.extractRawText(messages));
958
+ const handled = await deps.handleCommand(command?.name, firstMessage, ctx);
728
959
  if (handled) return;
960
+ if (command?.name && deps.expandPromptTemplateCommand) {
961
+ const expanded = deps.expandPromptTemplateCommand(
962
+ command.name,
963
+ command.args,
964
+ );
965
+ if (expanded !== undefined) {
966
+ await deps.enqueueTurn(
967
+ [deps.replaceMessageText(firstMessage, expanded), ...messages.slice(1)],
968
+ ctx,
969
+ );
970
+ return;
971
+ }
972
+ }
729
973
  await deps.enqueueTurn(messages, ctx);
730
974
  },
731
975
  };
@@ -761,6 +1005,39 @@ async function handleTelegramCommandRuntime<
761
1005
  sendTextReply: sendReplyFor(nextMessage),
762
1006
  });
763
1007
  },
1008
+ handleAbort: async (nextMessage, commandCtx) => {
1009
+ await handleTelegramAbortCommand({
1010
+ hasAbortHandler: deps.hasAbortHandler,
1011
+ clearPendingModelSwitch: deps.clearPendingModelSwitch,
1012
+ abortCurrentTurn: deps.abortCurrentTurn,
1013
+ setPreserveForIdle: () => deps.setPreserveQueuedTurnsAsHistory(true),
1014
+ updateStatus: updateStatusFor(commandCtx),
1015
+ sendTextReply: sendReplyFor(nextMessage),
1016
+ });
1017
+ },
1018
+ handleNext: async (nextMessage, commandCtx) => {
1019
+ await handleTelegramNextCommand({
1020
+ hasAbortHandler: deps.hasAbortHandler,
1021
+ isIdle: () => deps.isIdle(commandCtx),
1022
+ hasQueuedItems: deps.hasQueuedTelegramItems,
1023
+ clearPendingModelSwitch: deps.clearPendingModelSwitch,
1024
+ abortCurrentTurn: deps.abortCurrentTurn,
1025
+ dispatchNextQueuedTurn: () =>
1026
+ deps.dispatchNextQueuedTelegramTurn(commandCtx),
1027
+ setPreserveForDispatch: () =>
1028
+ deps.setPreserveQueuedTurnsAsHistory(false),
1029
+ updateStatus: updateStatusFor(commandCtx),
1030
+ sendTextReply: sendReplyFor(nextMessage),
1031
+ });
1032
+ },
1033
+ handleContinue: async (nextMessage, commandCtx) => {
1034
+ await handleTelegramContinueCommand(nextMessage, commandCtx, {
1035
+ enqueueContinueTurn: deps.enqueueContinueTurn,
1036
+ });
1037
+ },
1038
+ handleQueue: async (nextMessage, commandCtx) => {
1039
+ await deps.openQueueMenu(nextMessage, commandCtx);
1040
+ },
764
1041
  handleCompact: async (nextMessage, commandCtx) => {
765
1042
  await handleTelegramCompactCommand({
766
1043
  isIdle: () => deps.isIdle(commandCtx),
@@ -779,10 +1056,7 @@ async function handleTelegramCommandRuntime<
779
1056
  });
780
1057
  },
781
1058
  handleStatus: async (nextMessage, commandCtx) => {
782
- await handleTelegramStatusCommand<TContext>({
783
- ctx: commandCtx,
784
- showStatus: (controlCtx) => deps.showStatus(nextMessage, controlCtx),
785
- });
1059
+ await deps.showStatus(nextMessage, commandCtx);
786
1060
  },
787
1061
  handleModel: async (nextMessage, commandCtx) => {
788
1062
  await handleTelegramModelCommand<TContext>({
@@ -791,16 +1065,29 @@ async function handleTelegramCommandRuntime<
791
1065
  deps.openModelMenu(nextMessage, controlCtx),
792
1066
  });
793
1067
  },
794
- handleHelp: async (nextMessage, nextCommandName, commandCtx) => {
795
- await handleTelegramHelpCommand(nextCommandName, {
796
- senderUserId: nextMessage.from?.id,
797
- getAllowedUserId: deps.getAllowedUserId,
798
- setAllowedUserId: deps.setAllowedUserId,
799
- registerBotCommands: deps.registerBotCommands,
800
- persistConfig: deps.persistConfig,
801
- updateStatus: updateStatusFor(commandCtx),
802
- sendTextReply: sendReplyFor(nextMessage),
803
- });
1068
+ handleThinking: async (nextMessage, commandCtx) => {
1069
+ await deps.openThinkingMenu(nextMessage, commandCtx);
1070
+ },
1071
+ handleHelp: async (nextMessage, _nextCommandName, commandCtx) => {
1072
+ try {
1073
+ await deps.registerBotCommands();
1074
+ } catch (error) {
1075
+ const errorMessage = getTelegramCommandErrorMessage(error);
1076
+ await deps.sendTextReply(
1077
+ nextMessage,
1078
+ `Warning: failed to register bot commands menu: ${errorMessage}`,
1079
+ );
1080
+ }
1081
+ if (nextMessage.from?.id !== undefined) {
1082
+ await pairTelegramUserIfNeeded(nextMessage.from.id, {
1083
+ allowedUserId: deps.getAllowedUserId(),
1084
+ ctx: undefined,
1085
+ setAllowedUserId: deps.setAllowedUserId,
1086
+ persistConfig: deps.persistConfig,
1087
+ updateStatus: updateStatusFor(commandCtx),
1088
+ });
1089
+ }
1090
+ await deps.showStatus(nextMessage, commandCtx);
804
1091
  },
805
1092
  },
806
1093
  );
package/lib/config.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * Telegram bridge config and pairing helpers
3
+ * Zones: telegram config, pairing, filesystem
3
4
  * Owns persisted bot/session pairing state, local config storage, authorization policy, and first-user pairing side effects
4
5
  */
5
6
 
6
7
  import { existsSync } from "node:fs";
7
- import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
8
+ import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
8
9
  import { homedir } from "node:os";
9
10
  import { join, resolve } from "node:path";
10
11
 
@@ -76,10 +77,13 @@ export async function writeTelegramConfig(
76
77
  config: TelegramConfig,
77
78
  ): Promise<void> {
78
79
  await mkdir(agentDir, { recursive: true });
79
- await writeFile(configPath, JSON.stringify(config, null, "\t") + "\n", {
80
+ const tempConfigPath = `${configPath}.tmp-${process.pid}-${Date.now()}`;
81
+ await writeFile(tempConfigPath, JSON.stringify(config, null, "\t") + "\n", {
80
82
  encoding: "utf8",
81
83
  mode: 0o600,
82
84
  });
85
+ await chmod(tempConfigPath, 0o600);
86
+ await rename(tempConfigPath, configPath);
83
87
  await chmod(configPath, 0o600);
84
88
  }
85
89
 
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Telegram inline-keyboard structural contracts
3
+ * Zones: telegram ui, shared structure
4
+ * Owns the shared Bot API reply-markup shape while feature domains own their button semantics
5
+ */
6
+
7
+ export interface TelegramInlineKeyboardButton {
8
+ text: string;
9
+ callback_data: string;
10
+ }
11
+
12
+ export interface TelegramInlineKeyboardMarkup {
13
+ inline_keyboard: TelegramInlineKeyboardButton[][];
14
+ }