Add WeChat inbound file and voice attachment support

This commit is contained in:
Coffee
2026-03-27 10:44:47 +08:00
parent 935631421f
commit 7284e05cd9
7 changed files with 458 additions and 28 deletions
+1 -1
View File
@@ -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 |
+2
View File
@@ -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),
},
+40 -8
View File
@@ -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::<Vec<_>>();
@@ -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();
+369 -6
View File
@@ -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<Vec<InboundAttachment>, 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<Option<InboundAttachment>, 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<Option<InboundAttachment>, 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<Option<InboundAttachment>, 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<u64> {
raw.trim().parse::<u64>().ok()
}
fn infer_voice_media_type(encode_type: Option<i32>) -> (&'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<u32>) -> 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"));
}
}
+28
View File
@@ -170,6 +170,10 @@ pub struct MessageItem {
pub text_item: Option<TextItem>,
#[serde(default)]
pub image_item: Option<ImageItem>,
#[serde(default)]
pub voice_item: Option<VoiceItem>,
#[serde(default)]
pub file_item: Option<FileItem>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
@@ -197,6 +201,28 @@ pub struct ImageItem {
pub mid_size: Option<u64>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct VoiceItem {
#[serde(default)]
pub media: Option<CdnMedia>,
#[serde(default)]
pub encode_type: Option<i32>,
#[serde(default)]
pub playtime: Option<u64>,
#[serde(default)]
pub text: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct FileItem {
#[serde(default)]
pub media: Option<CdnMedia>,
#[serde(default)]
pub file_name: Option<String>,
#[serde(default)]
pub len: Option<String>,
}
#[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;
@@ -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.
+17 -12
View File
@@ -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<String> {
@@ -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<Vec<u8>, String> {
fn decrypt_wechat_attachment_bytes(
ciphertext: &[u8],
encoded_aes_key: &str,
) -> Result<Vec<u8>, 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<Vec<u8>, 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]