@llblab/pi-telegram 0.6.2 → 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.
@@ -0,0 +1,150 @@
1
+ /**
2
+ * π prompt-template bridge helpers
3
+ * Zones: pi agent prompts, telegram controls, filesystem
4
+ * Discovers π prompt-template slash commands and expands them before Telegram queue dispatch
5
+ */
6
+
7
+ import { readFileSync } from "node:fs";
8
+ import type { PiSlashCommandInfo } from "./pi.ts";
9
+
10
+ export interface TelegramPromptTemplateCommand {
11
+ command: string;
12
+ description?: string;
13
+ path: string;
14
+ }
15
+
16
+ export type TelegramPromptTemplateReader = (path: string) => string;
17
+
18
+ const TELEGRAM_BOT_COMMAND_NAME_PATTERN = /^[a-z0-9_]{1,32}$/;
19
+
20
+ function stripPromptTemplateFrontmatter(content: string): string {
21
+ if (!content.startsWith("---")) return content;
22
+ const lines = content.split("\n");
23
+ if (lines[0]?.trim() !== "---") return content;
24
+ for (let index = 1; index < lines.length; index += 1) {
25
+ if (lines[index]?.trim() === "---")
26
+ return lines.slice(index + 1).join("\n");
27
+ }
28
+ return content;
29
+ }
30
+
31
+ export function parsePromptTemplateArgs(argsString: string): string[] {
32
+ const args: string[] = [];
33
+ let current = "";
34
+ let quote: string | undefined;
35
+ for (const char of argsString) {
36
+ if (quote) {
37
+ if (char === quote) {
38
+ quote = undefined;
39
+ } else {
40
+ current += char;
41
+ }
42
+ continue;
43
+ }
44
+ if (char === '"' || char === "'") {
45
+ quote = char;
46
+ continue;
47
+ }
48
+ if (char === " " || char === "\t") {
49
+ if (current) args.push(current);
50
+ current = "";
51
+ continue;
52
+ }
53
+ current += char;
54
+ }
55
+ if (current) args.push(current);
56
+ return args;
57
+ }
58
+
59
+ export function substitutePromptTemplateArgs(
60
+ content: string,
61
+ args: readonly string[],
62
+ ): string {
63
+ let result = content.replace(/\$(\d+)/g, (_, num: string) => {
64
+ const index = Number.parseInt(num, 10) - 1;
65
+ return args[index] ?? "";
66
+ });
67
+ result = result.replace(
68
+ /\$\{@:(\d+)(?::(\d+))?\}/g,
69
+ (_, startValue: string, lengthValue: string | undefined) => {
70
+ const start = Math.max(Number.parseInt(startValue, 10) - 1, 0);
71
+ if (lengthValue) {
72
+ const length = Number.parseInt(lengthValue, 10);
73
+ return args.slice(start, start + length).join(" ");
74
+ }
75
+ return args.slice(start).join(" ");
76
+ },
77
+ );
78
+ const allArgs = args.join(" ");
79
+ return result.replace(/\$ARGUMENTS/g, allArgs).replace(/\$@/g, allArgs);
80
+ }
81
+
82
+ export function isTelegramPromptTemplateCommandName(name: string): boolean {
83
+ return TELEGRAM_BOT_COMMAND_NAME_PATTERN.test(name);
84
+ }
85
+
86
+ export function mapPiPromptTemplateNameToTelegramCommandName(
87
+ name: string,
88
+ ): string | undefined {
89
+ const command = name
90
+ .toLowerCase()
91
+ .replace(/[^a-z0-9_]+/g, "_")
92
+ .replace(/_+/g, "_")
93
+ .replace(/^_+|_+$/g, "")
94
+ .slice(0, 32)
95
+ .replace(/_+$/g, "");
96
+ return isTelegramPromptTemplateCommandName(command) ? command : undefined;
97
+ }
98
+
99
+ export interface TelegramPromptTemplateCommandGetterDeps {
100
+ getCommands: () => readonly PiSlashCommandInfo[];
101
+ reservedCommandNames?: readonly string[];
102
+ }
103
+
104
+ export function getTelegramPromptTemplateCommands(
105
+ commands: readonly PiSlashCommandInfo[],
106
+ reservedNames: ReadonlySet<string> = new Set(),
107
+ ): TelegramPromptTemplateCommand[] {
108
+ const seen = new Set<string>();
109
+ const promptCommands: TelegramPromptTemplateCommand[] = [];
110
+ for (const command of commands) {
111
+ if (command.source !== "prompt") continue;
112
+ const telegramCommand = mapPiPromptTemplateNameToTelegramCommandName(
113
+ command.name,
114
+ );
115
+ if (!telegramCommand) continue;
116
+ if (reservedNames.has(telegramCommand)) continue;
117
+ if (seen.has(telegramCommand)) continue;
118
+ seen.add(telegramCommand);
119
+ promptCommands.push({
120
+ command: telegramCommand,
121
+ description: command.description,
122
+ path: command.sourceInfo.path,
123
+ });
124
+ }
125
+ return promptCommands.sort((a, b) => a.command.localeCompare(b.command));
126
+ }
127
+
128
+ export function createTelegramPromptTemplateCommandGetter(
129
+ deps: TelegramPromptTemplateCommandGetterDeps,
130
+ ): () => TelegramPromptTemplateCommand[] {
131
+ const reservedNames = new Set(deps.reservedCommandNames);
132
+ return function getPromptTemplateCommands() {
133
+ return getTelegramPromptTemplateCommands(deps.getCommands(), reservedNames);
134
+ };
135
+ }
136
+
137
+ export function expandTelegramPromptTemplateCommand(
138
+ commandName: string,
139
+ args: string,
140
+ commands: readonly TelegramPromptTemplateCommand[],
141
+ readTemplate: TelegramPromptTemplateReader = (path) =>
142
+ readFileSync(path, "utf-8"),
143
+ ): string | undefined {
144
+ const command = commands.find(
145
+ (candidate) => candidate.command === commandName,
146
+ );
147
+ if (!command) return undefined;
148
+ const content = stripPromptTemplateFrontmatter(readTemplate(command.path));
149
+ return substitutePromptTemplateArgs(content, parsePromptTemplateArgs(args));
150
+ }
package/lib/prompts.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  /**
2
2
  * Telegram prompt injection helpers
3
+ * Zones: pi agent prompts, telegram guidance
3
4
  * Owns Telegram-specific system prompt suffixes injected into pi agent turns
4
5
  */
