lingshu-gateway 0.10.0

Multi-platform messaging gateway for the Lingshu agent
use std::path::{Path, PathBuf};
use std::time::Duration;

use lingshu_types::Platform;
use tokio::process::Command;
use tracing::warn;

#[derive(Debug, Clone)]
pub(crate) struct PreparedVoiceAttachment {
    pub path: PathBuf,
    pub file_name: String,
    pub mime: &'static str,
    pub is_native_voice_note: bool,
    pub cleanup_after_send: bool,
    pub duration_secs: Option<f32>,
}

impl PreparedVoiceAttachment {
    pub async fn cleanup(&self) {
        if self.cleanup_after_send {
            let _ = tokio::fs::remove_file(&self.path).await;
        }
    }
}

fn voice_note_extension(path: &Path) -> bool {
    matches!(
        path.extension()
            .and_then(|ext| ext.to_str())
            .map(|ext| ext.to_ascii_lowercase())
            .as_deref(),
        Some("ogg" | "opus")
    )
}

fn audio_mime_for_extension(path: &Path) -> &'static str {
    match path
        .extension()
        .and_then(|ext| ext.to_str())
        .map(|ext| ext.to_ascii_lowercase())
        .as_deref()
    {
        Some("ogg" | "opus") => "audio/ogg",
        Some("mp3") => "audio/mpeg",
        Some("wav") => "audio/wav",
        Some("m4a") => "audio/mp4",
        Some("aac") => "audio/aac",
        Some("flac") => "audio/flac",
        _ => "application/octet-stream",
    }
}

fn prefers_native_voice_note(platform: Platform) -> bool {
    matches!(platform, Platform::Telegram | Platform::Discord)
}

fn resolve_voice_attachment_path(
    original: &Path,
    platform: Platform,
    converted_path: anyhow::Result<Option<PathBuf>>,
) -> (PathBuf, bool, bool) {
    let prefers_native = prefers_native_voice_note(platform);
    if voice_note_extension(original) && prefers_native {
        return (original.to_path_buf(), true, false);
    }
    if !prefers_native {
        return (original.to_path_buf(), false, false);
    }

    match converted_path {
        Ok(Some(converted)) => (converted, true, true),
        Ok(None) | Err(_) => (original.to_path_buf(), false, false),
    }
}

async fn build_prepared_voice_attachment(
    prepared_path: PathBuf,
    native_voice_note: bool,
    cleanup_after_send: bool,
) -> anyhow::Result<PreparedVoiceAttachment> {
    let file_name = prepared_path
        .file_name()
        .and_then(|name| name.to_str())
        .unwrap_or(if native_voice_note {
            "voice-message.ogg"
        } else {
            "audio"
        })
        .to_string();
    let size_bytes = tokio::fs::metadata(&prepared_path).await?.len();
    let mime = if native_voice_note {
        "audio/ogg"
    } else {
        audio_mime_for_extension(&prepared_path)
    };

    Ok(PreparedVoiceAttachment {
        duration_secs: Some(estimate_audio_duration_secs(&prepared_path, size_bytes)),
        path: prepared_path,
        file_name,
        mime,
        is_native_voice_note: native_voice_note,
        cleanup_after_send,
    })
}

fn ffmpeg_binary() -> Option<String> {
    for candidate in [
        "ffmpeg",
        "/opt/homebrew/bin/ffmpeg",
        "/usr/local/bin/ffmpeg",
    ] {
        if let Ok(path) = which::which(candidate) {
            return Some(path.to_string_lossy().into_owned());
        }
        let path = Path::new(candidate);
        if path.is_file() {
            return Some(path.to_string_lossy().into_owned());
        }
    }
    None
}

async fn convert_to_ogg_opus(input_path: &Path) -> anyhow::Result<Option<PathBuf>> {
    let Some(ffmpeg) = ffmpeg_binary() else {
        return Ok(None);
    };

    let output_path = std::env::temp_dir().join(format!(
        "lingshu_voice_note_{}.ogg",
        uuid::Uuid::new_v4().simple()
    ));
    let output = tokio::time::timeout(
        Duration::from_secs(30),
        Command::new(ffmpeg)
            .args([
                "-y",
                "-loglevel",
                "error",
                "-i",
                &input_path.to_string_lossy(),
                "-vn",
                "-acodec",
                "libopus",
                "-b:a",
                "64k",
                &output_path.to_string_lossy(),
            ])
            .output(),
    )
    .await
    .map_err(|_| anyhow::anyhow!("ffmpeg voice-note conversion timed out after 30s"))?
    .map_err(|e| anyhow::anyhow!("failed to run ffmpeg for voice-note conversion: {e}"))?;

    if !output.status.success() || !output_path.is_file() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        anyhow::bail!("ffmpeg voice-note conversion failed: {stderr}");
    }

    Ok(Some(output_path))
}

