From 79df4e6d18380d1e1eef51bd9e0e25e80679df5c Mon Sep 17 00:00:00 2001 From: Agis Date: Tue, 9 Jun 2026 08:51:23 +0200 Subject: [PATCH 1/2] feat: make STT binaries (whisper/ffmpeg) platform-configurable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add WHISPER_BIN and FFMPEG_BIN env overrides so the speech-to-text engine can be swapped without editing source. Previously `whisper` and `ffmpeg` were invoked by literal binary name, forcing anyone with a differently-named binary (whisper.cpp, faster-whisper, a venv path, Homebrew vs pip) to patch the code. - TranscribeConfig gains `whisper_bin` / `ffmpeg_bin`, read in from_env() (default `whisper` / `ffmpeg`; empty/whitespace falls back to default). - check_transcribe_support() takes the config so the startup availability check resolves the same binaries that will actually be invoked. - find_executable() now resolves an explicit path to an existing executable directly, falling back to which/where for bare names. - transcribe_inner() invokes the configured binaries. - README: document WHISPER_BIN / FFMPEG_BIN. Values are trusted operator config (like WHISPER_MODEL) and invoked exec-direct (no shell), so no escaping is needed. Defaults preserve current behaviour — fully backward compatible. Co-authored-by: Agis (agent) --- README.md | 2 + src/main.rs | 2 +- src/telegram/transcribe.rs | 140 +++++++++++++++++++++++++++++++++++-- 3 files changed, 136 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index a2ef037..a8d50b2 100644 --- a/README.md +++ b/README.md @@ -412,6 +412,8 @@ If whisper or ffmpeg are not installed, voice messages are forwarded as `"(voice | `TELEGRAM_STATE_DIR` | `~/.claude/channels/telegram` | State directory (access.json, inbox, pairing codes) | | `WHISPER_MODEL` | `small` | Whisper model size (`tiny`, `base`, `small`, `medium`, `large`) | | `WHISPER_LANGUAGE` | auto-detect | Language hint (`Polish`, `English`, etc.) | +| `WHISPER_BIN` | `whisper` | Whisper executable — bare name in `PATH` or an explicit path. Point it at whisper.cpp, faster-whisper, a venv binary, etc. without recompiling. | +| `FFMPEG_BIN` | `ffmpeg` | ffmpeg executable — bare name in `PATH` or an explicit path. Useful for non-standard installs (Homebrew vs system, NAS, etc.) without recompiling. | | `HDCD_ECHO_TRANSCRIPT` | `true` | Send transcript back for user confirmation before delivering to Claude | | `ROUTER_STATE_DIR` | `~/.claude/channels/telegram-router` | Router state directory (config.json, sessions, mailbox) | | `RUST_LOG` | `hdcd_telegram=info` | Log level filter ([`tracing-subscriber`](https://docs.rs/tracing-subscriber) format) | diff --git a/src/main.rs b/src/main.rs index e9dbcba..547c1b0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -191,8 +191,8 @@ async fn run_standalone_mode() -> Result<()> { std::fs::create_dir_all(&inbox_dir)?; // Check whisper/ffmpeg availability for voice transcription. - let transcribe_support = transcribe::check_transcribe_support(); let transcribe_config = transcribe::TranscribeConfig::from_env(); + let transcribe_support = transcribe::check_transcribe_support(&transcribe_config); if transcribe_support.available { info!(model = %transcribe_config.model, language = ?transcribe_config.language, echo = transcribe_config.echo_transcript, "transcription config"); } diff --git a/src/telegram/transcribe.rs b/src/telegram/transcribe.rs index b928a92..5ffc40f 100644 --- a/src/telegram/transcribe.rs +++ b/src/telegram/transcribe.rs @@ -26,6 +26,11 @@ pub struct TranscribeConfig { pub model: String, pub language: Option, pub echo_transcript: bool, + /// Whisper binary name or path (`WHISPER_BIN`, default `whisper`). + /// Allows pointing at whisper.cpp, faster-whisper, a venv, etc. + pub whisper_bin: String, + /// ffmpeg binary name or path (`FFMPEG_BIN`, default `ffmpeg`). + pub ffmpeg_bin: String, } /// Allowed whisper model names. @@ -66,18 +71,48 @@ impl TranscribeConfig { let echo_transcript = std::env::var("HDCD_ECHO_TRANSCRIPT") .map(|v| v != "false" && v != "0") .unwrap_or(true); + + let whisper_bin = bin_from_env("WHISPER_BIN", "whisper"); + let ffmpeg_bin = bin_from_env("FFMPEG_BIN", "ffmpeg"); + Self { model, language, echo_transcript, + whisper_bin, + ffmpeg_bin, + } + } +} + +/// Read a binary name/path from an env var, falling back to `default`. +/// +/// The value is trusted operator config (like `WHISPER_MODEL`) and is invoked +/// exec-direct (no shell), so no escaping/sanitisation is needed. An empty or +/// whitespace-only value falls back to the default. +fn bin_from_env(var: &str, default: &str) -> String { + match std::env::var(var) { + Ok(v) => { + let trimmed = v.trim(); + if trimmed.is_empty() { + warn!(var = %var, fallback = %default, "value is empty, falling back to default"); + default.to_string() + } else { + trimmed.to_string() + } } + Err(_) => default.to_string(), } } -/// Check whether `whisper` and `ffmpeg` are available in PATH. -pub fn check_transcribe_support() -> TranscribeSupport { - let whisper_path = find_executable("whisper"); - let ffmpeg_path = find_executable("ffmpeg"); +/// Check whether the configured whisper/ffmpeg binaries are available. +/// +/// Resolves the binaries named by `config.whisper_bin` / `config.ffmpeg_bin` +/// (which may be bare names in PATH or explicit paths) so the startup +/// availability check matches what `transcribe` will actually invoke. +pub fn check_transcribe_support(config: &TranscribeConfig) -> TranscribeSupport { + let whisper_path = find_executable(&config.whisper_bin); + let ffmpeg_path = find_executable(&config.ffmpeg_bin); let available = whisper_path.is_some() && ffmpeg_path.is_some(); @@ -89,6 +124,8 @@ pub fn check_transcribe_support() -> TranscribeSupport { ); } else { warn!( + whisper_bin = %config.whisper_bin, + ffmpeg_bin = %config.ffmpeg_bin, whisper = ?whisper_path, ffmpeg = ?ffmpeg_path, "voice transcription disabled: whisper/ffmpeg not found" @@ -102,8 +139,18 @@ pub fn check_transcribe_support() -> TranscribeSupport { } } -/// Locate an executable in PATH. +/// Locate an executable by bare name (looked up in PATH) or by an +/// absolute/relative path to an existing executable file. fn find_executable(name: &str) -> Option { + // If the value points at an existing executable file, use it directly. + if name.contains('/') || name.contains('\\') { + let path = Path::new(name); + if is_executable_file(path) { + return Some(name.to_string()); + } + // Fall through to PATH lookup as a best-effort fallback. + } + #[cfg(unix)] let which_cmd = "which"; #[cfg(windows)] @@ -127,6 +174,26 @@ fn find_executable(name: &str) -> Option { } } +/// Whether `path` is an existing, regular (and, on Unix, executable) file. +fn is_executable_file(path: &Path) -> bool { + let meta = match std::fs::metadata(path) { + Ok(m) => m, + Err(_) => return false, + }; + if !meta.is_file() { + return false; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + meta.permissions().mode() & 0o111 != 0 + } + #[cfg(not(unix))] + { + true + } +} + /// Transcribe an OGG/OGA voice file to text using whisper CLI. /// /// 1. Converts the input to 16 kHz mono WAV via ffmpeg. @@ -163,7 +230,7 @@ async fn transcribe_inner( // Step 1: Convert OGG to WAV via ffmpeg. let ffmpeg = tokio::time::timeout( timeout, - tokio::process::Command::new("ffmpeg") + tokio::process::Command::new(&config.ffmpeg_bin) .args([ "-i", &ogg_path.to_string_lossy(), @@ -206,7 +273,7 @@ async fn transcribe_inner( let whisper = tokio::time::timeout( timeout, - tokio::process::Command::new("whisper") + tokio::process::Command::new(&config.whisper_bin) .args(&whisper_args) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) @@ -259,3 +326,62 @@ async fn cleanup(paths: &[&Path]) { let _ = tokio::fs::remove_file(path).await; } } + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + // Serialize env mutation across tests in this module — the process-wide + // environment is shared state and tests run in parallel by default. + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + fn clear_bin_env() { + std::env::remove_var("WHISPER_BIN"); + std::env::remove_var("FFMPEG_BIN"); + } + + #[test] + fn bin_defaults_when_unset() { + let _guard = ENV_LOCK.lock().unwrap(); + clear_bin_env(); + let cfg = TranscribeConfig::from_env(); + assert_eq!(cfg.whisper_bin, "whisper"); + assert_eq!(cfg.ffmpeg_bin, "ffmpeg"); + clear_bin_env(); + } + + #[test] + fn bin_overrides_from_env() { + let _guard = ENV_LOCK.lock().unwrap(); + clear_bin_env(); + std::env::set_var("WHISPER_BIN", "/opt/whisper.cpp/main"); + std::env::set_var("FFMPEG_BIN", "/usr/local/bin/ffmpeg"); + let cfg = TranscribeConfig::from_env(); + assert_eq!(cfg.whisper_bin, "/opt/whisper.cpp/main"); + assert_eq!(cfg.ffmpeg_bin, "/usr/local/bin/ffmpeg"); + clear_bin_env(); + } + + #[test] + fn bin_empty_falls_back_to_default() { + let _guard = ENV_LOCK.lock().unwrap(); + clear_bin_env(); + std::env::set_var("WHISPER_BIN", " "); + std::env::set_var("FFMPEG_BIN", ""); + let cfg = TranscribeConfig::from_env(); + assert_eq!(cfg.whisper_bin, "whisper"); + assert_eq!(cfg.ffmpeg_bin, "ffmpeg"); + clear_bin_env(); + } + + #[test] + fn bin_value_is_trimmed() { + let _guard = ENV_LOCK.lock().unwrap(); + clear_bin_env(); + std::env::set_var("WHISPER_BIN", " whisper-ctranslate2 "); + let cfg = TranscribeConfig::from_env(); + assert_eq!(cfg.whisper_bin, "whisper-ctranslate2"); + clear_bin_env(); + } +} From 4a6b68cef09773d4ea0752f26fdbceec9a27ea8b Mon Sep 17 00:00:00 2001 From: HyperDev Admin Date: Tue, 9 Jun 2026 10:20:38 +0200 Subject: [PATCH 2/2] refactor(transcribe): use resolved whisper/ffmpeg path at exec (review nit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pin the actual voice-transcription invocation to the absolute paths that check_transcribe_support() resolved at startup, instead of letting transcribe_inner() re-resolve the bare WHISPER_BIN/FFMPEG_BIN name via PATH at exec time. This closes a TOCTOU / PATH-drift gap where the startup availability check and the real invocation could resolve to different binaries. - main.rs: after a successful support check, write the resolved whisper_path/ffmpeg_path back into transcribe_config (kept as the configured name when a path didn't resolve, preserving fallback). transcribe_inner() is unchanged — it now spawns the resolved path. - Add a test asserting check_transcribe_support resolves a real executable path for an existing binary. Backward compatible: with no env set, the binaries still resolve to whisper/ffmpeg via PATH exactly as before. Addresses cross-model review nit (b) on PR #7. Co-authored-by: Agis (agent) --- src/main.rs | 14 ++++++++++++-- src/telegram/transcribe.rs | 27 +++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/main.rs b/src/main.rs index 547c1b0..6d9601f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -191,10 +191,20 @@ async fn run_standalone_mode() -> Result<()> { std::fs::create_dir_all(&inbox_dir)?; // Check whisper/ffmpeg availability for voice transcription. - let transcribe_config = transcribe::TranscribeConfig::from_env(); + let mut transcribe_config = transcribe::TranscribeConfig::from_env(); let transcribe_support = transcribe::check_transcribe_support(&transcribe_config); if transcribe_support.available { - info!(model = %transcribe_config.model, language = ?transcribe_config.language, echo = transcribe_config.echo_transcript, "transcription config"); + // Pin the actual invocation to the resolved absolute paths the support + // check found, so check-and-exec provably use the same binaries instead + // of re-resolving the bare name via PATH at exec time (TOCTOU / PATH + // drift). If a path didn't resolve, keep the configured name. + if let Some(ref whisper) = transcribe_support.whisper_path { + transcribe_config.whisper_bin = whisper.clone(); + } + if let Some(ref ffmpeg) = transcribe_support.ffmpeg_path { + transcribe_config.ffmpeg_bin = ffmpeg.clone(); + } + info!(model = %transcribe_config.model, language = ?transcribe_config.language, echo = transcribe_config.echo_transcript, whisper = %transcribe_config.whisper_bin, ffmpeg = %transcribe_config.ffmpeg_bin, "transcription config"); } let bot_api = Arc::new(api::BotApi::new(&token)); diff --git a/src/telegram/transcribe.rs b/src/telegram/transcribe.rs index 5ffc40f..c9a88a2 100644 --- a/src/telegram/transcribe.rs +++ b/src/telegram/transcribe.rs @@ -375,6 +375,33 @@ mod tests { clear_bin_env(); } + #[test] + fn check_resolves_absolute_path_for_existing_executable() { + let _guard = ENV_LOCK.lock().unwrap(); + clear_bin_env(); + // Point both binaries at a known-existing executable so the support + // check resolves a concrete path (this is the path main.rs pins the + // invocation to, so check-and-exec use the same binary). + let real = if Path::new("/bin/sh").exists() { + "/bin/sh" + } else { + "sh" + }; + std::env::set_var("WHISPER_BIN", real); + std::env::set_var("FFMPEG_BIN", real); + let cfg = TranscribeConfig::from_env(); + let support = check_transcribe_support(&cfg); + assert!(support.available); + // The resolved path must be a real, executable file on disk. + assert!(is_executable_file(Path::new( + support.whisper_path.as_deref().unwrap() + ))); + assert!(is_executable_file(Path::new( + support.ffmpeg_path.as_deref().unwrap() + ))); + clear_bin_env(); + } + #[test] fn bin_value_is_trimmed() { let _guard = ENV_LOCK.lock().unwrap();