@llblab/pi-telegram 0.10.8 → 0.11.1

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,8 @@ 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
+ - \`[time]\` gives the wall-clock time for this Telegram turn when the operator enabled time injection. Use it for relative-date requests like "today", "now", or scheduling; otherwise do not mention it.
19
+ - \`[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
20
  - 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
21
 
20
22
  Telegram-visible output:
@@ -25,7 +27,7 @@ Telegram-visible output:
25
27
 
26
28
  Native outbound actions:
27
29
  - 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.
30
+ - \`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
31
  - \`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
32
  - If only hidden action comments would remain, add visible parent text like "Choose one:".
31
33
  `;
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;
@@ -113,6 +114,7 @@ export interface TelegramInboundRouteRuntimeDeps<
113
114
  typeof PromptTemplates.getTelegramPromptTemplateCommands
114
115
  >[0];
115
116
  downloadFile: Media.DownloadTelegramMessageFilesDeps["downloadFile"];
117
+ resolveTimeLine?: (chatId: number) => string | null;
116
118
  getThinkingLevel: () => Model.ThinkingLevel;
117
119
  setThinkingLevel: (level: Model.ThinkingLevel) => void;
118
120
  persistScopedModelPatterns?: (
@@ -286,6 +288,15 @@ export function createTelegramInboundRouteRuntime<
286
288
  allocateQueueOrder: deps.bridgeRuntime.queue.allocateItemOrder,
287
289
  downloadFile: deps.downloadFile,
288
290
  processAttachments: deps.inboundHandlerRuntime.process,
291
+ resolveTimeLine: deps.resolveTimeLine,
292
+
293
+ // Voice policy for the current turn. Missing config still behaves as manual,
294
+ // but only explicit telegram.json voice.replyMode is shown in prompt context.
295
+ getVoiceReplyMode: () => getTelegramVoiceReplyMode(deps.configStore.get()),
296
+ isVoiceReplyModeConfigured: () => {
297
+ const mode = deps.configStore.get().voice?.replyMode;
298
+ return mode === "manual" || mode === "mirror" || mode === "always";
299
+ },
289
300
  });