fn estimate_audio_duration_secs(path: &Path, size_bytes: u64) -> f32 {
    match path
        .extension()
        .and_then(|ext| ext.to_str())
        .map(|ext| ext.to_ascii_lowercase())
        .as_deref()
    {
        Some("ogg" | "opus") => (size_bytes as f32 / 8_000.0).max(1.0),
        Some("mp3" | "m4a" | "aac") => (size_bytes as f32 / 16_000.0).max(1.0),
        Some("wav") => (size_bytes as f32 / 32_000.0).max(1.0),
        _ => 5.0,
    }
}

pub(crate) async fn prepare_voice_attachment(
    path: &str,
    platform: Platform,
) -> anyhow::Result<PreparedVoiceAttachment> {
    let original = PathBuf::from(path);
    if !original.is_file() {
        anyhow::bail!("voice attachment path does not exist: {path}");
    }

    let conversion = if prefers_native_voice_note(platform) && !voice_note_extension(&original) {
        convert_to_ogg_opus(&original).await
    } else {
        Ok(None)
    };
    if let Err(error) = conversion.as_ref() {
        warn!(
            platform = ?platform,
            path = %original.display(),
            error = %error,
            "voice-note conversion failed; falling back to the original audio attachment"
        );
    }

    let (prepared_path, native_voice_note, cleanup_after_send) =
        resolve_voice_attachment_path(&original, platform, conversion);
    build_prepared_voice_attachment(prepared_path, native_voice_note, cleanup_after_send).await
}

pub(crate) fn voice_delivery_doctor(platform: Platform) -> String {
    let ffmpeg = if ffmpeg_binary().is_some() {
        "available"
    } else {
        "missing"
    };
    match platform {
        Platform::Telegram => format!(
            "Voice delivery doctor\nPlatform: Telegram\nNative voice bubble: supported\nffmpeg: {ffmpeg}\nIf ffmpeg is available, Lingshu converts TTS audio to OGG/Opus so replies render as Telegram voice notes."
        ),
        Platform::Discord => format!(
            "Voice delivery doctor\nPlatform: Discord\nNative voice attachment: supported\nLive Discord voice channels: not implemented in Lingshu yet\nffmpeg: {ffmpeg}\nIf ffmpeg is available, Lingshu converts TTS audio to OGG/Opus and tries Discord's native voice-message API before falling back to a regular file upload."
        ),
        other => format!(
            "Voice delivery doctor\nPlatform: {other}\nNative voice-note optimization: not implemented for this platform\nffmpeg: {ffmpeg}\nLingshu will still send audio replies when the adapter supports audio attachments."
        ),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn voice_note_extension_detects_ogg_and_opus() {
        assert!(voice_note_extension(Path::new("/tmp/reply.ogg")));
        assert!(voice_note_extension(Path::new("/tmp/reply.opus")));
        assert!(!voice_note_extension(Path::new("/tmp/reply.mp3")));
    }

    #[test]
    fn audio_mime_matches_common_audio_types() {
        assert_eq!(
            audio_mime_for_extension(Path::new("voice.ogg")),
            "audio/ogg"
        );
        assert_eq!(
            audio_mime_for_extension(Path::new("voice.mp3")),
            "audio/mpeg"
        );
        assert_eq!(
            audio_mime_for_extension(Path::new("voice.wav")),
            "audio/wav"
        );
    }

    #[test]
    fn doctor_mentions_discord_voice_channel_gap_honestly() {
        let report = voice_delivery_doctor(Platform::Discord);
        assert!(report.contains("not implemented"));
        assert!(report.contains("Discord"));
    }

    #[test]
    fn native_voice_preference_is_limited_to_supported_platforms() {
        assert!(prefers_native_voice_note(Platform::Telegram));
        assert!(prefers_native_voice_note(Platform::Discord));
        assert!(!prefers_native_voice_note(Platform::Slack));
    }

    #[test]
    fn failed_conversion_falls_back_to_original_audio() {
        let (path, native, cleanup) = resolve_voice_attachment_path(
            Path::new("/tmp/reply.mp3"),
            Platform::Telegram,
            Err(anyhow::anyhow!("boom")),
        );
        assert_eq!(path, PathBuf::from("/tmp/reply.mp3"));
        assert!(!native);
        assert!(!cleanup);
    }

    #[test]
    fn successful_conversion_uses_native_voice_note() {
        let (path, native, cleanup) = resolve_voice_attachment_path(
            Path::new("/tmp/reply.mp3"),
            Platform::Discord,
            Ok(Some(PathBuf::from("/tmp/reply.ogg"))),
        );
        assert_eq!(path, PathBuf::from("/tmp/reply.ogg"));
        assert!(native);
        assert!(cleanup);
    }
}