@llblab/pi-telegram 0.10.8 → 0.11.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/preview.ts CHANGED
@@ -20,8 +20,10 @@ import {
20
20
  type TelegramRenderedChunk,
21
21
  type TelegramRenderMode,
22
22
  } from "./rendering.ts";
23
+
23
24
  import { buildTelegramReplyParameters } from "./replies.ts";
24
25
  import { stripTelegramCommentMarkupForPreview } from "./outbound-handlers.ts";
26
+ import { shouldSuppressPreviewForVoice } from "./voice.ts";
25
27
 
26
28
  const TELEGRAM_PREVIEW_THROTTLE_MS = 750;
27
29
  const TELEGRAM_DRAFT_ID_MAX = 2_147_483_647;
@@ -104,6 +106,8 @@ export interface TelegramPreviewRuntimeDeps<
104
106
 
105
107
  export interface TelegramPreviewActiveTurn {
106
108
  chatId: number;
109
+ voiceReplyPreferred?: boolean;
110
+ voiceReplyRequired?: boolean;
107
111
  }
108
112
 
109
113
  export interface TelegramAssistantMessagePreviewStartDeps<
@@ -504,6 +508,10 @@ export async function handleTelegramAssistantMessagePreviewStart<
504
508
  ): Promise<void> {
505
509
  const turn = deps.getActiveTurn();
506
510
  if (!turn || !deps.isAssistantMessage(message)) return;
511
+ if (shouldSuppressPreviewForVoice(turn)) {
512
+ deps.setState(undefined);
513
+ return;
514
+ }
507
515
  const state = deps.getState();
508
516
  if (
509
517
  state &&
@@ -526,6 +534,7 @@ export async function handleTelegramAssistantMessagePreviewUpdate<TMessage>(
526
534
  ): Promise<void> {
527
535
  const turn = deps.getActiveTurn();
528
536
  if (!turn || !deps.isAssistantMessage(message)) return;
537
+ if (shouldSuppressPreviewForVoice(turn)) return;
529
538
  let state = deps.getState();
530
539
  if (!state) {
531
540
  state = deps.createPreviewState();
package/lib/prompts.ts CHANGED
@@ -15,6 +15,7 @@ Inbound context:
15
15
  - \`[telegram]\` marks Telegram-originated messages. Suffixes \`|from:user\` (sender) and \`|guest:group\` (guest mode — message from another chat where the bot is not a member) may be present; the bot sees the message as if forwarded from that user/chat.
16
16
  - \`[reply]\` is quoted context from the replied-to message, not a new instruction by itself. Suffix \`|from:user\` identifies the original author in guest-mode replies. 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
17
  - \`[attachments]\` gives a base directory plus relative local files; resolve and read them as needed. \`[outputs]\` contains inbound-handler stdout such as transcriptions or extracted text for those attachments.
18
+ - \`[voice]\` describes Telegram voice reply policy for this turn. \`manual\` means answer normally and use explicit \`telegram_voice\` markup only when a spoken reply is useful; \`mirror\` means voice input prefers a voice reply; \`always\` means the final reply is expected to be converted to voice, so keep it TTS-friendly.
18
19
  - Unknown \`[callback] ...\` messages may be intended for another extension; if you see one, say the callback was not handled and the environment may be misconfigured.
19
20
 
20
21
  Telegram-visible output:
@@ -25,7 +26,7 @@ Telegram-visible output:
25
26
 
26
27
  Native outbound actions:
27
28
  - 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.
28
- - \`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.
29
+ - \`telegram_voice\`: text is synthesized by the registered voice synthesis provider and delivered by pi-telegram. 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.
29
30
  - \`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.
30
31
  - If only hidden action comments would remain, add visible parent text like "Choose one:".
31
32
  `;
package/lib/queue.ts CHANGED
@@ -4,6 +4,8 @@
4
4
  * Owns queue item contracts, lane admission, pure queue mutations, and dispatch planning
5
5
  */
6
6
 
7
+ import { isVoiceTurn } from "./voice.ts";
8
+
7
9
  // --- Queue Items ---
8
10
 
9
11
  export interface QueuedAttachment {
@@ -80,6 +82,11 @@ export interface PendingTelegramTurn extends TelegramQueueItemBase {
80
82
  content: TelegramPromptContent[];
81
83
  historyText: string;
82
84
  priorityEmoji?: string;
85
+
86
+ /** Turn should preferably be delivered as voice (mirror mode + user sent voice) */
87
+ voiceReplyPreferred?: boolean;
88
+ /** Turn must be delivered as voice (voice mode) */
89
+ voiceReplyRequired?: boolean;
83
90
  }
84
91
 
85
92
  export interface PendingTelegramControlItem<
@@ -371,6 +378,24 @@ export function formatQueuedTelegramItemsStatus<TContext = unknown>(
371
378
  return items.length === 0 ? "" : ` +${items.length}`;
372
379
  }
373
380
 
381
+ export function truncateTelegramQueueSummary(
382
+ text: string,
383
+ maxWords = 5,
384
+ maxLength = 40,
385
+ ): string {
386
+ const normalized = text.replace(/\s+/g, " ").trim();
387
+ if (!normalized) return "";
388
+ const words = normalized.split(" ");
389
+ let summary = words.slice(0, maxWords).join(" ");
390
+ if (summary.length === 0) summary = normalized;
391
+ if (summary.length > maxLength) {
392
+ summary = summary.slice(0, maxLength).trimEnd();
393
+ }
394
+ return summary.length < normalized.length || words.length > maxWords
395
+ ? `${summary}…`
396
+ : summary;
397
+ }
398
+
374
399
  export function canDispatchTelegramTurnState(
375
400
  state: TelegramDispatchGuardState,
376
401
  ): boolean {
@@ -788,7 +813,11 @@ export interface TelegramAgentEndRuntimeDeps<
788
813
  text: string,
789
814
  ) => Promise<unknown>;
790
815
  sendQueuedAttachments: (turn: TTurn) => Promise<void>;
791
- answerGuestQuery?: (guestQueryId: string, text?: string, options?: { parseMode?: string }) => Promise<void>;
816
+ answerGuestQuery?: (
817
+ guestQueryId: string,
818
+ text?: string,
819
+ options?: { parseMode?: string },
820
+ ) => Promise<void>;
792
821
  sendGuestReply?: (guestQueryId: string, markdown: string) => Promise<void>;
793
822
  planOutboundReply?: (
794
823
  markdown: string,
@@ -976,9 +1005,32 @@ export async function handleTelegramAgentEndRuntime<
976
1005
  >(deps: TelegramAgentEndRuntimeDeps<TTurn, TReplyMarkup>): Promise<void> {
977
1006
  const { turn, assistant } = deps;
978
1007
  const rawFinalText = assistant.text;
979
- const outboundReply = rawFinalText
1008
+ let outboundReply = rawFinalText
980
1009
  ? deps.planOutboundReply?.(rawFinalText)
981
1010
  : undefined;
1011
+ // Preserve the planned reply so voice-fallback can use stripped markdown + replyMarkup
1012
+ const plannedReply = outboundReply;
1013
+
1014
+ // Transparent voice interception: when the turn is voice-tagged and the agent
1015
+ // did not explicitly use <!-- telegram_voice --> markup, we automatically
1016
+ // convert the whole response to voice.
1017
+ const voiceInterceptionGuard =
1018
+ turn &&
1019
+ isVoiceTurn(turn) &&
1020
+ rawFinalText?.trim() &&
1021
+ deps.planOutboundReply &&
1022
+ (!outboundReply ||
1023
+ (!outboundReply.voiceText && !outboundReply.voiceReplies?.length));
1024
+ if (voiceInterceptionGuard) {
1025
+ const voiceText =
1026
+ plannedReply !== undefined
1027
+ ? plannedReply.markdown?.trim() || ""
1028
+ : (rawFinalText ?? "");
1029
+ outboundReply = outboundReply
1030
+ ? { ...outboundReply, voiceText, markdown: "" }
1031
+ : { markdown: "", voiceText };
1032
+ }
1033
+
982
1034
  const finalText = outboundReply ? outboundReply.markdown : rawFinalText;
983
1035
  const hasOutboundArtifacts =
984
1036
  !!outboundReply?.voiceText || !!outboundReply?.voiceReplies?.length;
@@ -1080,9 +1132,36 @@ export async function handleTelegramAgentEndRuntime<
1080
1132
  }
1081
1133
  }
1082
1134
  if (outboundReply && deps.sendOutboundReplyArtifacts) {
1083
- await deps.sendOutboundReplyArtifacts(turn, outboundReply, {
1084
- replyToPrompt: !finalText,
1085
- });
1135
+ try {
1136
+ await deps.sendOutboundReplyArtifacts(turn, outboundReply, {
1137
+ replyToPrompt: !finalText,
1138
+ });
1139
+ } catch (error) {
1140
+ deps.recordRuntimeEvent?.("delivery", error, {
1141
+ phase: "voice-artifacts",
1142
+ chatId: turn.chatId,
1143
+ });
1144
+ // Fallback to planned text when voice delivery fails and text wasn't already delivered
1145
+ if (rawFinalText?.trim() && !finalText && hasOutboundArtifacts) {
1146
+ try {
1147
+ const fallbackMarkdown =
1148
+ plannedReply?.markdown || outboundReply?.voiceText || rawFinalText;
1149
+ await deps.sendMarkdownReply(
1150
+ turn.chatId,
1151
+ turn.replyToMessageId,
1152
+ fallbackMarkdown,
1153
+ plannedReply?.replyMarkup
1154
+ ? { replyMarkup: plannedReply.replyMarkup }
1155
+ : undefined,
1156
+ );
1157
+ } catch (fallbackError) {
1158
+ deps.recordRuntimeEvent?.("delivery", fallbackError, {
1159
+ phase: "voice-fallback-text",
1160
+ chatId: turn.chatId,
1161
+ });
1162
+ }
1163
+ }
1164
+ }
1086
1165
  }
1087
1166
  if (endPlan.shouldSendAttachmentNotice) {
1088
1167
  await deps.sendTextReply(
package/lib/routing.ts CHANGED
@@ -18,6 +18,7 @@ import * as Queue from "./queue.ts";
18
18
  import type { TelegramBridgeRuntime } from "./runtime.ts";
19
19
  import * as TextGroups from "./text-groups.ts";
20
20
  import * as Turns from "./turns.ts";
21
+ import { getTelegramVoiceReplyMode } from "./voice.ts";
21
22
  import type { TelegramUser } from "./updates.ts";
22
23
  import * as Updates from "./updates.ts";
23
24
 
@@ -38,7 +39,7 @@ export interface TelegramInboundRouteRuntimeDeps<
38
39
  > {
39
40
  configStore: Pick<
40
41
  TelegramConfigStore,
41
- "getAllowedUserId" | "setAllowedUserId" | "persist"
42
+ "get" | "getAllowedUserId" | "setAllowedUserId" | "persist"
42
43
  >;
43
44
  bridgeRuntime: TelegramBridgeRuntime;
44
45
  activeTurnRuntime: Queue.TelegramActiveTurnStore;
@@ -286,6 +287,14 @@ export function createTelegramInboundRouteRuntime<
286
287
  allocateQueueOrder: deps.bridgeRuntime.queue.allocateItemOrder,
287
288
  downloadFile: deps.downloadFile,
288
289
  processAttachments: deps.inboundHandlerRuntime.process,
290
+
291
+ // Voice policy for the current turn. Missing config still behaves as manual,
292
+ // but only explicit telegram.json voice.replyMode is shown in prompt context.
293
+ getVoiceReplyMode: () => getTelegramVoiceReplyMode(deps.configStore.get()),
294
+ isVoiceReplyModeConfigured: () => {
295
+ const mode = deps.configStore.get().voice?.replyMode;
296
+ return mode === "manual" || mode === "mirror" || mode === "always";
297
+ },
289
298
  });
290
299
  const enqueueContinueTurn = async (
291
300
  message: TMessage,
package/lib/turns.ts CHANGED
@@ -8,16 +8,16 @@ import { readFile } from "node:fs/promises";
8
8
  import { basename, dirname, join } from "node:path";
9
9
 
10
10
  import {
11
+ appendTelegramReplyContext,
11
12
  collectTelegramMessageIds,
12
- type DownloadedTelegramMessageFile,
13
- type DownloadTelegramMessageFilesDeps,
14
13
  downloadTelegramMessageFiles,
15
14
  extractTelegramMessagesPromptText,
16
15
  extractTelegramMessagesText,
17
- appendTelegramReplyContext,
18
16
  extractTelegramReplyContextText,
19
17
  formatTelegramHistoryText,
20
18
  guessMediaType,
19
+ type DownloadedTelegramMessageFile,
20
+ type DownloadTelegramMessageFilesDeps,
21
21
  type TelegramMediaMessage,
22
22
  } from "./media.ts";
23
23
  import type {
@@ -27,6 +27,21 @@ import type {
27
27
  TelegramQueueStore,
28
28
  } from "./queue.ts";
29
29
 
30
+ import {
31
+ computeVoicePromptContribution,
32
+ computeVoiceTurnFlags,
33
+ getTelegramVoiceReplyMode,
34
+ TELEGRAM_VOICE_REPLY_MODES,
35
+ type TelegramVoiceReplyMode,
36
+ } from "./voice.ts";
37
+
38
+ // Re-export for backward compatibility with existing namespace imports (e.g. Turns.getTelegramVoiceReplyMode in routing.ts)
39
+ export {
40
+ getTelegramVoiceReplyMode,
41
+ TELEGRAM_VOICE_REPLY_MODES,
42
+ type TelegramVoiceReplyMode,
43
+ };
44
+
30
45
  export const TELEGRAM_PREFIX = "[telegram]";
31
46
 
32
47
  export interface TelegramTurnMessage {
@@ -36,23 +51,8 @@ export interface TelegramTurnMessage {
36
51
 
37
52
  export type DownloadedTelegramTurnFile = DownloadedTelegramMessageFile;
38
53
 
39
- export function truncateTelegramQueueSummary(
40
- text: string,
41
- maxWords = 5,
42
- maxLength = 40,
43
- ): string {
44
- const normalized = text.replace(/\s+/g, " ").trim();
45
- if (!normalized) return "";
46
- const words = normalized.split(" ");
47
- let summary = words.slice(0, maxWords).join(" ");
48
- if (summary.length === 0) summary = normalized;
49
- if (summary.length > maxLength) {
50
- summary = summary.slice(0, maxLength).trimEnd();
51
- }
52
- return summary.length < normalized.length || words.length > maxWords
53
- ? `${summary}…`
54
- : summary;
55
- }
54
+ import { truncateTelegramQueueSummary } from "./queue.ts";
55
+ export { truncateTelegramQueueSummary };
56
56
 
57
57
  export function formatTelegramTurnStatusSummary(
58
58
  rawText: string,
@@ -103,6 +103,23 @@ function appendTelegramPromptText(prompt: string, rawText: string): string {
103
103
  return `${prompt} ${rawText}`;
104
104
  }
105
105
 
106
+ function appendTelegramVoiceContext(
107
+ prompt: string,
108
+ entries: Record<string, string>,
109
+ ): string {
110
+ const prefix = prompt.length > 0 ? `${prompt}\n\n` : "";
111
+ const pairs = Object.entries(entries);
112
+ if (pairs.length === 1) {
113
+ const [key, value] = pairs[0];
114
+ return `${prefix}[voice] ${key}: ${value}`;
115
+ }
116
+ return `${prefix}[voice]\n${pairs
117
+ .map(([key, value]) => `- ${key}: ${value}`)
118
+ .join("\n")}`;
119
+ }
120
+
121
+ // --- Voice Policy And Tagging ---
122
+
106
123
  export function buildTelegramTurnPrompt(options: {
107
124
  telegramPrefix: string;
108
125
  rawText: string;
@@ -110,6 +127,7 @@ export function buildTelegramTurnPrompt(options: {
110
127
  promptFiles?: DownloadedTelegramTurnFile[];
111
128
  handlerOutputs?: string[];
112
129
  historyTurns?: Pick<PendingTelegramTurn, "historyText">[];
130
+ voiceContext?: Record<string, string>;
113
131
  }): string {
114
132
  let prompt = options.telegramPrefix;
115
133
  if ((options.historyTurns?.length ?? 0) > 0) {
@@ -133,6 +151,9 @@ export function buildTelegramTurnPrompt(options: {
133
151
  "outputs",
134
152
  options.handlerOutputs ?? [],
135
153
  );
154
+ if (options.voiceContext) {
155
+ prompt = appendTelegramVoiceContext(prompt, options.voiceContext);
156
+ }
136
157
  return prompt;
137
158
  }
138
159
 
@@ -311,6 +332,9 @@ export interface BuildTelegramPromptTurnOptions {
311
332
  handlerOutputs?: string[];
312
333
  readBinaryFile: (path: string) => Promise<Uint8Array>;
313
334
  inferImageMimeType: (path: string) => string | undefined;
335
+ voiceReplyMode?: TelegramVoiceReplyMode;
336
+ voiceReplyModeConfigured?: boolean;
337
+ voicePromptContribution?: string;
314
338
  }
315
339
 
316
340
  export type BuildTelegramPromptTurnRuntimeOptions = Omit<
@@ -331,6 +355,8 @@ export interface TelegramPromptTurnRuntimeBuilderDeps<
331
355
  promptFiles?: DownloadedTelegramTurnFile[];
332
356
  handlerOutputs?: string[];
333
357
  }>;
358
+ getVoiceReplyMode?: () => TelegramVoiceReplyMode;
359
+ isVoiceReplyModeConfigured?: () => boolean;
334
360
  }
335
361
 
336
362
  export function createTelegramPromptTurnRuntimeBuilder<
@@ -358,6 +384,8 @@ export function createTelegramPromptTurnRuntimeBuilder<
358
384
  processed.rawText,
359
385
  replyContext,
360
386
  );
387
+ // Compute voice mode once and pass it to both the turn builder and the prompt contribution helper
388
+ const voiceReplyMode = deps.getVoiceReplyMode?.();
361
389
  return buildTelegramPromptTurnRuntime({
362
390
  telegramPrefix: TELEGRAM_PREFIX,
363
391
  messages,
@@ -369,10 +397,26 @@ export function createTelegramPromptTurnRuntimeBuilder<
369
397
  promptFiles: processed.promptFiles,
370
398
  handlerOutputs: processed.handlerOutputs,
371
399
  inferImageMimeType: guessMediaType,
400
+ voiceReplyMode,
401
+ voiceReplyModeConfigured: deps.isVoiceReplyModeConfigured?.(),
402
+ voicePromptContribution: computeVoicePromptContribution(
403
+ voiceReplyMode,
404
+ files,
405
+ rawText,
406
+ ),
372
407
  });
373
408
  };
374
409
  }
375
410
 
411
+ function getTelegramVoicePromptContext(
412
+ voiceReplyMode: TelegramVoiceReplyMode,
413
+ hasVoiceFile: boolean,
414
+ ): Record<string, string> | undefined {
415
+ if (voiceReplyMode === "always") return { "reply mode": "always" };
416
+ if (!hasVoiceFile) return undefined;
417
+ return { "reply mode": voiceReplyMode };
418
+ }
419
+
376
420
  export async function buildTelegramPromptTurn(
377
421
  options: BuildTelegramPromptTurnOptions,
378
422
  ): Promise<PendingTelegramTurn> {
@@ -380,6 +424,12 @@ export async function buildTelegramPromptTurn(
380
424
  if (!firstMessage) {
381
425
  throw new Error("Missing Telegram message for turn creation");
382
426
  }
427
+ const hasVoiceFile = options.files.some(
428
+ (f) => f.kind === "voice" || f.kind === "audio",
429
+ );
430
+ const voiceReplyMode = options.voiceReplyMode ?? getTelegramVoiceReplyMode();
431
+ const showVoiceContext =
432
+ options.voiceReplyModeConfigured ?? options.voiceReplyMode !== undefined;
383
433
  const content: TelegramPromptContent[] = [
384
434
  {
385
435
  type: "text",
@@ -390,6 +440,9 @@ export async function buildTelegramPromptTurn(
390
440
  promptFiles: options.promptFiles,
391
441
  handlerOutputs: options.handlerOutputs,
392
442
  historyTurns: options.historyTurns,
443
+ voiceContext: showVoiceContext
444
+ ? getTelegramVoicePromptContext(voiceReplyMode, hasVoiceFile)
445
+ : undefined,
393
446
  }),
394
447
  },
395
448
  ];
@@ -404,6 +457,15 @@ export async function buildTelegramPromptTurn(
404
457
  mimeType: mediaType,
405
458
  });
406
459
  }
460
+ if (options.voicePromptContribution?.trim()) {
461
+ const textItem = content.find((c) => c.type === "text") as
462
+ | { type: "text"; text: string }
463
+ | undefined;
464
+ if (textItem) {
465
+ textItem.text = `${textItem.text}\n\n${options.voicePromptContribution.trim()}`;
466
+ }
467
+ }
468
+
407
469
  return {
408
470
  kind: "prompt",
409
471
  chatId: firstMessage.chat.id,
@@ -424,6 +486,8 @@ export async function buildTelegramPromptTurn(
424
486
  options.promptFiles ?? options.files,
425
487
  options.handlerOutputs,
426
488
  ),
489
+ // Voice tagging (used for preview suppression and prompt guidance)
490
+ ...computeVoiceTurnFlags(voiceReplyMode, hasVoiceFile),
427
491
  };
428
492
  }
429
493