290
301
  const enqueueContinueTurn = async (
291
302
  message: TMessage,
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Telegram per-chat time injection runtime
3
+ * Zones: telegram inbound, prompt content
4
+ * Owns the formatted `[time]` line and the per-chat interval bookkeeping that decides when to emit it
5
+ */
6
+
7
+ import type { ResolvedTelegramTimeConfig } from "./config.ts";
8
+
9
+ export interface TimeInjectionRuntime {
10
+ resolveLine: (chatId: number, now?: Date) => string | null;
11
+ }
12
+
13
+ export interface TimeInjectionRuntimeDeps {
14
+ getConfig: () => ResolvedTelegramTimeConfig;
15
+ recordRuntimeEvent?: (
16
+ category: string,
17
+ error: unknown,
18
+ details?: Record<string, unknown>,
19
+ ) => void;
20
+ }
21
+
22
+ export function formatTelegramTimeInjectionLine(
23
+ now: Date,
24
+ timezone: string,
25
+ ): string {
26
+ const parts = new Intl.DateTimeFormat("en-CA", {
27
+ timeZone: timezone,
28
+ year: "numeric",
29
+ month: "2-digit",
30
+ day: "2-digit",
31
+ hour: "2-digit",
32
+ minute: "2-digit",
33
+ second: "2-digit",
34
+ hour12: false,
35
+ }).formatToParts(now);
36
+ const get = (type: Intl.DateTimeFormatPartTypes): string =>
37
+ parts.find((part) => part.type === type)?.value ?? "";
38
+ const year = get("year");
39
+ const month = get("month");
40
+ const day = get("day");
41
+ const hourRaw = get("hour");
42
+ const hour = hourRaw === "24" ? "00" : hourRaw;
43
+ const minute = get("minute");
44
+ const second = get("second");
45
+ return `${year}-${month}-${day} ${hour}:${minute}:${second} ${timezone}`;
46
+ }
47
+
48
+ export function createTimeInjectionRuntime(
49
+ deps: TimeInjectionRuntimeDeps,
50
+ ): TimeInjectionRuntime {
51
+ const lastInjectedAt = new Map<number, number>();
52
+ return {
53
+ resolveLine: (chatId, now = new Date()) => {
54
+ const config = deps.getConfig();
55
+ if (config.injectionMode === "off") return null;
56
+ let line: string;
57
+ try {
58
+ line = formatTelegramTimeInjectionLine(now, config.timezone);
59
+ } catch (error) {
60
+ deps.recordRuntimeEvent?.("time-injection", error, {
61
+ timezone: config.timezone,
62
+ });
63
+ return null;
64
+ }
65
+ if (config.injectionMode === "always") return line;
66
+ const previous = lastInjectedAt.get(chatId);
67
+ const nowMs = now.getTime();
68
+ if (
69
+ previous !== undefined &&
70
+ nowMs - previous < config.interval
71
+ ) {
72
+ return null;
73
+ }
74
+ lastInjectedAt.set(chatId, nowMs);
75
+ return line;
76
+ },
77
+ };
78
+ }
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,8 @@ export function buildTelegramTurnPrompt(options: {
110
127
  promptFiles?: DownloadedTelegramTurnFile[];
111
128
  handlerOutputs?: string[];
112
129
  historyTurns?: Pick<PendingTelegramTurn, "historyText">[];
130
+ timeLine?: string | null;
131
+ voiceContext?: Record<string, string>;
113
132
  }): string {
114
133
  let prompt = options.telegramPrefix;
115
134
  if ((options.historyTurns?.length ?? 0) > 0) {
@@ -133,6 +152,12 @@ export function buildTelegramTurnPrompt(options: {
133
152
  "outputs",
134
153
  options.handlerOutputs ?? [],
135
154
  );
155
+ if (options.voiceContext) {
156
+ prompt = appendTelegramVoiceContext(prompt, options.voiceContext);
157
+ }
158
+ if (options.timeLine) {
159
+ prompt = `${prompt}\n\n[time] ${options.timeLine}`;
160
+ }
136
161
  return prompt;
137
162
  }
138
163
 
@@ -309,8 +334,12 @@ export interface BuildTelegramPromptTurnOptions {
309
334
  files: DownloadedTelegramTurnFile[];
310
335
  promptFiles?: DownloadedTelegramTurnFile[];
311
336
  handlerOutputs?: string[];
337
+ timeLine?: string | null;
312
338
  readBinaryFile: (path: string) => Promise<Uint8Array>;
313
339
  inferImageMimeType: (path: string) => string | undefined;
340
+ voiceReplyMode?: TelegramVoiceReplyMode;
341
+ voiceReplyModeConfigured?: boolean;
342
+ voicePromptContribution?: string;
314
343
  }
315
344
 
316
345
  export type BuildTelegramPromptTurnRuntimeOptions = Omit<
@@ -331,6 +360,9 @@ export interface TelegramPromptTurnRuntimeBuilderDeps<
331
360
  promptFiles?: DownloadedTelegramTurnFile[];
332
361
  handlerOutputs?: string[];
333
362
  }>;
363
+ resolveTimeLine?: (chatId: number) => string | null;
364
+ getVoiceReplyMode?: () => TelegramVoiceReplyMode;
365
+ isVoiceReplyModeConfigured?: () => boolean;
334
366
  }
335
367
 
336
368
  export function createTelegramPromptTurnRuntimeBuilder<
@@ -358,6 +390,13 @@ export function createTelegramPromptTurnRuntimeBuilder<
358
390
  processed.rawText,
359
391
  replyContext,
360
392
  );
