use crate::ChannelMessage;
use crate::document::{DocOutcome, INLINE_TEXT_MAX_CHARS, convert_document_file};
use crate::tools::chrome::ChromeTool;
use crate::util::media_target::{self, MediaTarget};
use crate::util::{
MEDIA_MARKER_RE, MediaMarkerKind, 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;
use std::sync::atomic::{AtomicU64, Ordering};
static URL_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r#"https?://[^\s<>"']+"#).expect("URL regex must compile"));
static LINK_ENRICHER_SEQ: AtomicU64 = AtomicU64::new(0);
#[cfg(target_os = "macos")]
const AUDIO_ICON: &str = "🔊✍️";
#[cfg(not(target_os = "macos"))]
const AUDIO_UNAVAILABLE: &str = "[Audio is not supported on this platform]";
#[cfg(target_os = "macos")]
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?;
let ext = media_path
.extension()
.and_then(|e| e.to_str())
.unwrap_or(fallback_ext);
let name = format!("upload_{}.{ext}", crate::util::unix_millis());
let dest = copy_to_uploads(media_path, dir, &name).await?;
Some(SavedMedia {
annotation: format!("[Saved {label}: {}]", dest.display()),
dest,
})
}
async fn copy_to_uploads(
src: &std::path::Path,
dir: &std::path::Path,
name: &str,
) -> Option<std::path::PathBuf> {
if let Err(e) = tokio::fs::create_dir_all(dir).await {
tracing::warn!(
dir = %dir.display(),
error = %e,
"Failed to create the workspace uploads dir"
);
return None;
}
let (mut dest, path) = match crate::util::create_unique_file(dir, name).await {
Ok(handle) => handle,
Err(e) => {
tracing::warn!(
dir = %dir.display(),
error = %e,
"Failed to create a file in the workspace uploads dir"
);
return None;
}
};
let copied = async {
let mut source = tokio::fs::File::open(src).await?;
tokio::io::copy(&mut source, &mut dest).await?;
Ok::<_, std::io::Error>(())
}
.await;
if let Err(e) = copied {
tracing::warn!(
path = %src.display(),
error = %e,
"Failed to copy a file into the workspace uploads dir"
);
let _ = tokio::fs::remove_file(&path).await;
return None;
}
Some(path)
}
enum ImageAction {
Keep,
Replace {
replacement: String,
upload_annotation: Option<String>,
delete_temp: bool,
},
Invalid { delete_temp: bool },
}
impl ImageAction {
fn delete_temp(&self) -> bool {
match self {
Self::Keep => false,
Self::Replace { delete_temp, .. } | Self::Invalid { delete_temp } => *delete_temp,
}
}
}
async fn bounded_image_data_uri(path: &std::path::Path) -> anyhow::Result<String> {
match crate::util::local_image_to_data_uri(path).await {
Ok(uri) if uri.len() <= media_target::MAX_DATA_URI_ENCODED_BYTES => Ok(uri),
Ok(_) => {
tracing::warn!(path = %path.display(), "image data-URI exceeds the encoded-payload cap — re-encoding to a bounded JPEG");
let reencoded = crate::util::local_image_to_compressed_data_uri(path).await?;
if reencoded.len() <= media_target::MAX_DATA_URI_ENCODED_BYTES {
Ok(reencoded)
} else {
Err(anyhow::Error::msg(
"image exceeds the compressed data-URI cap",
))
}
}
Err(e) => Err(e),
}
}
async fn handle_image(
path: &str,
path_obj: &std::path::Path,
uploads_dir: Option<&std::path::Path>,
staging_dirs: &[String],
) -> ImageAction {
if media_target::is_valid_remote_url(path) {
return ImageAction::Keep;
}
if !path_obj.exists() || !path_obj.is_file() {
tracing::warn!(%path, "Image file not found for enrichment");
return ImageAction::Invalid { delete_temp: false };
}
if !is_inbound_attachment(path_obj, staging_dirs).await {
tracing::warn!(%path, "Image path outside this message's inbound attachments — annotating without copy");
return ImageAction::Replace {
replacement: format!("[Image: {} attached]", file_name_or_path(path)),
upload_annotation: None,
delete_temp: false,
};
}
if !matches!(
crate::util::with_block_in_place(|| media_target::classify_media_image_target(path)),
MediaTarget::LocalImage
) {
return ImageAction::Invalid { delete_temp: true };
}
let data_uri = bounded_image_data_uri(path_obj).await;
let saved = match &data_uri {
Ok(_) => save_media_to_workspace(path_obj, uploads_dir, "image", "png")
.await
.map(|saved| saved.annotation),
Err(_) => None,
};
let data_uri = match data_uri {
Ok(data_uri) => data_uri,
Err(e) => {
tracing::warn!(%path, error = %e, "Failed to convert image to data URI");
return ImageAction::Invalid { delete_temp: true };
}
};
ImageAction::Replace {
replacement: format!("[IMAGE:{data_uri}]"),
upload_annotation: saved,
delete_temp: true,
}
}
async fn canonical_telegram_files_root() -> Option<std::path::PathBuf> {
tokio::fs::canonicalize(crate::util::telegram_files_root())
.await
.ok()
}
async fn is_inbound_attachment(path: &std::path::Path, staging_dirs: &[String]) -> bool {
if staging_dirs.is_empty() {
return false;
}
let Some(root) = canonical_telegram_files_root().await else {
return false;
};
let candidate = match tokio::fs::canonicalize(path).await {
Ok(canonical) => canonical,
Err(_) => match path.parent() {
Some(parent) => match tokio::fs::canonicalize(parent).await {
Ok(canonical) => canonical,
Err(_) => return false,
},
None => return false,
},
};
let Ok(relative) = candidate.strip_prefix(&root) else {
return false;
};
relative
.components()
.next()
.and_then(|component| component.as_os_str().to_str())
.is_some_and(|dir| staging_dirs.iter().any(|staging_dir| staging_dir == dir))
}
fn is_staging_dir_name(name: &str) -> bool {
!name.is_empty() && name != "." && name != ".." && !name.contains(std::path::is_separator)
}
struct VideoAction {
replacement: String,
delete_temp: bool,
transcription: Option<String>,
}
impl VideoAction {
fn annotation(replacement: String) -> Self {
Self {
replacement,
delete_temp: false,
transcription: None,
}
}
}
async fn handle_video(
path: &str,
path_obj: &std::path::Path,
uploads_dir: Option<&std::path::Path>,
workspace: &str,
staging_dirs: &[String],
) -> VideoAction {
if is_http_url(path) {
return VideoAction::annotation(format!("[Video: {path}]"));
}
if !path_obj.exists() || !path_obj.is_file() {
tracing::warn!(%path, "Video file not found for enrichment");
return VideoAction::annotation(format!("[Invalid video reference: {path}]"));
}
if !is_inbound_attachment(path_obj, staging_dirs).await {
tracing::warn!(%path, "Video path outside this message's inbound attachments — annotating without copy");
return VideoAction::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 VideoAction {
replacement: saved.annotation,
delete_temp: true,
transcription,
};
}
VideoAction {
replacement: format!("[Video: {} attached]", file_name_or_path(path)),
delete_temp: true,
transcription: None,
}
}
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_file(
path: &str,
path_obj: &std::path::Path,
uploads_dir: Option<&std::path::Path>,
staging_dirs: &[String],
batch: &mut EnrichmentBatch,
) -> Option<String> {
if !is_inbound_attachment(path_obj, staging_dirs).await {
tracing::warn!(%path, "File path outside this message's inbound attachment — leaving the marker untouched");
return None;
}
let name = file_name_or_path(path);
if path_obj.is_file() {
batch.files_to_delete.push(path_obj.to_path_buf());
} else if path_obj.exists() {
return Some(format!(
"[File {name}: the attachment is not a regular file]"
));
} else {
tracing::warn!(%path, "Inbound attachment missing for enrichment");
return Some(format!(
"[File {name}: could not be retrieved — the downloaded attachment is missing]"
));
}
let Some(uploads_dir) = uploads_dir else {
return Some(format!(
"[File {name}: received, not saved to the workspace]"
));
};
let copy = copy_to_uploads(path_obj, uploads_dir, name).await;
let copied_to_workspace = copy.is_some();
let replacement = match © {
Some(dest) => format!("[FILE:{}]", dest.display()),
None => format!("[File {name}: received, not saved to the workspace]"),
};
let out_dir = path_obj.parent().unwrap_or(path_obj);
match convert_document_file(path_obj, name, out_dir).await {
DocOutcome::Text {
text,
images,
notes,
unshown,
all_page_text_lost,
} => {
if !all_page_text_lost || !text.trim().is_empty() {
batch.annotations.push(
extracted_text_annotation(uploads_dir, name, &text, !images.is_empty()).await,
);
}
for line in unshown.lines() {
batch.annotations.push(format!("[File {name}: {line}]"));
}
for note in notes {
batch.annotations.push(format!("[File {name}: {note}]"));
}
let mut unreadable_pages = 0usize;
for image in images {
let image_path = image.to_string_lossy().to_string();
match handle_image(&image_path, &image, Some(uploads_dir), staging_dirs).await {
ImageAction::Keep => {}
ImageAction::Invalid { delete_temp } => {
unreadable_pages += 1;
if delete_temp {
batch.files_to_delete.push(image);
}
}
ImageAction::Replace {
replacement,
upload_annotation,
delete_temp,
} => {
batch.appended_markers.push(replacement);
if let Some(annotation) = upload_annotation {
batch.upload_annotations.push(annotation);
}
if delete_temp {
batch.files_to_delete.push(image);
}
}
}
}
if unreadable_pages > 0 {
batch.annotations.push(format!(
"[File {name}: {unreadable_pages} extracted page image(s) could not be read]"
));
}
}
DocOutcome::Unreadable { reason } => {
batch.annotations.push(format!("[File {name}: {reason}]"));
}
DocOutcome::Unsupported => {
let saved = if copied_to_workspace {
" — open the saved file directly"
} else {
""
};
batch.annotations.push(format!(
"[File {name}: received but not converted (unsupported format){saved}]"
));
}
}
Some(replacement)
}
async fn extracted_text_annotation(
uploads_dir: &std::path::Path,
name: &str,
text: &str,
has_images: bool,
) -> String {
let text = text.trim();
if text.is_empty() {
return if has_images {
format!("[File {name}: {}]", crate::document::NO_TEXT_LAYER_NOTE)
} else {
format!("[File {name}: {}]", crate::document::NO_TEXT_NOTE)
};
}
let char_count = text.chars().count();
if char_count <= INLINE_TEXT_MAX_CHARS {
return format!("[File {name}: extracted text follows]\n\n{text}");
}
let spilled = async {
let spill_name = format!("{}.extracted.txt", crate::util::name_stem(name));
let (mut file, path) = crate::util::create_unique_file(uploads_dir, &spill_name).await?;
match tokio::io::AsyncWriteExt::write_all(&mut file, text.as_bytes()).await {
Ok(()) => Ok::<_, std::io::Error>(path),
Err(e) => {
drop(file);
let _ = tokio::fs::remove_file(&path).await;
Err(e)
}
}
}
.await;
let spill = match spilled {
Ok(path) => path,
Err(e) => {
tracing::warn!(
dir = %uploads_dir.display(),
error = %e,
"Failed to save the extracted document text"
);
return format!(
"[File {name}: the extracted text could not be saved to the workspace]"
);
}
};
format!(
"[File {name}: {}]",
crate::document::spilled_text_note(char_count, &spill)
)
}
#[derive(Default)]
struct EnrichmentBatch {
annotations: Vec<String>,
upload_annotations: Vec<String>,
appended_markers: Vec<String>,
files_to_delete: Vec<std::path::PathBuf>,
}
impl EnrichmentBatch {
async fn finish(self, mut body: String, staging_dirs: &[String]) -> String {
for file_path in &self.files_to_delete {
if let Err(e) = tokio::fs::remove_file(file_path).await
&& e.kind() != std::io::ErrorKind::NotFound
{
tracing::warn!(
path = %file_path.display(),
error = %e,
"Failed to delete temp file after enrichment"
);
}
}
let root = crate::util::telegram_files_root();
for staging_dir in staging_dirs {
if !is_staging_dir_name(staging_dir) {
continue;
}
let _ = tokio::fs::remove_dir(root.join(staging_dir)).await;
}
if !self.appended_markers.is_empty() {
let _ = write!(body, "\n\n{}", self.appended_markers.join("\n"));
}
if !self.upload_annotations.is_empty() {
let annotation_block = self.upload_annotations.join("\n");
let _ = write!(body, "\n\n{annotation_block}");
}
let cleaned = MEDIA_MARKER_RE
.replace_all(&body, |caps: ®ex::Captures| {
if matches!(
parse_media_marker(caps).0,
MediaMarkerKind::Audio | MediaMarkerKind::Video
) {
String::new()
} else {
caps.get_match().as_str().to_string()
}
})
.to_string();
let cleaned = cleaned.trim().to_string();
if self.annotations.is_empty() {
return cleaned;
}
let prefix = self.annotations.join("\n");
if cleaned.is_empty() {
prefix
} else {
format!("{prefix}\n\n{cleaned}")
}
}
}
pub async fn enrich_message(msg: &mut ChannelMessage, workspace_path: Option<&std::path::Path>) {
let mut batch = EnrichmentBatch::default();
let mut result = msg.content.clone();
let uploads_dir = workspace_path.map(|p| p.join("uploads"));
let staging_dirs = msg.attachment_dirs.clone();
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 {
MediaMarkerKind::File => {
if let Some(replacement) = handle_file(
path,
path_obj,
uploads_dir.as_deref(),
&staging_dirs,
&mut batch,
)
.await
{
result = result.replacen(whole.as_str(), &replacement, 1);
}
}
MediaMarkerKind::Image => {
let action =
handle_image(path, path_obj, uploads_dir.as_deref(), &staging_dirs).await;
let delete_temp = action.delete_temp();
match action {
ImageAction::Keep => {
}
ImageAction::Invalid { .. } => {
let replacement = format!("[Invalid image reference: {path}]");
result = result.replacen(whole.as_str(), &replacement, 1);
}
ImageAction::Replace {
replacement,
upload_annotation,
..
} => {
result = result.replacen(whole.as_str(), &replacement, 1);
if let Some(ann) = upload_annotation {
batch.upload_annotations.push(ann);
}
}
}
if delete_temp {
batch.files_to_delete.push(path_obj.to_path_buf());
}
}
MediaMarkerKind::Audio => {
#[cfg(target_os = "macos")]
{
if path_obj.is_file() && is_inbound_attachment(path_obj, &staging_dirs).await {
batch.annotations.push(transcribe_audio_marker(path).await);
batch.files_to_delete.push(path_obj.to_path_buf());
} else {
tracing::warn!(%path, "Audio marker is not an inbound attachment file — annotating without transcription");
batch.annotations.push(AUDIO_ICON.to_string());
}
}
#[cfg(not(target_os = "macos"))]
{
tracing::debug!(%path, "Audio marker on a platform with no local audio — annotating without transcription");
batch.annotations.push(AUDIO_UNAVAILABLE.to_string());
}
}
MediaMarkerKind::Video => {
let VideoAction {
replacement,
delete_temp,
transcription,
} = handle_video(
path,
path_obj,
uploads_dir.as_deref(),
&msg.workspace,
&staging_dirs,
)
.await;
result = result.replacen(whole.as_str(), &replacement, 1);
if let Some(annotation) = transcription {
batch.annotations.push(annotation);
}
if delete_temp {
batch.files_to_delete.push(path_obj.to_path_buf());
}
}
}
}
msg.content = batch.finish(result, &staging_dirs).await;
}
#[must_use]
pub(crate) fn has_inbound_temp_marker(content: &str) -> bool {
let root = crate::util::telegram_files_root();
MEDIA_MARKER_RE.captures_iter(content).any(|caps| {
let (_, path) = parse_media_marker(&caps);
std::path::Path::new(path).starts_with(&root)
})
}
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::chrome_daemon::is_advertised()
&& matches!(
crate::tools::chrome_daemon::cli_probe().await,
crate::tools::chrome_daemon::CliStatus::Available
))
{
tracing::debug!("chrome-use not available, skipping link enrichment");
return Cow::Borrowed(content);
}
let chrome = std::sync::Arc::new(ChromeTool::default());
let mut tasks = Vec::with_capacity(urls.len());
for url in &urls {
let url = url.clone();
let tab = format!(
"link-enricher-{}",
LINK_ENRICHER_SEQ.fetch_add(1, Ordering::Relaxed)
);
let chrome = std::sync::Arc::clone(&chrome);
tasks.push(tokio::spawn(async move {
let result = chrome.fetch_page_text(&url, &tab).await;
chrome.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::*;
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD;
#[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);
}
const TEST_CHAT_ID: &str = "1";
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,
reply_reference: None,
chat_id: Some(TEST_CHAT_ID.into()),
message_id: None,
attachment_dirs: Vec::new(),
parts: Vec::new(),
}
}
fn inbound_album_msg(message_ids: &[i64], content: &str) -> ChannelMessage {
ChannelMessage {
message_id: message_ids.first().copied(),
attachment_dirs: message_ids
.iter()
.map(|id| crate::util::telegram_staging_dir_name(TEST_CHAT_ID, *id))
.collect(),
..test_msg(content)
}
}
fn inbound_msg(message_id: i64, content: &str) -> ChannelMessage {
inbound_album_msg(&[message_id], content)
}
fn telegram_root() -> std::path::PathBuf {
crate::util::telegram_files_root()
}
async fn ensure_telegram_files_dir() {
tokio::fs::create_dir_all(telegram_root()).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 = crate::util::test::test_root().join(prefix);
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)
}
async fn telegram_attachment_fixture(
chat_id: &str,
message_id: i64,
file_name: &str,
bytes: &[u8],
) -> (std::path::PathBuf, std::path::PathBuf) {
ensure_telegram_files_dir().await;
let msg_dir =
telegram_root().join(crate::util::telegram_staging_dir_name(chat_id, message_id));
tokio::fs::create_dir_all(&msg_dir).await.unwrap();
let attachment = msg_dir.join(file_name);
tokio::fs::write(&attachment, bytes).await.unwrap();
(msg_dir, attachment)
}
async fn inbound_attachment_fixture(
message_id: i64,
file_name: &str,
bytes: &[u8],
) -> (std::path::PathBuf, std::path::PathBuf) {
telegram_attachment_fixture(TEST_CHAT_ID, message_id, file_name, bytes).await
}
async fn inbound_ingest_fixture(
prefix: &str,
message_id: i64,
file_name: &str,
bytes: &[u8],
) -> (
std::path::PathBuf,
std::path::PathBuf,
std::path::PathBuf,
std::path::PathBuf,
) {
let tmp_root = crate::util::test::test_root().join(prefix);
let ws_path = tmp_root.join("myworkspace");
tokio::fs::create_dir_all(&ws_path).await.unwrap();
let (msg_dir, attachment) = inbound_attachment_fixture(message_id, file_name, bytes).await;
(tmp_root, ws_path, msg_dir, attachment)
}
fn real_png(w: u32, h: u32) -> Vec<u8> {
let img = image::RgbImage::from_fn(w, h, |x, y| {
image::Rgb([
u8::try_from(x % 256).expect("x % 256 fits in u8"),
u8::try_from(y % 256).expect("y % 256 fits in u8"),
128,
])
});
let mut bytes = Vec::new();
image::DynamicImage::ImageRgb8(img)
.write_to(
&mut std::io::Cursor::new(&mut bytes),
image::ImageFormat::Png,
)
.expect("fixture PNG must encode");
bytes
}
#[tokio::test]
async fn enrich_image_http_url_passthrough() {
let mut msg = test_msg("Check this [IMAGE:https://example.com/img.png] out");
enrich_message(&mut msg, None).await;
assert_eq!(
msg.content,
"Check this [IMAGE:https://example.com/img.png] out"
);
}
#[tokio::test]
async fn enrich_image_file_not_found() {
let mut msg = test_msg("Here is [IMAGE:/tmp/nonexistent_xyz_img.png] an image");
enrich_message(&mut msg, None).await;
assert!(
msg.content
.contains("[Invalid image reference: /tmp/nonexistent_xyz_img.png]")
);
}
#[cfg(target_os = "macos")]
fn audio_annotation() -> &'static str {
AUDIO_ICON
}
#[cfg(not(target_os = "macos"))]
fn audio_annotation() -> &'static str {
AUDIO_UNAVAILABLE
}
#[tokio::test]
async fn enrich_audio_annotation_and_strip() {
let mut msg = test_msg("Listen [AUDIO:/tmp/audio_xyz.mp3] to this");
enrich_message(&mut msg, None).await;
assert!(
msg.content.contains(audio_annotation()),
"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_image_valid_file_converts_to_data_uri_and_deletes_temp() {
let source_bytes = real_png(2, 1);
let (msg_dir, tmp) = inbound_attachment_fixture(7001, "photo.png", &source_bytes).await;
let path_str = tmp.to_string_lossy().to_string();
let mut msg = inbound_msg(7001, &format!("Image: [IMAGE:{path_str}]"));
enrich_message(&mut msg, None).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"
);
assert!(!msg_dir.exists(), "Per-message directory must be deleted");
}
#[tokio::test]
async fn enrich_image_with_workspace_creates_upload_annotation() {
let tmp_root = crate::util::test::test_root().join("test_enrich_ws");
let ws_path = tmp_root.join("myworkspace");
tokio::fs::create_dir_all(&ws_path).await.unwrap();
let source_bytes = real_png(2, 1);
let (_msg_dir, tmp_img) =
inbound_attachment_fixture(7002, "photo.png", &source_bytes).await;
let img_path_str = tmp_img.to_string_lossy().to_string();
let mut msg = inbound_msg(7002, &format!("Image: [IMAGE:{img_path_str}]"));
enrich_message(&mut msg, Some(ws_path.as_path())).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_video_with_workspace_copies_and_annotates() {
let tmp_root = crate::util::test::test_root().join("test_enrich_video_ws");
let ws_path = tmp_root.join("myworkspace");
tokio::fs::create_dir_all(&ws_path).await.unwrap();
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,
];
let (_msg_dir, tmp_video) = inbound_attachment_fixture(7003, "clip.mp4", mp4_header).await;
let video_path_str = tmp_video.to_string_lossy().to_string();
let mut msg = inbound_msg(7003, &format!("Edit this clip: [VIDEO:{video_path_str}]"));
enrich_message(&mut msg, Some(ws_path.as_path())).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_image_outside_inbound_attachments_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);
enrich_message(&mut msg, Some(ws_path.as_path())).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_video_http_url_kept_as_plain_text() {
let mut msg = test_msg("Edit [VIDEO:https://example.com/clip.mp4] this");
enrich_message(&mut msg, None).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_video_without_workspace_deletes_in_scope_temp() {
let (_msg_dir, tmp_video) = inbound_attachment_fixture(7004, "clip.mp4", b"fake mp4").await;
let video_path_str = tmp_video.to_string_lossy().to_string();
let mut msg = inbound_msg(7004, &format!("Watch [VIDEO:{video_path_str}] this clip"));
enrich_message(&mut msg, None).await;
assert!(
msg.content.contains(&format!(
"[Video: {} attached]",
tmp_video.file_name().unwrap().to_string_lossy()
)),
"Plain video annotation must be present, got: {}",
msg.content
);
assert!(
!msg.content.contains("[Saved video:"),
"No workspace copy may be made without an uploads dir, got: {}",
msg.content
);
assert!(!msg.content.contains("[VIDEO:"));
assert!(
!tmp_video.exists(),
"In-scope temp video must be deleted even without a workspace copy"
);
}
#[tokio::test]
async fn enrich_video_outside_inbound_attachments_annotates_without_copy_or_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, Some(ws_path.as_path())).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"
);
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_image_bytes_pass_through_byte_identical() {
let source_bytes = real_png(64, 48);
let (_msg_dir, tmp) = inbound_attachment_fixture(7006, "art.png", &source_bytes).await;
let path_str = tmp.to_string_lossy().to_string();
let mut msg = inbound_msg(7006, &format!("Art: [IMAGE:{path_str}]"));
enrich_message(&mut msg, None).await;
let expected = format!(
"[IMAGE:data:image/png;base64,{}]",
STANDARD.encode(&source_bytes)
);
assert!(
msg.content.contains(&expected),
"Image data URI must be byte-identical to the source, got: {}",
msg.content
);
assert!(
!tmp.exists(),
"Temp image file must be deleted after enrichment"
);
}
#[tokio::test]
async fn enrich_image_in_scope_non_raster_is_rejected_not_junk_data_uri() {
let source_bytes = b"not an image";
let (_msg_dir, tmp) = inbound_attachment_fixture(7007, "photo.png", source_bytes).await;
let path_str = tmp.to_string_lossy().to_string();
let mut msg = inbound_msg(7007, &format!("Photo: [IMAGE:{path_str}]"));
enrich_message(&mut msg, None).await;
assert!(
msg.content
.contains(&format!("[Invalid image reference: {path_str}]")),
"Non-raster in-scope file must degrade to an invalid reference, got: {}",
msg.content
);
assert!(
!msg.content.contains("[IMAGE:data:"),
"No junk data URI may be produced for a non-raster file, got: {}",
msg.content
);
assert!(
!tmp.exists(),
"In-scope non-raster temp file must be cleaned up"
);
let _ = tokio::fs::remove_file(&tmp).await;
}
#[tokio::test]
async fn enrich_image_corrupt_raster_does_not_fail_open_to_junk_data_uri() {
let img = image::RgbaImage::from_pixel(1, 1, image::Rgba([255, 0, 0, 255]));
let mut buf = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
.unwrap();
let truncated = &buf[..buf.len().min(24)];
let (_msg_dir, tmp) = inbound_attachment_fixture(7008, "photo.png", truncated).await;
let path_str = tmp.to_string_lossy().to_string();
let mut msg = inbound_msg(7008, &format!("Photo: [IMAGE:{path_str}]"));
enrich_message(&mut msg, None).await;
assert!(
msg.content
.contains(&format!("[Invalid image reference: {path_str}]")),
"Corrupt-but-magic-valid in-scope file must degrade to an invalid reference, got: {}",
msg.content
);
assert!(
!msg.content.contains("[IMAGE:data:"),
"No junk data URI may be produced for a corrupt file, got: {}",
msg.content
);
let _ = tokio::fs::remove_file(&tmp).await;
}
#[tokio::test]
async fn enrich_audio_file_deleted_on_failure() {
let (_msg_dir, tmp) =
inbound_attachment_fixture(7010, "voice.mp3", b"fake audio content").await;
let path_str = tmp.to_string_lossy().to_string();
let mut msg = inbound_msg(7010, &format!("Audio: [AUDIO:{path_str}]"));
enrich_message(&mut msg, None).await;
#[cfg(target_os = "macos")]
assert!(
!tmp.exists(),
"Audio temp file must be deleted on transcription failure"
);
#[cfg(not(target_os = "macos"))]
assert!(
msg.content.contains(AUDIO_UNAVAILABLE),
"Audio marker must be annotated honestly, got: {}",
msg.content
);
let _ = tokio::fs::remove_file(&tmp).await;
}
#[tokio::test]
async fn enrich_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);
enrich_message(&mut msg, None).await;
assert!(
msg.content.contains("[IMAGE:https://example.com/img.png]"),
"IMAGE with http URL must be preserved, got: {}",
msg.content
);
assert!(
msg.content.contains(audio_annotation()),
"Audio annotation must be present"
);
assert!(
!msg.content.contains("[AUDIO:"),
"AUDIO marker must be stripped"
);
}
async fn assert_no_markers_unchanged(workspace_path: Option<&std::path::Path>, content: &str) {
let mut msg = test_msg(content);
let original = msg.content.clone();
enrich_message(&mut msg, workspace_path).await;
assert_eq!(msg.content, original, "No markers = no changes");
}
#[tokio::test]
async fn enrich_no_annotations_when_no_markers() {
assert_no_markers_unchanged(None, "Just a plain message with no markers").await;
}
#[tokio::test]
async fn enrich_file_inlines_text_and_copies_to_uploads() {
let document = "# Title\n\nHello from a document";
let (tmp_root, ws_path, msg_dir, attachment) =
inbound_ingest_fixture("test_enrich_file_md", 7011, "notes.md", document.as_bytes())
.await;
let marker = format!("Read [FILE:{}] please", attachment.display());
let mut msg = inbound_msg(7011, &marker);
enrich_message(&mut msg, Some(ws_path.as_path())).await;
let copy = ws_path.join("uploads").join("notes.md");
assert!(
msg.content.contains(&format!("[FILE:{}]", copy.display())),
"Marker must point at the workspace copy, got: {}",
msg.content
);
assert!(
msg.content.contains("Hello from a document"),
"Extracted document text must be inline, got: {}",
msg.content
);
assert!(
!msg.content.contains(&attachment.display().to_string()),
"The temp path must not survive anywhere, got: {}",
msg.content
);
assert_eq!(
tokio::fs::read(©).await.unwrap(),
document.as_bytes(),
"Workspace copy must be byte-identical to the inbound file"
);
assert!(!attachment.exists(), "Inbound temp file must be deleted");
assert!(
!msg_dir.exists(),
"Inbound per-message directory must be deleted"
);
let _ = tokio::fs::remove_dir_all(&tmp_root).await;
}
#[tokio::test]
async fn enrich_file_keeps_text_of_pages_that_fail_after_producing_it() {
let (tmp_root, ws_path, _msg_dir, attachment) = inbound_ingest_fixture(
"test_enrich_file_partial_pdf",
7015,
"report.pdf",
&crate::document::test_fixtures::pdf_with_all_pages_failing_midway(),
)
.await;
let marker = format!("Read [FILE:{}] please", attachment.display());
let mut msg = inbound_msg(7015, &marker);
enrich_message(&mut msg, Some(ws_path.as_path())).await;
assert_eq!(
msg.content.matches("Kept page text long enough").count(),
2,
"every page's text must be inline, got: {}",
msg.content
);
assert!(
msg.content.contains(
"[File report.pdf: the text of pages 1, 2 could not be read in full (2 of 2 pages)]"
),
"the pages must be named in a delimited note, got: {}",
msg.content
);
assert_eq!(
msg.content
.matches("[IMAGE:data:image/jpeg;base64,")
.count(),
2,
"each page that could not be read in full comes back as an image, got: {}",
msg.content
);
assert!(
!msg.content.contains(crate::document::NO_TEXT_NOTE),
"delivered text must never be reported as a document without one, got: {}",
msg.content
);
let _ = tokio::fs::remove_dir_all(&tmp_root).await;
}
#[tokio::test]
async fn enrich_file_names_what_the_reading_did_not_show() {
use crate::ooxml::test_fixtures::{DOCX_BODY, zip_fixture};
let package = zip_fixture(&[
("word/document.xml", DOCX_BODY),
("word/media/clip.mp4", b"not a picture at all"),
]);
let (tmp_root, ws_path, _msg_dir, attachment) =
inbound_ingest_fixture("test_enrich_document_report", 7040, "clip.docx", &package)
.await;
let marker = format!("Read [FILE:{}] please", attachment.display());
let mut msg = inbound_msg(7040, &marker);
enrich_message(&mut msg, Some(ws_path.as_path())).await;
assert!(
msg.content
.contains("[File clip.docx: 1 embedded video(s) not shown]"),
"the report must reach the inbound answer too, got: {}",
msg.content
);
let _ = tokio::fs::remove_dir_all(&tmp_root).await;
}
fn pdf_marks_with_every_page_text_lost() -> Vec<u8> {
use crate::document::test_fixtures::{PdfFixture, one_page_pdf, pdf_text_string};
const CONTENT: &[u8] = b"0.1 0.5 0.9 rg 0 0 612 792 re f";
let mut fixture = PdfFixture::new();
let highlight = fixture.push(format!(
"<< /Subtype /Highlight /T {} /Contents {} >>",
pdf_text_string("Иван Петров"),
pdf_text_string("проверьте третью фигуру")
));
let field = fixture.push(format!(
"<< /Subtype /Widget /FT /Tx /T (Name) /V {} /Rect [0 0 0 0] >>",
pdf_text_string("Иван Петров")
));
let acroform = fixture.push(format!("<< /Fields [{field}] >>"));
one_page_pdf(
fixture,
CONTENT,
&format!("/Annots [{highlight} {field}]"),
&format!(" /AcroForm {acroform}"),
)
}
#[tokio::test]
async fn enrich_file_delivers_marks_when_every_page_text_was_lost() {
let (tmp_root, ws_path, _msg_dir, attachment) = inbound_ingest_fixture(
"test_enrich_file_marks_only_pdf",
7023,
"marked.pdf",
&pdf_marks_with_every_page_text_lost(),
)
.await;
let marker = format!("Read [FILE:{}] please", attachment.display());
let mut msg = inbound_msg(7023, &marker);
enrich_message(&mut msg, Some(ws_path.as_path())).await;
assert!(
msg.content
.contains("highlight (Иван Петров) on page 1:\n проверьте третью фигуру"),
"the annotation must survive the lost page text, got: {}",
msg.content
);
assert!(
msg.content.contains("Name on page 1: Иван Петров"),
"the filled field must survive the lost page text, got: {}",
msg.content
);
assert!(
!msg.content.contains(crate::document::NO_TEXT_NOTE),
"delivered marks are text, not a document without one, got: {}",
msg.content
);
assert_eq!(
msg.content
.matches("[IMAGE:data:image/jpeg;base64,")
.count(),
1,
"the page whose text was lost still comes back as an image, got: {}",
msg.content
);
let _ = tokio::fs::remove_dir_all(&tmp_root).await;
}
#[tokio::test]
async fn enrich_file_long_text_spills_to_extracted_sidecar() {
let text = "Lorem ipsum dolor sit amet. ".repeat(300);
assert!(text.chars().count() > INLINE_TEXT_MAX_CHARS);
let (tmp_root, ws_path, _msg_dir, attachment) =
inbound_ingest_fixture("test_enrich_file_long", 7012, "long.txt", text.as_bytes())
.await;
let marker = format!("Read [FILE:{}]", attachment.display());
let mut msg = inbound_msg(7012, &marker);
enrich_message(&mut msg, Some(ws_path.as_path())).await;
assert!(
!msg.content.contains(&text[..200]),
"Long text must not be inlined, got: {}",
msg.content
);
let uploads_dir = ws_path.join("uploads");
let mut entries = tokio::fs::read_dir(&uploads_dir).await.unwrap();
let mut spill = None;
while let Some(entry) = entries.next_entry().await.unwrap() {
if entry
.file_name()
.to_string_lossy()
.ends_with(".extracted.txt")
{
spill = Some(entry.path());
}
}
let spill = spill.expect("an .extracted.txt sidecar must be written");
assert!(
msg.content.contains(&format!(
"[File long.txt: the extracted text is too long to inline ({} characters); \
the full text was saved to {} — read that file.]",
text.trim().chars().count(),
spill.display()
)),
"Annotation must be a delimited note naming the sidecar, got: {}",
msg.content
);
assert_eq!(
tokio::fs::read_to_string(&spill).await.unwrap(),
text.trim(),
"Sidecar must carry the full extracted text"
);
let _ = tokio::fs::remove_dir_all(&tmp_root).await;
}
#[tokio::test]
async fn enrich_file_out_of_scope_marker_left_verbatim() {
let (tmp_root, ws_path, arbitrary) =
out_of_scope_fixture("test_enrich_file_outside", "secret.md", b"# top secret").await;
let marker = format!("Look at [FILE:{}]", arbitrary.display());
let mut msg = test_msg(&marker);
enrich_message(&mut msg, Some(ws_path.as_path())).await;
assert_eq!(
msg.content, marker,
"Out-of-scope marker must stay verbatim"
);
assert_eq!(
tokio::fs::read(&arbitrary).await.unwrap(),
b"# top secret",
"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_file_other_chat_attachment_left_inert() {
let tmp_root = crate::util::test::test_root().join("test_enrich_file_other_chat");
let ws_path = tmp_root.join("myworkspace");
tokio::fs::create_dir_all(&ws_path).await.unwrap();
let (other_msg_dir, other_attachment) =
telegram_attachment_fixture("2", 7014, "secret.md", b"# other chat").await;
let (longer_id_dir, longer_id_attachment) =
telegram_attachment_fixture("12", 7015, "secret.md", b"# chat 12").await;
let (sibling_msg_dir, sibling_attachment) =
telegram_attachment_fixture(TEST_CHAT_ID, 7016, "secret.md", b"# sibling").await;
for attachment in [
&other_attachment,
&longer_id_attachment,
&sibling_attachment,
] {
let marker = format!("Look at [FILE:{}]", attachment.display());
let mut msg = inbound_msg(7014, &marker);
enrich_message(&mut msg, Some(ws_path.as_path())).await;
assert_eq!(msg.content, marker, "Another message's attachment is inert");
}
assert_eq!(
tokio::fs::read(&other_attachment).await.unwrap(),
b"# other chat",
"Another chat's attachment must not be read or deleted"
);
assert_eq!(
tokio::fs::read(&longer_id_attachment).await.unwrap(),
b"# chat 12",
"A longer chat id's attachment must not be read or deleted"
);
assert!(
!ws_path.join("uploads").exists(),
"No uploads copy may be created for another message's attachment"
);
let _ = tokio::fs::remove_dir_all(&other_msg_dir).await;
let _ = tokio::fs::remove_dir_all(&longer_id_dir).await;
let _ = tokio::fs::remove_dir_all(&sibling_msg_dir).await;
let _ = tokio::fs::remove_dir_all(&tmp_root).await;
}
#[tokio::test]
async fn enrich_file_album_members_all_ingested() {
let (tmp_root, ws_path, first_dir, first) =
inbound_ingest_fixture("test_enrich_file_album", 7017, "first.md", b"# first").await;
let (second_dir, second) =
telegram_attachment_fixture(TEST_CHAT_ID, 7018, "second.md", b"# second").await;
let marker = format!(
"Album [FILE:{}] and [FILE:{}]",
first.display(),
second.display()
);
let mut msg = inbound_album_msg(&[7017, 7018], &marker);
enrich_message(&mut msg, Some(ws_path.as_path())).await;
let uploads_dir = ws_path.join("uploads");
for (name, bytes) in [
("first.md", &b"# first"[..]),
("second.md", &b"# second"[..]),
] {
let copy = uploads_dir.join(name);
assert!(
msg.content.contains(&format!("[FILE:{}]", copy.display())),
"Marker for {name} must point at the workspace copy, got: {}",
msg.content
);
assert_eq!(
tokio::fs::read(©).await.unwrap(),
bytes,
"Workspace copy of {name} must be byte-identical to the inbound file"
);
}
for temp in [&first, &second] {
assert!(
!msg.content.contains(&temp.display().to_string()),
"No raw temp path may survive, got: {}",
msg.content
);
}
assert!(
!first_dir.exists() && !second_dir.exists(),
"Both per-message directories must be deleted"
);
let _ = tokio::fs::remove_dir_all(&tmp_root).await;
}
#[tokio::test]
async fn enrich_file_vanished_attachment_annotated_and_dir_removed() {
let (tmp_root, ws_path, msg_dir, attachment) =
inbound_ingest_fixture("test_enrich_file_vanished", 7020, "gone.md", b"# gone").await;
tokio::fs::remove_file(&attachment).await.unwrap();
let marker = format!("Read [FILE:{}]", attachment.display());
let mut msg = inbound_msg(7020, &marker);
enrich_message(&mut msg, Some(ws_path.as_path())).await;
assert!(
msg.content.contains(
"[File gone.md: could not be retrieved — the downloaded attachment is missing]"
),
"A vanished attachment must be annotated, got: {}",
msg.content
);
assert!(
!msg_dir.exists(),
"The now-empty staging directory must be deleted"
);
assert!(
!ws_path.join("uploads").exists(),
"A vanished attachment must not be copied into the workspace"
);
let _ = tokio::fs::remove_dir_all(&tmp_root).await;
}
#[tokio::test]
async fn enrich_vanished_image_attachment_leaves_no_staging_dir() {
let (tmp_root, ws_path, msg_dir, attachment) =
inbound_ingest_fixture("test_enrich_image_vanished", 7022, "gone.png", b"png").await;
tokio::fs::remove_file(&attachment).await.unwrap();
let marker = format!("Look at [IMAGE:{}]", attachment.display());
let mut msg = inbound_msg(7022, &marker);
enrich_message(&mut msg, Some(ws_path.as_path())).await;
assert!(
msg.content.contains("[Invalid image reference:"),
"A vanished image must be annotated, got: {}",
msg.content
);
assert!(
!msg_dir.exists(),
"The empty staging directory must be removed"
);
let _ = tokio::fs::remove_dir_all(&tmp_root).await;
}
#[tokio::test]
async fn enrich_file_marker_naming_staging_dir_itself_is_not_removed_and_dir_cleaned_up() {
let (msg_dir, attachment) =
inbound_attachment_fixture(7021, "placeholder.md", b"# x").await;
tokio::fs::remove_file(&attachment).await.unwrap();
let marker = format!("Look at [FILE:{}]", msg_dir.display());
let mut msg = inbound_msg(7021, &marker);
enrich_message(&mut msg, None).await;
assert!(
msg.content
.contains(": the attachment is not a regular file]"),
"A directory marker must be annotated as a non-file, got: {}",
msg.content
);
assert!(
!msg_dir.exists(),
"The empty staging directory must be removed"
);
}
#[cfg(unix)]
#[tokio::test]
async fn enrich_file_symlink_in_inbound_dir_marker_left_verbatim() {
let (tmp_root, ws_path, outside) =
out_of_scope_fixture("test_enrich_file_symlink", "secret.md", b"# top secret").await;
let (msg_dir, _) =
telegram_attachment_fixture(TEST_CHAT_ID, 7019, "placeholder.bin", b"x").await;
let link = msg_dir.join("link.md");
std::os::unix::fs::symlink(&outside, &link).expect("plant the symlink");
let marker = format!("Look at [FILE:{}]", link.display());
let mut msg = inbound_msg(7019, &marker);
enrich_message(&mut msg, Some(ws_path.as_path())).await;
assert_eq!(msg.content, marker, "A symlinked marker must stay verbatim");
assert_eq!(
tokio::fs::read(&outside).await.unwrap(),
b"# top secret",
"The symlink target must never be read or deleted"
);
assert!(
tokio::fs::symlink_metadata(&link).await.is_ok(),
"The symlink itself must not be deleted"
);
assert!(
msg_dir.exists(),
"The per-message directory holding the symlink must not be deleted"
);
assert!(
!ws_path.join("uploads").exists(),
"No uploads copy may be created through a symlink"
);
let _ = tokio::fs::remove_file(&link).await;
let _ = tokio::fs::remove_dir_all(&msg_dir).await;
let _ = tokio::fs::remove_dir_all(&tmp_root).await;
}
#[tokio::test]
async fn enrich_file_unsupported_blob_annotates_and_keeps_workspace_copy() {
let bytes: &[u8] = &[0x00, 0x01, 0x02, 0xFF, 0xFE, b'x'];
let (tmp_root, ws_path, msg_dir, attachment) =
inbound_ingest_fixture("test_enrich_file_blob", 7013, "blob.bin", bytes).await;
let marker = format!("Attached [FILE:{}]", attachment.display());
let mut msg = inbound_msg(7013, &marker);
enrich_message(&mut msg, Some(ws_path.as_path())).await;
let copy = ws_path.join("uploads").join("blob.bin");
assert!(
msg.content.contains(&format!("[FILE:{}]", copy.display())),
"Marker must point at the workspace copy, got: {}",
msg.content
);
assert!(
msg.content.contains(
"[File blob.bin: received but not converted (unsupported format) — open the saved file directly]"
),
"Unsupported format must be annotated, got: {}",
msg.content
);
assert_eq!(
tokio::fs::read(©).await.unwrap(),
bytes,
"Workspace copy must be byte-identical to the inbound file"
);
assert!(!attachment.exists() && !msg_dir.exists());
let _ = tokio::fs::remove_dir_all(&tmp_root).await;
}
}