From 6bc339f83ccf27cfccb53e3b907feb42cb660acd Mon Sep 17 00:00:00 2001 From: Coffee Date: Fri, 27 Mar 2026 14:45:30 +0800 Subject: [PATCH] Add SILK voice fallback and WeChat polling diagnostics --- Cargo.lock | 168 +++++++++++++++--- Cargo.toml | 1 + FEATURE_PARITY.md | 2 +- channels-src/wechat/src/lib.rs | 49 +++++ channels-src/wechat/src/types.rs | 2 +- channels-src/wechat/wechat.capabilities.json | 2 +- .../2026-03-25-wechat-integration-design.md | 2 +- src/channels/wasm/attachment_hydration.rs | 126 ++++++++++++- 8 files changed, 320 insertions(+), 32 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9526e2d2..6bc78b29 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -121,6 +121,15 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" +[[package]] +name = "ansi_term" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2" +dependencies = [ + "winapi", +] + [[package]] name = "anstream" version = "0.6.21" @@ -157,7 +166,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -168,7 +177,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -392,6 +401,17 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi 0.1.19", + "libc", + "winapi", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -941,6 +961,29 @@ dependencies = [ "serde", ] +[[package]] +name = "bindgen" +version = "0.59.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bd2a9a458e8f4304c52c43ebb0cfbd520289f8379a52e329a38afda99bf8eb8" +dependencies = [ + "bitflags 1.3.2", + "cexpr", + "clang-sys", + "clap 2.34.0", + "env_logger", + "lazy_static", + "lazycell", + "log", + "peeking_take_while", + "proc-macro2", + "quote", + "regex", + "rustc-hash 1.1.0", + "shlex", + "which", +] + [[package]] name = "bindgen" version = "0.66.1" @@ -1360,6 +1403,21 @@ dependencies = [ "libloading", ] +[[package]] +name = "clap" +version = "2.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0610544180c38b88101fecf2dd634b174a62eef6946f84dfc6a7127512b381c" +dependencies = [ + "ansi_term", + "atty", + "bitflags 1.3.2", + "strsim 0.8.0", + "textwrap", + "unicode-width 0.1.14", + "vec_map", +] + [[package]] name = "clap" version = "4.5.60" @@ -1379,7 +1437,7 @@ dependencies = [ "anstream", "anstyle", "clap_lex", - "strsim", + "strsim 0.11.1", ] [[package]] @@ -1388,7 +1446,7 @@ version = "4.5.66" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c757a3b7e39161a4e56f9365141ada2a6c915a8622c408ab6bb4b5d047371031" dependencies = [ - "clap", + "clap 4.5.60", ] [[package]] @@ -1697,7 +1755,7 @@ dependencies = [ "anes", "cast", "ciborium", - "clap", + "clap 4.5.60", "criterion-plot", "is-terminal", "itertools 0.10.5", @@ -1940,7 +1998,7 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim", + "strsim 0.11.1", "syn 2.0.117", ] @@ -2136,7 +2194,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2310,6 +2368,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "env_logger" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a12e6657c4c97ebab115a42dcee77225f7f482cdd841cf7088c657a42e9e00e7" +dependencies = [ + "atty", + "humantime", + "log", + "regex", + "termcolor", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -2323,7 +2394,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2848,6 +2919,15 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + [[package]] name = "hermit-abi" version = "0.5.2" @@ -3008,6 +3088,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + [[package]] name = "hyper" version = "0.14.32" @@ -3150,7 +3236,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.3", "system-configuration", "tokio", "tower-service", @@ -3407,7 +3493,7 @@ dependencies = [ "bytes", "chrono", "chrono-tz", - "clap", + "clap 4.5.60", "clap_complete", "criterion", "cron", @@ -3459,6 +3545,7 @@ dependencies = [ "serde_json", "serde_yml", "sha2", + "silk-rs", "subtle", "tar", "tempfile", @@ -3522,9 +3609,9 @@ version = "0.4.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ - "hermit-abi", + "hermit-abi 0.5.2", "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3758,7 +3845,7 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f2a50a585a1184a43621a9133b7702ba5cb7a87ca5e704056b19d8005de6faf" dependencies = [ - "bindgen", + "bindgen 0.66.1", "cc", ] @@ -4144,7 +4231,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4232,7 +4319,7 @@ version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" dependencies = [ - "hermit-abi", + "hermit-abi 0.5.2", "libc", ] @@ -4710,7 +4797,7 @@ checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" dependencies = [ "cfg-if", "concurrent-queue", - "hermit-abi", + "hermit-abi 0.5.2", "pin-project-lite", "rustix 1.1.4", "windows-sys 0.61.2", @@ -4930,7 +5017,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.1", "rustls 0.23.37", - "socket2 0.5.10", + "socket2 0.6.3", "thiserror 2.0.18", "tokio", "tracing", @@ -4967,9 +5054,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.3", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -5482,7 +5569,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6108,6 +6195,18 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "silk-rs" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "014e6619f35a385ff848570e73a0b8c36b31031e0ee11cac70192b50097b1cfe" +dependencies = [ + "bindgen 0.59.2", + "bytes", + "cc", + "thiserror 1.0.69", +] + [[package]] name = "simd-adler32" version = "0.3.8" @@ -6164,7 +6263,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6236,6 +6335,12 @@ dependencies = [ "unicode-properties", ] +[[package]] +name = "strsim" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" + [[package]] name = "strsim" version = "0.11.1" @@ -6389,7 +6494,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6476,6 +6581,15 @@ dependencies = [ "testcontainers", ] +[[package]] +name = "textwrap" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" +dependencies = [ + "unicode-width 0.1.14", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -7189,7 +7303,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7342,6 +7456,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vec_map" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" + [[package]] name = "version_check" version = "0.9.5" @@ -8039,7 +8159,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index cd0cc9a7..74427967 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -175,6 +175,7 @@ base64 = "0.22.1" mime_guess = "2.0.5" clap_complete = "4.5.0" lru = "0.16.3" +silk-rs = "0.2.0" # HTML to Markdown conversion (feature gated) html-to-markdown-rs = { version = "2.3", optional = true } diff --git a/FEATURE_PARITY.md b/FEATURE_PARITY.md index 61115751..537154ca 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, image send/receive, inbound file extraction, and inbound voice attachment passthrough; multi-account plus fuller voice/video and outbound file 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 handling with SILK-to-WAV fallback; multi-account plus 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/lib.rs b/channels-src/wechat/src/lib.rs index 52c65d7f..6cddebea 100644 --- a/channels-src/wechat/src/lib.rs +++ b/channels-src/wechat/src/lib.rs @@ -138,6 +138,16 @@ impl Guest for WechatChannel { let mut context_tokens_changed = false; for message in response.msgs { + log_channel( + channel_host::LogLevel::Info, + &format!( + "Received WeChat message: id={:?} from_user_id={:?} message_type={:?} item_types=[{}]", + message.message_id, + message.from_user_id, + message.message_type, + summarize_item_types(&message), + ), + ); if let Some(from_user_id) = message.from_user_id.as_deref() { if let Some(context_token) = message.context_token.as_deref() { let changed = context_tokens @@ -149,6 +159,15 @@ impl Guest for WechatChannel { } match incoming_bundle_from_message(&config, message) { Ok(Some(bundle)) => { + log_channel( + channel_host::LogLevel::Info, + &format!( + "Mapped WeChat message into bundle: from_user_id={} text_len={} attachment_count={}", + bundle.from_user_id, + bundle.text.trim().chars().count(), + bundle.attachments.len(), + ), + ); let emitted = process_incoming_bundle( &mut pending_inbound, bundle, @@ -369,6 +388,15 @@ fn process_incoming_bundle( } fn emit_buffered_bundle(bundle: PendingInboundBundle) { + log_channel( + channel_host::LogLevel::Info, + &format!( + "Emitting WeChat bundle to agent: from_user_id={} text_len={} attachment_count={}", + bundle.from_user_id, + bundle.text.trim().chars().count(), + bundle.attachments.len(), + ), + ); let metadata = json!({ "from_user_id": bundle.from_user_id, "to_user_id": bundle.to_user_id, @@ -413,6 +441,27 @@ fn merge_text(existing: &str, incoming: &str) -> String { } } +fn summarize_item_types(message: &WechatMessage) -> String { + let item_types = message + .item_list + .iter() + .map(|item| match item.r#type { + Some(MESSAGE_ITEM_TEXT) => "text".to_string(), + Some(crate::types::MESSAGE_ITEM_IMAGE) => "image".to_string(), + Some(crate::types::MESSAGE_ITEM_VOICE) => "voice".to_string(), + Some(crate::types::MESSAGE_ITEM_FILE) => "file".to_string(), + Some(other) => format!("unknown:{other}"), + None => "missing".to_string(), + }) + .collect::>(); + + if item_types.is_empty() { + "none".to_string() + } else { + item_types.join(",") + } +} + fn send_response( config: &WechatConfig, metadata: &OutboundMetadata, diff --git a/channels-src/wechat/src/types.rs b/channels-src/wechat/src/types.rs index dee13506..362a2097 100644 --- a/channels-src/wechat/src/types.rs +++ b/channels-src/wechat/src/types.rs @@ -31,7 +31,7 @@ fn default_poll_interval_ms() -> u32 { } fn default_long_poll_timeout_ms() -> u32 { - 35_000 + 15_000 } impl Default for WechatConfig { diff --git a/channels-src/wechat/wechat.capabilities.json b/channels-src/wechat/wechat.capabilities.json index 75a28d01..ba148c83 100644 --- a/channels-src/wechat/wechat.capabilities.json +++ b/channels-src/wechat/wechat.capabilities.json @@ -45,6 +45,6 @@ "cdn_base_url": "https://novac2c.cdn.weixin.qq.com/c2c", "bot_type": "3", "poll_interval_ms": 30000, - "long_poll_timeout_ms": 35000 + "long_poll_timeout_ms": 15000 } } diff --git a/docs/plans/2026-03-25-wechat-integration-design.md b/docs/plans/2026-03-25-wechat-integration-design.md index d822910f..395ff698 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 the current image + inbound-file + inbound-voice path (outbound files, fuller voice handling, and video) +- broader media parity beyond the current image + inbound-file + inbound-voice path (outbound files 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 23314776..db079cdd 100644 --- a/src/channels/wasm/attachment_hydration.rs +++ b/src/channels/wasm/attachment_hydration.rs @@ -4,6 +4,7 @@ use aes::Aes128; use aes::cipher::{BlockDecrypt, KeyInit, generic_array::GenericArray}; use base64::Engine as _; use serde::Deserialize; +use silk_rs::decode_silk; use crate::channels::wasm::capabilities::ChannelCapabilities; use crate::channels::wasm::host::{Attachment, ChannelHostState}; @@ -11,6 +12,7 @@ use crate::channels::wasm::host::{Attachment, ChannelHostState}; const AES_BLOCK_SIZE: usize = 16; const MAX_ATTACHMENT_BYTES: usize = 20 * 1024 * 1024; const WECHAT_CHANNEL_NAME: &str = "wechat"; +const WECHAT_SILK_SAMPLE_RATE_HZ: i32 = 24_000; #[derive(Debug, Deserialize)] struct WechatAttachmentExtras { @@ -43,10 +45,19 @@ pub(crate) async fn hydrate_attachment_for_channel( Ok(ciphertext) => match decrypt_wechat_attachment_bytes(&ciphertext, &encoded_aes_key) { Ok(plaintext) => { attachment.size_bytes = Some(plaintext.len() as u64); - if attachment.mime_type.starts_with("image/") { - attachment.mime_type = detect_image_mime(&plaintext).to_string(); - } attachment.data = plaintext; + if attachment.mime_type.starts_with("image/") { + attachment.mime_type = detect_image_mime(&attachment.data).to_string(); + } else if is_wechat_silk_attachment(attachment) + && let Err(error) = maybe_transcode_wechat_silk_attachment(attachment) + { + tracing::warn!( + channel = %channel_name, + attachment_id = %attachment.id, + error = %error, + "Failed to transcode WeChat SILK attachment; preserving raw SILK" + ); + } } Err(error) => { tracing::warn!( @@ -68,6 +79,15 @@ pub(crate) async fn hydrate_attachment_for_channel( } } +fn is_wechat_silk_attachment(attachment: &Attachment) -> bool { + attachment.mime_type.eq_ignore_ascii_case("audio/silk") + || attachment + .filename + .as_deref() + .and_then(|filename| filename.rsplit_once('.').map(|(_, ext)| ext)) + .is_some_and(|ext| ext.eq_ignore_ascii_case("silk")) +} + fn should_hydrate_wechat_attachment(channel_name: &str, attachment: &Attachment) -> bool { channel_name == WECHAT_CHANNEL_NAME && attachment.data.is_empty() @@ -230,6 +250,68 @@ fn detect_image_mime(bytes: &[u8]) -> &'static str { } } +fn maybe_transcode_wechat_silk_attachment(attachment: &mut Attachment) -> Result<(), String> { + if attachment.data.is_empty() { + return Err("SILK attachment has no data".to_string()); + } + + let pcm = decode_silk(&attachment.data, WECHAT_SILK_SAMPLE_RATE_HZ) + .map_err(|error| format!("SILK decode failed: {error}"))?; + if pcm.is_empty() { + return Err("SILK decoder returned empty PCM".to_string()); + } + + let wav = pcm_s16le_to_wav(&pcm, WECHAT_SILK_SAMPLE_RATE_HZ as u32)?; + attachment.data = wav; + attachment.size_bytes = Some(attachment.data.len() as u64); + attachment.mime_type = "audio/wav".to_string(); + if let Some(filename) = attachment.filename.as_mut() { + replace_attachment_extension(filename, "wav"); + } + Ok(()) +} + +fn pcm_s16le_to_wav(pcm: &[u8], sample_rate_hz: u32) -> Result, String> { + if !pcm.len().is_multiple_of(2) { + return Err("PCM buffer length must be even for 16-bit mono audio".to_string()); + } + + let data_len = u32::try_from(pcm.len()) + .map_err(|_| "PCM buffer exceeds WAV container size limits".to_string())?; + let total_len = 44u32 + .checked_add(data_len) + .ok_or_else(|| "WAV container size overflowed".to_string())?; + let byte_rate = sample_rate_hz + .checked_mul(2) + .ok_or_else(|| "WAV byte rate overflowed".to_string())?; + + let mut wav = Vec::with_capacity(total_len as usize); + wav.extend_from_slice(b"RIFF"); + wav.extend_from_slice(&(total_len - 8).to_le_bytes()); + wav.extend_from_slice(b"WAVE"); + wav.extend_from_slice(b"fmt "); + wav.extend_from_slice(&16u32.to_le_bytes()); + wav.extend_from_slice(&1u16.to_le_bytes()); + wav.extend_from_slice(&1u16.to_le_bytes()); + wav.extend_from_slice(&sample_rate_hz.to_le_bytes()); + wav.extend_from_slice(&byte_rate.to_le_bytes()); + wav.extend_from_slice(&2u16.to_le_bytes()); + wav.extend_from_slice(&16u16.to_le_bytes()); + wav.extend_from_slice(b"data"); + wav.extend_from_slice(&data_len.to_le_bytes()); + wav.extend_from_slice(pcm); + Ok(wav) +} + +fn replace_attachment_extension(filename: &mut String, replacement: &str) { + if let Some((stem, _)) = filename.rsplit_once('.') { + *filename = format!("{stem}.{replacement}"); + } else { + filename.push('.'); + filename.push_str(replacement); + } +} + #[cfg(test)] fn encrypt_aes_ecb_pkcs7(plaintext: &[u8], key: &[u8]) -> Result, String> { use aes::cipher::BlockEncrypt; @@ -250,7 +332,8 @@ fn encrypt_aes_ecb_pkcs7(plaintext: &[u8], key: &[u8]) -> Result, String mod tests { use super::{ Attachment, decrypt_wechat_attachment_bytes, detect_image_mime, encrypt_aes_ecb_pkcs7, - hydrate_attachment_for_channel, should_hydrate_wechat_attachment, + hydrate_attachment_for_channel, maybe_transcode_wechat_silk_attachment, pcm_s16le_to_wav, + should_hydrate_wechat_attachment, }; use crate::channels::wasm::ChannelCapabilities; use base64::Engine as _; @@ -315,4 +398,39 @@ mod tests { assert!(attachment.data.is_empty()); assert_eq!(attachment.size_bytes, None); } + + #[test] + fn pcm_s16le_to_wav_wraps_pcm_with_expected_header() { + let wav = pcm_s16le_to_wav(&[0x00, 0x00, 0x01, 0x00], 24_000).expect("wav wrapping"); + assert!(wav.starts_with(b"RIFF")); + assert_eq!(&wav[8..12], b"WAVE"); + assert_eq!(&wav[12..16], b"fmt "); + assert_eq!(&wav[36..40], b"data"); + assert_eq!(&wav[40..44], &(4u32).to_le_bytes()); + assert_eq!(&wav[44..], &[0x00, 0x00, 0x01, 0x00]); + } + + #[test] + fn silk_transcode_failure_preserves_raw_silk_path_for_callers() { + let mut attachment = Attachment { + id: "wechat-voice-1".to_string(), + mime_type: "audio/silk".to_string(), + filename: Some("wechat-voice-1.silk".to_string()), + size_bytes: Some(3), + source_url: None, + storage_key: None, + extracted_text: None, + extras_json: encode_test_extras_json("ZmFrZS1rZXk="), + data: vec![1, 2, 3], + duration_secs: Some(1), + }; + + let original = attachment.data.clone(); + let error = + maybe_transcode_wechat_silk_attachment(&mut attachment).expect_err("invalid SILK"); + assert!(error.contains("SILK decode failed")); + assert_eq!(attachment.mime_type, "audio/silk"); + assert_eq!(attachment.filename.as_deref(), Some("wechat-voice-1.silk")); + assert_eq!(attachment.data, original); + } }