From 7bed9e767a55d86ac2b9142a49ca078f95623c8b Mon Sep 17 00:00:00 2001 From: pandorafuture Date: Wed, 22 Jul 2026 11:20:16 +0800 Subject: [PATCH] feat: expose avatars and image quality metadata --- CHANGELOG.md | 13 +++++++ CHANGELOG.zh-CN.md | 31 ++++++++++++++++ Cargo.lock | 16 ++++---- Cargo.toml | 2 +- README.md | 4 ++ SKILL.md | 7 +++- crates/wx-cli/src/cmd/serve/event.rs | 2 + crates/wx-cli/src/cmd/serve/media.rs | 19 ++++++++++ crates/wx-cli/src/cmd/watch.rs | 1 + crates/wx-cli/src/schema.rs | 9 +++++ crates/wx-cli/tests/serve-media.rs | 42 +++++++++++++++++++++ crates/wx-context/src/contact.rs | 11 ++++++ crates/wx-db/src/contacts.rs | 42 +++++++++++++++++---- crates/wx-db/src/model.rs | 3 ++ crates/wx-db/tests/contacts-sessions.rs | 49 +++++++++++++++++++++++++ crates/wx-media/tests/dat-decrypt.rs | 24 ++++++------ crates/wx-paths/src/lib.rs | 6 +-- 17 files changed, 247 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f19cae7..4746ea8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ All notable changes to this project will be documented in this file. +## [Unreleased] + +## [0.7.4] - 2026-07-22 + +### Features + +- **Contact and conversation avatars** — Expose an optional `avatar_url` in contact and enriched session JSON, preferring the small avatar and falling back to the large avatar while remaining compatible with older database schemas +- **Image quality metadata** — Mark `/api/v1/media` image responses as `full` or `thumbnail`, while continuing to prefer locally available high-resolution/original image files + +### Maintenance + +- Keep formatting and Clippy clean on the current stable Rust toolchain, and serialize integration-test server startup to avoid ephemeral-port races + ## [0.7.3] - 2026-07-10 ### Features diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index fe6f581..a477e1c 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -2,6 +2,37 @@ 本文件记录项目的所有重要变更。 +## [未发布] + +## [0.7.4] - 2026-07-22 + +### 功能 + +- **联系人和会话头像** — 联系人与会话 JSON 新增可选 `avatar_url`;优先使用小头像、回退大头像,并兼容没有头像字段的旧数据库 +- **图片质量元数据** — `/api/v1/media` 图片响应会标记为 `full` 或 `thumbnail`,并继续优先返回本机已有的高清/原图文件 + +### 维护 + +- 适配当前 stable Rust 的格式化和 Clippy 检查,并串行化集成测试服务启动,避免并行抢占临时端口 + +## [0.7.3] - 2026-07-10 + +### 功能 + +- **Agent 可用的微信数据层** — 提供 Agent Skill,让 Claude Code、Codex、Cursor 等 Agent 查询和订阅本地微信数据 +- **跨会话时间线** — 新增 `GET /api/v1/timeline`,可在有界时间范围内一次读取所有会话,并支持分页、排序、消息类型筛选和隐私过滤 +- **精简时间线输出** — 返回记忆、归档和报告 Agent 所需的会话、发送者、方向、时间、类型和摘要字段 + +### 性能 + +- 复用 SQLCipher 派生密钥,避免在打开、计数、刷新和重开数据库时重复执行 25.6 万轮 KDF +- 限制时间线排序内存,长驻服务会复用预热的数据库连接 + +### 文档与维护 + +- 重写 README,补充 Release 安装方式和 Agent Skill 说明 +- 更新依赖与 GitHub Actions,恢复格式化、Clippy 和全量测试基线 + ## [0.7.2] - 2026-04-06 ### 功能 diff --git a/Cargo.lock b/Cargo.lock index 3f0eb4a..78ad455 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3132,7 +3132,7 @@ checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "wx-cli" -version = "0.7.3" +version = "0.7.4" dependencies = [ "axum", "chrono", @@ -3168,7 +3168,7 @@ dependencies = [ [[package]] name = "wx-context" -version = "0.7.3" +version = "0.7.4" dependencies = [ "aes", "cbc", @@ -3192,7 +3192,7 @@ dependencies = [ [[package]] name = "wx-db" -version = "0.7.3" +version = "0.7.4" dependencies = [ "hex", "insta", @@ -3209,7 +3209,7 @@ dependencies = [ [[package]] name = "wx-decrypt" -version = "0.7.3" +version = "0.7.4" dependencies = [ "aes", "cbc", @@ -3222,7 +3222,7 @@ dependencies = [ [[package]] name = "wx-keychain" -version = "0.7.3" +version = "0.7.4" dependencies = [ "aes", "cbc", @@ -3244,7 +3244,7 @@ dependencies = [ [[package]] name = "wx-media" -version = "0.7.3" +version = "0.7.4" dependencies = [ "aes", "base64", @@ -3262,7 +3262,7 @@ dependencies = [ [[package]] name = "wx-monitor" -version = "0.7.3" +version = "0.7.4" dependencies = [ "aes", "cbc", @@ -3283,7 +3283,7 @@ dependencies = [ [[package]] name = "wx-paths" -version = "0.7.3" +version = "0.7.4" dependencies = [ "dirs", "libc", diff --git a/Cargo.toml b/Cargo.toml index 02a2b62..0ef9c63 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/wx-decrypt", "crates/wx-keychain", "crates/wx-cli", "crates/w resolver = "2" [workspace.package] -version = "0.7.3" +version = "0.7.4" edition = "2021" license = "MIT" repository = "https://github.com/pandorafuture/wx-cli" diff --git a/README.md b/README.md index 805a67d..76e4ad8 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,10 @@ wx-cli server restart # 重启 REST 端点:`/api/v1/health`、`/api/v1/sessions`、`/api/v1/contacts`、`/api/v1/messages`、`/api/v1/timeline`、`/api/v1/search`、`/api/v1/media`、`/api/v1/events`(SSE)。 +`/api/v1/media` 的图片响应会通过 `X-Wechat-Media-Quality: full|thumbnail` 标明本机返回的是完整图还是缩略图;服务始终优先选择本机已经下载的高清/原图。 + +会话与联系人 JSON 在本地数据库有记录时会返回可选的 `avatar_url`,可用于展示个人或群聊头像;没有头像字段的旧数据库会省略该值。 + 其中 `/api/v1/timeline?since=&until=` 可在一次请求中读取时间范围内所有会话的消息,适合 Agent 记忆补全、归档和批处理,避免逐会话反复调用。 所有查询命令加 `--format json` 可获取 JSON 格式输出。 diff --git a/SKILL.md b/SKILL.md index 764d83f..ed00289 100644 --- a/SKILL.md +++ b/SKILL.md @@ -263,6 +263,8 @@ wx-cli server status / stop / restart # 管理服务 | `GET /api/v1/search` | 全文搜索 | `q`(必填), `limit`, `offset` | | `GET /api/v1/events` | SSE 事件流 | 无 | +图片响应会携带 `X-Wechat-Media-Quality: full|thumbnail`。服务会优先返回本机已有的高清/原图文件;若微信只下载过缩略图,则返回 `thumbnail`,调用方不应长期缓存,并可在微信下载原图后重试。 + **认证:** 带 `--token` 启动时须携带 `Authorization: Bearer `。`--host` 不是本机地址时 `--token` 必填。 当前 HTTP API 为**只读**,没有 send/reply/webhook 等写接口。 @@ -312,14 +314,15 @@ wx-cli sessions # 4. 重试查询 | 命令 | item 关键字段 | |------|-------------| -| sessions | `username`, `display_name`, `summary`, `sort_timestamp`, `direction?` | +| sessions | `username`, `display_name`, `avatar_url?`, `summary`, `sort_timestamp`, `direction?` | | query | `sort_seq`, `server_id`, `msg_type`, `sender`, `content`, `direction` | | timeline API | `sort_seq`, `server_id`, `msg_type`, `sender`, `talker`, `talker_display_name`, `create_time`, `direction`, `snippet`;统一按时间跨会话排序 | -| contacts | `user_name`, `alias`, `remark`, `nick_name`, `phone`, `labels` | +| contacts | `user_name`, `alias`, `remark`, `nick_name`, `avatar_url?`, `phone`, `labels` | | search | `server_id`, `talker`, `sender`, `snippet`, `hit_type` | **易混淆字段:** - contacts 用 `user_name`(下划线),sessions 用 `username`(无下划线) +- `avatar_url` 优先取微信小头像地址,缺失时回退大头像地址;本地没有记录时不输出 - message 的 `sender` 才是消息级 self/other 判断依据 - `/api/v1/health` 的 `current_account.wxid` 是判断"我发的"的主事实源 diff --git a/crates/wx-cli/src/cmd/serve/event.rs b/crates/wx-cli/src/cmd/serve/event.rs index 2cb960b..7a6da26 100644 --- a/crates/wx-cli/src/cmd/serve/event.rs +++ b/crates/wx-cli/src/cmd/serve/event.rs @@ -47,6 +47,7 @@ mod tests { last_sender_display_name: None, }, display_name: "wxid_friend".to_string(), + avatar_url: None, direction: None, detected_at: None, }, @@ -70,6 +71,7 @@ mod tests { last_sender_display_name: None, }, display_name: "wxid_friend".to_string(), + avatar_url: None, direction: None, detected_at: Some(2), }, diff --git a/crates/wx-cli/src/cmd/serve/media.rs b/crates/wx-cli/src/cmd/serve/media.rs index 9bded8e..01c9043 100644 --- a/crates/wx-cli/src/cmd/serve/media.rs +++ b/crates/wx-cli/src/cmd/serve/media.rs @@ -42,6 +42,7 @@ enum MediaPayload { InlineBytes { bytes: Vec, content_type: &'static str, + quality: Option<&'static str>, }, ServePath { path: PathBuf, @@ -56,11 +57,17 @@ impl MediaPayload { Self::InlineBytes { bytes, content_type, + quality, } => { let mut response = bytes.into_response(); response .headers_mut() .insert(CONTENT_TYPE, HeaderValue::from_static(content_type)); + if let Some(quality) = quality { + response + .headers_mut() + .insert("x-wechat-media-quality", HeaderValue::from_static(quality)); + } Ok(response) } Self::ServePath { @@ -242,6 +249,14 @@ fn resolve_image( let dat_path = lookup.recommended.ok_or_else(|| { ServeError::NotFound(format!("no candidate image file found for md5={md5}")) })?; + let quality = if dat_path + .file_name() + .is_some_and(|name| name.to_string_lossy().ends_with("_t.dat")) + { + "thumbnail" + } else { + "full" + }; let data = std::fs::read(&dat_path) .map_err(|e| ServeError::Internal(format!("failed to read {}: {e}", dat_path.display())))?; let decoded = wx_media::decrypt_dat(&data, &dat_decrypt) @@ -259,12 +274,14 @@ fn resolve_image( return Ok(MediaPayload::InlineBytes { bytes: transcoded.data, content_type: image_content_type(transcoded.ext), + quality: Some(quality), }); } Ok(MediaPayload::InlineBytes { bytes: decoded.data, content_type: image_content_type(&decoded.ext), + quality: Some(quality), }) } @@ -288,6 +305,7 @@ fn resolve_voice( return Ok(MediaPayload::InlineBytes { bytes: cached.bytes.clone(), content_type: cached.content_type, + quality: None, }); } } @@ -331,6 +349,7 @@ fn resolve_voice( return Ok(MediaPayload::InlineBytes { bytes: result.data, content_type: result.mime, + quality: None, }); } Err(wx_media::MediaError::LookupMiss(_)) diff --git a/crates/wx-cli/src/cmd/watch.rs b/crates/wx-cli/src/cmd/watch.rs index bcb8885..8325c99 100644 --- a/crates/wx-cli/src/cmd/watch.rs +++ b/crates/wx-cli/src/cmd/watch.rs @@ -65,6 +65,7 @@ fn format_watch_line( } #[cfg(test)] +#[allow(clippy::items_after_test_module)] mod tests { use super::*; diff --git a/crates/wx-cli/src/schema.rs b/crates/wx-cli/src/schema.rs index ab7bbe5..412c93b 100644 --- a/crates/wx-cli/src/schema.rs +++ b/crates/wx-cli/src/schema.rs @@ -17,6 +17,8 @@ pub struct EnrichedSession { pub session: Session, pub display_name: String, #[serde(skip_serializing_if = "Option::is_none")] + pub avatar_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub direction: Option, #[serde(skip_serializing_if = "Option::is_none")] pub detected_at: Option, @@ -61,10 +63,12 @@ pub fn enrich_session( detected_at: Option, ) -> EnrichedSession { let display_name = resolver.display_with_id(&session.username); + let avatar_url = resolver.avatar_url(&session.username).map(str::to_string); let direction = derive_session_direction(session.last_msg_sender.as_deref(), self_wxid); EnrichedSession { session, display_name, + avatar_url, direction, detected_at, } @@ -76,6 +80,7 @@ pub fn enrich_session_event( resolver: &ContactResolver, ) -> EnrichedSession { let display_name = resolver.display_with_id(&ev.username); + let avatar_url = resolver.avatar_url(&ev.username).map(str::to_string); let direction = derive_session_direction(ev.last_msg_sender.as_deref(), self_wxid); let session = Session { username: ev.username, @@ -88,6 +93,7 @@ pub fn enrich_session_event( EnrichedSession { session, display_name, + avatar_url, direction, detected_at: Some(ev.detected_at), } @@ -743,6 +749,7 @@ mod tests { last_sender_display_name: None, }, display_name: "Spam".to_string(), + avatar_url: None, direction: Some(Direction::Incoming), detected_at: None, }; @@ -764,6 +771,7 @@ mod tests { last_sender_display_name: Some("Spammer".to_string()), }, display_name: "Group".to_string(), + avatar_url: None, direction: Some(Direction::Incoming), detected_at: None, }; @@ -787,6 +795,7 @@ mod tests { last_sender_display_name: Some("Normal".to_string()), }, display_name: "Group".to_string(), + avatar_url: None, direction: Some(Direction::Incoming), detected_at: None, }; diff --git a/crates/wx-cli/tests/serve-media.rs b/crates/wx-cli/tests/serve-media.rs index 4c4696a..f443326 100644 --- a/crates/wx-cli/tests/serve-media.rs +++ b/crates/wx-cli/tests/serve-media.rs @@ -4,6 +4,7 @@ use std::io::{Read, Write}; use std::net::{TcpListener, TcpStream}; use std::path::Path; use std::process::{Child, Command, Stdio}; +use std::sync::Mutex; use std::thread; use std::time::{Duration, Instant}; @@ -18,6 +19,7 @@ const MSG_TABLE: &str = "Msg_29a6db07e8bbdb53f5d54cc3c309f3f1"; const GROUP_TALKER: &str = "test@chatroom"; const GROUP_MSG_TABLE: &str = "Msg_1d282e28b02b5c9f9522f855de32f9a8"; const HIDDEN_SENDER: &str = "wxid_spam"; +static SERVER_START_LOCK: Mutex<()> = Mutex::new(()); fn bin() -> &'static str { env!("CARGO_BIN_EXE_wx-cli") @@ -150,9 +152,22 @@ fn serve_media_dispatch_image_returns_png_bytes() { ); assert_eq!(response.status_code, 200, "{response:#?}"); assert_eq!(response.header("content-type"), Some("image/png")); + assert_eq!(response.header("x-wechat-media-quality"), Some("full")); assert_eq!(&response.body[..8], b"\x89PNG\r\n\x1a\n"); } +#[test] +fn serve_media_thumbnail_only_image_reports_quality() { + let server = spawn_test_server(); + let response = http_get( + &server.base_url, + "/api/v1/media?server_id=3009&talker=wxid_alice", + ); + assert_eq!(response.status_code, 200, "{response:#?}"); + assert_eq!(response.header("content-type"), Some("image/png")); + assert_eq!(response.header("x-wechat-media-quality"), Some("thumbnail")); +} + #[test] fn serve_media_wxgf_embedded_png_returns_png_without_ffmpeg() { let server = spawn_test_server_with_env(&[("FFMPEG_PATH", "/definitely-missing-ffmpeg")]); @@ -326,6 +341,10 @@ fn spawn_test_server_with_setup(envs: &[(&str, &str)], hidden_contacts: &[&str]) } let account_dir = fixture.path().join(TEST_ACCOUNT_ID); let runtime_root = fixture.path().join("runtime"); + // Keep ephemeral-port selection and worker binding atomic across parallel tests. + let _start_guard = SERVER_START_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); let port = find_open_port(); let mut command = Command::new(bin()); command @@ -712,6 +731,27 @@ fn create_encrypted_message_db(path: &Path, raw_key: &[u8; 32]) { ) .expect("insert image ok message"); + let image_thumb_only_info = + encode_packed_info_for_test(Some("md5_image_thumb_only"), None); + conn.execute( + &format!( + "INSERT INTO [{table}] VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + table = MSG_TABLE + ), + params![ + 301_i64, + 3009_i64, + 3_i64, + 1_i64, + 1_709_251_200_i64, + Vec::::new(), + image_thumb_only_info, + 0_i32, + None::, + ], + ) + .expect("insert thumbnail-only image message"); + let image_wxgf_png_info = encode_packed_info_for_test(Some("md5_image_wxgf_png"), None); conn.execute( &format!( @@ -985,6 +1025,8 @@ fn create_image_fixture(attach_dir: &Path) { let encrypted = xor_bytes(&png, xor_key); fs::write(month_dir.join("md5_image_ok_t.dat"), &encrypted).expect("write thumb dat"); fs::write(month_dir.join("md5_image_ok.dat"), &encrypted).expect("write image dat"); + fs::write(month_dir.join("md5_image_thumb_only_t.dat"), &encrypted) + .expect("write thumbnail-only dat"); let wxgf_png = sample_wxgf_with_embedded_png(); let wxgf_png_encrypted = xor_bytes(&wxgf_png, xor_key); diff --git a/crates/wx-context/src/contact.rs b/crates/wx-context/src/contact.rs index 0931416..d9e77a2 100644 --- a/crates/wx-context/src/contact.rs +++ b/crates/wx-context/src/contact.rs @@ -15,6 +15,7 @@ struct ResolvedContact { signature: Option, region: Option, labels: Vec, + avatar_url: Option, } pub struct ContactResolver { @@ -56,6 +57,7 @@ impl ContactResolver { signature: c.signature, region: c.region, labels: c.labels, + avatar_url: c.avatar_url, }, ); } @@ -92,6 +94,13 @@ impl ContactResolver { .unwrap_or(&[]) } + /// Resolve a wxid to its preferred avatar URL. + pub fn avatar_url(&self, wxid: &str) -> Option<&str> { + self.contacts + .get(wxid) + .and_then(|contact| contact.avatar_url.as_deref()) + } + /// Iterate all contacts with their wxid and labels. /// Used by VisibilityIndex to expand ignore_tags. pub fn all_labels(&self) -> impl Iterator { @@ -200,6 +209,7 @@ mod tests { signature: None, region: None, labels: Vec::new(), + avatar_url: None, }, ) }) @@ -235,6 +245,7 @@ mod tests { signature: None, region: None, labels: labels.iter().map(|s| s.to_string()).collect(), + avatar_url: None, }, ) }) diff --git a/crates/wx-db/src/contacts.rs b/crates/wx-db/src/contacts.rs index 38dcfc4..adc7c1b 100644 --- a/crates/wx-db/src/contacts.rs +++ b/crates/wx-db/src/contacts.rs @@ -43,16 +43,19 @@ impl WechatDb { limit: usize, label_map: &HashMap, ) -> Result, DbError> { + let avatar_expr = self.contact_avatar_select_expr()?; let total_rows: usize = self.contact_conn .query_row("SELECT COUNT(*) FROM contact", [], |row| { row.get::<_, i64>(0) })? as usize; - let mut stmt = self.contact_conn.prepare( - "SELECT username, alias, remark, nick_name, description, extra_buffer \ - FROM contact ORDER BY username ASC LIMIT ?1 OFFSET ?2", - )?; + let sql = format!( + "SELECT username, alias, remark, nick_name, description, extra_buffer, \ + {avatar_expr} AS avatar_url \ + FROM contact ORDER BY username ASC LIMIT ?1 OFFSET ?2" + ); + let mut stmt = self.contact_conn.prepare(&sql)?; let rows = stmt.query_map( [ Value::Integer(limit as i64), @@ -81,6 +84,7 @@ impl WechatDb { label_map: &HashMap, ) -> Result, DbError> { let kw_lower = query.keyword.as_ref().unwrap().to_lowercase(); + let avatar_expr = self.contact_avatar_select_expr()?; let total_rows: usize = self.contact_conn @@ -88,10 +92,12 @@ impl WechatDb { row.get::<_, i64>(0) })? as usize; - let mut stmt = self.contact_conn.prepare( - "SELECT username, alias, remark, nick_name, description, extra_buffer \ - FROM contact ORDER BY username ASC", - )?; + let sql = format!( + "SELECT username, alias, remark, nick_name, description, extra_buffer, \ + {avatar_expr} AS avatar_url \ + FROM contact ORDER BY username ASC" + ); + let mut stmt = self.contact_conn.prepare(&sql)?; let rows = stmt.query_map([], |row| self.map_contact_row(row, label_map))?; let all_contacts: Vec = rows.filter_map(|r| r.ok()).collect(); @@ -126,6 +132,10 @@ impl WechatDb { let nick_name: String = row.get::<_, String>(3).unwrap_or_default(); let memo: Option = row.get::<_, Option>(4).unwrap_or(None); let extra_buffer: Vec = row.get::<_, Vec>(5).unwrap_or_default(); + let avatar_url: Option = row + .get::<_, Option>(6) + .unwrap_or(None) + .filter(|value| !value.is_empty()); let extra = contact_proto::decode_extra_buffer(&extra_buffer); @@ -147,6 +157,22 @@ impl WechatDb { source_scene: extra.source_scene, phone: extra.phone, labels, + avatar_url, + }) + } + + /// Build a compatible avatar expression for WeChat database variants. + /// Older fixtures and database versions may not contain either column. + fn contact_avatar_select_expr(&self) -> Result<&'static str, DbError> { + let has_small = + decode::check_column_exists(&self.contact_conn, "contact", "small_head_url")?; + let has_big = decode::check_column_exists(&self.contact_conn, "contact", "big_head_url")?; + + Ok(match (has_small, has_big) { + (true, true) => "COALESCE(NULLIF(small_head_url, ''), NULLIF(big_head_url, ''))", + (true, false) => "NULLIF(small_head_url, '')", + (false, true) => "NULLIF(big_head_url, '')", + (false, false) => "NULL", }) } diff --git a/crates/wx-db/src/model.rs b/crates/wx-db/src/model.rs index 813c37c..2886749 100644 --- a/crates/wx-db/src/model.rs +++ b/crates/wx-db/src/model.rs @@ -380,6 +380,9 @@ pub struct Contact { pub phone: Option, /// Resolved label names from extra_buffer + contact_label table. pub labels: Vec, + /// Preferred avatar URL from `small_head_url`, falling back to `big_head_url`. + #[serde(skip_serializing_if = "Option::is_none")] + pub avatar_url: Option, } /// A WeChat chatroom (group chat) entry with its member list. diff --git a/crates/wx-db/tests/contacts-sessions.rs b/crates/wx-db/tests/contacts-sessions.rs index e26a877..66d41d3 100644 --- a/crates/wx-db/tests/contacts-sessions.rs +++ b/crates/wx-db/tests/contacts-sessions.rs @@ -282,6 +282,55 @@ fn contacts_sessions_query_contacts_limit() { assert_eq!(result.items.len(), 1); } +#[test] +fn contacts_sessions_query_contacts_avatar_url() { + let dir = create_fixture(); + let contact_path = dir.path().join("contact").join("contact.db"); + let conn = Connection::open(contact_path).unwrap(); + conn.execute_batch( + "ALTER TABLE contact ADD COLUMN small_head_url TEXT; + ALTER TABLE contact ADD COLUMN big_head_url TEXT;", + ) + .unwrap(); + conn.execute( + "UPDATE contact SET small_head_url = ?1, big_head_url = ?2 WHERE username = ?3", + params![ + "https://avatar.example/alice-small.jpg", + "https://avatar.example/alice-big.jpg", + "wxid_alice" + ], + ) + .unwrap(); + conn.execute( + "UPDATE contact SET big_head_url = ?1 WHERE username = ?2", + params!["https://avatar.example/bob-big.jpg", "wxid_bob"], + ) + .unwrap(); + drop(conn); + + let db = WechatDb::open(dir.path()).unwrap(); + let result = db.query_contacts(&ContactQuery::new()).unwrap(); + let alice = result + .items + .iter() + .find(|contact| contact.user_name == "wxid_alice") + .unwrap(); + let bob = result + .items + .iter() + .find(|contact| contact.user_name == "wxid_bob") + .unwrap(); + + assert_eq!( + alice.avatar_url.as_deref(), + Some("https://avatar.example/alice-small.jpg") + ); + assert_eq!( + bob.avatar_url.as_deref(), + Some("https://avatar.example/bob-big.jpg") + ); +} + // ---- sessions tests ---- #[test] diff --git a/crates/wx-media/tests/dat-decrypt.rs b/crates/wx-media/tests/dat-decrypt.rs index 09e2945..1bf907b 100644 --- a/crates/wx-media/tests/dat-decrypt.rs +++ b/crates/wx-media/tests/dat-decrypt.rs @@ -15,21 +15,21 @@ fn make_xor_dat(key: u8) -> Vec { /// Header: 07 08 V1 08 07 (6B) + aes_size LE (4B) + xor_size LE (4B) + 0x01 (1B) = 15B /// Then AES-ECB encrypted payload with fixed key, then raw, then XOR tail. fn make_v1_dat() -> Vec { - use aes::cipher::{BlockCipherEncrypt, KeyInit}; + use aes::cipher::{Array, BlockCipherEncrypt, KeyInit}; use aes::Aes128; let key = b"cfcd208495d565ef"; // md5("0")[:16] let cipher = Aes128::new(key.into()); // Plaintext: JPEG header (16 bytes = 1 AES block) with PKCS7 padding - let mut block1 = [ + let mut block1 = Array::from([ 0xFFu8, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, - ]; - let mut block2 = [16u8; 16]; // full PKCS7 padding block + ]); + let mut block2 = Array::from([16u8; 16]); // full PKCS7 padding block - cipher.encrypt_block(aes::cipher::Array::from_mut_slice(&mut block1)); - cipher.encrypt_block(aes::cipher::Array::from_mut_slice(&mut block2)); + cipher.encrypt_block(&mut block1); + cipher.encrypt_block(&mut block2); let aes_size: u32 = 16; // original plaintext size let xor_size: u32 = 0; @@ -46,20 +46,20 @@ fn make_v1_dat() -> Vec { /// Build a V2-encrypted `.dat` file with known AES key and XOR tail. fn make_v2_dat(aes_key: &[u8; 16], xor_key: u8) -> Vec { - use aes::cipher::{BlockCipherEncrypt, KeyInit}; + use aes::cipher::{Array, BlockCipherEncrypt, KeyInit}; use aes::Aes128; let cipher = Aes128::new(aes_key.into()); // Plaintext: PNG header (16 bytes = 1 block) - let mut block1 = [ + let mut block1 = Array::from([ 0x89u8, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, - ]; - let mut block2 = [16u8; 16]; // PKCS7 padding block + ]); + let mut block2 = Array::from([16u8; 16]); // PKCS7 padding block - cipher.encrypt_block(aes::cipher::Array::from_mut_slice(&mut block1)); - cipher.encrypt_block(aes::cipher::Array::from_mut_slice(&mut block2)); + cipher.encrypt_block(&mut block1); + cipher.encrypt_block(&mut block2); let aes_size: u32 = 16; // Tail: 4 bytes XOR-encrypted diff --git a/crates/wx-paths/src/lib.rs b/crates/wx-paths/src/lib.rs index 450c6a2..ed67927 100644 --- a/crates/wx-paths/src/lib.rs +++ b/crates/wx-paths/src/lib.rs @@ -369,21 +369,21 @@ mod tests { #[test] fn server_lock_file_under_server_state() { let ap = AppPaths::new().unwrap(); - assert!(ap.server_lock_file().starts_with(&ap.server_state_dir())); + assert!(ap.server_lock_file().starts_with(ap.server_state_dir())); assert!(ap.server_lock_file().ends_with("manager.lock")); } #[test] fn server_config_file_under_server_state() { let ap = AppPaths::new().unwrap(); - assert!(ap.server_config_file().starts_with(&ap.server_state_dir())); + assert!(ap.server_config_file().starts_with(ap.server_state_dir())); assert!(ap.server_config_file().ends_with("config.json")); } #[test] fn server_state_file_under_server_state() { let ap = AppPaths::new().unwrap(); - assert!(ap.server_state_file().starts_with(&ap.server_state_dir())); + assert!(ap.server_state_file().starts_with(ap.server_state_dir())); assert!(ap.server_state_file().ends_with("state.json")); }