feat(feishu): 优化文件下载文件名推断逻辑,支持从响应头和内容中提取文件名

This commit is contained in:
ooodc 2026-04-22 09:55:37 +08:00
parent 0dfa615ca9
commit 09ccd71cc7

View File

@ -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<String, ChannelError> {
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<String> {
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<String> {
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::<String>()
.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]