Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
16 changes: 13 additions & 3 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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_support = transcribe::check_transcribe_support();
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));
Expand Down
167 changes: 160 additions & 7 deletions src/telegram/transcribe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ pub struct TranscribeConfig {
pub model: String,
pub language: Option<String>,
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.
Expand Down Expand Up @@ -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,
}
}
}

/// 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");
/// 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 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();

Expand All @@ -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"
Expand All @@ -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<String> {
// 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)]
Expand All @@ -127,6 +174,26 @@ fn find_executable(name: &str) -> Option<String> {
}
}

/// 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.
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -259,3 +326,89 @@ 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 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();
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();
}
}
Loading