use async_trait::async_trait;
use lingshu_types::Platform;
use regex::Regex;
use std::collections::BTreeMap;
use std::sync::OnceLock;
use tokio::sync::mpsc;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub enum ChatType {
#[default]
Dm,
Group,
Channel,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum MessageAttachmentKind {
Image,
Video,
Audio,
Voice,
Document,
Sticker,
#[default]
Other,
}
impl MessageAttachmentKind {
pub fn label(&self) -> &'static str {
match self {
Self::Image => "image",
Self::Video => "video",
Self::Audio => "audio",
Self::Voice => "voice",
Self::Document => "document",
Self::Sticker => "sticker",
Self::Other => "attachment",
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct MessageAttachment {
pub kind: MessageAttachmentKind,
pub file_name: Option<String>,
pub mime_type: Option<String>,
pub url: Option<String>,
pub local_path: Option<String>,
pub size_bytes: Option<u64>,
}
impl MessageAttachment {
pub fn display_source(&self) -> Option<&str> {
self.local_path
.as_deref()
.filter(|value| !value.trim().is_empty())
.or_else(|| self.url.as_deref().filter(|value| !value.trim().is_empty()))
}
pub fn vision_source(&self) -> Option<&str> {
self.local_path
.as_deref()
.filter(|value| !value.trim().is_empty())
.or_else(|| {
self.url.as_deref().filter(|value| {
let trimmed = value.trim();
!trimmed.is_empty()
&& (trimmed.starts_with("http://") || trimmed.starts_with("https://"))
})
})
}
}
#[derive(Debug, Clone)]
pub struct IncomingMessage {
pub platform: Platform,
pub user_id: String,
pub channel_id: Option<String>,
pub chat_type: ChatType,
pub text: String,
pub thread_id: Option<String>,
pub metadata: MessageMetadata,
}
impl IncomingMessage {
pub fn is_command(&self) -> bool {
self.text.trim_start().starts_with('/')
}
pub fn get_command(&self) -> Option<&str> {
if !self.is_command() {
return None;
}
let trimmed = self.text.trim_start().trim_start_matches('/');
let end = trimmed.find(char::is_whitespace).unwrap_or(trimmed.len());
let end = trimmed[..end].find('@').map_or(end, |pos| pos);
Some(&trimmed[..end])
}
pub fn get_command_args(&self) -> &str {
if !self.is_command() {
return &self.text;
}
let trimmed = self.text.trim_start();
let after_slash = trimmed.trim_start_matches('/');
let word_end = after_slash
.find(char::is_whitespace)
.unwrap_or(after_slash.len());
after_slash[word_end..].trim_start()
}
}
#[derive(Debug, Clone)]
pub struct ClarifyPrompt {
pub interaction_id: u64,
pub question: String,
pub choices: Option<Vec<String>>,
}
#[derive(Debug, Clone)]
pub struct OutgoingMessage {
pub text: String,
pub metadata: MessageMetadata,
}
#[derive(Debug, Clone, Default)]
pub struct MessageMetadata {
pub message_id: Option<String>,
pub channel_id: Option<String>,
pub thread_id: Option<String>,
pub user_display_name: Option<String>,
pub attachments: Vec<MessageAttachment>,
pub webhook_delivery: Option<WebhookDelivery>,
pub preloaded_skills: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct WebhookDelivery {
pub deliver: String,
pub deliver_extra: BTreeMap<String, String>,
}
#[async_trait]
pub trait PlatformAdapter: Send + Sync + 'static {
fn platform(&self) -> Platform;
async fn start(&self, tx: mpsc::Sender<IncomingMessage>) -> anyhow::Result<()>;
async fn send(&self, msg: OutgoingMessage) -> anyhow::Result<()>;
fn format_response(&self, text: &str, metadata: &MessageMetadata) -> String;
fn max_message_length(&self) -> usize;
fn supports_markdown(&self) -> bool;
fn supports_images(&self) -> bool;
fn supports_files(&self) -> bool;
fn supports_editing(&self) -> bool {
false
}
async fn edit_message(
&self,
message_id: &str,
metadata: &MessageMetadata,
new_text: &str,
) -> anyhow::Result<String> {
let _ = (message_id, metadata, new_text);
anyhow::bail!("edit_message not supported by {:?}", self.platform())
}
async fn send_status(&self, text: &str, metadata: &MessageMetadata) -> anyhow::Result<()> {
self.send(OutgoingMessage {
text: text.to_string(),
metadata: metadata.clone(),
})
.await
}
async fn send_clarify(
&self,
prompt: &ClarifyPrompt,
metadata: &MessageMetadata,
) -> anyhow::Result<bool> {
let _ = (prompt, metadata);
Ok(false)
}
async fn send_typing(&self, metadata: &MessageMetadata) -> anyhow::Result<()> {
let _ = metadata;
Ok(())
}
async fn send_and_get_id(&self, msg: OutgoingMessage) -> anyhow::Result<Option<String>> {
self.send(msg).await?;
Ok(None)
}
async fn send_photo(
&self,
path: &str,
caption: Option<&str>,
metadata: &MessageMetadata,
) -> anyhow::Result<()> {
let text = match caption {
Some(c) => format!("[Image: {}]\n{}", path, c),
None => format!("[Image: {}]", path),
};
self.send(OutgoingMessage {
text,
metadata: metadata.clone(),
})
.await
}
async fn send_document(
&self,
path: &str,
caption: Option<&str>,
metadata: &MessageMetadata,
) -> anyhow::Result<()> {
let text = match caption {
Some(c) => format!("[File: {}]\n{}", path, c),
None => format!("[File: {}]", path),
};
self.send(OutgoingMessage {
text,
metadata: metadata.clone(),
})
.await
}
async fn send_voice(
&self,
path: &str,
caption: Option<&str>,
metadata: &MessageMetadata,
) -> anyhow::Result<()> {
self.send_document(path, caption, metadata).await
}
async fn create_handoff_thread(
&self,
parent_chat_id: &str,
name: &str,
) -> anyhow::Result<Option<String>> {
let _ = (parent_chat_id, name);
Ok(None)
}
}
pub fn format_image_attachment_block(attachments: &[MessageAttachment]) -> Option<String> {
let image_sources: Vec<&str> = attachments
.iter()
.filter(|a| a.kind == MessageAttachmentKind::Image)
.filter_map(MessageAttachment::vision_source)
.collect();
if image_sources.is_empty() {
return None;
}
let image_list = image_sources.join(", ");
let count = image_sources.len();
Some(format!(
"*** ATTACHED IMAGES - ACTION REQUIRED ***\n\
The user has attached {count} image source(s): {image_list}\n\
You MUST call vision_analyze for EACH image before responding.\n\
- Use tool: vision_analyze\n\
- Parameter: image_source = <the source above>\n\
- DO NOT use browser_vision (that captures web pages, not user image inputs)\n\
- If the source is a local file, DO NOT use read_file on it (binary file)\n\
*** END ATTACHED IMAGES ***"
))
}
#[derive(Debug, Clone, PartialEq)]
pub struct MediaRef {
pub path: String,
pub is_image: bool,
}
impl MediaRef {
pub fn detect_image(path: &str) -> bool {
let p = path.to_ascii_lowercase();
p.ends_with(".jpg")
|| p.ends_with(".jpeg")
|| p.ends_with(".png")
|| p.ends_with(".gif")
|| p.ends_with(".webp")
|| p.ends_with(".svg")
|| p.ends_with(".bmp")
}
pub fn detect_audio(path: &str) -> bool {
let p = path.to_ascii_lowercase();
p.ends_with(".ogg")
|| p.ends_with(".opus")
|| p.ends_with(".mp3")
|| p.ends_with(".wav")
|| p.ends_with(".m4a")
|| p.ends_with(".aac")
|| p.ends_with(".flac")
}
}
pub fn extract_media_from_response(text: &str) -> (String, Vec<MediaRef>) {
let (cleaned, mut refs) = extract_bracketed_media_from_response(text);
let (cleaned, raw_refs) = extract_raw_media_from_response(&cleaned);
refs.extend(raw_refs);
let (cleaned, local_refs) = extract_local_files_from_response(&cleaned);
refs.extend(local_refs);
dedupe_media_refs(&mut refs);
(normalize_media_cleaned_text(&cleaned), refs)
}
pub fn response_text_after_media_extraction(
original: &str,
cleaned: &str,
media_refs: &[MediaRef],
) -> Option<String> {
let cleaned = cleaned.trim();
if !cleaned.is_empty() {
return Some(cleaned.to_string());
}
if media_refs.is_empty() {
let original = original.trim();
if !original.is_empty() {
return Some(original.to_string());
}
}
None
}
fn extract_bracketed_media_from_response(text: &str) -> (String, Vec<MediaRef>) {
let mut refs = Vec::new();
let mut cleaned = String::with_capacity(text.len());
let mut remaining = text;
while let Some(open) = remaining.find('[') {
let after_bracket = &remaining[open + 1..];
let prefix_end = parse_media_prefix(after_bracket);
if let Some((prefix_len, _tag)) = prefix_end {
let rest = &after_bracket[prefix_len..];
if let Some(close) = rest.find(']') {
let path = rest[..close].trim().to_string();
if !path.is_empty() {
refs.push(MediaRef {
is_image: MediaRef::detect_image(&path),
path,
});
}
cleaned.push_str(&remaining[..open]);
let consumed = open + 1 + after_bracket.len() - rest.len() + close + 1;
remaining = &remaining[consumed..];
continue;
}
}
cleaned.push_str(&remaining[..open + 1]);
remaining = &remaining[open + 1..];
}
cleaned.push_str(remaining);
(cleaned, refs)
}
fn extract_raw_media_from_response(text: &str) -> (String, Vec<MediaRef>) {
let mut refs = Vec::new();
let mut cleaned = String::with_capacity(text.len());
let mut index = 0usize;
while index < text.len() {
let remaining = &text[index..];
if let Some((prefix_len, tag)) = parse_media_prefix(remaining) {
let prev_char = text[..index].chars().next_back();
if is_media_boundary(prev_char) {
let after_prefix = &remaining[prefix_len..];
if let Some((path, consumed, suffix)) = parse_raw_media_path(after_prefix) {
refs.push(MediaRef {
is_image: tag == "IMAGE" || MediaRef::detect_image(&path),
path,
});
cleaned.push_str(&suffix);
index += prefix_len + consumed;
continue;
}
}
}
if let Some(ch) = remaining.chars().next() {
cleaned.push(ch);
index += ch.len_utf8();
} else {
break;
}
}
(cleaned, refs)
}
fn parse_media_prefix(input: &str) -> Option<(usize, &'static str)> {
for (prefix, tag) in [("MEDIA:", "MEDIA"), ("IMAGE:", "IMAGE"), ("FILE:", "FILE")] {
if input
.get(..prefix.len())
.is_some_and(|candidate| candidate.eq_ignore_ascii_case(prefix))
{
return Some((prefix.len(), tag));
}
}
None
}
fn is_media_boundary(prev_char: Option<char>) -> bool {
prev_char
.is_none_or(|ch| ch.is_whitespace() || matches!(ch, '(' | '[' | '{' | '<' | '"' | '\''))
}
fn parse_raw_media_path(input: &str) -> Option<(String, usize, String)> {
let mut chars = input.char_indices();
let (_, first) = chars.next()?;
if matches!(first, '"' | '\'') {
let quote = first;
let mut closing = None;
for (idx, ch) in chars {
if ch == quote {
closing = Some(idx);
break;
}
}
let closing = closing?;
let path = input[1..closing].trim().to_string();
if path.is_empty() {
return None;
}
return Some((path, closing + quote.len_utf8(), String::new()));
}
let end = input
.char_indices()
.find(|(_, ch)| ch.is_whitespace())
.map(|(idx, _)| idx)
.unwrap_or(input.len());
if end == 0 {
return None;
}
let token = &input[..end];
let trimmed_end = token.trim_end_matches(['.', ',', ';', '!', '?']).len();
let path = token[..trimmed_end].trim().to_string();
if path.is_empty() {
return None;
}
Some((path, end, token[trimmed_end..].to_string()))
}
fn normalize_media_cleaned_text(text: &str) -> String {
text.replace("\n\n\n", "\n\n").trim().to_string()
}
fn dedupe_media_refs(refs: &mut Vec<MediaRef>) {
let mut unique = Vec::with_capacity(refs.len());
for media_ref in refs.drain(..) {
if unique
.iter()
.any(|existing: &MediaRef| existing.path == media_ref.path)
{
continue;
}
unique.push(media_ref);
}
*refs = unique;
}
fn extract_local_files_from_response(text: &str) -> (String, Vec<MediaRef>) {
let mut refs = Vec::new();
let mut cleaned = text.to_string();
let mut raw_matches = Vec::new();
for capture in local_file_regex().captures_iter(text) {
let Some(matched) = capture.get(0) else {
continue;
};
if !is_media_boundary(text[..matched.start()].chars().next_back()) {
continue;
}
if span_inside_code_block(text, matched.start()) {
continue;
}
let raw = matched.as_str();
let expanded = if let Some(rest) = raw.strip_prefix("~/") {
match dirs::home_dir() {
Some(home) => home.join(rest).to_string_lossy().to_string(),
None => continue,
}
} else {
raw.to_string()
};
if !std::fs::metadata(&expanded).is_ok_and(|metadata| metadata.is_file()) {
continue;
}
refs.push(MediaRef {
is_image: MediaRef::detect_image(&expanded),
path: expanded,
});
raw_matches.push(raw.to_string());
}
for raw in raw_matches {
cleaned = cleaned.replace(&raw, "");
}
(normalize_media_cleaned_text(&cleaned), refs)
}
fn local_file_regex() -> &'static Regex {
static REGEX: OnceLock<Regex> = OnceLock::new();
REGEX.get_or_init(|| {
Regex::new(
r"(?ix)
(?:~/|/)
(?:[\w.\-]+/)*
[\w.\-]+\.
(?:png|jpe?g|gif|webp|svg|bmp|heic|heif|
mp4|mov|avi|mkv|webm|3gp|
ogg|opus|mp3|wav|m4a|aac|
pdf|txt|md|csv|json|zip|docx?|xlsx?|pptx?)
\b",
)
.expect("valid local file regex")
})
}
fn span_inside_code_block(text: &str, position: usize) -> bool {
fenced_code_spans(text)
.into_iter()
.chain(inline_code_spans(text))
.any(|(start, end)| start <= position && position < end)
}
fn fenced_code_spans(text: &str) -> Vec<(usize, usize)> {
let mut spans = Vec::new();
let mut search_start = 0usize;
while let Some(open_rel) = text[search_start..].find("```") {
let open = search_start + open_rel;
let after_open = open + 3;
let Some(close_rel) = text[after_open..].find("```") else {
break;
};
let close = after_open + close_rel + 3;
spans.push((open, close));
search_start = close;
}
spans
}
fn inline_code_spans(text: &str) -> Vec<(usize, usize)> {
let mut spans = Vec::new();
let mut current = None;
for (index, ch) in text.char_indices() {
if ch == '`' {
if let Some(start) = current.take() {
spans.push((start, index + ch.len_utf8()));
} else {
current = Some(index);
}
}
}
spans
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn incoming_message_default_metadata() {
let msg = IncomingMessage {
platform: Platform::Cli,
user_id: "user1".into(),
channel_id: None,
chat_type: ChatType::Dm,
text: "hello".into(),
thread_id: None,
metadata: MessageMetadata::default(),
};
assert_eq!(msg.text, "hello");
assert!(msg.metadata.message_id.is_none());
assert!(msg.metadata.attachments.is_empty());
}
#[test]
fn outgoing_message_construction() {
let msg = OutgoingMessage {
text: "response".into(),
metadata: MessageMetadata {
channel_id: Some("ch123".into()),
..Default::default()
},
};
assert_eq!(msg.text, "response");
assert_eq!(msg.metadata.channel_id.as_deref(), Some("ch123"));
}
#[test]
fn attachment_kind_labels() {
assert_eq!(MessageAttachmentKind::Image.label(), "image");
assert_eq!(MessageAttachmentKind::Document.label(), "document");
assert_eq!(MessageAttachmentKind::Other.label(), "attachment");
}
#[test]
fn attachment_vision_source_prefers_local_path() {
let attachment = MessageAttachment {
local_path: Some("/tmp/cached.png".into()),
url: Some("https://example.com/image.png".into()),
..Default::default()
};
assert_eq!(attachment.vision_source(), Some("/tmp/cached.png"));
}
#[test]
fn attachment_vision_source_accepts_https_url() {
let attachment = MessageAttachment {
url: Some("https://example.com/image.png".into()),
..Default::default()
};
assert_eq!(
attachment.vision_source(),
Some("https://example.com/image.png")
);
}
#[test]
fn attachment_vision_source_rejects_opaque_scheme() {
let attachment = MessageAttachment {
url: Some("signal://attachment/abc123".into()),
..Default::default()
};
assert_eq!(attachment.vision_source(), None);
}
fn make_image_attachment(path: &str) -> MessageAttachment {
MessageAttachment {
kind: MessageAttachmentKind::Image,
local_path: Some(path.to_string()),
..Default::default()
}
}
#[test]
fn image_block_none_when_no_attachments() {
assert!(format_image_attachment_block(&[]).is_none());
}
#[test]
fn image_block_none_when_no_image_attachments() {
let attachments = vec![MessageAttachment {
kind: MessageAttachmentKind::Document,
local_path: Some("/tmp/report.pdf".into()),
..Default::default()
}];
assert!(format_image_attachment_block(&attachments).is_none());
}
#[test]
fn image_block_contains_path_and_instructions() {
let attachments = vec![make_image_attachment("/home/user/.lingshu/images/img.png")];
let block = format_image_attachment_block(&attachments).expect("should return Some");
assert!(block.contains("ATTACHED IMAGES"), "must contain marker");
assert!(block.contains("vision_analyze"), "must name vision_analyze");
assert!(
block.contains("/home/user/.lingshu/images/img.png"),
"must contain path"
);
assert!(
block.contains("DO NOT use browser_vision"),
"must warn against browser_vision"
);
}
#[test]
fn image_block_rejects_opaque_transport_attachment_urls() {
let attachments = vec![MessageAttachment {
kind: MessageAttachmentKind::Image,
local_path: None,
url: Some("signal://attachment/abc123".into()),
..Default::default()
}];
assert!(format_image_attachment_block(&attachments).is_none());
}
#[test]
fn image_block_accepts_https_image_urls() {
let attachments = vec![MessageAttachment {
kind: MessageAttachmentKind::Image,
url: Some("https://example.com/img.png".into()),
..Default::default()
}];
let block = format_image_attachment_block(&attachments).expect("some");
assert!(block.contains("https://example.com/img.png"));
}
#[test]
fn image_block_lists_multiple_paths() {
let attachments = vec![
make_image_attachment("/tmp/a.png"),
make_image_attachment("/tmp/b.jpg"),
];
let block = format_image_attachment_block(&attachments).expect("some");
assert!(block.contains("/tmp/a.png"));
assert!(block.contains("/tmp/b.jpg"));
assert!(block.contains("2 image source(s)"));
}
#[test]
fn image_block_excludes_document_attachments_from_block() {
let attachments = vec![
make_image_attachment("/tmp/photo.jpg"),
MessageAttachment {
kind: MessageAttachmentKind::Document,
local_path: Some("/tmp/report.pdf".into()),
..Default::default()
},
];
let block = format_image_attachment_block(&attachments).expect("some");
assert!(
block.contains("/tmp/photo.jpg"),
"image path must be in block"
);
assert!(
!block.contains("/tmp/report.pdf"),
"document must NOT appear in image block"
);
assert!(
block.contains("1 image source(s)"),
"count must be 1, not 2"
);
}
#[test]
fn image_block_handles_whatsapp_image_cache_path() {
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
let path = format!("{home}/.lingshu/image_cache/img_001.jpg");
let attachments = vec![make_image_attachment(&path)];
let block = format_image_attachment_block(&attachments).expect("some");
assert!(
block.contains(&path),
"WhatsApp image_cache path must appear in block"
);
assert!(
block.contains("vision_analyze"),
"must instruct vision_analyze"
);
}
#[test]
fn image_block_handles_telegram_gateway_media_path() {
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
let path = format!("{home}/.lingshu/gateway_media/telegram/photo_001.jpg");
let attachments = vec![make_image_attachment(&path)];
let block = format_image_attachment_block(&attachments).expect("some");
assert!(
block.contains(&path),
"Telegram gateway_media path must appear in block"
);
assert!(
block.contains("vision_analyze"),
"must instruct vision_analyze"
);
}
#[test]
fn extract_media_no_tags_returns_unchanged() {
let text = "Hello, world!";
let (cleaned, refs) = extract_media_from_response(text);
assert_eq!(cleaned, text);
assert!(refs.is_empty());
}
#[test]
fn extract_media_single_image_tag() {
let text = "Here is your chart: [IMAGE:/tmp/chart.png]\nEnjoy!";
let (cleaned, refs) = extract_media_from_response(text);
assert!(!cleaned.contains("[IMAGE:"), "tag should be stripped");
assert!(cleaned.contains("Enjoy!"), "surrounding text must remain");
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].path, "/tmp/chart.png");
assert!(refs[0].is_image);
}
#[test]
fn extract_media_single_file_tag() {
let text = "Report ready: [FILE:/tmp/report.pdf]";
let (cleaned, refs) = extract_media_from_response(text);
assert!(!cleaned.contains("[FILE:"), "tag should be stripped");
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].path, "/tmp/report.pdf");
assert!(!refs[0].is_image, "pdf is not an image");
}
#[test]
fn extract_media_multiple_tags() {
let text = "[IMAGE:/tmp/a.png] and [MEDIA:/tmp/b.pdf]";
let (cleaned, refs) = extract_media_from_response(text);
assert_eq!(refs.len(), 2);
assert!(refs[0].is_image);
assert!(!refs[1].is_image);
assert!(!cleaned.contains('['), "both tags stripped");
}
#[test]
fn extract_media_case_insensitive_tags() {
let text = "[image:/tmp/photo.jpg]";
let (_, refs) = extract_media_from_response(text);
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].path, "/tmp/photo.jpg");
}
#[test]
fn extract_media_raw_file_tag() {
let text = "Send this now: MEDIA:/tmp/report.pdf";
let (cleaned, refs) = extract_media_from_response(text);
assert_eq!(cleaned, "Send this now:");
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].path, "/tmp/report.pdf");
assert!(!refs[0].is_image);
}
#[test]
fn extract_media_raw_quoted_image_tag_preserves_punctuation() {
let text = "Attached IMAGE:\"/tmp/chart final.png\".";
let (cleaned, refs) = extract_media_from_response(text);
assert_eq!(cleaned, "Attached .");
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].path, "/tmp/chart final.png");
assert!(refs[0].is_image);
}
#[test]
fn response_text_after_media_extraction_omits_pure_media_messages() {
let (cleaned, refs) = extract_media_from_response("MEDIA:/tmp/report.pdf");
let text = response_text_after_media_extraction("MEDIA:/tmp/report.pdf", &cleaned, &refs);
assert!(text.is_none());
assert_eq!(refs.len(), 1);
}
#[cfg(not(windows))]
#[test]
fn extract_media_detects_existing_bare_local_file() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("report.pdf");
std::fs::write(&path, b"lingshu").expect("write");
let input = format!("Please send {}", path.display());
let (cleaned, refs) = extract_media_from_response(&input);
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].path, path.to_string_lossy());
assert!(!refs[0].is_image);
assert_eq!(cleaned, "Please send");
}
#[test]
fn extract_media_handles_unicode_without_panicking() {
let input = "Raphaël! (◕‿◕)★ I'm here and ready — how can I help?";
let (cleaned, refs) = extract_media_from_response(input);
assert_eq!(cleaned, input);
assert!(refs.is_empty());
}
#[test]
fn extract_media_handles_unicode_before_raw_tag() {
let input = "Raphaël — here you go ★ MEDIA:/tmp/report.pdf";
let (cleaned, refs) = extract_media_from_response(input);
assert_eq!(cleaned, "Raphaël — here you go ★");
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].path, "/tmp/report.pdf");
}
#[test]
fn extract_media_ignores_local_paths_inside_code_blocks() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("report.pdf");
std::fs::write(&path, b"lingshu").expect("write");
let input = format!("```sh\ncat {}\n```", path.display());
let (cleaned, refs) = extract_media_from_response(&input);
assert!(refs.is_empty());
assert_eq!(cleaned, input);
}
#[test]
fn detect_image_extensions() {
assert!(MediaRef::detect_image("/tmp/a.png"));
assert!(MediaRef::detect_image("/tmp/b.JPG")); assert!(MediaRef::detect_image("/tmp/c.webp"));
assert!(!MediaRef::detect_image("/tmp/d.pdf"));
assert!(!MediaRef::detect_image("/tmp/e.txt"));
}
#[test]
fn detect_audio_extensions() {
assert!(MediaRef::detect_audio("/tmp/reply.ogg"));
assert!(MediaRef::detect_audio("/tmp/reply.mp3"));
assert!(!MediaRef::detect_audio("/tmp/reply.png"));
}
}