5
6
 
@@ -9,15 +10,22 @@ import { TELEGRAM_PREFIX } from "./turns.ts";
9
10
  const SYSTEM_PROMPT_SUFFIX = `
10
11
 
11
12
  Telegram bridge extension is active.
12
- - Messages forwarded from Telegram are prefixed with "[telegram]".
13
- - [telegram] messages may include [attachments] sections with a base directory plus relative local file entries. Resolve and read those files as needed.
14
- - [telegram] messages may include a [reply] block after the user's current text. Treat [reply] as quoted context from the Telegram message the user replied to, not as a new instruction by itself; use it to resolve references like "this", "it", or "that message". The actual new user instruction is the message text before [reply], unless it explicitly asks you to act on the quoted context.
15
- - Telegram is often read on narrow phone screens, so prefer narrow table columns when presenting tabular data; wide monospace tables can become unreadable.
16
- - If a [telegram] user asked for a file or generated artifact, use telegram_attach with the local path instead of only mentioning the path in text.
17
- - Do not assume mentioning a local file path in plain text will send it to Telegram. Use telegram_attach.
18
- - For Telegram-native outbound actions, use hidden top-level Markdown comments instead of agent-side tool calls: write a normal answer plus correctly formatted column-zero \`telegram_voice\` or \`telegram_button\` blocks outside code, quotes, and lists. The bridge handles delivery after \`agent_end\`, so do not call or register transport/TTS/text-to-OGG tools for these actions.
19
- - A \`telegram_voice\` block body is the text to synthesize through the extension's configured outbound-handler pipeline. It may be a short companion summary when useful, but no specific summary format is required. Keep it TTS-friendly; avoid raw Markdown, code, formulas, tables, or long lists.
20
- - Button blocks should contain quick reply prompts the user can tap; use independent blocks like \`<!-- telegram_button label="OK"\nPrompt text\n-->\`, or \`<!-- telegram_button label="OK" -->\` when the prompt should equal the label. The callback prompt is routed back as a normal Telegram turn.`;
13
+
14
+ Inbound context:
15
+ - \`[telegram]\` marks Telegram-originated messages.
16
+ - \`[reply]\` is quoted context from the replied-to message, not a new instruction by itself. Use it to resolve references like "this", "it", or "that message"; the actual instruction is before [reply] unless it explicitly asks to act on the quote.
17
+ - \`[attachments]\` gives a base directory plus relative local files; resolve and read them as needed. \`[outputs]\` contains attachment-handler stdout such as transcriptions or extracted text for those attachments.
18
+
19
+ Telegram-visible output:
20
+ - Telegram is often phone-width; prefer narrow table columns because wide monospace tables can become unreadable.
21
+ - For requested/generated files, call tool \`telegram_attach(local_path)\`; mentioning a local path in text does not send it.
22
+
23
+ Native outbound actions:
24
+ - Use top-level column-zero hidden Markdown comments outside code, quotes, and lists; the bridge handles them after agent_end, so do not call or register transport/TTS/text-to-OGG tools.
25
+ - \`telegram_voice\`: text is synthesized through the configured outbound-handler pipeline. Use body text for multiline voice, \`<!-- telegram_voice text="Short summary" -->\` for explicit one-line voice, or \`<!-- telegram_voice: Short summary -->\` for one-line voice with no attributes. A companion summary is optional, no specific summary format is required. Keep it TTS-friendly; avoid raw Markdown, code, formulas, tables, or long lists.
26
+ - \`telegram_button\`: callback prompt is routed back as a normal Telegram turn. Use \`<!-- telegram_button: OK -->\` when prompt equals label, \`<!-- telegram_button label=Continue prompt="Continue with the current plan." -->\` for one-line prompts, or body form \`<!-- telegram_button label="Show risks"\nList the main risks first.\n-->\` for multiline prompts.
27
+ - If only hidden action comments would remain, add visible parent text like "Choose one:".
28
+ `;
21
29
 
