From 7284e05cd9ea0a650a9d0f720301a4e0686489e5 Mon Sep 17 00:00:00 2001 From: Coffee Date: Fri, 27 Mar 2026 10:44:47 +0800 Subject: [PATCH] Add WeChat inbound file and voice attachment support --- FEATURE_PARITY.md | 2 +- channels-src/wechat/src/api.rs | 2 + channels-src/wechat/src/lib.rs | 48 ++- channels-src/wechat/src/media.rs | 375 +++++++++++++++++- channels-src/wechat/src/types.rs | 28 ++ .../2026-03-25-wechat-integration-design.md | 2 +- src/channels/wasm/attachment_hydration.rs | 29 +- 7 files changed, 458 insertions(+), 28 deletions(-) diff --git a/FEATURE_PARITY.md b/FEATURE_PARITY.md index b8619525..61115751 100644 --- a/FEATURE_PARITY.md +++ b/FEATURE_PARITY.md @@ -77,7 +77,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O | Linq | ✅ | ❌ | P3 | Real iMessage via API, no Mac required | | Feishu/Lark | ✅ | 🚧 | P3 | WASM channel with Event Subscription v2.0; Bitable/Docx tools planned | | LINE | ✅ | ❌ | P3 | | -| WeChat (iLink bot) | ✅ | 🚧 | P2 | Extension-first channel (`channels-src/wechat`), single-account DM flow with QR login, typing, and image send/receive; multi-account and broader media parity follow-up | +| WeChat (iLink bot) | ✅ | 🚧 | P2 | Extension-first channel (`channels-src/wechat`), single-account DM flow with QR login, typing, image send/receive, inbound file extraction, and inbound voice attachment passthrough; multi-account plus fuller voice/video and outbound file parity follow-up | | WebChat | ✅ | ✅ | - | Web gateway chat | | Matrix | ✅ | ❌ | P3 | E2EE support | | Mattermost | ✅ | ❌ | P3 | Emoji reactions, interactive buttons, model picker | diff --git a/channels-src/wechat/src/api.rs b/channels-src/wechat/src/api.rs index 77ca2a66..43df2fc5 100644 --- a/channels-src/wechat/src/api.rs +++ b/channels-src/wechat/src/api.rs @@ -136,6 +136,8 @@ pub fn send_text_message( text: text.to_string(), }), image_item: None, + voice_item: None, + file_item: None, }], context_token: context_token.map(str::to_string), }, diff --git a/channels-src/wechat/src/lib.rs b/channels-src/wechat/src/lib.rs index a3a238f0..52c65d7f 100644 --- a/channels-src/wechat/src/lib.rs +++ b/channels-src/wechat/src/lib.rs @@ -174,10 +174,10 @@ impl Guest for WechatChannel { log_channel( channel_host::LogLevel::Info, &format!( - "Flushing buffered WeChat image-only message for {} after waiting one poll cycle", - bundle.from_user_id - ), - ); + "Flushing buffered WeChat attachment-only message for {} after waiting one poll cycle", + bundle.from_user_id + ), + ); emit_buffered_bundle(bundle); } } @@ -292,7 +292,7 @@ fn incoming_bundle_from_message( }; let text = extract_text(&message); - let attachments = media::extract_image_attachments(config, &message)? + let attachments = media::extract_inbound_attachments(config, &message)? .into_iter() .map(StoredInboundAttachment::from) .collect::>(); @@ -357,7 +357,7 @@ fn process_incoming_bundle( log_channel( channel_host::LogLevel::Info, &format!( - "Buffered WeChat image-only message for {} and will wait one poll cycle for follow-up text", + "Buffered WeChat attachment-only message for {} and will wait one poll cycle for follow-up text", bundle.from_user_id ), ); @@ -465,6 +465,11 @@ fn extract_text(message: &WechatMessage) -> String { .find_map(|item| { if item.r#type == Some(MESSAGE_ITEM_TEXT) { item.text_item.as_ref().map(|item| item.text.clone()) + } else if item.r#type == Some(crate::types::MESSAGE_ITEM_VOICE) { + item.voice_item + .as_ref() + .and_then(|item| item.text.as_ref()) + .cloned() } else { None } @@ -617,10 +622,11 @@ mod tests { use std::collections::HashMap; use super::{ - classify_status_update, merge_text, process_incoming_bundle, PendingInboundBundle, - StoredInboundAttachment, WechatStatusAction, + classify_status_update, extract_text, merge_text, process_incoming_bundle, + PendingInboundBundle, StoredInboundAttachment, WechatStatusAction, }; use crate::exports::near::agent::channel::{StatusType, StatusUpdate}; + use crate::types::{MessageItem, VoiceItem, WechatMessage, MESSAGE_ITEM_VOICE}; fn make_bundle(user_id: &str, text: &str, image_count: usize) -> PendingInboundBundle { PendingInboundBundle { @@ -730,6 +736,32 @@ mod tests { assert_eq!(merge_text("look", ""), "look"); } + #[test] + fn test_extract_text_uses_voice_transcript_when_present() { + let message = WechatMessage { + message_id: Some(1), + from_user_id: Some("user-1".to_string()), + to_user_id: Some("bot-1".to_string()), + session_id: None, + message_type: None, + context_token: None, + item_list: vec![MessageItem { + r#type: Some(MESSAGE_ITEM_VOICE), + text_item: None, + image_item: None, + voice_item: Some(VoiceItem { + media: None, + encode_type: Some(6), + playtime: Some(1500), + text: Some("voice transcript".to_string()), + }), + file_item: None, + }], + }; + + assert_eq!(extract_text(&message), "voice transcript"); + } + #[test] fn test_process_incoming_bundle_merges_buffered_image_with_follow_up_text() { let mut pending = HashMap::new(); diff --git a/channels-src/wechat/src/media.rs b/channels-src/wechat/src/media.rs index d163a376..938aaca5 100644 --- a/channels-src/wechat/src/media.rs +++ b/channels-src/wechat/src/media.rs @@ -8,8 +8,9 @@ use serde_json::json; use crate::exports::near::agent::channel::Attachment; use crate::near::agent::channel_host::{self, InboundAttachment}; use crate::types::{ - CdnMedia, ImageItem, MessageItem, SendMessageRequest, WechatConfig, MESSAGE_ITEM_IMAGE, - MESSAGE_STATE_FINISH, MESSAGE_TYPE_BOT, UPLOAD_MEDIA_TYPE_IMAGE, + CdnMedia, FileItem, ImageItem, MessageItem, SendMessageRequest, WechatConfig, + MESSAGE_ITEM_FILE, MESSAGE_ITEM_IMAGE, MESSAGE_ITEM_VOICE, MESSAGE_STATE_FINISH, + MESSAGE_TYPE_BOT, UPLOAD_MEDIA_TYPE_IMAGE, }; const AES_BLOCK_SIZE: usize = 16; @@ -21,7 +22,7 @@ pub struct UploadImage { pub file_size_ciphertext: u64, } -pub fn extract_image_attachments( +pub fn extract_inbound_attachments( config: &WechatConfig, message: &crate::types::WechatMessage, ) -> Result, String> { @@ -29,7 +30,9 @@ pub fn extract_image_attachments( .item_list .iter() .enumerate() - .filter_map(|(index, item)| map_image_attachment(config, message, item, index).transpose()) + .filter_map(|(index, item)| { + map_inbound_attachment(config, message, item, index).transpose() + }) .collect() } @@ -70,6 +73,8 @@ pub fn send_image_attachment( aeskey: None, mid_size: Some(upload.file_size_ciphertext), }), + voice_item: None, + file_item: None, }], context_token: context_token.map(str::to_string), }, @@ -79,6 +84,24 @@ pub fn send_image_attachment( crate::api::send_message_request(config, &request) } +fn map_inbound_attachment( + config: &WechatConfig, + message: &crate::types::WechatMessage, + item: &MessageItem, + index: usize, +) -> Result, String> { + if item.r#type == Some(MESSAGE_ITEM_IMAGE) { + return map_image_attachment(config, message, item, index); + } + if item.r#type == Some(MESSAGE_ITEM_VOICE) { + return map_voice_attachment(config, message, item, index); + } + if item.r#type == Some(MESSAGE_ITEM_FILE) { + return map_file_attachment(config, message, item, index); + } + Ok(None) +} + fn map_image_attachment( config: &WechatConfig, message: &crate::types::WechatMessage, @@ -127,6 +150,132 @@ fn map_image_attachment( })) } +fn map_file_attachment( + config: &WechatConfig, + message: &crate::types::WechatMessage, + item: &MessageItem, + index: usize, +) -> Result, String> { + if item.r#type != Some(MESSAGE_ITEM_FILE) { + return Ok(None); + } + + let file = item.file_item.as_ref().ok_or_else(|| { + format!( + "WeChat file message {:?} is missing file_item payload", + message.message_id + ) + })?; + let media = file.media.as_ref().ok_or_else(|| { + format!( + "WeChat file message {:?} is missing media payload", + message.message_id + ) + })?; + let encrypt_query_param = media.encrypt_query_param.as_deref().ok_or_else(|| { + format!( + "WeChat file message {:?} is missing encrypt_query_param", + message.message_id + ) + })?; + let aes_key = media + .aes_key + .as_deref() + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| { + format!( + "WeChat file message {:?} is missing aes_key", + message.message_id + ) + })?; + let message_id = message + .message_id + .ok_or_else(|| "WeChat file message is missing message_id".to_string())?; + let filename = inbound_file_name(file, message_id, index); + let size_bytes = file.len.as_deref().and_then(parse_file_size); + + Ok(Some(InboundAttachment { + id: format!("wechat-file-{}-{}", message_id, index), + mime_type: infer_file_mime_type(&filename), + filename: Some(filename), + size_bytes, + source_url: Some(build_cdn_download_url( + &config.cdn_base_url, + encrypt_query_param, + )), + storage_key: None, + extracted_text: None, + extras_json: json!({ "wechat_aes_key": aes_key }).to_string(), + })) +} + +fn map_voice_attachment( + config: &WechatConfig, + message: &crate::types::WechatMessage, + item: &MessageItem, + index: usize, +) -> Result, String> { + if item.r#type != Some(MESSAGE_ITEM_VOICE) { + return Ok(None); + } + + let voice = item.voice_item.as_ref().ok_or_else(|| { + format!( + "WeChat voice message {:?} is missing voice_item payload", + message.message_id + ) + })?; + let media = voice.media.as_ref().ok_or_else(|| { + format!( + "WeChat voice message {:?} is missing media payload", + message.message_id + ) + })?; + let encrypt_query_param = media.encrypt_query_param.as_deref().ok_or_else(|| { + format!( + "WeChat voice message {:?} is missing encrypt_query_param", + message.message_id + ) + })?; + let aes_key = media + .aes_key + .as_deref() + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| { + format!( + "WeChat voice message {:?} is missing aes_key", + message.message_id + ) + })?; + let message_id = message + .message_id + .ok_or_else(|| "WeChat voice message is missing message_id".to_string())?; + let (mime_type, extension) = infer_voice_media_type(voice.encode_type); + let duration_secs = voice.playtime.map(|millis| (millis / 1000) as u32); + + Ok(Some(InboundAttachment { + id: format!("wechat-voice-{}-{}", message_id, index), + mime_type: mime_type.to_string(), + filename: Some(format!( + "wechat-voice-{}-{}.{}", + message_id, index, extension + )), + size_bytes: None, + source_url: Some(build_cdn_download_url( + &config.cdn_base_url, + encrypt_query_param, + )), + storage_key: None, + extracted_text: voice + .text + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string), + extras_json: build_voice_extras_json(aes_key, duration_secs), + })) +} + fn preferred_image_aes_key<'a>(image: &'a ImageItem, media: &'a CdnMedia) -> Option<&'a str> { image .aeskey @@ -140,6 +289,77 @@ fn preferred_image_aes_key<'a>(image: &'a ImageItem, media: &'a CdnMedia) -> Opt }) } +fn inbound_file_name(file: &FileItem, message_id: i64, index: usize) -> String { + file.file_name + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| format!("wechat-file-{}-{}.bin", message_id, index)) +} + +fn parse_file_size(raw: &str) -> Option { + raw.trim().parse::().ok() +} + +fn infer_voice_media_type(encode_type: Option) -> (&'static str, &'static str) { + match encode_type { + Some(7) => ("audio/mpeg", "mp3"), + Some(8) => ("audio/ogg", "ogg"), + Some(5) => ("audio/amr", "amr"), + Some(6) => ("audio/silk", "silk"), + _ => ("audio/silk", "silk"), + } +} + +fn build_voice_extras_json(aes_key: &str, duration_secs: Option) -> String { + let mut extras = serde_json::Map::new(); + extras.insert("wechat_aes_key".to_string(), json!(aes_key)); + if let Some(duration_secs) = duration_secs { + extras.insert("duration_secs".to_string(), json!(duration_secs)); + } + serde_json::Value::Object(extras).to_string() +} + +fn infer_file_mime_type(filename: &str) -> String { + let extension = filename + .rsplit_once('.') + .map(|(_, ext)| ext.trim().to_ascii_lowercase()); + + match extension.as_deref() { + Some("pdf") => "application/pdf", + Some("doc") => "application/msword", + Some("docx") => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + Some("xls") => "application/vnd.ms-excel", + Some("xlsx") => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + Some("ppt") => "application/vnd.ms-powerpoint", + Some("pptx") => "application/vnd.openxmlformats-officedocument.presentationml.presentation", + Some("txt") => "text/plain", + Some("csv") => "text/csv", + Some("json") => "application/json", + Some("xml") => "application/xml", + Some("md") => "text/markdown", + Some("zip") => "application/zip", + Some("tar") => "application/x-tar", + Some("gz") => "application/gzip", + Some("mp3") => "audio/mpeg", + Some("ogg") => "audio/ogg", + Some("wav") => "audio/wav", + Some("mp4") => "video/mp4", + Some("mov") => "video/quicktime", + Some("webm") => "video/webm", + Some("mkv") => "video/x-matroska", + Some("avi") => "video/x-msvideo", + Some("png") => "image/png", + Some("jpg") | Some("jpeg") => "image/jpeg", + Some("gif") => "image/gif", + Some("webp") => "image/webp", + Some("bmp") => "image/bmp", + _ => "application/octet-stream", + } + .to_string() +} + fn upload_image( config: &WechatConfig, to_user_id: &str, @@ -305,9 +525,14 @@ fn padded_size(raw_size: u64) -> u64 { #[cfg(test)] mod tests { - use super::{encode_hex, encrypt_aes_ecb_pkcs7, map_image_attachment, AES_BLOCK_SIZE}; + use super::{ + build_voice_extras_json, encode_hex, encrypt_aes_ecb_pkcs7, infer_file_mime_type, + infer_voice_media_type, map_file_attachment, map_image_attachment, map_voice_attachment, + AES_BLOCK_SIZE, + }; use crate::types::{ - CdnMedia, ImageItem, MessageItem, WechatConfig, WechatMessage, MESSAGE_ITEM_IMAGE, + CdnMedia, FileItem, ImageItem, MessageItem, VoiceItem, WechatConfig, WechatMessage, + MESSAGE_ITEM_FILE, MESSAGE_ITEM_IMAGE, MESSAGE_ITEM_VOICE, }; #[test] @@ -345,6 +570,8 @@ mod tests { aeskey: None, mid_size: Some(128), }), + voice_item: None, + file_item: None, }], }; @@ -352,4 +579,140 @@ mod tests { .expect_err("missing message_id should error"); assert!(error.contains("missing message_id")); } + + #[test] + fn test_map_file_attachment_uses_filename_and_size_metadata() { + let config = WechatConfig::default(); + let message = WechatMessage { + message_id: Some(42), + from_user_id: Some("user-1".to_string()), + to_user_id: Some("bot-1".to_string()), + session_id: None, + message_type: None, + context_token: None, + item_list: vec![MessageItem { + r#type: Some(MESSAGE_ITEM_FILE), + text_item: None, + image_item: None, + voice_item: None, + file_item: Some(FileItem { + media: Some(CdnMedia { + encrypt_query_param: Some("enc".to_string()), + aes_key: Some("YWJjZGVmZ2hpamtsbW5vcA==".to_string()), + encrypt_type: Some(1), + }), + file_name: Some("report.PDF".to_string()), + len: Some("256".to_string()), + }), + }], + }; + + let attachment = map_file_attachment(&config, &message, &message.item_list[0], 0) + .expect("file attachment should map") + .expect("file attachment should be present"); + assert_eq!(attachment.id, "wechat-file-42-0"); + assert_eq!(attachment.mime_type, "application/pdf"); + assert_eq!(attachment.filename.as_deref(), Some("report.PDF")); + assert_eq!(attachment.size_bytes, Some(256)); + assert!(attachment.extras_json.contains("wechat_aes_key")); + } + + #[test] + fn test_map_file_attachment_errors_when_message_id_missing() { + let config = WechatConfig::default(); + let message = WechatMessage { + message_id: None, + from_user_id: Some("user-1".to_string()), + to_user_id: Some("bot-1".to_string()), + session_id: None, + message_type: None, + context_token: None, + item_list: vec![MessageItem { + r#type: Some(MESSAGE_ITEM_FILE), + text_item: None, + image_item: None, + voice_item: None, + file_item: Some(FileItem { + media: Some(CdnMedia { + encrypt_query_param: Some("enc".to_string()), + aes_key: Some("aes".to_string()), + encrypt_type: Some(1), + }), + file_name: Some("report.pdf".to_string()), + len: Some("256".to_string()), + }), + }], + }; + + let error = map_file_attachment(&config, &message, &message.item_list[0], 0) + .expect_err("missing message_id should error"); + assert!(error.contains("missing message_id")); + } + + #[test] + fn test_infer_file_mime_type_defaults_to_octet_stream() { + assert_eq!( + infer_file_mime_type("archive.unknown"), + "application/octet-stream" + ); + assert_eq!(infer_file_mime_type("README"), "application/octet-stream"); + } + + #[test] + fn test_infer_voice_media_type_defaults_to_silk() { + assert_eq!(infer_voice_media_type(Some(6)), ("audio/silk", "silk")); + assert_eq!(infer_voice_media_type(Some(8)), ("audio/ogg", "ogg")); + assert_eq!(infer_voice_media_type(None), ("audio/silk", "silk")); + } + + #[test] + fn test_build_voice_extras_json_includes_duration() { + let extras = build_voice_extras_json("aes-key", Some(9)); + assert!(extras.contains("wechat_aes_key")); + assert!(extras.contains("duration_secs")); + } + + #[test] + fn test_map_voice_attachment_sets_audio_metadata() { + let config = WechatConfig::default(); + let message = WechatMessage { + message_id: Some(77), + from_user_id: Some("user-1".to_string()), + to_user_id: Some("bot-1".to_string()), + session_id: None, + message_type: None, + context_token: None, + item_list: vec![MessageItem { + r#type: Some(MESSAGE_ITEM_VOICE), + text_item: None, + image_item: None, + voice_item: Some(VoiceItem { + media: Some(CdnMedia { + encrypt_query_param: Some("enc".to_string()), + aes_key: Some("YWJjZGVmZ2hpamtsbW5vcA==".to_string()), + encrypt_type: Some(1), + }), + encode_type: Some(8), + playtime: Some(4200), + text: Some("hello from voice".to_string()), + }), + file_item: None, + }], + }; + + let attachment = map_voice_attachment(&config, &message, &message.item_list[0], 0) + .expect("voice attachment should map") + .expect("voice attachment should be present"); + assert_eq!(attachment.id, "wechat-voice-77-0"); + assert_eq!(attachment.mime_type, "audio/ogg"); + assert_eq!( + attachment.filename.as_deref(), + Some("wechat-voice-77-0.ogg") + ); + assert_eq!( + attachment.extracted_text.as_deref(), + Some("hello from voice") + ); + assert!(attachment.extras_json.contains("duration_secs")); + } } diff --git a/channels-src/wechat/src/types.rs b/channels-src/wechat/src/types.rs index 07c5f4c8..dee13506 100644 --- a/channels-src/wechat/src/types.rs +++ b/channels-src/wechat/src/types.rs @@ -170,6 +170,10 @@ pub struct MessageItem { pub text_item: Option, #[serde(default)] pub image_item: Option, + #[serde(default)] + pub voice_item: Option, + #[serde(default)] + pub file_item: Option, } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -197,6 +201,28 @@ pub struct ImageItem { pub mid_size: Option, } +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct VoiceItem { + #[serde(default)] + pub media: Option, + #[serde(default)] + pub encode_type: Option, + #[serde(default)] + pub playtime: Option, + #[serde(default)] + pub text: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct FileItem { + #[serde(default)] + pub media: Option, + #[serde(default)] + pub file_name: Option, + #[serde(default)] + pub len: Option, +} + #[derive(Debug, Clone, Deserialize, Serialize)] pub struct OutboundMetadata { pub from_user_id: String, @@ -215,6 +241,8 @@ pub const MESSAGE_TYPE_BOT: i32 = 2; pub const MESSAGE_STATE_FINISH: i32 = 2; pub const MESSAGE_ITEM_TEXT: i32 = 1; pub const MESSAGE_ITEM_IMAGE: i32 = 2; +pub const MESSAGE_ITEM_VOICE: i32 = 3; +pub const MESSAGE_ITEM_FILE: i32 = 4; pub const TYPING_STATUS_TYPING: i32 = 1; pub const TYPING_STATUS_CANCEL: i32 = 2; pub const UPLOAD_MEDIA_TYPE_IMAGE: i32 = 1; diff --git a/docs/plans/2026-03-25-wechat-integration-design.md b/docs/plans/2026-03-25-wechat-integration-design.md index e741fc97..d822910f 100644 --- a/docs/plans/2026-03-25-wechat-integration-design.md +++ b/docs/plans/2026-03-25-wechat-integration-design.md @@ -92,7 +92,7 @@ The point of this phase is to keep the channel aligned with upstream behavior wh These are upstream features, so they belong on the roadmap, but they do not need to block the first implementation cut: -- broader media parity beyond images (files/video/voice) +- broader media parity beyond the current image + inbound-file + inbound-voice path (outbound files, fuller voice handling, and video) We should not spend time listing non-goals that come from outside the upstream capability boundary. diff --git a/src/channels/wasm/attachment_hydration.rs b/src/channels/wasm/attachment_hydration.rs index 5870013d..23314776 100644 --- a/src/channels/wasm/attachment_hydration.rs +++ b/src/channels/wasm/attachment_hydration.rs @@ -34,16 +34,18 @@ pub(crate) async fn hydrate_attachment_for_channel( tracing::warn!( channel = %channel_name, attachment_id = %attachment.id, - "Skipping WeChat image hydration: missing AES key metadata" + "Skipping WeChat attachment hydration: missing AES key metadata" ); return; }; match download_wechat_attachment_bytes(channel_name, capabilities, source_url).await { - Ok(ciphertext) => match decrypt_wechat_image_bytes(&ciphertext, &encoded_aes_key) { + Ok(ciphertext) => match decrypt_wechat_attachment_bytes(&ciphertext, &encoded_aes_key) { Ok(plaintext) => { attachment.size_bytes = Some(plaintext.len() as u64); - attachment.mime_type = detect_image_mime(&plaintext).to_string(); + if attachment.mime_type.starts_with("image/") { + attachment.mime_type = detect_image_mime(&plaintext).to_string(); + } attachment.data = plaintext; } Err(error) => { @@ -51,7 +53,7 @@ pub(crate) async fn hydrate_attachment_for_channel( channel = %channel_name, attachment_id = %attachment.id, error = %error, - "Failed to decrypt WeChat image attachment" + "Failed to decrypt WeChat attachment" ); } }, @@ -60,7 +62,7 @@ pub(crate) async fn hydrate_attachment_for_channel( channel = %channel_name, attachment_id = %attachment.id, error = %error, - "Failed to download WeChat image attachment" + "Failed to download WeChat attachment" ); } } @@ -69,7 +71,7 @@ pub(crate) async fn hydrate_attachment_for_channel( fn should_hydrate_wechat_attachment(channel_name: &str, attachment: &Attachment) -> bool { channel_name == WECHAT_CHANNEL_NAME && attachment.data.is_empty() - && attachment.mime_type.starts_with("image/") + && attachment.source_url.is_some() } fn wechat_aes_key(extras_json: &str) -> Option { @@ -122,14 +124,17 @@ async fn download_wechat_attachment_bytes( } if bytes.len() > MAX_ATTACHMENT_BYTES { return Err(format!( - "WeChat image attachment exceeds {MAX_ATTACHMENT_BYTES} bytes" + "WeChat attachment exceeds {MAX_ATTACHMENT_BYTES} bytes" )); } Ok(bytes) } -fn decrypt_wechat_image_bytes(ciphertext: &[u8], encoded_aes_key: &str) -> Result, String> { +fn decrypt_wechat_attachment_bytes( + ciphertext: &[u8], + encoded_aes_key: &str, +) -> Result, String> { let key = parse_aes_key(encoded_aes_key)?; decrypt_aes_ecb_pkcs7(ciphertext, &key) } @@ -244,7 +249,7 @@ fn encrypt_aes_ecb_pkcs7(plaintext: &[u8], key: &[u8]) -> Result, String #[cfg(test)] mod tests { use super::{ - Attachment, decrypt_wechat_image_bytes, detect_image_mime, encrypt_aes_ecb_pkcs7, + Attachment, decrypt_wechat_attachment_bytes, detect_image_mime, encrypt_aes_ecb_pkcs7, hydrate_attachment_for_channel, should_hydrate_wechat_attachment, }; use crate::channels::wasm::ChannelCapabilities; @@ -278,7 +283,7 @@ mod tests { let plaintext = vec![0xFF, 0xD8, 0xFF, 0xDB, 0x00, 0x11]; let ciphertext = encrypt_aes_ecb_pkcs7(&plaintext, &key).unwrap(); let encoded_key = base64::engine::general_purpose::STANDARD.encode(key); - let decrypted = decrypt_wechat_image_bytes(&ciphertext, &encoded_key).unwrap(); + let decrypted = decrypt_wechat_attachment_bytes(&ciphertext, &encoded_key).unwrap(); assert_eq!(decrypted, plaintext); } @@ -292,14 +297,14 @@ mod tests { } #[test] - fn wechat_attachment_hydration_only_applies_to_wechat_images() { + fn wechat_attachment_hydration_applies_to_wechat_encrypted_media() { let mut attachment = make_attachment(); attachment.extras_json = encode_test_extras_json("ZmFrZS1rZXk="); assert!(should_hydrate_wechat_attachment("wechat", &attachment)); assert!(!should_hydrate_wechat_attachment("telegram", &attachment)); attachment.mime_type = "application/pdf".to_string(); - assert!(!should_hydrate_wechat_attachment("wechat", &attachment)); + assert!(should_hydrate_wechat_attachment("wechat", &attachment)); } #[tokio::test]