use crate::ChannelMessage;
use crate::tools::browser::BrowserTool;
use crate::util::{MEDIA_MARKER_RE, file_name_or_path, is_http_url, parse_media_marker};
use regex::Regex;
use std::borrow::Cow;
use std::collections::HashSet;
use std::fmt::Write;
use std::sync::LazyLock;
static URL_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r#"https?://[^\s<>"']+"#).expect("URL regex must compile"));
const AUDIO_ICON: &str = "🔊✍️";
async fn transcribe_audio_marker(path: &str) -> String {
let path_buf = std::path::PathBuf::from(path);
let use_local = crate::config::CONFIG
.snapshot()
.audio_transcription_use_local
.as_deref()
!= Some("false");
if use_local {
match crate::audio::local_transcriber::transcribe_file_async(
&path_buf,
crate::audio::local_transcriber::INFERENCE_TIMEOUT,
)
.await
{
Ok(text) => {
tracing::debug!("Local audio transcription succeeded");
let text = text.trim();
return if text.is_empty() {
AUDIO_ICON.to_string()
} else {
format!("{AUDIO_ICON} {text}")
};
}
Err(e) => {
tracing::warn!(error = %e, "Local audio transcription failed");
}
}
}
tracing::warn!("Audio transcription unavailable");
AUDIO_ICON.to_string()
}
struct SavedMedia {
annotation: String,
dest: std::path::PathBuf,
}
async fn save_media_to_workspace(
media_path: &std::path::Path,
uploads_dir: Option<&std::path::Path>,
label: &str,
fallback_ext: &str,
) -> Option<SavedMedia> {
let dir = uploads_dir?;
tokio::fs::create_dir_all(dir).await.ok()?;
let ext = media_path
.extension()
.and_then(|e| e.to_str())
.unwrap_or(fallback_ext);
let timestamp = crate::util::unix_millis();
let dest_name = format!("upload_{timestamp}.{ext}");
let dest_path = dir.join(&dest_name);
tokio::fs::copy(media_path, &dest_path).await.ok()?;
Some(SavedMedia {
annotation: format!("[Saved {label}: {}]", dest_path.display()),
dest: dest_path,
})
}
#[derive(Debug, Clone)]
pub enum EnrichmentStrategy {
Multimodal {
workspace_path: Option<std::path::PathBuf>,
},
NonMultimodal,
}
enum MultimodalImageAction {
Keep,
Replace {
replacement: String,
upload_annotation: Option<String>,
delete_temp: bool,
},
}
async fn handle_multimodal_image(
path: &str,
path_obj: &std::path::Path,
uploads_dir: Option<&std::path::Path>,
) -> MultimodalImageAction {
if is_http_url(path) {
return MultimodalImageAction::Keep;
}
let invalid_ref = format!("[Invalid image reference: {path}]");
if !path_obj.exists() || !path_obj.is_file() {
tracing::warn!(%path, "Image file not found for multimodal enrichment");
return MultimodalImageAction::Replace {
replacement: invalid_ref,
upload_annotation: None,
delete_temp: false,
};
}
if !is_under_telegram_files(path_obj).await {
tracing::warn!(%path, "Image path outside telegram temp dir — annotating without copy");
return MultimodalImageAction::Replace {
replacement: format!("[Image: {} attached]", file_name_or_path(path)),
upload_annotation: None,
delete_temp: false,
};
}
let saved = save_media_to_workspace(path_obj, uploads_dir, "image", "png")
.await
.map(|saved| saved.annotation);
let replacement = match crate::util::local_image_to_data_uri(path_obj).await {
Ok(data_uri) => format!("[IMAGE:{data_uri}]"),
Err(e) => {
tracing::warn!(%path, error = %e, "Failed to convert image to data URI");
invalid_ref
}
};
MultimodalImageAction::Replace {
replacement,
upload_annotation: saved,
delete_temp: true,
}
}
async fn is_under_telegram_files(path: &std::path::Path) -> bool {
let Ok(canonical) = tokio::fs::canonicalize(path).await else {
return false;
};
let root = std::env::temp_dir().join(crate::util::TELEGRAM_FILES_DIR);
let Ok(canonical_root) = tokio::fs::canonicalize(&root).await else {
return false;
};
crate::tools::path::is_path_under_roots(&canonical, &[canonical_root])
}
struct MultimodalVideoAction {
replacement: String,
delete_temp: bool,
transcription: Option<String>,
}
impl MultimodalVideoAction {
fn annotation(replacement: String) -> Self {
Self {
replacement,
delete_temp: false,
transcription: None,
}
}
}
async fn handle_multimodal_video(
path: &str,
path_obj: &std::path::Path,
uploads_dir: Option<&std::path::Path>,
workspace: &str,
) -> MultimodalVideoAction {
if is_http_url(path) {
return MultimodalVideoAction::annotation(format!("[Video: {path}]"));
}
if !path_obj.exists() || !path_obj.is_file() {
tracing::warn!(%path, "Video file not found for multimodal enrichment");
return MultimodalVideoAction::annotation(format!("[Invalid video reference: {path}]"));
}
if !is_under_telegram_files(path_obj).await {
tracing::warn!(%path, "Video path outside telegram temp dir — annotating without copy");
return MultimodalVideoAction::annotation(format!(
"[Video: {} attached]",
file_name_or_path(path)
));
}
if let Some(saved) = save_media_to_workspace(path_obj, uploads_dir, "video", "mp4").await {
let transcription =
transcribe_saved_video(&saved.dest, file_name_or_path(path), workspace).await;
return MultimodalVideoAction {
replacement: saved.annotation,
delete_temp: true,
transcription,
};
}
MultimodalVideoAction::annotation(format!("[Video: {} attached]", file_name_or_path(path)))
}
async fn transcribe_saved_video(
path: &std::path::Path,
file_name: &str,
workspace: &str,
) -> Option<String> {
let text = crate::providers::transcribe_video_file(path, Some(workspace)).await?;
Some(format!("[Video transcription of {file_name}]: {text}"))
}
async fn handle_non_multimodal_image(
path_obj: &std::path::Path,
file_name: &str,
workspace: &str,
) -> String {
if let Some(ref transcriber) = crate::providers::media_transcriber() {
match transcribe_image_file(path_obj, transcriber, workspace).await {
Ok(description) => format!("[Image: {description}]"),
Err(e) => {
tracing::warn!(path = %path_obj.display(), error = %e, "Image transcription failed");
format!("[Image: {file_name} attached]")
}
}
} else {
format!("[Image: {file_name} attached]")
}
}
#[expect(clippy::too_many_lines)]
pub async fn enrich_message(msg: &mut ChannelMessage, strategy: &EnrichmentStrategy) {
let mut annotations: Vec<String> = Vec::new();
let mut result = msg.content.clone();
let mut upload_annotations: Vec<String> = Vec::new();
let mut files_to_delete: Vec<std::path::PathBuf> = Vec::new();
let uploads_dir = match strategy {
EnrichmentStrategy::Multimodal { workspace_path } => {
workspace_path.as_ref().map(|p| p.join("uploads"))
}
EnrichmentStrategy::NonMultimodal => None,
};
for caps in MEDIA_MARKER_RE.captures_iter(&msg.content) {
let whole = caps.get_match();
let (kind, path) = parse_media_marker(&caps);
let path_obj = std::path::Path::new(path);
match kind {
"IMAGE" => match strategy {
EnrichmentStrategy::Multimodal { .. } => {
match handle_multimodal_image(path, path_obj, uploads_dir.as_deref()).await {
MultimodalImageAction::Keep => {
}
MultimodalImageAction::Replace {
replacement,
upload_annotation,
delete_temp,
} => {
result = result.replacen(whole.as_str(), &replacement, 1);
if let Some(ann) = upload_annotation {
upload_annotations.push(ann);
}
if delete_temp {
files_to_delete.push(path_obj.to_path_buf());
}
}
}
}
EnrichmentStrategy::NonMultimodal => {
let file_name = file_name_or_path(path);
let in_scope = is_under_telegram_files(path_obj).await;
let annotation = if in_scope {
handle_non_multimodal_image(path_obj, file_name, &msg.workspace).await
} else {
tracing::warn!(%path, "Image path outside telegram temp dir — annotating without transcription");
format!("[Image: {file_name} attached]")
};
annotations.push(annotation);
if in_scope {
files_to_delete.push(path_obj.to_path_buf());
}
}
},
"AUDIO" => {
if is_under_telegram_files(path_obj).await {
annotations.push(transcribe_audio_marker(path).await);
files_to_delete.push(path_obj.to_path_buf());
} else {
tracing::warn!(%path, "Audio path outside telegram temp dir — annotating without transcription");
annotations.push(AUDIO_ICON.to_string());
}
}
"VIDEO" => match strategy {
EnrichmentStrategy::Multimodal { .. } => {
let MultimodalVideoAction {
replacement,
delete_temp,
transcription,
} = handle_multimodal_video(
path,
path_obj,
uploads_dir.as_deref(),
&msg.workspace,
)
.await;
result = result.replacen(whole.as_str(), &replacement, 1);
if let Some(annotation) = transcription {
annotations.push(annotation);
}
if delete_temp {
files_to_delete.push(path_obj.to_path_buf());
}
}
EnrichmentStrategy::NonMultimodal => {
annotations.push(format!("[Video: {} attached]", file_name_or_path(path)));
if is_under_telegram_files(path_obj).await {
files_to_delete.push(path_obj.to_path_buf());
}
}
},
_ => {
tracing::warn!(kind, %path, "Unknown media marker kind");
}
}
}
for file_path in &files_to_delete {
if let Err(e) = tokio::fs::remove_file(file_path).await {
tracing::warn!(
path = %file_path.display(),
error = %e,
"Failed to delete temp file after enrichment"
);
}
}
if !upload_annotations.is_empty() {
let annotation_block = upload_annotations.join("\n");
let _ = write!(result, "\n\n{annotation_block}");
}
let keep_image = matches!(strategy, EnrichmentStrategy::Multimodal { .. });
let cleaned = MEDIA_MARKER_RE
.replace_all(&result, |caps: ®ex::Captures| {
if keep_image && parse_media_marker(caps).0 == "IMAGE" {
caps.get_match().as_str().to_string()
} else {
String::new()
}
})
.to_string();
let cleaned = cleaned.trim().to_string();
msg.content = if annotations.is_empty() {
cleaned
} else {
let prefix = annotations.join("\n");
if cleaned.is_empty() {
prefix
} else {
format!("{prefix}\n\n{cleaned}")
}
};
}
#[must_use]
pub fn has_only_audio_markers(content: &str) -> bool {
let mut has_audio = false;
for caps in MEDIA_MARKER_RE.captures_iter(content) {
if parse_media_marker(&caps).0 == "AUDIO" {
has_audio = true;
} else {
return false;
}
}
has_audio
}
async fn transcribe_image_file(
path: &std::path::Path,
transcriber: &crate::providers::transcribe::MediaTranscriber,
workspace: &str,
) -> anyhow::Result<String> {
if !path.exists() || !path.is_file() {
anyhow::bail!("image file not found: {}", path.display());
}
let data_uri = crate::util::local_image_to_data_uri(path).await?;
transcriber.transcribe(&data_uri, Some(workspace)).await
}
fn extract_urls(text: &str) -> Vec<String> {
let mut seen = HashSet::new();
let mut result = Vec::new();
for m in URL_RE.find_iter(text) {
let mut url = m.as_str().to_string();
while url.ends_with(&[',', '.', ')', ']', '}', ':', ';', '!', '?'][..]) {
url.pop();
}
if seen.insert(url.clone()) {
result.push(url);
}
}
result
}
pub async fn enrich_links(content: &str) -> Cow<'_, str> {
const MAX_TEXT_LEN: usize = 5000;
let urls = extract_urls(content);
if urls.is_empty() {
return Cow::Borrowed(content);
}
if !(crate::tools::browser_daemon::is_advertised()
&& matches!(
crate::tools::browser_daemon::cli_probe().await,
crate::tools::browser_daemon::CliStatus::Available
))
{
tracing::debug!("chrome-use not available, skipping link enrichment");
return Cow::Borrowed(content);
}
let browser = std::sync::Arc::new(BrowserTool::default());
let mut tasks = Vec::with_capacity(urls.len());
for (i, url) in urls.iter().enumerate() {
let url = url.clone();
let tab = format!("link-enricher-{i}");
let browser = std::sync::Arc::clone(&browser);
tasks.push(tokio::spawn(async move {
let result = browser.fetch_page_text(&url, &tab).await;
browser.close_session(&tab).await;
(url, result)
}));
}
let mut enrichments: Vec<String> = Vec::new();
for task in tasks {
match task.await {
Ok((url, Ok(body_text))) => {
if body_text.trim().is_empty() {
tracing::debug!(url, "Link enricher: page text is empty, skipping snippet");
continue;
}
let snippet = if body_text.len() > MAX_TEXT_LEN {
format!("{}…", crate::util::truncate_bytes(&body_text, MAX_TEXT_LEN))
} else {
body_text
};
enrichments.push(format!("📄 [{url}]\n{snippet}"));
}
Ok((url, Err(e))) => {
tracing::debug!(url, error = %e, "Link enricher: failed to fetch page text");
}
Err(e) => {
tracing::debug!("Link enricher task panicked: {e}");
}
}
}
if enrichments.is_empty() {
return Cow::Borrowed(content);
}
let prefix = enrichments.join("\n\n");
Cow::Owned(format!("{prefix}\n\n{content}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_urls_finds_http_and_https() {
let urls = extract_urls("Check https://example.com and http://test.org/page for info");
assert_eq!(urls, vec!["https://example.com", "http://test.org/page"]);
}
#[test]
fn extract_urls_deduplicates() {
let urls = extract_urls("Visit https://example.com and https://example.com again");
assert_eq!(urls.len(), 1);
}
#[test]
fn extract_urls_strips_trailing_punctuation() {
let urls = extract_urls("See https://example.com, and https://test.org.");
assert_eq!(urls, vec!["https://example.com", "https://test.org"]);
}
#[test]
fn extract_urls_handles_urls_in_parens() {
let urls = extract_urls("(https://example.com) and [https://test.org]");
assert_eq!(urls, vec!["https://example.com", "https://test.org"]);
}
#[tokio::test]
async fn enrich_links_returns_borrowed_when_no_urls() {
let content = "Hello, this is a plain message without any URLs.";
let result = enrich_links(content).await;
assert!(matches!(result, Cow::Borrowed(_)));
assert_eq!(result.as_ref(), content);
}
fn test_msg(content: &str) -> ChannelMessage {
ChannelMessage {
user_name: "test".into(),
reply_target: "test".into(),
content: content.to_string(),
channel: "test".into(),
workspace: "test".into(),
optimistic_id: None,
callback_query_id: None,
}
}
async fn ensure_telegram_files_dir() {
let tg_dir = std::env::temp_dir().join(crate::util::TELEGRAM_FILES_DIR);
tokio::fs::create_dir_all(&tg_dir).await.unwrap();
}
async fn out_of_scope_fixture(
prefix: &str,
file_name: &str,
contents: &[u8],
) -> (std::path::PathBuf, std::path::PathBuf, std::path::PathBuf) {
ensure_telegram_files_dir().await;
let tmp_root = std::env::temp_dir().join(format!("{prefix}_{}", std::process::id()));
let ws_path = tmp_root.join("myworkspace");
tokio::fs::create_dir_all(&ws_path).await.unwrap();
let arbitrary = tmp_root.join(file_name);
tokio::fs::write(&arbitrary, contents).await.unwrap();
(tmp_root, ws_path, arbitrary)
}
#[tokio::test]
async fn enrich_multimodal_image_http_url_passthrough() {
let mut msg = test_msg("Check this [IMAGE:https://example.com/img.png] out");
let strategy = EnrichmentStrategy::Multimodal {
workspace_path: None,
};
enrich_message(&mut msg, &strategy).await;
assert_eq!(
msg.content,
"Check this [IMAGE:https://example.com/img.png] out"
);
}
#[tokio::test]
async fn enrich_multimodal_image_file_not_found() {
let mut msg = test_msg("Here is [IMAGE:/tmp/nonexistent_xyz_img.png] an image");
let strategy = EnrichmentStrategy::Multimodal {
workspace_path: None,
};
enrich_message(&mut msg, &strategy).await;
assert!(
msg.content
.contains("[Invalid image reference: /tmp/nonexistent_xyz_img.png]")
);
}
#[tokio::test]
async fn enrich_multimodal_audio_annotation_and_strip() {
let mut msg = test_msg("Listen [AUDIO:/tmp/audio_xyz.mp3] to this");
let strategy = EnrichmentStrategy::Multimodal {
workspace_path: None,
};
enrich_message(&mut msg, &strategy).await;
assert!(
msg.content.contains("🔊✍️"),
"Audio annotation must be present, got: {}",
msg.content
);
assert!(
!msg.content.contains("[AUDIO:"),
"AUDIO marker must be stripped"
);
assert!(
!msg.content.contains("audio_xyz"),
"Audio temp file name must not appear, got: {}",
msg.content
);
assert!(msg.content.contains("Listen"), "Original text preserved");
assert!(msg.content.contains("to this"), "Original text preserved");
}
#[tokio::test]
async fn enrich_multimodal_image_valid_file_converts_to_data_uri_and_deletes_temp() {
let tg_dir = std::env::temp_dir().join(crate::util::TELEGRAM_FILES_DIR);
tokio::fs::create_dir_all(&tg_dir).await.unwrap();
let tmp = tg_dir.join(format!("test_enrich_img_{}.png", std::process::id()));
let png_header: &[u8] = &[
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48,
0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00,
0x00, 0x90, 0x77, 0x53, 0xDE, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, 0x54, 0x08,
0xD7, 0x63, 0x60, 0x60, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0xE7, 0x21, 0x33, 0x7C,
0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
];
tokio::fs::write(&tmp, png_header).await.unwrap();
let path_str = tmp.to_string_lossy().to_string();
let mut msg = test_msg(&format!("Image: [IMAGE:{path_str}]"));
let strategy = EnrichmentStrategy::Multimodal {
workspace_path: None,
};
enrich_message(&mut msg, &strategy).await;
assert!(
msg.content.contains("[IMAGE:data:image/png;base64,"),
"Expected data URI, got: {}",
msg.content
);
assert!(
!msg.content.contains(&path_str),
"Raw file path must not remain in content"
);
assert!(
!tmp.exists(),
"Temp image file must be deleted after enrichment"
);
}
#[tokio::test]
async fn enrich_multimodal_image_with_workspace_creates_upload_annotation() {
let tmp_root = std::env::temp_dir().join(format!("test_enrich_ws_{}", std::process::id()));
let ws_path = tmp_root.join("myworkspace");
tokio::fs::create_dir_all(&ws_path).await.unwrap();
let tg_dir = std::env::temp_dir().join(crate::util::TELEGRAM_FILES_DIR);
tokio::fs::create_dir_all(&tg_dir).await.unwrap();
let tmp_img = tg_dir.join(format!("test_enrich_ws_img_{}.png", std::process::id()));
let png_header: &[u8] = &[
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48,
0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00,
0x00, 0x90, 0x77, 0x53, 0xDE, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, 0x54, 0x08,
0xD7, 0x63, 0x60, 0x60, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0xE7, 0x21, 0x33, 0x7C,
0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
];
tokio::fs::write(&tmp_img, png_header).await.unwrap();
let img_path_str = tmp_img.to_string_lossy().to_string();
let mut msg = test_msg(&format!("Image: [IMAGE:{img_path_str}]"));
let strategy = EnrichmentStrategy::Multimodal {
workspace_path: Some(ws_path.clone()),
};
enrich_message(&mut msg, &strategy).await;
assert!(msg.content.contains("[IMAGE:data:image/png;base64,"));
assert!(
msg.content.contains("[Saved image:"),
"Upload annotation must be present, got: {}",
msg.content
);
assert!(
!tmp_img.exists(),
"Temp file must be deleted after enrichment"
);
let _ = tokio::fs::remove_dir_all(&tmp_root).await;
}
#[tokio::test]
async fn enrich_non_multimodal_image_annotation() {
let mut msg = test_msg("Here is [IMAGE:/tmp/photo_xyz.jpg] from the camera");
enrich_message(&mut msg, &EnrichmentStrategy::NonMultimodal).await;
assert!(
msg.content.contains("[Image:"),
"Image annotation must be present, got: {}",
msg.content
);
assert!(
!msg.content.contains("[IMAGE:"),
"IMAGE marker must be stripped"
);
assert!(msg.content.contains("from the camera"));
}
#[tokio::test]
async fn enrich_non_multimodal_http_image_url_passthrough() {
let mut msg = test_msg("Check [IMAGE:https://example.com/photo.png] online");
enrich_message(&mut msg, &EnrichmentStrategy::NonMultimodal).await;
assert!(
msg.content.contains("[Image:"),
"Image annotation must be present despite HTTP URL"
);
assert!(
!msg.content.contains("[IMAGE:"),
"IMAGE marker must be stripped"
);
}
#[tokio::test]
async fn enrich_non_multimodal_video_annotation() {
let mut msg = test_msg("Watch [VIDEO:/tmp/clip_xyz.mp4] this video");
enrich_message(&mut msg, &EnrichmentStrategy::NonMultimodal).await;
assert!(
msg.content.contains("[Video: clip_xyz.mp4 attached]"),
"Video annotation must be present, got: {}",
msg.content
);
assert!(
!msg.content.contains("[VIDEO:"),
"VIDEO marker must be stripped"
);
}
#[tokio::test]
async fn enrich_multimodal_video_with_workspace_copies_and_annotates() {
let tmp_root =
std::env::temp_dir().join(format!("test_enrich_video_ws_{}", std::process::id()));
let ws_path = tmp_root.join("myworkspace");
tokio::fs::create_dir_all(&ws_path).await.unwrap();
let tg_dir = std::env::temp_dir().join(crate::util::TELEGRAM_FILES_DIR);
tokio::fs::create_dir_all(&tg_dir).await.unwrap();
let tmp_video = tg_dir.join(format!("test_enrich_video_{}.mp4", std::process::id()));
let mp4_header: &[u8] = &[
0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6F, 0x6D, 0x00, 0x00,
0x00, 0x00, 0x69, 0x73, 0x6F, 0x6D, 0x69, 0x73, 0x6F, 0x32,
];
tokio::fs::write(&tmp_video, mp4_header).await.unwrap();
let video_path_str = tmp_video.to_string_lossy().to_string();
let mut msg = test_msg(&format!("Edit this clip: [VIDEO:{video_path_str}]"));
let strategy = EnrichmentStrategy::Multimodal {
workspace_path: Some(ws_path.clone()),
};
enrich_message(&mut msg, &strategy).await;
assert!(
msg.content.contains("[Saved video:"),
"Video upload annotation must be present, got: {}",
msg.content
);
assert!(
msg.content
.contains(&ws_path.join("uploads").display().to_string()),
"Annotation must point into workspace uploads, got: {}",
msg.content
);
assert!(
!msg.content.contains("[VIDEO:"),
"VIDEO marker must be stripped"
);
assert!(
!tmp_video.exists(),
"Temp video file must be deleted after enrichment"
);
let _ = tokio::fs::remove_file(&tmp_video).await;
let _ = tokio::fs::remove_dir_all(&tmp_root).await;
}
#[tokio::test]
async fn enrich_multimodal_video_outside_telegram_files_annotates_without_copy() {
let (tmp_root, ws_path, arbitrary) =
out_of_scope_fixture("test_enrich_video_outside", "secret.txt", b"top secret").await;
let marker = format!("Edit [VIDEO:{}]", arbitrary.display());
let mut msg = test_msg(&marker);
let strategy = EnrichmentStrategy::Multimodal {
workspace_path: Some(ws_path.clone()),
};
enrich_message(&mut msg, &strategy).await;
assert!(
msg.content.contains("[Video: secret.txt attached]"),
"Out-of-scope path must degrade to a plain-text annotation, got: {}",
msg.content
);
assert!(!msg.content.contains("[Saved video:"));
assert!(!msg.content.contains("[VIDEO:"));
assert!(
arbitrary.exists(),
"Source file outside the telegram temp dir must not be deleted"
);
assert!(
!ws_path.join("uploads").exists(),
"No uploads copy may be created for out-of-scope paths"
);
let _ = tokio::fs::remove_dir_all(&tmp_root).await;
}
#[tokio::test]
async fn enrich_multimodal_image_outside_telegram_files_annotates_without_read_or_copy() {
let (tmp_root, ws_path, arbitrary) = out_of_scope_fixture(
"test_enrich_img_outside",
"secret.png",
b"top secret image bytes",
)
.await;
let marker = format!("Look at [IMAGE:{}]", arbitrary.display());
let mut msg = test_msg(&marker);
let strategy = EnrichmentStrategy::Multimodal {
workspace_path: Some(ws_path.clone()),
};
enrich_message(&mut msg, &strategy).await;
assert!(
msg.content.contains("[Image: secret.png attached]"),
"Out-of-scope path must degrade to a plain-text annotation, got: {}",
msg.content
);
assert!(
!msg.content.contains("data:image"),
"No data URI may be produced for out-of-scope paths (would read the file)"
);
assert!(!msg.content.contains("[Saved image:"));
assert!(!msg.content.contains("[IMAGE:"));
assert!(
arbitrary.exists(),
"Source file outside the telegram temp dir must not be deleted"
);
assert_eq!(
tokio::fs::read(&arbitrary).await.unwrap(),
b"top secret image bytes",
"Source file contents must be unchanged"
);
assert!(
!ws_path.join("uploads").exists(),
"No uploads copy may be created for out-of-scope paths"
);
let _ = tokio::fs::remove_dir_all(&tmp_root).await;
}
#[tokio::test]
async fn enrich_non_multimodal_image_outside_telegram_files_annotates_without_read_or_delete() {
let (tmp_root, _ws_path, arbitrary) = out_of_scope_fixture(
"test_enrich_img_nonmm",
"secret.png",
b"top secret image bytes",
)
.await;
let marker = format!("Look at [IMAGE:{}]", arbitrary.display());
let mut msg = test_msg(&marker);
enrich_message(&mut msg, &EnrichmentStrategy::NonMultimodal).await;
assert!(
msg.content.contains("[Image: secret.png attached]"),
"Out-of-scope path must degrade to a plain-text annotation, got: {}",
msg.content
);
assert!(
!msg.content.contains("data:image"),
"No data URI may be produced for out-of-scope paths (would read the file)"
);
assert!(!msg.content.contains("[IMAGE:"));
assert!(
arbitrary.exists(),
"Source file outside the telegram temp dir must not be deleted"
);
assert_eq!(
tokio::fs::read(&arbitrary).await.unwrap(),
b"top secret image bytes",
"Source file contents must be unchanged"
);
let _ = tokio::fs::remove_dir_all(&tmp_root).await;
}
#[tokio::test]
async fn enrich_non_multimodal_video_outside_telegram_files_annotates_without_delete() {
let (tmp_root, _ws_path, arbitrary) = out_of_scope_fixture(
"test_enrich_video_nonmm",
"secret.mp4",
b"top secret video bytes",
)
.await;
let marker = format!("Watch [VIDEO:{}]", arbitrary.display());
let mut msg = test_msg(&marker);
enrich_message(&mut msg, &EnrichmentStrategy::NonMultimodal).await;
assert!(
msg.content.contains("[Video: secret.mp4 attached]"),
"Out-of-scope path must degrade to a plain-text annotation, got: {}",
msg.content
);
assert!(!msg.content.contains("[VIDEO:"));
assert!(
arbitrary.exists(),
"Source file outside the telegram temp dir must not be deleted"
);
assert_eq!(
tokio::fs::read(&arbitrary).await.unwrap(),
b"top secret video bytes",
"Source file contents must be unchanged"
);
let _ = tokio::fs::remove_dir_all(&tmp_root).await;
}
#[tokio::test]
async fn enrich_multimodal_video_http_url_kept_as_plain_text() {
let mut msg = test_msg("Edit [VIDEO:https://example.com/clip.mp4] this");
let strategy = EnrichmentStrategy::Multimodal {
workspace_path: None,
};
enrich_message(&mut msg, &strategy).await;
assert!(
msg.content
.contains("[Video: https://example.com/clip.mp4]"),
"HTTP video URL must be kept as plain-text reference, got: {}",
msg.content
);
assert!(!msg.content.contains("[VIDEO:"));
}
#[tokio::test]
async fn enrich_non_multimodal_all_markers_stripped_and_annotated() {
let mut msg = test_msg(
"Check [IMAGE:/tmp/img_xyz.png] and listen [AUDIO:/tmp/audio_xyz.mp3] and watch [VIDEO:/tmp/vid_xyz.mp4]",
);
enrich_message(&mut msg, &EnrichmentStrategy::NonMultimodal).await;
assert!(!msg.content.contains("[IMAGE:"));
assert!(!msg.content.contains("[AUDIO:"));
assert!(!msg.content.contains("[VIDEO:"));
assert!(msg.content.contains("[Image:"), "Image annotation missing");
assert!(msg.content.contains("🔊✍️"), "Audio annotation missing");
assert!(msg.content.contains("[Video:"), "Video annotation missing");
assert!(msg.content.contains("Check"));
assert!(msg.content.contains("listen"));
assert!(msg.content.contains("watch"));
}
#[tokio::test]
async fn enrich_audio_file_deleted_on_failure() {
let tg_dir = std::env::temp_dir().join(crate::util::TELEGRAM_FILES_DIR);
tokio::fs::create_dir_all(&tg_dir).await.unwrap();
let tmp = tg_dir.join(format!("test_enrich_audio_{}.mp3", std::process::id()));
tokio::fs::write(&tmp, b"fake audio content").await.unwrap();
let path_str = tmp.to_string_lossy().to_string();
let mut msg = test_msg(&format!("Audio: [AUDIO:{path_str}]"));
enrich_message(&mut msg, &EnrichmentStrategy::NonMultimodal).await;
assert!(
!tmp.exists(),
"Audio temp file must be deleted on transcription failure"
);
let _ = tokio::fs::remove_file(&tmp).await;
}
#[tokio::test]
async fn enrich_multimodal_combined_image_preserved_audio_annotated() {
let msg_content = "Here [IMAGE:https://example.com/img.png] and [AUDIO:/tmp/sound_xyz.mp3]";
let mut msg = test_msg(msg_content);
let strategy = EnrichmentStrategy::Multimodal {
workspace_path: None,
};
enrich_message(&mut msg, &strategy).await;
assert!(
msg.content.contains("[IMAGE:https://example.com/img.png]"),
"IMAGE with http URL must be preserved in multimodal mode, got: {}",
msg.content
);
assert!(
msg.content.contains("🔊✍️"),
"Audio annotation must be present"
);
assert!(
!msg.content.contains("[AUDIO:"),
"AUDIO marker must be stripped"
);
}
async fn assert_no_markers_unchanged(strategy: EnrichmentStrategy, content: &str) {
let mut msg = test_msg(content);
let original = msg.content.clone();
enrich_message(&mut msg, &strategy).await;
assert_eq!(msg.content, original, "No markers = no changes");
}
#[tokio::test]
async fn enrich_multimodal_no_annotations_when_no_markers() {
assert_no_markers_unchanged(
EnrichmentStrategy::Multimodal {
workspace_path: None,
},
"Just a plain message with no markers",
)
.await;
}
#[tokio::test]
async fn enrich_non_multimodal_no_annotations_when_no_markers() {
assert_no_markers_unchanged(
EnrichmentStrategy::NonMultimodal,
"Plain text, no markers here",
)
.await;
}
}