22
30
  export function buildTelegramBridgeSystemPrompt(options: {
23
31
  prompt: string;
package/lib/queue.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  /**
2
- * Telegram queue and queue-runtime domain helpers
3
- * Owns queue items, queue mutations, dispatch and lifecycle planning, session resets, and queue-adjacent runtime helpers
2
+ * Telegram queue core contracts and pure planning helpers
3
+ * Zones: telegram queue, pi agent lifecycle, scheduling
4
+ * Owns queue item contracts, lane admission, pure queue mutations, and dispatch planning
4
5
  */
5
6
 
6
7
  // --- Queue Items ---
@@ -178,6 +179,14 @@ export function createTelegramQueueStore<TContext = unknown>(
178
179
  };
179
180
  }
180
181
 
182
+ export function createTelegramQueueItemCountGetter<TContext = unknown>(
183
+ store: Pick<TelegramQueueStore<TContext>, "getQueuedItems">,
184
+ ): () => number {
185
+ return function getTelegramQueueItemCount() {
186
+ return store.getQueuedItems().length;
187
+ };
188
+ }
189
+
181
190
  export function createTelegramActiveTurnStore<
182
191
  TTurn extends PendingTelegramTurn = PendingTelegramTurn,
183
192
  >(): TelegramActiveTurnStore<TTurn> {
@@ -344,7 +353,7 @@ function formatTelegramQueueItemStatusSummary<TContext = unknown>(
344
353
  item: TelegramQueueItem<TContext>,
345
354
  ): string {
346
355
  if (item.queueLane === "priority") {
347
- return `⬆ ${item.statusSummary}`;
356
+ return `⚡ ${item.statusSummary}`;
348
357
  }
349
358
  return item.statusSummary;
350
359
  }
@@ -665,18 +674,32 @@ export type TelegramAgentLifecycleHooksRuntimeDeps<
665
674
  TTurn extends PendingTelegramTurn,
666
675
  TContext,
667
676
  TMessage,
677
+ TReplyMarkup = unknown,
668
678
  > = TelegramAgentStartHookRuntimeDeps<TTurn, TContext> &
669
- TelegramAgentEndHookRuntimeDeps<TTurn, TContext, TMessage> &
679
+ TelegramAgentEndHookRuntimeDeps<TTurn, TContext, TMessage, TReplyMarkup> &
670
680
  TelegramToolExecutionHookRuntimeDeps<TContext>;
671
681
 
672
682
  export function createTelegramAgentLifecycleHooks<
673
683
  TTurn extends PendingTelegramTurn,
674
684
  TContext,
675
685
  TMessage,
676
- >(deps: TelegramAgentLifecycleHooksRuntimeDeps<TTurn, TContext, TMessage>) {
686
+ TReplyMarkup = unknown,
687
+ >(
688
+ deps: TelegramAgentLifecycleHooksRuntimeDeps<
689
+ TTurn,
690
+ TContext,
691
+ TMessage,
692
+ TReplyMarkup
693
+ >,
694
+ ) {
677
695
  return {
678
696
  onAgentStart: createTelegramAgentStartHook<TTurn, TContext>(deps),
679
- onAgentEnd: createTelegramAgentEndHook<TTurn, TContext, TMessage>(deps),
697
+ onAgentEnd: createTelegramAgentEndHook<
698
+ TTurn,
699
+ TContext,
700
+ TMessage,
701
+ TReplyMarkup
702
+ >(deps),
680
703
  ...createTelegramToolExecutionHooks<TContext>(deps),
681
704
  };
682
705
  }
@@ -737,6 +760,7 @@ export interface TelegramAgentEndOutboundReplyPlan<TReplyMarkup = unknown> {
737
760
 
738
761
  export interface TelegramAgentEndRuntimeDeps<
739
762
  TTurn extends PendingTelegramTurn,
763
+ TReplyMarkup = unknown,
740
764
  > {
741
765
  turn: TTurn | undefined;
742
766
  assistant: TelegramAgentEndAssistantResult;
@@ -750,13 +774,13 @@ export interface TelegramAgentEndRuntimeDeps<
750
774
  chatId: number,
751
775
  markdown: string,
752
776
  replyToMessageId: number,
753
- options?: { replyMarkup?: unknown },
777
+ options?: { replyMarkup?: TReplyMarkup },
754
778
  ) => Promise<boolean>;
755
779
  sendMarkdownReply: (
756
780
  chatId: number,
757
781
  replyToMessageId: number,
758
782
  markdown: string,
759
- options?: { replyMarkup?: unknown },
783
+ options?: { replyMarkup?: TReplyMarkup },
760
784
  ) => Promise<unknown>;
761
785
  sendTextReply: (
762
786
  chatId: number,
@@ -764,7 +788,9 @@ export interface TelegramAgentEndRuntimeDeps<
764
788
  text: string,
765
789
  ) => Promise<unknown>;
766
790
  sendQueuedAttachments: (turn: TTurn) => Promise<void>;
767
- planOutboundReply?: (markdown: string) => TelegramAgentEndOutboundReplyPlan;
791
+ planOutboundReply?: (
792
+ markdown: string,
793
+ ) => TelegramAgentEndOutboundReplyPlan<TReplyMarkup>;
768
794
  sendOutboundReplyArtifacts?: (
769
795
  turn: TTurn,
770
796
  plan: TelegramAgentEndOutboundReplyPlan,
@@ -776,6 +802,7 @@ export interface TelegramAgentEndHookRuntimeDeps<
776
802
  TTurn extends PendingTelegramTurn,
777
803
  TContext,
778
804
  TMessage,
805
+ TReplyMarkup = unknown,
779
806
  > {
780
807
  getActiveTurn: () => TTurn | undefined;
781
808
  extractAssistant: (
@@ -790,11 +817,11 @@ export interface TelegramAgentEndHookRuntimeDeps<
790
817
  ) => void;
791
818
  clearPreview: (chatId: number) => Promise<void>;
792
819
  setPreviewPendingText: (text: string) => void;
793
- finalizeMarkdownPreview: TelegramAgentEndRuntimeDeps<TTurn>["finalizeMarkdownPreview"];
794
- sendMarkdownReply: TelegramAgentEndRuntimeDeps<TTurn>["sendMarkdownReply"];
820
+ finalizeMarkdownPreview: TelegramAgentEndRuntimeDeps<TTurn, TReplyMarkup>["finalizeMarkdownPreview"];
821
+ sendMarkdownReply: TelegramAgentEndRuntimeDeps<TTurn, TReplyMarkup>["sendMarkdownReply"];
795
822
  sendTextReply: TelegramAgentEndRuntimeDeps<TTurn>["sendTextReply"];
796
823
  sendQueuedAttachments: (turn: TTurn) => Promise<void>;
797
- planOutboundReply?: TelegramAgentEndRuntimeDeps<TTurn>["planOutboundReply"];
824
+ planOutboundReply?: TelegramAgentEndRuntimeDeps<TTurn, TReplyMarkup>["planOutboundReply"];
798
825
  sendOutboundReplyArtifacts?: TelegramAgentEndRuntimeDeps<TTurn>["sendOutboundReplyArtifacts"];
799
826
  }
800
827
 
@@ -872,7 +899,15 @@ export function createTelegramAgentEndHook<
872
899
  TTurn extends PendingTelegramTurn,
873
900
  TContext,
874
901
  TMessage,
875
- >(deps: TelegramAgentEndHookRuntimeDeps<TTurn, TContext, TMessage>) {
902
+ TReplyMarkup = unknown,
903
+ >(
904
+ deps: TelegramAgentEndHookRuntimeDeps<
905
+ TTurn,
906
+ TContext,
907
+ TMessage,
908
+ TReplyMarkup
909
+ >,
910
+ ) {
876
911
  return async function onAgentEnd(
877
912
  event: TelegramAgentEndHookEvent<TMessage>,
878
913
  ctx: TContext,
@@ -903,7 +938,8 @@ export function createTelegramAgentEndHook<
903
938
 
904
939
  export async function handleTelegramAgentEndRuntime<
905
940
  TTurn extends PendingTelegramTurn,
906
- >(deps: TelegramAgentEndRuntimeDeps<TTurn>): Promise<void> {
941
+ TReplyMarkup = unknown,
942
+ >(deps: TelegramAgentEndRuntimeDeps<TTurn, TReplyMarkup>): Promise<void> {
907
943
  const { turn, assistant } = deps;
908
944
  const rawFinalText = assistant.text;
909
945
  const outboundReply = rawFinalText
@@ -934,7 +970,7 @@ export async function handleTelegramAgentEndRuntime<
934
970
  turn.chatId,
935
971
  turn.replyToMessageId,
936
972
  assistant.errorMessage ||
937
- "Telegram bridge: pi failed while processing the request.",
973
+ "Telegram bridge: π failed while processing the request.",
938
974
  );
939
975
  if (endPlan.shouldDispatchNext) deps.dispatchNextQueuedTelegramTurn();
940
976
  return;
package/lib/rendering.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  /**
2
2
  * Telegram preview and markdown rendering helpers
3
+ * Zones: telegram rendering, shared text utils
3
4
  * Converts assistant output into Telegram-safe plain text and HTML chunks with chunk-boundary handling
4
5
  */
5
6
 
package/lib/replies.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  /**
2
2
  * Telegram reply delivery helpers
3
+ * Zones: telegram outbound, rendering transport
3
4
  * Owns rendered-message delivery, reply transport wiring, and plain or markdown final replies
4
5
  */
5
6
 
@@ -10,10 +11,48 @@ import {
10
11
  type TelegramRenderMode,
11
12
  } from "./rendering.ts";
12
13
 
14
+ // --- Reply Dedup ---
15
+
16
+ /** Non-persistent reply deduplication for a single agent turn.
17
+ * First reply to a prompt gets `reply_parameters.reply_to_message_id`;
18
+ * subsequent replies in the same turn skip it to avoid stacking
19
+ * duplicate reply headers in the chat viewport. */
20
+ export interface ReplyDedupRuntime {
21
+ /** Returns true if this is the first reply for the given prompt
22
+ * message id in the current turn. Side-effect: marks it replied. */
23
+ shouldReply(promptMessageId: number): boolean;
24
+ /** Reset the tracker when a new prompt enters the queue. */
25
+ reset(): void;
26
+ }
27
+
28
+ export function createReplyDedupRuntime(): ReplyDedupRuntime {
29
+ const replied = new Map<number, boolean>();
30
+ return {
31
+ shouldReply(promptMessageId: number): boolean {
32
+ if (replied.has(promptMessageId)) return false;
33
+ replied.set(promptMessageId, true);
34
+ return true;
35
+ },
36
+ reset(): void {
37
+ replied.clear();
38
+ },
39
+ };
40
+ }
41
+
42
+ // --- Transport-level dedup ---
43
+
44
+ let lastRepliedToMessageId: number | undefined;
45
+
46
+ export function resetTransportReplyDedup(): void {
47
+ lastRepliedToMessageId = undefined;
48
+ }
49
+
13
50
  export function buildTelegramReplyParameters(
14
51
  messageId: number | undefined,
15
52
  ): TelegramReplyParameters | undefined {
16
53
  if (messageId === undefined) return undefined;
54
+ if (messageId === lastRepliedToMessageId) return undefined;
55
+ lastRepliedToMessageId = messageId;
17
56
  return { message_id: messageId, allow_sending_without_reply: true };
18
57
  }
19
58
 
@@ -219,13 +258,13 @@ export interface TelegramRenderedMessageRuntimeDeps<TReplyMarkup> {
219
258
  export interface TelegramRenderedMessageRuntime<TReplyMarkup> {
220
259
  sendTextReply: (
221
260
  chatId: number,
222
- replyToMessageId: number,
261
+ replyToMessageId: number | undefined,
223
262
  text: string,
224
263
  options?: { parseMode?: "HTML" },
225
264
  ) => Promise<number | undefined>;
226
265
  sendMarkdownReply: (
227
266
  chatId: number,
228
- replyToMessageId: number,
267
+ replyToMessageId: number | undefined,
229
268
  markdown: string,
230
269
  options?: { replyMarkup?: unknown },
231
270
  ) => Promise<number | undefined>;
@@ -332,3 +371,48 @@ export function createTelegramRenderedMessageRuntime<TReplyMarkup>(
332
371
  },
333
372
  };
334
373
  }
374
+
375
+ // --- Dedup-wrapped Reply Wrappers ---
376
+
377
+ /** Wrap a sendTextReply with reply dedup so only the first message
378
+ * in a turn carries `reply_to_message_id`. */
379
+ export function dedupSendTextReply(
380
+ dedup: ReplyDedupRuntime,
381
+ inner: (
382
+ chatId: number,
383
+ replyToMessageId: number | undefined,
384
+ text: string,
385
+ options?: { parseMode?: "HTML" },
386
+ ) => Promise<number | undefined>,
387
+ ): (
388
+ chatId: number,
389
+ replyToMessageId: number,
390
+ text: string,
391
+ options?: { parseMode?: "HTML" },
392
+ ) => Promise<number | undefined> {
393
+ return async (chatId, replyToMessageId, text, options) => {
394
+ const effectiveReplyTo = dedup.shouldReply(replyToMessageId) ? replyToMessageId : undefined;
395
+ return inner(chatId, effectiveReplyTo, text, options);
396
+ };
397
+ }
398
+
399
+ /** Wrap a sendMarkdownReply with reply dedup. */
400
+ export function dedupSendMarkdownReply<TReplyMarkup = unknown>(
401
+ dedup: ReplyDedupRuntime,
402
+ inner: (
403
+ chatId: number,
404
+ replyToMessageId: number | undefined,
405
+ markdown: string,
406
+ options?: { replyMarkup?: TReplyMarkup },
407
+ ) => Promise<number | undefined>,
408
+ ): (
409
+ chatId: number,
410
+ replyToMessageId: number,
411
+ markdown: string,
412
+ options?: { replyMarkup?: TReplyMarkup },
413
+ ) => Promise<number | undefined> {
414
+ return async (chatId, replyToMessageId, markdown, options) => {
415
+ const effectiveReplyTo = dedup.shouldReply(replyToMessageId) ? replyToMessageId : undefined;
416
+ return inner(chatId, effectiveReplyTo, markdown, options);
417
+ };
418
+ }
package/lib/routing.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  /**
2
2
  * Telegram inbound routing composition
3
+ * Zones: telegram inbound, orchestration, queue/menu/command composition
3
4
  * Wires authorized updates into menus, commands, media grouping, and prompt queueing
4
5
  */
5
6
 
@@ -11,6 +12,7 @@ import * as Media from "./media.ts";
11
12
  import * as Menu from "./menu.ts";
12
13
  import * as Model from "./model.ts";
13
14
  import * as Queue from "./queue.ts";
15
+ import * as PromptTemplates from "./prompt-templates.ts";
14
16
  import type { TelegramBridgeRuntime } from "./runtime.ts";
15
17
  import * as Turns from "./turns.ts";
16
18
  import * as Updates from "./updates.ts";
@@ -25,11 +27,6 @@ export type TelegramRoutedCallbackQuery = Updates.TelegramCallbackQuery &
25
27
  Menu.MenuCallbackQuery;
26
28
 
27
29
  export interface TelegramInboundRouteRuntimeDeps<
28
- TUpdate extends Updates.TelegramUpdateFlow & {
29
- message?: TMessage;
30
- edited_message?: TMessage;
31
- callback_query?: TCallbackQuery;
32
- },
33
30
  TMessage extends TelegramRoutedMessage,
34
31
  TCallbackQuery extends TelegramRoutedCallbackQuery,
35
32
  TContext,
@@ -51,6 +48,15 @@ export interface TelegramInboundRouteRuntimeDeps<
51
48
  Model.ScopedTelegramModel<TModel>
52
49
  >;
53
50
  menuActions: Menu.TelegramMenuActionRuntime<TContext, TModel>;
51
+ openQueueMenu: (
52
+ chatId: number,
53
+ replyToMessageId: number,
54
+ ctx: TContext,
55
+ ) => Promise<void>;
56
+ queueMenuCallbackHandler: (
57
+ query: TCallbackQuery,
58
+ ctx: TContext,
59
+ ) => Promise<boolean>;
54
60
  buttonActionStore?: OutboundHandlers.TelegramButtonActionStore;
55
61
  attachmentHandlerRuntime: TelegramAttachmentHandlerRuntime<TContext>;
56
62
  updateStatus: (ctx: TContext, error?: string) => void;
@@ -65,6 +71,7 @@ export interface TelegramInboundRouteRuntimeDeps<
65
71
  text: string,
66
72
  ) => Promise<number | undefined>;
67
73
  setMyCommands: Commands.TelegramBotCommandRegistrationDeps["setMyCommands"];
74
+ getCommands: () => Parameters<typeof PromptTemplates.getTelegramPromptTemplateCommands>[0];
68
75
  downloadFile: Media.DownloadTelegramMessageFilesDeps["downloadFile"];
69
76
  getThinkingLevel: () => Model.ThinkingLevel;
70
77
  setThinkingLevel: (level: Model.ThinkingLevel) => void;
@@ -94,7 +101,6 @@ export function createTelegramInboundRouteRuntime<
94
101
  TModel extends Model.MenuModel,
95
102
  >(
96
103
  deps: TelegramInboundRouteRuntimeDeps<
97
- TUpdate,
98
104
  TMessage,
99
105
  TCallbackQuery,
100
106
  TContext,
@@ -159,8 +165,53 @@ export function createTelegramInboundRouteRuntime<
159
165
  );
160
166
  if (handled) return;
161
167
  }
168
+ const handledByQueue = await deps.queueMenuCallbackHandler(query, ctx);
169
+ if (handledByQueue) return;
162
170
  await menuCallbackHandler(query, ctx);
163
171
  };
172
+ const promptTurnBuilder = Turns.createTelegramPromptTurnRuntimeBuilder<
173
+ TMessage,
174
+ TContext
175
+ >({
176
+ allocateQueueOrder: deps.bridgeRuntime.queue.allocateItemOrder,
177
+ downloadFile: deps.downloadFile,
178
+ processAttachments: deps.attachmentHandlerRuntime.process,
179
+ });
180
+ const enqueueContinueTurn = async (
181
+ message: TMessage,
182
+ ctx: TContext,
183
+ ): Promise<void> => {
184
+ const enqueuePlan = Queue.planTelegramPromptEnqueue(
185
+ deps.telegramQueueStore.getQueuedItems(),
186
+ deps.bridgeRuntime.lifecycle.shouldPreserveQueuedTurnsAsHistory(),
187
+ );
188
+ deps.bridgeRuntime.lifecycle.setPreserveQueuedTurnsAsHistory(false);
189
+ const continueMessage = {
190
+ ...message,
191
+ text: "continue",
192
+ caption: undefined,
193
+ } as TMessage;
194
+ const turn = await promptTurnBuilder(
195
+ [continueMessage],
196
+ enqueuePlan.historyTurns,
197
+ ctx,
198
+ );
199
+ const continueTurn = {
200
+ ...turn,
201
+ queueLane: "priority" as const,
202
+ laneOrder: Number.MIN_SAFE_INTEGER + turn.queueOrder,
203
+ statusSummary: "continue",
204
+ };
205
+ deps.telegramQueueStore.setQueuedItems(enqueuePlan.remainingItems);
206
+ deps.queueMutationRuntime.append(continueTurn, ctx);
207
+ deps.dispatchNextQueuedTelegramTurn(ctx);
208
+ };
209
+ const reservedCommandNames = new Set(Commands.TELEGRAM_RESERVED_COMMAND_NAMES);
210
+ const getPromptTemplateCommands = () =>
211
+ PromptTemplates.getTelegramPromptTemplateCommands(
212
+ deps.getCommands(),
213
+ reservedCommandNames,
214
+ );
164
215
  const commandHandler = Commands.createTelegramCommandHandlerTargetRuntime<
165
216
  TMessage,
166
217
  TContext
@@ -181,15 +232,25 @@ export function createTelegramInboundRouteRuntime<
181
232
  deps.bridgeRuntime.lifecycle.setCompactionInProgress,
182
233
  updateStatus: deps.updateStatus,
183
234
  dispatchNextQueuedTelegramTurn: deps.dispatchNextQueuedTelegramTurn,
235
+ enqueueContinueTurn,
184
236
  compact: deps.compact,
185
237
  allocateItemOrder: deps.bridgeRuntime.queue.allocateItemOrder,
186
238
  allocateControlOrder: deps.bridgeRuntime.queue.allocateControlOrder,
187
239
  appendControlItem: deps.queueMutationRuntime.append,
188
240
  showStatus: deps.menuActions.sendStatusMessage,
189
241
  openModelMenu: deps.menuActions.openModelMenu,
242
+ openThinkingMenu: (message, ctx) => {
243
+ const chatId = (message as { chat: { id: number } }).chat.id;
244
+ return deps.menuActions.openThinkingMenu(chatId, message.message_id, ctx);
245
+ },
246
+ openQueueMenu: (message, ctx) => {
247
+ const chatId = (message as { chat: { id: number } }).chat.id;
248
+ return deps.openQueueMenu(chatId, message.message_id, ctx);
249
+ },
190
250
  getAllowedUserId: deps.configStore.getAllowedUserId,
191
251
  setAllowedUserId: deps.configStore.setAllowedUserId,
192
252
  setMyCommands: deps.setMyCommands,
253
+ getPromptTemplateCommands,
193
254
  persistConfig: deps.configStore.persist,
194
255
  sendTextReply: deps.sendTextReply,
195
256
  recordRuntimeEvent: deps.recordRuntimeEvent,
@@ -203,14 +264,7 @@ export function createTelegramInboundRouteRuntime<
203
264
  deps.bridgeRuntime.lifecycle.shouldPreserveQueuedTurnsAsHistory,
204
265
  setPreserveQueuedTurnsAsHistory:
205
266
  deps.bridgeRuntime.lifecycle.setPreserveQueuedTurnsAsHistory,
206
- createTurn: Turns.createTelegramPromptTurnRuntimeBuilder<
207
- TMessage,
208
- TContext
209
- >({
210
- allocateQueueOrder: deps.bridgeRuntime.queue.allocateItemOrder,
211
- downloadFile: deps.downloadFile,
212
- processAttachments: deps.attachmentHandlerRuntime.process,
213
- }),
267
+ createTurn: promptTurnBuilder,
214
268
  updateStatus: deps.updateStatus,
215
269
  dispatchNextQueuedTelegramTurn: deps.dispatchNextQueuedTelegramTurn,
216
270
  }).enqueue;
@@ -220,6 +274,14 @@ export function createTelegramInboundRouteRuntime<
220
274
  >({
221
275
  extractRawText: Media.extractFirstTelegramMessageText,
222
276
  handleCommand: commandHandler,
277
+ expandPromptTemplateCommand: (commandName, args) =>
278
+ PromptTemplates.expandTelegramPromptTemplateCommand(
279
+ commandName,
280
+ args,
281
+ getPromptTemplateCommands(),
282
+ ),
283
+ replaceMessageText: (message, text) =>
284
+ ({ ...message, text, caption: undefined }) as TMessage,
223
285
  enqueueTurn: promptEnqueue,
224
286
  });
225
287
  const mediaDispatch = Media.createTelegramMediaGroupDispatchRuntime<
package/lib/runtime.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  /**
2
2
  * Telegram bridge runtime-state helpers
3
+ * Zones: pi agent runtime state, telegram session, shared coordination
3
4
  * Owns small session-local runtime primitives that are shared by orchestration but are not specific to queueing, rendering, polling, or Telegram transport
4
5
  */
5
6
 
@@ -350,6 +351,7 @@ export function createTelegramTypingLoopStarter<TContext>(
350
351
  } catch (error) {
351
352
  const message =
352
353
  error instanceof Error ? error.message : String(error);
354
+ deps.updateStatus(ctx, message);
353
355
  deps.recordRuntimeEvent?.("typing", error, {
354
356
  chatId: targetChatId,
355
357
  });
package/lib/setup.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  /**
2
2
  * Telegram setup prompt helpers
3
+ * Zones: pi agent command ui, telegram config
3
4
  * Computes token-prefill defaults and prompt mode selection for /telegram-setup
4
5
  */
5
6