From 09ccd71cc756a6840f1ac942ff7fb38e2158cae0 Mon Sep 17 00:00:00 2001 From: ooodc <549496103@qq.com> Date: Wed, 22 Apr 2026 09:55:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(feishu):=20=E4=BC=98=E5=8C=96=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E4=B8=8B=E8=BD=BD=E6=96=87=E4=BB=B6=E5=90=8D=E6=8E=A8?= =?UTF-8?q?=E6=96=AD=E9=80=BB=E8=BE=91=EF=BC=8C=E6=94=AF=E6=8C=81=E4=BB=8E?= =?UTF-8?q?=E5=93=8D=E5=BA=94=E5=A4=B4=E5=92=8C=E5=86=85=E5=AE=B9=E4=B8=AD?= =?UTF-8?q?=E6=8F=90=E5=8F=96=E6=96=87=E4=BB=B6=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/channels/feishu.rs | 159 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 152 insertions(+), 7 deletions(-) diff --git a/src/channels/feishu.rs b/src/channels/feishu.rs index c3c1398..f76273b 100644 --- a/src/channels/feishu.rs +++ b/src/channels/feishu.rs @@ -412,16 +412,19 @@ impl FeishuChannel { return Err(ChannelError::Other(format!("File download failed {}: {}", status, error_text))); } + let response_headers = resp.headers().clone(); + let data = resp.bytes().await .map_err(|e| ChannelError::Other(format!("Failed to read file data: {}", e)))? .to_vec(); - let extension = match file_type { - "audio" => "mp3", - "video" => "mp4", - _ => "bin", - }; - let filename = format!("{}_{}.{}", message_id, &file_key[..8.min(file_key.len())], extension); + let filename = infer_download_filename( + content_json, + &response_headers, + message_id, + file_key, + file_type, + ); let file_path = media_dir.join(&filename); tokio::fs::write(&file_path, &data).await @@ -437,6 +440,15 @@ impl FeishuChannel { Ok((format!("[{}: {}]", file_type, filename), Some(media_item))) } + fn fallback_download_filename(message_id: &str, file_key: &str, file_type: &str) -> String { + let extension = match file_type { + "audio" => "mp3", + "video" => "mp4", + _ => "bin", + }; + format!("{}_{}.{}", message_id, &file_key[..8.min(file_key.len())], extension) + } + /// Upload image to Feishu and return the image_key async fn upload_image(&self, file_path: &str) -> Result { let token = self.get_tenant_access_token().await?; @@ -1920,9 +1932,83 @@ impl FeishuChannel { } } +fn infer_download_filename( + content_json: &serde_json::Value, + headers: &reqwest::header::HeaderMap, + message_id: &str, + file_key: &str, + file_type: &str, +) -> String { + if let Some(file_name) = extract_original_file_name(content_json, headers) { + let sanitized = sanitize_download_file_name(&file_name); + if !sanitized.is_empty() { + return format!("{}_{}", message_id, sanitized); + } + } + + FeishuChannel::fallback_download_filename(message_id, file_key, file_type) +} + +fn extract_original_file_name( + content_json: &serde_json::Value, + headers: &reqwest::header::HeaderMap, +) -> Option { + let content_name = ["file_name", "filename", "name"] + .into_iter() + .find_map(|key| content_json.get(key).and_then(|value| value.as_str())) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned); + + if content_name.is_some() { + return content_name; + } + + extract_file_name_from_content_disposition(headers) +} + +fn extract_file_name_from_content_disposition( + headers: &reqwest::header::HeaderMap, +) -> Option { + let header = headers + .get(reqwest::header::CONTENT_DISPOSITION) + .and_then(|value| value.to_str().ok())?; + + for segment in header.split(';').map(str::trim) { + if let Some(value) = segment.strip_prefix("filename*=") { + let decoded = value.split("''").last().unwrap_or(value).trim_matches('"'); + if !decoded.is_empty() { + return Some(decoded.to_string()); + } + } + + if let Some(value) = segment.strip_prefix("filename=") { + let cleaned = value.trim_matches('"').trim(); + if !cleaned.is_empty() { + return Some(cleaned.to_string()); + } + } + } + + None +} + +fn sanitize_download_file_name(file_name: &str) -> String { + file_name + .chars() + .map(|ch| match ch { + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_', + _ => ch, + }) + .collect::() + .trim_matches('.') + .trim() + .to_string() +} + #[cfg(test)] mod tests { - use super::{FeishuChannel, MsgFormat}; + use super::{extract_file_name_from_content_disposition, infer_download_filename, sanitize_download_file_name, FeishuChannel, MsgFormat}; #[test] fn markdown_post_uses_md_tag() { @@ -1945,6 +2031,65 @@ mod tests { let content = "intro\n## heading"; assert_eq!(FeishuChannel::detect_msg_format(content), MsgFormat::Interactive); } + + #[test] + fn infer_download_filename_prefers_original_file_name() { + let content = serde_json::json!({ + "file_key": "file_key_123", + "file_name": "demo-archive.zip" + }); + let headers = reqwest::header::HeaderMap::new(); + + let filename = infer_download_filename(&content, &headers, "om_123", "file_key_123", "file"); + + assert_eq!(filename, "om_123_demo-archive.zip"); + } + + #[test] + fn infer_download_filename_uses_content_disposition_when_message_lacks_name() { + let content = serde_json::json!({ + "file_key": "file_key_123" + }); + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + reqwest::header::CONTENT_DISPOSITION, + reqwest::header::HeaderValue::from_static("attachment; filename=meeting-notes.zip"), + ); + + let filename = infer_download_filename(&content, &headers, "om_123", "file_key_123", "file"); + + assert_eq!(filename, "om_123_meeting-notes.zip"); + } + + #[test] + fn infer_download_filename_falls_back_to_bin_without_name() { + let content = serde_json::json!({ + "file_key": "file_key_123" + }); + let headers = reqwest::header::HeaderMap::new(); + + let filename = infer_download_filename(&content, &headers, "om_123", "file_key_123", "file"); + + assert_eq!(filename, "om_123_file_key.bin"); + } + + #[test] + fn sanitize_download_file_name_replaces_path_separators() { + let sanitized = sanitize_download_file_name("../../demo/archive.zip"); + assert_eq!(sanitized, "_.._demo_archive.zip"); + } + + #[test] + fn extract_file_name_from_content_disposition_supports_filename_star() { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + reqwest::header::CONTENT_DISPOSITION, + reqwest::header::HeaderValue::from_static("attachment; filename*=UTF-8''archive.zip"), + ); + + let file_name = extract_file_name_from_content_disposition(&headers); + assert_eq!(file_name.as_deref(), Some("archive.zip")); + } } #[async_trait]