393
+ // Compute voice mode once and pass it to both the turn builder and the prompt contribution helper
394
+ const voiceReplyMode = deps.getVoiceReplyMode?.();
395
+ const chatId = messages[0]?.chat.id;
396
+ const timeLine =
397
+ deps.resolveTimeLine && chatId !== undefined
398
+ ? deps.resolveTimeLine(chatId)
399
+ : null;
361
400
  return buildTelegramPromptTurnRuntime({
362
401
  telegramPrefix: TELEGRAM_PREFIX,
363
402
  messages,
@@ -368,11 +407,28 @@ export function createTelegramPromptTurnRuntimeBuilder<
368
407
  files,
369
408
  promptFiles: processed.promptFiles,
370
409
  handlerOutputs: processed.handlerOutputs,
410
+ timeLine,
371
411
  inferImageMimeType: guessMediaType,
412
+ voiceReplyMode,
413
+ voiceReplyModeConfigured: deps.isVoiceReplyModeConfigured?.(),
414
+ voicePromptContribution: computeVoicePromptContribution(
415
+ voiceReplyMode,
416
+ files,
417
+ rawText,
418
+ ),
372
419
  });
373
420
  };
374
421
  }
375
422
 
423
+ function getTelegramVoicePromptContext(
424
+ voiceReplyMode: TelegramVoiceReplyMode,
425
+ hasVoiceFile: boolean,
426
+ ): Record<string, string> | undefined {
427
+ if (voiceReplyMode === "always") return { "reply mode": "always" };
428
+ if (!hasVoiceFile) return undefined;
429
+ return { "reply mode": voiceReplyMode };
430
+ }
431
+
376
432
  export async function buildTelegramPromptTurn(
377
433
  options: BuildTelegramPromptTurnOptions,
378
434
  ): Promise<PendingTelegramTurn> {
@@ -380,6 +436,12 @@ export async function buildTelegramPromptTurn(
380
436
  if (!firstMessage) {
381
437
  throw new Error("Missing Telegram message for turn creation");
382
438
  }
439
+ const hasVoiceFile = options.files.some(
440
+ (f) => f.kind === "voice" || f.kind === "audio",
441
+ );
442
+ const voiceReplyMode = options.voiceReplyMode ?? getTelegramVoiceReplyMode();
443
+ const showVoiceContext =
444
+ options.voiceReplyModeConfigured ?? options.voiceReplyMode !== undefined;
383
445
  const content: TelegramPromptContent[] = [
384
446
  {
385
447
  type: "text",
@@ -390,6 +452,10 @@ export async function buildTelegramPromptTurn(
390
452
  promptFiles: options.promptFiles,
391
453
  handlerOutputs: options.handlerOutputs,
392
454
  historyTurns: options.historyTurns,
455
+ timeLine: options.timeLine,
456
+ voiceContext: showVoiceContext
457
+ ? getTelegramVoicePromptContext(voiceReplyMode, hasVoiceFile)
458
+ : undefined,
393
459
  }),
394
460
  },
395
461
  ];
@@ -404,6 +470,15 @@ export async function buildTelegramPromptTurn(
404
470
  mimeType: mediaType,
405
471
  });
406
472
  }
473
+ if (options.voicePromptContribution?.trim()) {
474
+ const textItem = content.find((c) => c.type === "text") as
475
+ | { type: "text"; text: string }
476
+ | undefined;
477
+ if (textItem) {
478
+ textItem.text = `${textItem.text}\n\n${options.voicePromptContribution.trim()}`;
479
+ }
480
+ }
481
+
407
482
  return {
408
483
  kind: "prompt",
409
484
  chatId: firstMessage.chat.id,
@@ -424,6 +499,8 @@ export async function buildTelegramPromptTurn(
424
499
  options.promptFiles ?? options.files,
425
500
  options.handlerOutputs,
426
501
  ),
502
+ // Voice tagging (used for preview suppression and prompt guidance)
503
+ ...computeVoiceTurnFlags(voiceReplyMode, hasVoiceFile),
427
504
  };
428
505
  }
429
506