pi-braid-telegram 0.0.0-stage → 0.1.4

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/src/lib/tts.ts ADDED
@@ -0,0 +1,451 @@
1
+ /**
2
+ * tts.ts — outbound text-to-speech for `telegram_send voice: true`.
3
+ *
4
+ * The Telegram voice path (tool-logic.ts) calls
5
+ * `VoiceTtsHook.synthesize(text)` and expects OGG/Opus bytes for sendVoice.
6
+ * This module is the single place a TTS source is wired in: it resolves the
7
+ * `braid.telegram.tts` block in `~/.pi/agent/settings.json` (same reader
8
+ * pattern as telegram-settings.ts: never throws, invalid entries fall back to
9
+ * the defaults) and returns either a hook or `undefined` when synthesis is
10
+ * disabled (`source: "none"`) — the refusal path in tool-logic.ts then keeps
11
+ * returning its clear "no TTS source is configured" error, never text.
12
+ *
13
+ * Config shape (all keys optional; an absent block means edge-tts defaults):
14
+ * { "source": "edge-tts" | "edge-tts+say" | "say" | "none",
15
+ * "rate": "+20%",
16
+ * "voices": { "ru": "ru-RU-DmitryNeural",
17
+ * "en": "en-US-GuyNeural",
18
+ * "he": "he-IL-AvriNeural" } }
19
+ *
20
+ * Sources:
21
+ * edge-tts — Microsoft edge-tts via uvx, the primary path (default).
22
+ * edge-tts+say — edge-tts, falling back to macOS `say` only after the
23
+ * edge attempts are exhausted (emergency: its quality is a
24
+ * known compromise, so it stays behind this explicit
25
+ * choice — better than losing the note to a flaky run).
26
+ * say — macOS `say` only, for a machine without uv.
27
+ * none — synthesis disabled; voice:true is refused, never text.
28
+ *
29
+ * Pipeline (matches the voice-replies skill):
30
+ * edge-tts: `uvx edge-tts -v <voice> --rate=<rate> --file <txt>
31
+ * --write-media <mp3>`
32
+ * say: `say -v <sayVoice> -f <txt> -o <aiff>`
33
+ * both: `ffmpeg -y -v error -i <in> -c:a libopus -b:a 48k -ar 48000
34
+ * -ac 1 <ogg>`
35
+ *
36
+ * edge-tts `NoAudioReceived` is intermittent (fail, ok, fail, ok-on-retry),
37
+ * so the primary path retries up to DEFAULT_TTS_ATTEMPTS times before giving
38
+ * up. Voice selection follows the text's script: Cyrillic → ru, Hebrew → he,
39
+ * otherwise en. edge-tts must be version ≥ 7 via uvx (older pins such as
40
+ * 6.1.12 die with WSServerHandshakeError 403), so the command pins nothing.
41
+ *
42
+ * Synthesis runs in a fresh temp dir cleaned up in a finally. Every spawn
43
+ * goes through the injectable `RunCommand` seam: tests substitute a mock, the
44
+ * default wraps node:child_process.spawn (argv arrays only — no shell).
45
+ */
46
+
47
+ import { existsSync, readFileSync } from "node:fs";
48
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
49
+ import { homedir, tmpdir } from "node:os";
50
+ import { join } from "node:path";
51
+ import type { VoiceTtsHook } from "./tool-logic.js";
52
+
53
+ /** pi-native settings file (same path telegram-settings.ts reads). */
54
+ const SETTINGS_PATH = join(homedir(), ".pi", "agent", "settings.json");
55
+
56
+ /** Default voice-note rate: +20%. */
57
+ export const DEFAULT_TTS_RATE = "+20%";
58
+
59
+ /** edge-tts `NoAudioReceived` is intermittent; retry the primary path twice. */
60
+ export const DEFAULT_TTS_ATTEMPTS = 3;
61
+
62
+ /** macOS `say` voice for the emergency fallback (ru-female). */
63
+ export const DEFAULT_SAY_VOICE = "Milena";
64
+
65
+ /** Per-command timeouts: uvx may fetch edge-tts on first run. */
66
+ const EDGE_TTS_TIMEOUT_MS = 60_000;
67
+ const FFMPEG_TIMEOUT_MS = 30_000;
68
+ const SAY_TIMEOUT_MS = 30_000;
69
+
70
+ export type VoiceTtsSource = "edge-tts" | "edge-tts+say" | "say" | "none";
71
+
72
+ export interface VoiceTtsVoices {
73
+ ru: string;
74
+ en: string;
75
+ he: string;
76
+ }
77
+
78
+ export interface VoiceTtsConfig {
79
+ source: VoiceTtsSource;
80
+ rate: string;
81
+ voices: VoiceTtsVoices;
82
+ }
83
+
84
+ /** Defaults for the absent block: edge-tts, rate +20%, the default voices. */
85
+ export const DEFAULT_VOICE_TTS: VoiceTtsConfig = {
86
+ source: "edge-tts",
87
+ rate: DEFAULT_TTS_RATE,
88
+ voices: {
89
+ ru: "ru-RU-DmitryNeural",
90
+ en: "en-US-GuyNeural",
91
+ he: "he-IL-AvriNeural",
92
+ },
93
+ };
94
+
95
+ interface RawBraidSettings {
96
+ braid?: {
97
+ telegram?: {
98
+ tts?: {
99
+ source?: unknown;
100
+ rate?: unknown;
101
+ voices?: {
102
+ ru?: unknown;
103
+ en?: unknown;
104
+ he?: unknown;
105
+ };
106
+ };
107
+ };
108
+ };
109
+ }
110
+
111
+ /** A known source string passes through; anything else → the default. */
112
+ function sanitizeSource(raw: unknown): VoiceTtsSource {
113
+ switch (raw) {
114
+ case "none":
115
+ return "none";
116
+ case "say":
117
+ return "say";
118
+ case "edge-tts+say":
119
+ return "edge-tts+say";
120
+ default:
121
+ return "edge-tts";
122
+ }
123
+ }
124
+
125
+ /** edge-tts rate syntax: a sign and 1-3 digits, e.g. "+20%". */
126
+ const RATE_PATTERN = /^[+-]\d{1,3}%$/;
127
+
128
+ function sanitizeRate(raw: unknown): string {
129
+ return typeof raw === "string" && RATE_PATTERN.test(raw.trim())
130
+ ? raw.trim()
131
+ : DEFAULT_TTS_RATE;
132
+ }
133
+
134
+ function sanitizeVoice(raw: unknown, fallback: string): string {
135
+ return typeof raw === "string" && raw.trim() ? raw.trim() : fallback;
136
+ }
137
+
138
+ /**
139
+ * Resolve the TTS config from `settingsPath` (default
140
+ * `~/.pi/agent/settings.json`). Never throws: a missing file, unreadable
141
+ * JSON, or an invalid entry falls back to the default for that field.
142
+ */
143
+ export function readVoiceTtsConfig(
144
+ settingsPath: string = SETTINGS_PATH,
145
+ ): VoiceTtsConfig {
146
+ let parsed: RawBraidSettings | undefined;
147
+ try {
148
+ if (existsSync(settingsPath)) {
149
+ parsed = JSON.parse(
150
+ readFileSync(settingsPath, "utf8"),
151
+ ) as RawBraidSettings;
152
+ }
153
+ } catch {
154
+ // non-fatal — fall through to the defaults
155
+ }
156
+ const raw = parsed?.braid?.telegram?.tts;
157
+ return {
158
+ source: sanitizeSource(raw?.source),
159
+ rate: sanitizeRate(raw?.rate),
160
+ voices: {
161
+ ru: sanitizeVoice(raw?.voices?.ru, DEFAULT_VOICE_TTS.voices.ru),
162
+ en: sanitizeVoice(raw?.voices?.en, DEFAULT_VOICE_TTS.voices.en),
163
+ he: sanitizeVoice(raw?.voices?.he, DEFAULT_VOICE_TTS.voices.he),
164
+ },
165
+ };
166
+ }
167
+
168
+ /** Result of one external command; never rejects, ENOENT/failure is `code: null`. */
169
+ export interface CommandResult {
170
+ code: number | null;
171
+ stderr: string;
172
+ }
173
+
174
+ /** Injected process seam: spawn one command and capture its exit + stderr. */
175
+ export type RunCommand = (
176
+ command: string,
177
+ args: string[],
178
+ opts?: { timeoutMs?: number },
179
+ ) => Promise<CommandResult>;
180
+
181
+ /** Default process seam: spawn (argv only, no shell), kill on timeout, never rejects. */
182
+ export const defaultRunCommand: RunCommand = async (
183
+ command,
184
+ args,
185
+ opts = {},
186
+ ) => {
187
+ const { spawn } = await import("node:child_process");
188
+ return await new Promise<CommandResult>((resolve) => {
189
+ let child: ReturnType<typeof spawn>;
190
+ try {
191
+ child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
192
+ } catch (error) {
193
+ resolve({
194
+ code: null,
195
+ stderr: error instanceof Error ? error.message : String(error),
196
+ });
197
+ return;
198
+ }
199
+ const stderrChunks: Buffer[] = [];
200
+ let settled = false;
201
+ let timedOut = false;
202
+ const timer = opts.timeoutMs
203
+ ? setTimeout(() => {
204
+ timedOut = true;
205
+ child.kill("SIGKILL");
206
+ }, opts.timeoutMs)
207
+ : undefined;
208
+ const settle = (result: CommandResult): void => {
209
+ if (settled) return;
210
+ settled = true;
211
+ if (timer) clearTimeout(timer);
212
+ resolve(result);
213
+ };
214
+ child.stdout?.resume(); // drain — only the exit code and stderr are used
215
+ child.stderr?.on("data", (chunk: Buffer) => stderrChunks.push(chunk));
216
+ child.on("error", (error) => settle({ code: null, stderr: error.message }));
217
+ child.on("close", (code) => {
218
+ const stderr = Buffer.concat(stderrChunks).toString("utf8");
219
+ settle({
220
+ // 124 is the timeout convention — a killed command is not a spawn failure.
221
+ code: timedOut ? 124 : code,
222
+ stderr:
223
+ timedOut && !stderr.trim()
224
+ ? `killed after ${opts.timeoutMs}ms`
225
+ : stderr,
226
+ });
227
+ });
228
+ });
229
+ };
230
+
231
+ const CYRILLIC_PATTERN = /[\u0400-\u04FF]/;
232
+ const HEBREW_PATTERN = /[\u0590-\u05FF]/;
233
+
234
+ /** Script-driven voice choice: Cyrillic → ru, Hebrew → he, otherwise en. */
235
+ export function pickVoice(text: string, voices: VoiceTtsVoices): string {
236
+ if (CYRILLIC_PATTERN.test(text)) return voices.ru;
237
+ if (HEBREW_PATTERN.test(text)) return voices.he;
238
+ return voices.en;
239
+ }
240
+
241
+ /** edge-tts argv for one synthesis attempt. */
242
+ export function buildEdgeTtsArgs(opts: {
243
+ voice: string;
244
+ rate: string;
245
+ textFile: string;
246
+ mediaFile: string;
247
+ }): string[] {
248
+ return [
249
+ "edge-tts",
250
+ "-v",
251
+ opts.voice,
252
+ `--rate=${opts.rate}`,
253
+ "--file",
254
+ opts.textFile,
255
+ "--write-media",
256
+ opts.mediaFile,
257
+ ];
258
+ }
259
+
260
+ /** macOS `say` argv: read the text file, write an AIFF for ffmpeg. */
261
+ export function buildSayArgs(opts: {
262
+ voice: string;
263
+ textFile: string;
264
+ outFile: string;
265
+ }): string[] {
266
+ return ["-v", opts.voice, "-f", opts.textFile, "-o", opts.outFile];
267
+ }
268
+
269
+ /** ffmpeg argv: any input → OGG/Opus at 48 kHz mono (Telegram sendVoice). */
270
+ export function buildOggOpusArgs(
271
+ inputFile: string,
272
+ outputFile: string,
273
+ ): string[] {
274
+ return [
275
+ "-y",
276
+ "-v",
277
+ "error",
278
+ "-i",
279
+ inputFile,
280
+ "-c:a",
281
+ "libopus",
282
+ "-b:a",
283
+ "48k",
284
+ "-ar",
285
+ "48000",
286
+ "-ac",
287
+ "1",
288
+ outputFile,
289
+ ];
290
+ }
291
+
292
+ /** Last line of stderr (or its tail) for a one-line attempt error. */
293
+ function describeFailure(source: string, result: CommandResult): string {
294
+ const detail = result.stderr.trim().split("\n").filter(Boolean).pop() ?? "";
295
+ const status =
296
+ result.code === null ? "failed to spawn" : `exit ${result.code}`;
297
+ return detail
298
+ ? `${source} ${status}: ${detail.slice(0, 200)}`
299
+ : `${source} ${status}`;
300
+ }
301
+
302
+ /** Run ffmpeg on `inputFile`; return non-empty OGG/Opus bytes or an error. */
303
+ async function convertToOggOpus(
304
+ run: RunCommand,
305
+ inputFile: string,
306
+ outputFile: string,
307
+ ): Promise<{ ok: true; bytes: Buffer } | { ok: false; error: string }> {
308
+ const result = await run("ffmpeg", buildOggOpusArgs(inputFile, outputFile), {
309
+ timeoutMs: FFMPEG_TIMEOUT_MS,
310
+ });
311
+ if (result.code !== 0) {
312
+ return { ok: false, error: describeFailure("ffmpeg", result) };
313
+ }
314
+ try {
315
+ const bytes = await readFile(outputFile);
316
+ if (bytes.byteLength === 0) {
317
+ return { ok: false, error: "ffmpeg produced empty output" };
318
+ }
319
+ return { ok: true, bytes };
320
+ } catch (error) {
321
+ return {
322
+ ok: false,
323
+ error: `ffmpeg output unreadable: ${error instanceof Error ? error.message : String(error)}`,
324
+ };
325
+ }
326
+ }
327
+
328
+ /** One edge-tts attempt: synthesize to mp3, convert to ogg/opus. */
329
+ async function attemptEdgeTts(
330
+ run: RunCommand,
331
+ config: VoiceTtsConfig,
332
+ voice: string,
333
+ textFile: string,
334
+ dir: string,
335
+ attempt: number,
336
+ ): Promise<{ ok: true; bytes: Buffer } | { ok: false; error: string }> {
337
+ const mediaFile = join(dir, `edge-${attempt}.mp3`);
338
+ const oggFile = join(dir, `edge-${attempt}.ogg`);
339
+ const synth = await run(
340
+ "uvx",
341
+ buildEdgeTtsArgs({ voice, rate: config.rate, textFile, mediaFile }),
342
+ { timeoutMs: EDGE_TTS_TIMEOUT_MS },
343
+ );
344
+ if (synth.code !== 0) {
345
+ return { ok: false, error: describeFailure("edge-tts", synth) };
346
+ }
347
+ return await convertToOggOpus(run, mediaFile, oggFile);
348
+ }
349
+
350
+ /** macOS say: synthesize to AIFF, convert to ogg/opus. */
351
+ async function synthesizeWithSay(
352
+ run: RunCommand,
353
+ textFile: string,
354
+ dir: string,
355
+ ): Promise<Buffer> {
356
+ const aiffFile = join(dir, "say.aiff");
357
+ const oggFile = join(dir, "say.ogg");
358
+ const synth = await run(
359
+ "say",
360
+ buildSayArgs({
361
+ voice: DEFAULT_SAY_VOICE,
362
+ textFile,
363
+ outFile: aiffFile,
364
+ }),
365
+ { timeoutMs: SAY_TIMEOUT_MS },
366
+ );
367
+ if (synth.code !== 0) {
368
+ throw new Error(describeFailure("say", synth));
369
+ }
370
+ const converted = await convertToOggOpus(run, aiffFile, oggFile);
371
+ if (!converted.ok) throw new Error(converted.error);
372
+ return converted.bytes;
373
+ }
374
+
375
+ /**
376
+ * Synthesize `text` with the configured source. Never falls back to text:
377
+ * on total failure this rejects with a voice-specific error, which
378
+ * tool-logic.ts surfaces as a failed voice outcome.
379
+ */
380
+ async function synthesizeVoice(
381
+ text: string,
382
+ config: VoiceTtsConfig,
383
+ run: RunCommand,
384
+ ): Promise<Buffer> {
385
+ const dir = await mkdtemp(join(tmpdir(), "braid-tts-"));
386
+ try {
387
+ const textFile = join(dir, "speech.txt");
388
+ await writeFile(textFile, text, "utf8");
389
+ const voice = pickVoice(text, config.voices);
390
+
391
+ let edgeError = "not attempted";
392
+ if (config.source !== "say") {
393
+ for (let attempt = 1; attempt <= DEFAULT_TTS_ATTEMPTS; attempt++) {
394
+ const result = await attemptEdgeTts(
395
+ run,
396
+ config,
397
+ voice,
398
+ textFile,
399
+ dir,
400
+ attempt,
401
+ );
402
+ if (result.ok) return result.bytes;
403
+ edgeError = result.error;
404
+ }
405
+ if (config.source === "edge-tts") {
406
+ throw new Error(
407
+ `TTS: edge-tts failed after ${DEFAULT_TTS_ATTEMPTS} attempts (${edgeError})`,
408
+ );
409
+ }
410
+ }
411
+
412
+ try {
413
+ return await synthesizeWithSay(run, textFile, dir);
414
+ } catch (error) {
415
+ const sayError = error instanceof Error ? error.message : String(error);
416
+ throw new Error(
417
+ config.source === "edge-tts+say"
418
+ ? `TTS: edge-tts failed after ${DEFAULT_TTS_ATTEMPTS} attempts (${edgeError}); say fallback failed (${sayError})`
419
+ : `TTS: say failed (${sayError})`,
420
+ { cause: error },
421
+ );
422
+ }
423
+ } finally {
424
+ await rm(dir, { recursive: true, force: true }).catch(() => {
425
+ // temp cleanup is best-effort — a failed synthesis result stands
426
+ });
427
+ }
428
+ }
429
+
430
+ /** Extra seams for tests; production calls populateVoiceTts() bare. */
431
+ export interface PopulateVoiceTtsOptions {
432
+ settingsPath?: string;
433
+ runCommand?: RunCommand;
434
+ }
435
+
436
+ /**
437
+ * The single TTS wiring seam (index.ts calls it once at factory start).
438
+ * Returns the synthesis hook when a source is configured, `undefined` when
439
+ * synthesis is disabled (`source: "none"`) so the voice-refusal path
440
+ * in tool-logic.ts stays intact.
441
+ */
442
+ export function populateVoiceTts(
443
+ opts: PopulateVoiceTtsOptions = {},
444
+ ): VoiceTtsHook | undefined {
445
+ const config = readVoiceTtsConfig(opts.settingsPath);
446
+ if (config.source === "none") return undefined;
447
+ const run = opts.runCommand ?? defaultRunCommand;
448
+ return {
449
+ synthesize: (text: string) => synthesizeVoice(text, config, run),
450
+ };
451
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * venice-catalog-base-url.ts — chat-mode STT default baseUrl from the
3
+ * userland venice catalog.
4
+ *
5
+ * Canonical reader: packages/providers/venice-catalog.ts (loadVeniceCatalog /
6
+ * defaultVeniceCatalogPath). This module is a dependency-free mirror of its
7
+ * path resolution + baseUrl validation ONLY — telegram is zero-dep BY DESIGN,
8
+ * so there is NO package edge to providers and NO model parsing here. The
9
+ * deployed STT credential is a gateway-era key that Venice direct rejects;
10
+ * the catalog's baseUrl is the gateway that accepts it (the same pairing the
11
+ * providers extension already uses for all model traffic).
12
+ *
13
+ * Resolution: $VENICE_CATALOG_FILE, else ~/.pi/agent/venice-models.json.
14
+ * Valid JSON object with a string baseUrl starting "https://" → that baseUrl;
15
+ * absent file, malformed JSON, wrong shape, or any read error → null. Never
16
+ * throws (best-effort: the STT path degrades to the legacy default instead of
17
+ * crashing extension load).
18
+ */
19
+
20
+ import { existsSync, readFileSync } from "node:fs";
21
+ import { join } from "node:path";
22
+ import { homedir } from "node:os";
23
+
24
+ /** Read the userland venice catalog's gateway baseUrl (best-effort, never throws). */
25
+ export function readCatalogBaseUrl(filePath?: string): string | null {
26
+ const p =
27
+ filePath ??
28
+ process.env.VENICE_CATALOG_FILE ??
29
+ join(homedir(), ".pi", "agent", "venice-models.json");
30
+ if (!existsSync(p)) return null;
31
+ try {
32
+ const raw = JSON.parse(readFileSync(p, "utf8"));
33
+ if (
34
+ !raw ||
35
+ typeof raw !== "object" ||
36
+ typeof raw.baseUrl !== "string" ||
37
+ !raw.baseUrl.startsWith("https://")
38
+ ) {
39
+ return null;
40
+ }
41
+ return raw.baseUrl;
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * voice.ts — shared inbound voice extraction (owner voice attachments).
3
+ *
4
+ * Both inbound consumers (background poller in inbound-poller.ts and the
5
+ * agentic pull in telegram-inbox.ts) route accepted voice messages through
6
+ * these helpers: resolve the single Telegram `Voice` object to a download
7
+ * path via getFile, fetch the bytes, save them under the media dir, and
8
+ * produce the agent-facing delivery text carrying the absolute path (or, on
9
+ * failure, the file_id + an error note — never drop the message). The
10
+ * transcript (when the caller has STT configured) is appended by the caller
11
+ * via formatVoiceText's `transcript` / `transcriptError` options.
12
+ *
13
+ * Pure + injectable-fetch driven, mirroring photo.ts: `fetchFn` defaults to
14
+ * globalThis.fetch, so tests substitute a recorded fixture fake with NO
15
+ * network.
16
+ */
17
+
18
+ import { mkdir, writeFile } from "node:fs/promises";
19
+ import { join } from "node:path";
20
+ import { defaultMediaDir, warnUnderivableRoot } from "./state-paths.js";
21
+ import {
22
+ getFile,
23
+ downloadFile,
24
+ type TelegramFetch,
25
+ } from "./telegram-client.js";
26
+
27
+ /** A Telegram `Voice` object (single object, NOT an array — unlike photo). */
28
+ export interface TelegramVoice {
29
+ file_id: string;
30
+ file_unique_id?: string;
31
+ duration?: number;
32
+ mime_type?: string;
33
+ file_size?: number;
34
+ }
35
+
36
+ /** True when a message carries a usable `voice` object (voice inbound). */
37
+ export function hasVoice(message: unknown): boolean {
38
+ if (message == null || typeof message !== "object") return false;
39
+ const voice = (message as { voice?: unknown }).voice;
40
+ return (
41
+ voice != null &&
42
+ typeof voice === "object" &&
43
+ typeof (voice as { file_id?: unknown }).file_id === "string"
44
+ );
45
+ }
46
+
47
+ /** The voice note's file_id (or undefined when absent). */
48
+ export function getVoiceFileId(voice: unknown): string | undefined {
49
+ if (voice == null || typeof voice !== "object") return undefined;
50
+ const fileId = (voice as { file_id?: unknown }).file_id;
51
+ return typeof fileId === "string" ? fileId : undefined;
52
+ }
53
+
54
+ /** Derive a safe short file extension from the Telegram file_path (fallback ogg). */
55
+ function extFromFilePath(filePath?: string): string {
56
+ if (!filePath) return "ogg";
57
+ const last = filePath.split("/").pop() ?? "";
58
+ const dot = last.lastIndexOf(".");
59
+ if (dot >= 0 && dot < last.length - 1) {
60
+ const ext = last.slice(dot + 1).toLowerCase();
61
+ if (/^[a-z0-9]{1,8}$/.test(ext)) return ext;
62
+ }
63
+ return "ogg"; // Telegram voice notes are OGG/Opus
64
+ }
65
+
66
+ /** Outcome of materializeVoice: saved path + bytes, or a degraded file_id + error. */
67
+ export type MaterializeVoiceResult =
68
+ | {
69
+ ok: true;
70
+ fileId: string;
71
+ filePath: string;
72
+ filePathOnTelegram: string;
73
+ bytes: Uint8Array;
74
+ }
75
+ | { ok: false; fileId: string; error: string };
76
+
77
+ /**
78
+ * Download a voice note and save it to `<mediaDir>/<updateId>.<ext>`. Never
79
+ * throws: a getFile / download / write failure returns
80
+ * `{ ok: false, fileId, error }` so the caller can degrade instead of drop.
81
+ * The saved bytes are returned so the caller can transcribe them.
82
+ */
83
+ export async function materializeVoice(opts: {
84
+ token: string;
85
+ updateId: number;
86
+ voice: TelegramVoice;
87
+ fetchFn?: TelegramFetch;
88
+ baseUrl?: string;
89
+ mediaDir?: string;
90
+ }): Promise<MaterializeVoiceResult> {
91
+ const { token, updateId, voice, fetchFn, baseUrl } = opts;
92
+ const fileId = voice.file_id;
93
+ const mediaDir = opts.mediaDir ?? defaultMediaDir();
94
+ if (mediaDir === null) {
95
+ warnUnderivableRoot("voice download refused");
96
+ return {
97
+ ok: false,
98
+ fileId,
99
+ error:
100
+ "braid media directory is underivable (BRAID_HOME unset and no absolute home); voice not saved",
101
+ };
102
+ }
103
+
104
+ const fileInfo = await getFile(token, fileId, { fetch: fetchFn, baseUrl });
105
+ const filePathOnTelegram = fileInfo.ok ? fileInfo.file?.file_path : undefined;
106
+ if (!fileInfo.ok || !filePathOnTelegram) {
107
+ return {
108
+ ok: false,
109
+ fileId,
110
+ error: fileInfo.error ?? "getFile returned no file_path",
111
+ };
112
+ }
113
+
114
+ const fileDownload = await downloadFile(token, filePathOnTelegram, {
115
+ fetch: fetchFn,
116
+ baseUrl,
117
+ });
118
+ if (!fileDownload.ok || !fileDownload.bytes) {
119
+ return {
120
+ ok: false,
121
+ fileId,
122
+ error: fileDownload.error ?? "file download failed",
123
+ };
124
+ }
125
+
126
+ const dest = join(
127
+ mediaDir,
128
+ `${updateId}.${extFromFilePath(filePathOnTelegram)}`,
129
+ );
130
+ try {
131
+ await mkdir(mediaDir, { recursive: true });
132
+ await writeFile(dest, fileDownload.bytes);
133
+ } catch (error) {
134
+ return {
135
+ ok: false,
136
+ fileId,
137
+ error: `failed to save ${dest}: ${error instanceof Error ? error.message : String(error)}`,
138
+ };
139
+ }
140
+ return {
141
+ ok: true,
142
+ fileId,
143
+ filePath: dest,
144
+ filePathOnTelegram,
145
+ bytes: fileDownload.bytes,
146
+ };
147
+ }
148
+
149
+ /** Options for formatVoiceText — the agent-facing delivery string. */
150
+ export interface VoiceDeliveryTextOptions {
151
+ /** Sender label, e.g. "@owner" or "id 123" (matches the whisper header). */
152
+ senderLabel: string;
153
+ /** Absolute path of the saved file (when the download succeeded). */
154
+ filePath?: string;
155
+ /** Telegram file_id (always present for a voice message). */
156
+ fileId?: string;
157
+ /** Message caption (trimmed), if any. */
158
+ caption?: string;
159
+ /** Download/save failure note (degraded delivery). */
160
+ error?: string;
161
+ /** Recognized transcript (quoted in the delivery string). */
162
+ transcript?: string;
163
+ /** Transcript failure/unavailability note (rendered after the caption). */
164
+ transcriptError?: string;
165
+ }
166
+
167
+ /**
168
+ * Build the delivered text the receiving agent can act on. Downloaded +
169
+ * transcribed form:
170
+ * 🎤 Voice from @owner: /abs/path.ogg (caption: none) (transcript: "hello")
171
+ * Downloaded, no STT configured:
172
+ * 🎤 Voice from @owner: /abs/path.ogg (caption: none) (transcript: unavailable — set BRAID_TG_STT_API_KEY)
173
+ * Degraded (download failed) form — never dropped:
174
+ * 🎤 Voice from @owner: file_id <id> (caption: none) (download failed: <error>)
175
+ */
176
+ export function formatVoiceText(opts: VoiceDeliveryTextOptions): string {
177
+ const captionPart = opts.caption
178
+ ? ` (caption: ${opts.caption})`
179
+ : " (caption: none)";
180
+ let transcriptPart = "";
181
+ if (opts.transcript) {
182
+ transcriptPart = ` (transcript: "${opts.transcript}")`;
183
+ } else if (opts.transcriptError) {
184
+ transcriptPart = ` (transcript: ${opts.transcriptError})`;
185
+ }
186
+ if (opts.filePath) {
187
+ return `🎤 Voice from ${opts.senderLabel}: ${opts.filePath}${captionPart}${transcriptPart}`;
188
+ }
189
+ const filePart = opts.fileId ? `file_id ${opts.fileId}` : "file_id unknown";
190
+ const errorPart = opts.error ? ` (download failed: ${opts.error})` : "";
191
+ return `🎤 Voice from ${opts.senderLabel}: ${filePart}${errorPart}${captionPart}`;
192
+ }