/** * Voice & audio handler — transcribes Telegram voice notes / audio files to * text (any language) and submits them as prompts. * * Requires STT_API_URL (and optionally STT_API_KEY). Grok Build CLI over ACP * does not accept audio content blocks, so without an STT endpoint voice is * disabled rather than attaching raw audio the agent cannot hear. */ import type { Bot, Context } from "grammy"; import { textPrompt } from "../../app/types.js"; import { createLogger } from "../../logger.js"; import type { BotDeps } from "../deps.js"; import { extractReplyContext } from "../reply-context.js"; const log = createLogger("voice"); type VoiceMediaKind = "voice" | "audio" | "video_note"; export function registerVoice(bot: Bot, deps: BotDeps): void { const handle = async ( ctx: Context, fileId: string, mime: string, name: string, mediaKind: VoiceMediaKind, ): Promise => { const chatId = ctx.chat!.id; if (deps.wizard.isActive(chatId)) { await ctx.reply("Finish or /cancel the task wizard before sending voice."); return; } if (!deps.stt.enabled) { await ctx.reply( "\u{1F399} Voice isn't configured. Set STT_API_URL (and STT_API_KEY if needed) in .env.", ); return; } await ctx.replyWithChatAction("typing").catch(() => {}); try { const bytes = await download(ctx, fileId, deps.cfg.token); if (!bytes) throw new Error("could not download the audio"); const text = await deps.stt.transcribe(bytes, mime, name); if (!text) { await ctx.reply("\u{1F399} I couldn't make out any speech."); return; } const { resolveScope } = await import("../scope.js"); const { adoptUserPrompt } = await import("../prompt-anchor.js"); const scope = resolveScope(ctx, deps); const quoted = extractReplyContext(ctx); const userMsgId = ctx.message?.message_id; const media = mediaKind === "video_note" ? [{ type: "video_note" as const, fileId }] : mediaKind === "voice" ? [{ type: "voice" as const, fileId }] : [{ type: "audio" as const, fileId, fileName: name }]; const anchor = await adoptUserPrompt(deps.api, { chatId, text: `\u201C${text}\u201D`, userMessageIds: userMsgId !== undefined ? [userMsgId] : [], messageThreadId: scope.threadExtra.message_thread_id, projectName: scope.rt.projectName, prefix: "\u{1F399} Voice", media, }); const outcome = await scope.rt.submit( textPrompt(text, anchor?.replyTo ?? userMsgId, quoted, { promptId: anchor?.promptId, }), ); if (outcome === "queued") { const extra: Record = { ...scope.threadExtra }; if (anchor?.replyTo !== undefined) { extra.reply_parameters = { message_id: anchor.replyTo, allow_sending_without_reply: true, }; } await ctx.reply("\u{1F4E5} Queued \u2014 will run after the current task.", extra); } } catch (e) { log.warn("voice failed:", (e as Error).message); await ctx.reply(`\u274C Voice transcription failed: ${(e as Error).message}`); } }; bot.on("message:voice", (ctx) => handle( ctx, ctx.message.voice.file_id, ctx.message.voice.mime_type || "audio/ogg", "voice.ogg", "voice", ), ); bot.on("message:audio", (ctx) => handle( ctx, ctx.message.audio.file_id, ctx.message.audio.mime_type || "audio/mpeg", ctx.message.audio.file_name || "audio.mp3", "audio", ), ); bot.on("message:video_note", (ctx) => handle(ctx, ctx.message.video_note.file_id, "video/mp4", "note.mp4", "video_note"), ); } async function download(ctx: Context, fileId: string, token: string): Promise { const file = await ctx.api.getFile(fileId); if (!file.file_path) return undefined; const res = await fetch(`https://api.telegram.org/file/bot${token}/${file.file_path}`); if (!res.ok) throw new Error(`download HTTP ${res.status}`); return Buffer.from(await res.arrayBuffer()); }