48 changed files with 866 additions and 382 deletions
+21
View File
@@ -2,6 +2,27 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [0.7.3] - 2026-07-10
### Features
- **Agent-ready WeChat data layer** — Ship an Agent Skill for Claude Code, Codex, Cursor, and other compatible agents to query and subscribe to local WeChat data
- **Cross-conversation timeline** — Add `GET /api/v1/timeline` for time-bounded message reads across all conversations in one request, with pagination, ordering, message-type filters, and privacy filtering
- **Compact timeline output** — Return the conversation identity, sender, direction, timestamp, type, and snippet needed by memory, archive, and reporting agents without duplicating raw message payloads
### Performance
- **Reuse SQLCipher derived keys** — Cache per-database derived keys and reuse them across open, count, refresh, and reopen paths instead of repeating the 256k-round KDF
- **Bound timeline memory** — Keep only the candidates required for the requested page rather than retaining the complete cross-conversation history during sorting
- **Faster long-running server queries** — Reuse warm database connections for batch timeline reads, avoiding one CLI process and HTTP round trip per conversation
### Documentation and maintenance
- Rewrite the README around user-facing capabilities: local database access, real-time subscriptions, Agent integration, automation, memory, CRM, and workflow use cases
- Document Release installation and the bundled Agent Skill
- Update project dependencies and GitHub Actions
- Restore clean `cargo fmt --check`, `cargo clippy -- -D warnings`, and full-workspace test baselines
## [0.7.2] - 2026-04-06 ## [0.7.2] - 2026-04-06
### Features ### Features
Generated
+8 -8
View File
@@ -3132,7 +3132,7 @@ checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]] [[package]]
name = "wx-cli" name = "wx-cli"
version = "0.7.2" version = "0.7.3"
dependencies = [ dependencies = [
"axum", "axum",
"chrono", "chrono",
@@ -3168,7 +3168,7 @@ dependencies = [
[[package]] [[package]]
name = "wx-context" name = "wx-context"
version = "0.7.2" version = "0.7.3"
dependencies = [ dependencies = [
"aes", "aes",
"cbc", "cbc",
@@ -3192,7 +3192,7 @@ dependencies = [
[[package]] [[package]]
name = "wx-db" name = "wx-db"
version = "0.7.2" version = "0.7.3"
dependencies = [ dependencies = [
"hex", "hex",
"insta", "insta",
@@ -3209,7 +3209,7 @@ dependencies = [
[[package]] [[package]]
name = "wx-decrypt" name = "wx-decrypt"
version = "0.7.2" version = "0.7.3"
dependencies = [ dependencies = [
"aes", "aes",
"cbc", "cbc",
@@ -3222,7 +3222,7 @@ dependencies = [
[[package]] [[package]]
name = "wx-keychain" name = "wx-keychain"
version = "0.7.2" version = "0.7.3"
dependencies = [ dependencies = [
"aes", "aes",
"cbc", "cbc",
@@ -3244,7 +3244,7 @@ dependencies = [
[[package]] [[package]]
name = "wx-media" name = "wx-media"
version = "0.7.2" version = "0.7.3"
dependencies = [ dependencies = [
"aes", "aes",
"base64", "base64",
@@ -3262,7 +3262,7 @@ dependencies = [
[[package]] [[package]]
name = "wx-monitor" name = "wx-monitor"
version = "0.7.2" version = "0.7.3"
dependencies = [ dependencies = [
"aes", "aes",
"cbc", "cbc",
@@ -3283,7 +3283,7 @@ dependencies = [
[[package]] [[package]]
name = "wx-paths" name = "wx-paths"
version = "0.7.2" version = "0.7.3"
dependencies = [ dependencies = [
"dirs", "dirs",
"libc", "libc",
+1 -1
View File
@@ -3,7 +3,7 @@ members = ["crates/wx-decrypt", "crates/wx-keychain", "crates/wx-cli", "crates/w
resolver = "2" resolver = "2"
[workspace.package] [workspace.package]
version = "0.7.2" version = "0.7.3"
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"
repository = "https://github.com/pandorafuture/wx-cli" repository = "https://github.com/pandorafuture/wx-cli"
+39 -5
View File
@@ -1,6 +1,39 @@
# wx-cli # wx-cli
WeChat macOS 数据库解密与查询工具。支持通过 `key extract`(LLDB hook)提取密钥,解密并查询 WeChat 4.1.7.x / 4.1.8.x 的 Apple SEE 加密 SQLite 数据 > 把微信变成 Agent 能读取、能搜索、能实时订阅的数据
wx-cli 直接读取 Mac 上的微信本地数据,让你和 Agent 都能访问自己的聊天记录、联系人、群聊和媒体消息。数据默认留在本机,不需要上传聊天数据库,也不依赖云端导出。
## 它能做什么
- **读取微信本地数据库**:按联系人、群聊、时间范围和消息类型查询历史消息。
- **搜索全部聊天记录**:从所有会话中查关键词,快速找回客户需求、承诺、文件和讨论结论。
- **一次读取跨会话时间线**:按时间范围取得所有会话的新消息,适合记忆补全、归档和日报任务。
- **实时订阅新消息**:用命令行持续监听,或通过 SSE 把新消息实时推给 Agent 和其他程序。
- **导出与处理内容**:把会话导出为 JSON 或文本,并读取图片、语音、视频等媒体内容。
- **让 Agent 直接使用**:项目自带 Agent SkillClaude Code、Codex、Cursor 等工具安装后就知道怎样查询和订阅微信。
- **提供稳定的本地服务**:REST API 可供个人助理、自动化任务、工作流和多个 Agent 共同使用。
- **保护不想暴露的内容**:可隐藏指定联系人、群聊、标签或群成员,查询和订阅时自动过滤。
## 你可以基于它在微信上做什么
wx-cli 提供了最关键的两样东西:完整的历史上下文,以及持续发生的实时消息。把它接给 Agent 后,你可以基于这个项目在微信上做任何事,例如:
- 给 Agent 建立长期微信记忆,自动维护联系人画像和关系上下文;
- 从聊天里识别待办、承诺、商机、风险和需要跟进的人;
- 做个人或团队的微信搜索、知识库、CRM、客服和销售助手;
- 自动生成日报、周报、客户纪要、对账线索和项目进展;
- 监听关键词或关键联系人,在重要消息出现时触发提醒和工作流;
- 结合你已有的 Agent 操作或消息发送能力,实现自动回复、业务办理和端到端协作。
它不是只用来“导出聊天记录”的工具,而是微信之上的 Agent 能力层。
## 为什么对 Agent 友好
- 自带可直接安装的 Skill,不需要每次重新教 Agent 命令和数据格式;
- 命令行、JSON、REST API 和实时事件订阅覆盖查询与持续运行两类任务;
- 长驻服务可复用已打开的数据库,适合高频查询和定时记忆任务;
- 所有能力都以本地数据为中心,便于控制隐私边界。
## 支持范围 ## 支持范围
@@ -60,10 +93,9 @@ source ~/.zshrc
wx-cli --version wx-cli --version
``` ```
### 让 Agent 直接使用
### AI 编程助手集成 本项目提供 [Agent Skill](https://skills.sh)。安装后,Claude Code、Codex、Cursor 等 Agent 可以直接理解 wx-cli 的能力,并帮你读取历史消息、搜索聊天和订阅新消息:
本项目提供 [Agent Skill](https://skills.sh),安装后 Claude Code、Codex、Cursor 等 AI 编程助手可直接协助查询微信数据:
```bash ```bash
npx skills add pandorafuture/wx-cli npx skills add pandorafuture/wx-cli
@@ -141,7 +173,9 @@ wx-cli server stop # 停止
wx-cli server restart # 重启 wx-cli server restart # 重启
``` ```
REST 端点:`/api/v1/health``/api/v1/sessions``/api/v1/contacts``/api/v1/messages``/api/v1/search``/api/v1/media``/api/v1/events`SSE)。 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/timeline?since=<unix>&until=<unix>` 可在一次请求中读取时间范围内所有会话的消息,适合 Agent 记忆补全、归档和批处理,避免逐会话反复调用。
所有查询命令加 `--format json` 可获取 JSON 格式输出。 所有查询命令加 `--format json` 可获取 JSON 格式输出。
+2
View File
@@ -258,6 +258,7 @@ wx-cli server status / stop / restart # 管理服务
| `GET /api/v1/sessions` | 会话列表 | `limit`, `offset`, `order`, `show_hidden` | | `GET /api/v1/sessions` | 会话列表 | `limit`, `offset`, `order`, `show_hidden` |
| `GET /api/v1/contacts` | 联系人列表 | `limit`, `offset`, `search`, `show_hidden` | | `GET /api/v1/contacts` | 联系人列表 | `limit`, `offset`, `search`, `show_hidden` |
| `GET /api/v1/messages` | 消息查询 | `contact`(必填), `limit`, `offset`, `since`, `until`, `type`, `order`, `around_sort_seq`, `around_server_id`, `after_sort_seq`, `context`, `show_hidden` | | `GET /api/v1/messages` | 消息查询 | `contact`(必填), `limit`, `offset`, `since`, `until`, `type`, `order`, `around_sort_seq`, `around_server_id`, `after_sort_seq`, `context`, `show_hidden` |
| `GET /api/v1/timeline` | 跨全部会话按时间批量读取消息 | `since`(必填), `until`(必填), `limit`, `offset`, `type`, `order`, `show_hidden` |
| `GET /api/v1/media` | 媒体内容直出 | `server_id`(必填), `talker`(必填), `format=ogg\|mp3`(仅语音) | | `GET /api/v1/media` | 媒体内容直出 | `server_id`(必填), `talker`(必填), `format=ogg\|mp3`(仅语音) |
| `GET /api/v1/search` | 全文搜索 | `q`(必填), `limit`, `offset` | | `GET /api/v1/search` | 全文搜索 | `q`(必填), `limit`, `offset` |
| `GET /api/v1/events` | SSE 事件流 | 无 | | `GET /api/v1/events` | SSE 事件流 | 无 |
@@ -313,6 +314,7 @@ wx-cli sessions # 4. 重试查询
|------|-------------| |------|-------------|
| sessions | `username`, `display_name`, `summary`, `sort_timestamp`, `direction?` | | sessions | `username`, `display_name`, `summary`, `sort_timestamp`, `direction?` |
| query | `sort_seq`, `server_id`, `msg_type`, `sender`, `content`, `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`, `phone`, `labels` |
| search | `server_id`, `talker`, `sender`, `snippet`, `hit_type` | | search | `server_id`, `talker`, `sender`, `snippet`, `hit_type` |
+3 -1
View File
@@ -6,7 +6,9 @@ use wx_db::Contact;
use super::thin_client::{ThinClientCliArgs, ThinClientOptions}; use super::thin_client::{ThinClientCliArgs, ThinClientOptions};
use crate::output::JsonEnvelope; use crate::output::JsonEnvelope;
use crate::settings::Settings; use crate::settings::Settings;
use crate::util::{effective_limit_all, open_db_core, print_cache_stats, print_detection_note, try_remote_or_local}; use crate::util::{
effective_limit_all, open_db_core, print_cache_stats, print_detection_note, try_remote_or_local,
};
use crate::visibility_projection::project_contacts_envelope; use crate::visibility_projection::project_contacts_envelope;
use crate::OutputFormat; use crate::OutputFormat;
+5 -7
View File
@@ -74,8 +74,7 @@ pub fn cmd_decrypt(
KeyMaterial::EncKey { key, salt } => { KeyMaterial::EncKey { key, salt } => {
wx_decrypt::decrypt_db_direct(db_path, &out_path, key, salt, params) wx_decrypt::decrypt_db_direct(db_path, &out_path, key, salt, params)
} }
KeyMaterial::EncKeys(pairs) => { KeyMaterial::EncKeys(pairs) => match wx_decrypt::read_main_db_salt_for_path(db_path) {
match wx_decrypt::read_main_db_salt_for_path(db_path) {
Ok(db_salt) => match pairs.iter().find(|p| p.salt == db_salt) { Ok(db_salt) => match pairs.iter().find(|p| p.salt == db_salt) {
Some(pair) => wx_decrypt::decrypt_db_direct( Some(pair) => wx_decrypt::decrypt_db_direct(
db_path, &out_path, &pair.key, &pair.salt, params, db_path, &out_path, &pair.key, &pair.salt, params,
@@ -83,8 +82,7 @@ pub fn cmd_decrypt(
None => Err(wx_decrypt::DecryptError::NoMatchingEncKey), None => Err(wx_decrypt::DecryptError::NoMatchingEncKey),
}, },
Err(e) => Err(e), Err(e) => Err(e),
} },
}
}; };
match db_result { match db_result {
@@ -98,9 +96,9 @@ pub fn cmd_decrypt(
KeyMaterial::RawKey(key) => { KeyMaterial::RawKey(key) => {
wx_decrypt::decrypt_wal(&wal_path, &out_path, key, params) wx_decrypt::decrypt_wal(&wal_path, &out_path, key, params)
} }
KeyMaterial::EncKey { key, salt } => wx_decrypt::decrypt_wal_direct( KeyMaterial::EncKey { key, salt } => {
&wal_path, &out_path, key, salt, params, wx_decrypt::decrypt_wal_direct(&wal_path, &out_path, key, salt, params)
), }
KeyMaterial::EncKeys(pairs) => { KeyMaterial::EncKeys(pairs) => {
match wx_decrypt::read_main_db_salt_for_path(&wal_path) { match wx_decrypt::read_main_db_salt_for_path(&wal_path) {
Ok(db_salt) => match pairs.iter().find(|p| p.salt == db_salt) { Ok(db_salt) => match pairs.iter().find(|p| p.salt == db_salt) {
+5 -4
View File
@@ -7,9 +7,9 @@ use wx_context::{
}; };
use wx_db::{is_group_chat, MessageContent, MessageQuery, SortOrder, MAX_QUERY_LIMIT}; use wx_db::{is_group_chat, MessageContent, MessageQuery, SortOrder, MAX_QUERY_LIMIT};
use crate::cmd::contacts::build_visibility;
use crate::cmd::export_media::{MediaKind, MediaStats}; use crate::cmd::export_media::{MediaKind, MediaStats};
use crate::cmd::query::resolve_talker; use crate::cmd::query::resolve_talker;
use crate::cmd::contacts::build_visibility;
use crate::output::{JsonEnvelope, PagingMeta, StatsMeta}; use crate::output::{JsonEnvelope, PagingMeta, StatsMeta};
use crate::schema::{enrich_message, project_message_items, EnrichedMessage}; use crate::schema::{enrich_message, project_message_items, EnrichedMessage};
use crate::util::{ use crate::util::{
@@ -247,10 +247,9 @@ pub fn cmd_export(
} }
// Resolve media via parallel pipeline (or skip) // Resolve media via parallel pipeline (or skip)
let (media_map, media_stats, _media_errors) = if no_media || cache.is_none() { let (media_map, media_stats, _media_errors) = if no_media {
(vec![vec![]; projected.len()], MediaStats::default(), None) (vec![vec![]; projected.len()], MediaStats::default(), None)
} else { } else if let Some(c) = cache.as_ref() {
let c = cache.as_ref().unwrap();
let attach_dir = acct.data_dir.join("msg").join("attach"); let attach_dir = acct.data_dir.join("msg").join("attach");
let decrypted_media = c.decrypted_root().join("message"); let decrypted_media = c.decrypted_root().join("message");
let hardlink_db = c.decrypted_root().join("hardlink").join("hardlink.db"); let hardlink_db = c.decrypted_root().join("hardlink").join("hardlink.db");
@@ -294,6 +293,8 @@ pub fn cmd_export(
}; };
combined.print_report(); combined.print_report();
(media_map, stats, Some(combined)) (media_map, stats, Some(combined))
} else {
(vec![vec![]; projected.len()], MediaStats::default(), None)
}; };
let total_media: usize = media_map.iter().map(Vec::len).sum(); let total_media: usize = media_map.iter().map(Vec::len).sum();
+4 -1
View File
@@ -421,7 +421,10 @@ impl MediaBridge {
} }
} }
pub fn export_image_bytes(decoded_data: Vec<u8>, decoded_ext: &str) -> (Vec<u8>, String, bool, bool) { pub fn export_image_bytes(
decoded_data: Vec<u8>,
decoded_ext: &str,
) -> (Vec<u8>, String, bool, bool) {
if decoded_ext != "wxgf" { if decoded_ext != "wxgf" {
return (decoded_data, decoded_ext.to_string(), false, false); return (decoded_data, decoded_ext.to_string(), false, false);
} }
+46 -42
View File
@@ -197,10 +197,9 @@ impl VoiceConnectionPool {
fn open_all(&self) -> Vec<Connection> { fn open_all(&self) -> Vec<Connection> {
let mut conns = Vec::new(); let mut conns = Vec::new();
for path in &self.db_paths { for path in &self.db_paths {
if let Ok(conn) = Connection::open_with_flags( if let Ok(conn) =
path, Connection::open_with_flags(path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY, {
) {
conns.push(conn); conns.push(conn);
} }
} }
@@ -209,7 +208,7 @@ impl VoiceConnectionPool {
pub fn with_connections<R>(&self, f: impl FnOnce(&[Connection]) -> R) -> R { pub fn with_connections<R>(&self, f: impl FnOnce(&[Connection]) -> R) -> R {
thread_local! { thread_local! {
static CONNS: RefCell<Option<(u64, Vec<Connection>)>> = RefCell::new(None); static CONNS: RefCell<Option<(u64, Vec<Connection>)>> = const { RefCell::new(None) };
} }
CONNS.with(|cell| { CONNS.with(|cell| {
let mut borrow = cell.borrow_mut(); let mut borrow = cell.borrow_mut();
@@ -242,16 +241,12 @@ impl HardlinkConnectionPool {
} }
fn open(&self) -> Option<Connection> { fn open(&self) -> Option<Connection> {
Connection::open_with_flags( Connection::open_with_flags(&self.db_path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY).ok()
&self.db_path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
)
.ok()
} }
pub fn with_connection<R>(&self, f: impl FnOnce(&Connection) -> R) -> Option<R> { pub fn with_connection<R>(&self, f: impl FnOnce(&Connection) -> R) -> Option<R> {
thread_local! { thread_local! {
static CONN: RefCell<Option<(u64, Connection)>> = RefCell::new(None); static CONN: RefCell<Option<(u64, Connection)>> = const { RefCell::new(None) };
} }
CONN.with(|cell| { CONN.with(|cell| {
let mut borrow = cell.borrow_mut(); let mut borrow = cell.borrow_mut();
@@ -299,6 +294,7 @@ pub struct DupMap {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Build shared context from account/session info (pre-compute stage). /// Build shared context from account/session info (pre-compute stage).
#[allow(clippy::too_many_arguments)]
pub fn build_shared_context( pub fn build_shared_context(
attach_dir: PathBuf, attach_dir: PathBuf,
media_dir: PathBuf, media_dir: PathBuf,
@@ -432,12 +428,7 @@ pub fn dedup(tasks: Vec<MediaTask>) -> (Vec<MediaTask>, DupMap) {
unique.push(task); unique.push(task);
} }
( (unique, DupMap { duplicates })
unique,
DupMap {
duplicates,
},
)
} }
/// Default rayon thread pool size: min(num_cpus, 4). /// Default rayon thread pool size: min(num_cpus, 4).
@@ -469,7 +460,12 @@ pub fn resolve_parallel(
batches.entry(task.kind()).or_default().push(task); batches.entry(task.kind()).or_default().push(task);
} }
let order = [TaskKind::Image, TaskKind::Voice, TaskKind::Video, TaskKind::File]; let order = [
TaskKind::Image,
TaskKind::Voice,
TaskKind::Video,
TaskKind::File,
];
let mut all_results = Vec::new(); let mut all_results = Vec::new();
let mut all_errors = ErrorSummary::default(); let mut all_errors = ErrorSummary::default();
@@ -531,7 +527,10 @@ pub fn resolve_parallel(
fn resolve_one(task: &MediaTask, ctx: &SharedContext) -> ResolvedAsset { fn resolve_one(task: &MediaTask, ctx: &SharedContext) -> ResolvedAsset {
match task { match task {
MediaTask::Image { md5, msg_index } => resolve_image(md5, *msg_index, ctx), MediaTask::Image { md5, msg_index } => resolve_image(md5, *msg_index, ctx),
MediaTask::Voice { server_id, msg_index } => resolve_voice(*server_id, *msg_index, ctx), MediaTask::Voice {
server_id,
msg_index,
} => resolve_voice(*server_id, *msg_index, ctx),
MediaTask::Video { MediaTask::Video {
md5, md5,
create_time, create_time,
@@ -668,7 +667,8 @@ fn resolve_voice(server_id: i64, msg_index: usize, ctx: &SharedContext) -> Resol
let blob = ctx.voice_pool.with_connections(|conns| { let blob = ctx.voice_pool.with_connections(|conns| {
for conn in conns { for conn in conns {
if let Ok(b) = wx_media::extract_voice_with_conn_hint(conn, &svr_id, chat_name_id_hint) { if let Ok(b) = wx_media::extract_voice_with_conn_hint(conn, &svr_id, chat_name_id_hint)
{
return Some(b); return Some(b);
} }
} }
@@ -750,9 +750,9 @@ fn resolve_video(
ctx: &SharedContext, ctx: &SharedContext,
) -> ResolvedAsset { ) -> ResolvedAsset {
// Try hardlink DB first // Try hardlink DB first
let hardlink_result = ctx.hardlink_pool.with_connection(|conn| { let hardlink_result = ctx
wx_media::query_hardlink_with_conn(conn, "video", md5) .hardlink_pool
}); .with_connection(|conn| wx_media::query_hardlink_with_conn(conn, "video", md5));
let entries = match hardlink_result { let entries = match hardlink_result {
Some(Ok(e)) => Some(e), Some(Ok(e)) => Some(e),
@@ -860,9 +860,9 @@ fn resolve_file(
ctx: &SharedContext, ctx: &SharedContext,
) -> ResolvedAsset { ) -> ResolvedAsset {
// Try hardlink DB first // Try hardlink DB first
let hardlink_result = ctx.hardlink_pool.with_connection(|conn| { let hardlink_result = ctx
wx_media::query_hardlink_with_conn(conn, "file", md5) .hardlink_pool
}); .with_connection(|conn| wx_media::query_hardlink_with_conn(conn, "file", md5));
let entries = match hardlink_result { let entries = match hardlink_result {
Some(Ok(e)) => Some(e), Some(Ok(e)) => Some(e),
@@ -889,7 +889,7 @@ fn resolve_file(
let filename = format!("{}_{}", md5, entry.file_name); let filename = format!("{}_{}", md5, entry.file_name);
if ctx.write_gate.claim(&filename) { if ctx.write_gate.claim(&filename) {
let out_path = ctx.output_media_dir.join(&filename); let out_path = ctx.output_media_dir.join(&filename);
if let Err(e) = std::fs::copy(&source, &out_path) { if let Err(e) = std::fs::copy(source, &out_path) {
return ResolvedAsset { return ResolvedAsset {
msg_index, msg_index,
asset: None, asset: None,
@@ -973,23 +973,19 @@ pub fn collect(
let mut errors = ErrorSummary::default(); let mut errors = ErrorSummary::default();
// Build index from results by msg_index // Build index from results by msg_index
let mut by_index: HashMap<usize, (Option<MediaAsset>, Vec<TaskTag>, Option<ExportError>)> = let mut by_index: HashMap<usize, (Option<MediaAsset>, Vec<TaskTag>)> = HashMap::new();
HashMap::new();
for r in results { for r in results {
if let Some(e) = r.error { if let Some(e) = r.error {
errors.errors.push(e); errors.errors.push(e);
} }
by_index.insert( by_index.insert(r.msg_index, (r.asset, r.tags));
r.msg_index,
(r.asset, r.tags, None),
);
} }
// Place canonical results — count tags always, copy asset only when present. // Place canonical results — count tags always, copy asset only when present.
// Matches old MediaBridge: SkippedVideo/SkippedFile stats counted unconditionally; // Matches old MediaBridge: SkippedVideo/SkippedFile stats counted unconditionally;
// image stats (ThumbnailImage, WxgfTranscoded, WxgfFallback) also counted // image stats (ThumbnailImage, WxgfTranscoded, WxgfFallback) also counted
// because canonical always does the full resolve. // because canonical always does the full resolve.
for (msg_idx, (asset, tags, _)) in &by_index { for (msg_idx, (asset, tags)) in &by_index {
apply_tags(&mut stats, tags); apply_tags(&mut stats, tags);
if let Some(a) = asset { if let Some(a) = asset {
media_map[*msg_idx].push(a.clone()); media_map[*msg_idx].push(a.clone());
@@ -1003,8 +999,12 @@ pub fn collect(
// This two-step approach matches old MediaBridge behavior where skipped/fallback // This two-step approach matches old MediaBridge behavior where skipped/fallback
// stats were counted regardless of dedup, but image stats only counted once. // stats were counted regardless of dedup, but image stats only counted once.
for (dup_msg_idx, canonical_msg_idx) in &dup_map.duplicates { for (dup_msg_idx, canonical_msg_idx) in &dup_map.duplicates {
if let Some((asset, tags, _)) = by_index.get(canonical_msg_idx) { if let Some((asset, tags)) = by_index.get(canonical_msg_idx) {
let dup_tags: Vec<TaskTag> = tags.iter().copied().filter(|t| t.counts_on_duplicate()).collect(); let dup_tags: Vec<TaskTag> = tags
.iter()
.copied()
.filter(|t| t.counts_on_duplicate())
.collect();
apply_tags(&mut stats, &dup_tags); apply_tags(&mut stats, &dup_tags);
if let Some(a) = asset { if let Some(a) = asset {
media_map[*dup_msg_idx].push(a.clone()); media_map[*dup_msg_idx].push(a.clone());
@@ -1416,7 +1416,10 @@ mod tests {
// Second task: Voice // Second task: Voice
assert!(matches!( assert!(matches!(
&tasks[1], &tasks[1],
MediaTask::Voice { server_id: 2, msg_index: 1 } MediaTask::Voice {
server_id: 2,
msg_index: 1
}
)); ));
} }
@@ -1446,7 +1449,11 @@ mod tests {
let encrypted: Vec<u8> = wxgf.iter().map(|b| b ^ xor_key).collect(); let encrypted: Vec<u8> = wxgf.iter().map(|b| b ^ xor_key).collect();
let username_hash = format!("{:x}", wx_media::md5_hash(talker.as_bytes())); let username_hash = format!("{:x}", wx_media::md5_hash(talker.as_bytes()));
let img_dir = root.join("attach").join(&username_hash).join("2026-03").join("Img"); let img_dir = root
.join("attach")
.join(&username_hash)
.join("2026-03")
.join("Img");
std::fs::create_dir_all(&img_dir).unwrap(); std::fs::create_dir_all(&img_dir).unwrap();
std::fs::write(img_dir.join(format!("{md5}.dat")), &encrypted).unwrap(); std::fs::write(img_dir.join(format!("{md5}.dat")), &encrypted).unwrap();
@@ -1572,10 +1579,7 @@ mod tests {
let silk = sample_silk(); let silk = sample_silk();
create_voice_media_db( create_voice_media_db(
&media_dir.join("media_0.db"), &media_dir.join("media_0.db"),
&[ &[(55, 1000, 1, 101, &silk), (55, 1001, 2, 102, &silk)],
(55, 1000, 1, 101, &silk),
(55, 1001, 2, 102, &silk),
],
); );
let ctx = Arc::new(build_shared_context( let ctx = Arc::new(build_shared_context(
+5 -15
View File
@@ -64,10 +64,7 @@ pub async fn cmd_key_extract(timeout_secs: u64) -> Result<(), Box<dyn std::error
Some(matched.base_wxid.clone()), Some(matched.base_wxid.clone()),
); );
store.save_default()?; store.save_default()?;
eprintln!( eprintln!("Key saved to {:?}", wx_keychain::KeyStore::default_path()?);
"Key saved to {:?}",
wx_keychain::KeyStore::default_path()?
);
Ok(()) Ok(())
} }
@@ -102,8 +99,7 @@ pub fn cmd_key_scan() -> Result<(), Box<dyn std::error::Error>> {
// Scan process memory. // Scan process memory.
eprintln!("Scanning WeChat process memory..."); eprintln!("Scanning WeChat process memory...");
let results = let results = wx_keychain::capture_key_mach(pid, &accounts, &wx_decrypt::MACOS_4_1_7_31)?;
wx_keychain::capture_key_mach(pid, &accounts, &wx_decrypt::MACOS_4_1_7_31)?;
// Count total pairs across all results // Count total pairs across all results
let total_pairs: usize = results let total_pairs: usize = results
@@ -126,11 +122,8 @@ pub fn cmd_key_scan() -> Result<(), Box<dyn std::error::Error>> {
for r in &results { for r in &results {
let matched = &r.matched_account; let matched = &r.matched_account;
let nickname = wx_keychain::resolve_nickname( let nickname =
&matched.data_dir, wx_keychain::resolve_nickname(&matched.data_dir, &r.key_material, &matched.base_wxid)
&r.key_material,
&matched.base_wxid,
)
.unwrap_or_else(|e| { .unwrap_or_else(|e| {
eprintln!( eprintln!(
" Warning: nickname resolution failed for {}: {e}", " Warning: nickname resolution failed for {}: {e}",
@@ -173,10 +166,7 @@ pub fn cmd_key_scan() -> Result<(), Box<dyn std::error::Error>> {
} }
store.save_default()?; store.save_default()?;
eprintln!( eprintln!("Keys saved to {:?}", wx_keychain::KeyStore::default_path()?);
"Keys saved to {:?}",
wx_keychain::KeyStore::default_path()?
);
Ok(()) Ok(())
} }
+7 -1
View File
@@ -39,7 +39,13 @@ fn print_paths_table(summary: &PathsSummary) {
} else { } else {
"[missing]" "[missing]"
}; };
println!("{:<width$} {:<60} {}", label, display, status, width = max_label); println!(
"{:<width$} {:<60} {}",
label,
display,
status,
width = max_label
);
} }
} }
+1 -3
View File
@@ -298,9 +298,7 @@ fn load_local_query(
// When limit pushdown was used (non-anchor, non-all), total_rows only reflects the // When limit pushdown was used (non-anchor, non-all), total_rows only reflects the
// scanned window. Use a lightweight COUNT(*) query to get the actual DB-level total. // scanned window. Use a lightweight COUNT(*) query to get the actual DB-level total.
if !has_anchor && !all { if !has_anchor && !all {
let mt_filter = msg_type let mt_filter = msg_type.as_ref().and_then(|s| wx_db::parse_msg_type(s));
.as_ref()
.and_then(|s| wx_db::parse_msg_type(s));
let db_total = db.count_messages( let db_total = db.count_messages(
&talker, &talker,
since.unwrap_or(0), since.unwrap_or(0),
+5 -7
View File
@@ -5,7 +5,9 @@ use wx_context::{register_mm_fts_tokenizer, AccountContext, ContactResolver, Res
use super::thin_client::{ThinClient, ThinClientCliArgs, ThinClientOptions}; use super::thin_client::{ThinClient, ThinClientCliArgs, ThinClientOptions};
use crate::output::{JsonEnvelope, PagingMeta, StatsMeta}; use crate::output::{JsonEnvelope, PagingMeta, StatsMeta};
use crate::schema::{enrich_message_as_hit, enrich_native_fts_hit, SearchHit}; use crate::schema::{enrich_message_as_hit, enrich_native_fts_hit, SearchHit};
use crate::util::{effective_limit_all, open_db_all, print_cache_stats, print_detection_note, try_remote_or_local}; use crate::util::{
effective_limit_all, open_db_all, print_cache_stats, print_detection_note, try_remote_or_local,
};
use crate::OutputFormat; use crate::OutputFormat;
// Unused imports kept for Task 6 cleanup reference: // Unused imports kept for Task 6 cleanup reference:
@@ -67,12 +69,8 @@ fn load_local_search(
Ok(conn) Ok(conn)
}) { }) {
Ok(conn) => { Ok(conn) => {
match wx_db::native_fts::search_message_fts( match wx_db::native_fts::search_message_fts(&conn, keyword, effective_limit, offset)
&conn, {
keyword,
effective_limit,
offset,
) {
Ok(result) => { Ok(result) => {
return native_fts_envelope( return native_fts_envelope(
result, result,
+32 -22
View File
@@ -23,10 +23,7 @@ struct BridgeState {
startup_watermark: i64, startup_watermark: i64,
} }
fn should_broadcast_talker( fn should_broadcast_talker(visibility: &wx_context::VisibilityIndex, talker: &str) -> bool {
visibility: &wx_context::VisibilityIndex,
talker: &str,
) -> bool {
!visibility.is_hidden_talker(talker) !visibility.is_hidden_talker(talker)
} }
@@ -407,25 +404,40 @@ mod tests {
#[test] #[test]
fn enrich_messages_filters_hidden_sender_in_group() { fn enrich_messages_filters_hidden_sender_in_group() {
let visibility = VisibilityIndex::build( let visibility =
&["wxid_spam".to_string()], VisibilityIndex::build(&["wxid_spam".to_string()], &[], &ContactResolver::empty());
&[],
&ContactResolver::empty(),
);
let msgs = vec![ let msgs = vec![
wx_db::Message { wx_db::Message {
sort_seq: 1, server_id: 1, msg_type: 1, sub_type: 0, sort_seq: 1,
sender: "wxid_spam".to_string(), talker: "group@chatroom".to_string(), server_id: 1,
create_time: 100, content: wx_db::MessageContent::Text("spam".into()), status: 0, msg_type: 1,
sub_type: 0,
sender: "wxid_spam".to_string(),
talker: "group@chatroom".to_string(),
create_time: 100,
content: wx_db::MessageContent::Text("spam".into()),
status: 0,
}, },
wx_db::Message { wx_db::Message {
sort_seq: 2, server_id: 2, msg_type: 1, sub_type: 0, sort_seq: 2,
sender: "wxid_normal".to_string(), talker: "group@chatroom".to_string(), server_id: 2,
create_time: 101, content: wx_db::MessageContent::Text("hello".into()), status: 0, msg_type: 1,
sub_type: 0,
sender: "wxid_normal".to_string(),
talker: "group@chatroom".to_string(),
create_time: 101,
content: wx_db::MessageContent::Text("hello".into()),
status: 0,
}, },
]; ];
let result = enrich_messages(msgs, "wxid_me", &ContactResolver::empty(), "group@chatroom", &visibility); let result = enrich_messages(
msgs,
"wxid_me",
&ContactResolver::empty(),
"group@chatroom",
&visibility,
);
assert_eq!(result.len(), 1, "hidden sender message should be filtered"); assert_eq!(result.len(), 1, "hidden sender message should be filtered");
assert_eq!(result[0].message.sender, "wxid_normal"); assert_eq!(result[0].message.sender, "wxid_normal");
} }
@@ -433,11 +445,8 @@ mod tests {
#[test] #[test]
fn session_sender_redaction_in_bridge() { fn session_sender_redaction_in_bridge() {
use crate::schema::project_session_sender; use crate::schema::project_session_sender;
let visibility = VisibilityIndex::build( let visibility =
&["wxid_spam".to_string()], VisibilityIndex::build(&["wxid_spam".to_string()], &[], &ContactResolver::empty());
&[],
&ContactResolver::empty(),
);
let ev = wx_monitor::SessionEvent { let ev = wx_monitor::SessionEvent {
username: "group@chatroom".to_string(), username: "group@chatroom".to_string(),
sort_timestamp: 1, sort_timestamp: 1,
@@ -448,7 +457,8 @@ mod tests {
last_msg_sender: Some("wxid_spam".to_string()), last_msg_sender: Some("wxid_spam".to_string()),
last_sender_display_name: Some("Spammer".to_string()), last_sender_display_name: Some("Spammer".to_string()),
}; };
let mut enriched = crate::schema::enrich_session_event(ev, "wxid_me", &ContactResolver::empty()); let mut enriched =
crate::schema::enrich_session_event(ev, "wxid_me", &ContactResolver::empty());
project_session_sender(&mut enriched, &visibility); project_session_sender(&mut enriched, &visibility);
assert_eq!(enriched.session.summary, "[消息已隐藏]"); assert_eq!(enriched.session.summary, "[消息已隐藏]");
+288 -18
View File
@@ -8,20 +8,18 @@ use axum::http::StatusCode;
use axum::response::sse::{Event, KeepAlive, Sse}; use axum::response::sse::{Event, KeepAlive, Sse};
use axum::response::IntoResponse; use axum::response::IntoResponse;
use axum::Json; use axum::Json;
use serde::Deserialize; use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
use tokio_stream::wrappers::BroadcastStream; use tokio_stream::wrappers::BroadcastStream;
use tokio_stream::StreamExt; use tokio_stream::StreamExt;
use crate::cmd::server::types::{RuntimeAccountState, ServerHealthPayload}; use crate::cmd::server::types::{RuntimeAccountState, ServerHealthPayload};
use crate::output::JsonEnvelope; use crate::output::{JsonEnvelope, PagingMeta, StatsMeta};
use crate::schema::{ use crate::schema::{
enrich_message, enrich_message_as_hit, enrich_native_fts_hit, enrich_session, enrich_message, enrich_message_as_hit, enrich_native_fts_hit, enrich_session,
project_message_items, project_message_items,
}; };
use crate::visibility_projection::{ use crate::visibility_projection::{project_contacts_envelope, project_sessions_envelope_enriched};
project_contacts_envelope, project_sessions_envelope_enriched,
};
use super::error::ServeError; use super::error::ServeError;
use super::event::SseEvent; use super::event::SseEvent;
@@ -92,6 +90,68 @@ mod tests {
])) ]))
); );
} }
fn timeline_params(since: Option<i64>, until: Option<i64>) -> TimelineParams {
TimelineParams {
since,
until,
limit: 20,
offset: 0,
msg_type: None,
order: "asc".to_string(),
show_hidden: None,
}
}
#[test]
fn timeline_requires_a_bounded_time_range() {
assert!(timeline_bounds(&timeline_params(None, Some(20))).is_err());
assert!(timeline_bounds(&timeline_params(Some(10), None)).is_err());
assert!(timeline_bounds(&timeline_params(Some(20), Some(10))).is_err());
match timeline_bounds(&timeline_params(Some(10), Some(20))) {
Ok(bounds) => assert_eq!(bounds, (10, 20)),
Err(_) => panic!("valid timeline bounds should be accepted"),
}
}
fn timeline_message(create_time: i64) -> TimelineMessage {
TimelineMessage {
sort_seq: create_time,
server_id: create_time,
msg_type: 1,
sub_type: 0,
sender: "wxid_other".to_string(),
talker: "wxid_other".to_string(),
talker_display_name: "Other".to_string(),
create_time,
status: 0,
sender_display_name: "Other".to_string(),
direction: wx_context::Direction::detect("wxid_other", "wxid_me"),
snippet: create_time.to_string(),
}
}
#[test]
fn timeline_candidate_trimming_keeps_requested_edge() {
let source = [5, 1, 3, 2, 4]
.into_iter()
.map(timeline_message)
.collect::<Vec<_>>();
let mut asc = source.clone();
trim_timeline_candidates(&mut asc, wx_db::SortOrder::Asc, 2, false);
assert_eq!(
asc.iter().map(|item| item.create_time).collect::<Vec<_>>(),
vec![1, 2]
);
let mut desc = source;
trim_timeline_candidates(&mut desc, wx_db::SortOrder::Desc, 2, false);
assert_eq!(
desc.iter().map(|item| item.create_time).collect::<Vec<_>>(),
vec![5, 4]
);
}
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -221,8 +281,7 @@ pub async fn handler_contacts(
.query_contacts(&query) .query_contacts(&query)
.map_err(|e| ServeError::Db(e.to_string()))?; .map_err(|e| ServeError::Db(e.to_string()))?;
let envelope = let envelope = JsonEnvelope::from_query_result(result, wx_db::MAX_QUERY_LIMIT, 0, |c| c);
JsonEnvelope::from_query_result(result, wx_db::MAX_QUERY_LIMIT, 0, |c| c);
Ok::<_, ServeError>(project_contacts_envelope( Ok::<_, ServeError>(project_contacts_envelope(
envelope.items, envelope.items,
&visibility, &visibility,
@@ -379,9 +438,7 @@ pub async fn handler_messages(
// When limit pushdown was used (non-anchor), total_rows only reflects the // When limit pushdown was used (non-anchor), total_rows only reflects the
// scanned window. Use a lightweight COUNT(*) query for accurate DB-level total. // scanned window. Use a lightweight COUNT(*) query for accurate DB-level total.
if !has_anchor { if !has_anchor {
let mt_filter = msg_type let mt_filter = msg_type.as_ref().and_then(|s| wx_db::parse_msg_type(s));
.as_ref()
.and_then(|s| wx_db::parse_msg_type(s));
let db_total = guard.count_messages( let db_total = guard.count_messages(
&talker, &talker,
since.unwrap_or(0), since.unwrap_or(0),
@@ -405,6 +462,219 @@ pub async fn handler_messages(
Ok(Json(result)) Ok(Json(result))
} }
// ---------------------------------------------------------------------------
// Timeline — messages across every conversation in one bounded query
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
pub struct TimelineParams {
pub since: Option<i64>,
pub until: Option<i64>,
#[serde(default = "default_limit")]
pub limit: usize,
#[serde(default)]
pub offset: usize,
#[serde(rename = "type")]
pub msg_type: Option<String>,
#[serde(default = "default_order")]
pub order: String,
pub show_hidden: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
pub struct TimelineMessage {
pub sort_seq: i64,
pub server_id: i64,
pub msg_type: u32,
pub sub_type: u32,
pub sender: String,
pub talker: String,
pub talker_display_name: String,
pub create_time: i64,
pub status: i32,
pub sender_display_name: String,
pub direction: wx_context::Direction,
pub snippet: String,
}
impl TimelineMessage {
fn from_enriched(message: crate::schema::EnrichedMessage, talker_display_name: String) -> Self {
let crate::schema::EnrichedMessage {
message,
sender_display_name,
direction,
snippet,
} = message;
Self {
sort_seq: message.sort_seq,
server_id: message.server_id,
msg_type: message.msg_type,
sub_type: message.sub_type,
sender: message.sender,
talker: message.talker,
talker_display_name,
create_time: message.create_time,
status: message.status,
sender_display_name,
direction,
snippet,
}
}
}
fn sort_timeline_messages(messages: &mut [TimelineMessage], order: wx_db::SortOrder) {
match order {
wx_db::SortOrder::Asc => {
messages.sort_unstable_by_key(|item| (item.create_time, item.sort_seq, item.server_id))
}
wx_db::SortOrder::Desc => messages.sort_unstable_by(|a, b| {
(b.create_time, b.sort_seq, b.server_id).cmp(&(a.create_time, a.sort_seq, a.server_id))
}),
}
}
fn trim_timeline_candidates(
messages: &mut Vec<TimelineMessage>,
order: wx_db::SortOrder,
keep_limit: usize,
force: bool,
) {
if force || messages.len() > keep_limit.saturating_mul(2) {
sort_timeline_messages(messages, order);
messages.truncate(keep_limit);
}
}
fn timeline_bounds(params: &TimelineParams) -> Result<(i64, i64), ServeError> {
let since = params
.since
.ok_or_else(|| ServeError::InvalidParam("missing required parameter: since".into()))?;
let until = params
.until
.ok_or_else(|| ServeError::InvalidParam("missing required parameter: until".into()))?;
if since > until {
return Err(ServeError::InvalidParam(
"since must be less than or equal to until".into(),
));
}
Ok((since, until))
}
/// Read a time-bounded timeline across all conversations in one server request.
///
/// This is intentionally implemented inside the warm server process. Agent memory jobs and
/// archive tools no longer need to launch one CLI process and make one HTTP round-trip for every
/// active conversation.
pub async fn handler_timeline(
State(state): State<Arc<AppState>>,
Query(params): Query<TimelineParams>,
) -> Result<impl IntoResponse, ServeError> {
let (since, until) = timeline_bounds(&params)?;
let db = Arc::clone(&state.db);
let resolver = Arc::clone(&state.resolver);
let visibility = Arc::clone(&state.visibility);
let self_wxid = state.self_wxid.clone();
let limit = wx_db::effective_limit(params.limit);
let offset = params.offset;
let keep_limit = offset.saturating_add(limit);
let order = parse_order(&params.order);
let msg_type = params.msg_type.as_deref().and_then(wx_db::parse_msg_type);
let show_hidden = matches!(params.show_hidden.as_deref(), Some("1") | Some("true"));
let result = tokio::task::spawn_blocking(move || {
let guard = db.lock().map_err(|e| ServeError::Internal(e.to_string()))?;
let sessions = guard
.query_sessions(
&wx_db::SessionQuery::new()
.limit(wx_db::MAX_QUERY_LIMIT)
.offset(0),
)
.map_err(|e| ServeError::Db(e.to_string()))?;
let mut messages = Vec::new();
let mut total = 0usize;
let mut scanned = 0usize;
let mut skipped = sessions.stats.skipped;
let mut shard_warnings = Vec::new();
for session in sessions.items {
let talker = session.username;
if !show_hidden && visibility.is_hidden_talker(&talker) {
continue;
}
let talker_display_name = resolver.display_with_id(&talker);
let mut talker_offset = 0usize;
loop {
let mut query = wx_db::MessageQuery::for_talker(&talker)
.since(since)
.until(until)
.limit(wx_db::MAX_QUERY_LIMIT)
.offset(talker_offset)
.order(order);
if let Some(mt) = msg_type {
query = query.msg_type(mt);
}
let page = guard
.query_messages(&query)
.map_err(|e| ServeError::Db(e.to_string()))?;
let returned = page.items.len();
scanned = scanned.saturating_add(page.stats.total_rows);
skipped = skipped.saturating_add(page.stats.skipped);
shard_warnings.extend(page.shard_warnings);
let enriched = page
.items
.into_iter()
.map(|message| enrich_message(message, &self_wxid, &resolver))
.collect();
let projected = project_message_items(enriched, &talker, &visibility, show_hidden);
total = total.saturating_add(projected.len());
messages.extend(projected.into_iter().map(|message| {
TimelineMessage::from_enriched(message, talker_display_name.clone())
}));
// Retain only the global candidates needed for this page. The common first-page
// path now stays close to 2×limit instead of holding the entire history in memory.
trim_timeline_candidates(&mut messages, order, keep_limit, false);
if returned < wx_db::MAX_QUERY_LIMIT {
break;
}
talker_offset = talker_offset.saturating_add(returned);
}
}
trim_timeline_candidates(&mut messages, order, keep_limit, true);
let start = offset.min(total);
let items: Vec<_> = messages.into_iter().skip(start).take(limit).collect();
let returned = items.len();
Ok::<_, ServeError>(JsonEnvelope {
items,
paging: PagingMeta {
limit,
offset,
returned,
has_more: start + returned < total,
total,
},
stats: StatsMeta {
scanned,
skipped,
elapsed_ms: None,
shard_warnings,
},
})
})
.await
.map_err(|e| ServeError::Internal(e.to_string()))??;
Ok(Json(result))
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Search // Search
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -447,7 +717,9 @@ pub async fn handler_search(
.map_err(|e: std::sync::PoisonError<_>| ServeError::Internal(e.to_string()))?; .map_err(|e: std::sync::PoisonError<_>| ServeError::Internal(e.to_string()))?;
// Lazy-init name2id cache with proper error propagation. // Lazy-init name2id cache with proper error propagation.
let name2id = { let name2id = {
let mut cache_guard = state_arc.name2id_cache.lock() let mut cache_guard = state_arc
.name2id_cache
.lock()
.map_err(|e: std::sync::PoisonError<_>| ServeError::Internal(e.to_string()))?; .map_err(|e: std::sync::PoisonError<_>| ServeError::Internal(e.to_string()))?;
match cache_guard.as_ref() { match cache_guard.as_ref() {
Some(map) => map.clone(), Some(map) => map.clone(),
@@ -524,9 +796,7 @@ pub async fn handler_search(
let first_attempt = guard let first_attempt = guard
.pool() .pool()
.and_then(|pool| pool.fts_conn()) .and_then(|pool| pool.fts_conn())
.map(|fts_conn| { .map(|fts_conn| wx_db::native_fts::search_message_fts(fts_conn, &q, limit, offset));
wx_db::native_fts::search_message_fts(fts_conn, &q, limit, offset)
});
match first_attempt { match first_attempt {
Some(Ok(r)) => Some(r), Some(Ok(r)) => Some(r),
@@ -539,8 +809,8 @@ pub async fn handler_search(
guard guard
.pool() .pool()
.and_then(|pool| pool.fts_conn()) .and_then(|pool| pool.fts_conn())
.and_then( .and_then(|fts_conn| {
|fts_conn| match wx_db::native_fts::search_message_fts( match wx_db::native_fts::search_message_fts(
fts_conn, &q, limit, offset, fts_conn, &q, limit, offset,
) { ) {
Ok(r) => Some(r), Ok(r) => Some(r),
@@ -551,8 +821,8 @@ pub async fn handler_search(
); );
None None
} }
}, }
) })
} }
Err(reopen_err) => { Err(reopen_err) => {
eprintln!( eprintln!(
+6 -7
View File
@@ -8,13 +8,11 @@ use axum::http::HeaderValue;
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use tower::ServiceExt; use tower::ServiceExt;
use tower_http::services::ServeFile; use tower_http::services::ServeFile;
use wx_db::{ use wx_db::{open_readonly_connection, Message, MessageContent, MessageQuery, SortOrder, WechatDb};
open_readonly_connection, Message, MessageContent, MessageQuery, SortOrder, WechatDb,
};
use crate::util::{format_month, sanitize_filename};
use super::error::ServeError; use super::error::ServeError;
use super::state::{AppState, CachedVoicePayload}; use super::state::{AppState, CachedVoicePayload};
use crate::util::{format_month, sanitize_filename};
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum MediaFormat { pub enum MediaFormat {
@@ -147,8 +145,9 @@ async fn resolve_media(
))), ))),
MessageContent::Voice => { MessageContent::Voice => {
let db_paths = { let db_paths = {
let mut cache_guard = state_for_cache.media_db_paths.lock() let mut cache_guard = state_for_cache.media_db_paths.lock().map_err(
.map_err(|e: std::sync::PoisonError<_>| ServeError::Internal(e.to_string()))?; |e: std::sync::PoisonError<_>| ServeError::Internal(e.to_string()),
)?;
match cache_guard.as_ref() { match cache_guard.as_ref() {
Some(paths) => paths.clone(), Some(paths) => paths.clone(),
None => { None => {
@@ -297,7 +296,7 @@ fn resolve_voice(
let mut first_db_error: Option<String> = None; let mut first_db_error: Option<String> = None;
for db_path in db_paths { for db_path in db_paths {
let conn = match open_readonly_connection(&db_path, raw_key.as_ref()) { let conn = match open_readonly_connection(db_path, raw_key.as_ref()) {
Ok(conn) => conn, Ok(conn) => conn,
Err(err) => { Err(err) => {
if first_db_error.is_none() { if first_db_error.is_none() {
+7 -2
View File
@@ -280,8 +280,12 @@ pub async fn cmd_serve(
hardlink_db_conn, hardlink_db_conn,
raw_key: acct.raw_key, raw_key: acct.raw_key,
dat_decrypt, dat_decrypt,
voice_cache: Arc::new(std::sync::Mutex::new(LruCache::new(NonZeroUsize::new(256).unwrap()))), voice_cache: Arc::new(std::sync::Mutex::new(LruCache::new(
image_xor_cache: Arc::new(std::sync::Mutex::new(LruCache::new(NonZeroUsize::new(1024).unwrap()))), NonZeroUsize::new(256).unwrap(),
))),
image_xor_cache: Arc::new(std::sync::Mutex::new(LruCache::new(
NonZeroUsize::new(1024).unwrap(),
))),
name2id_cache: Arc::new(std::sync::Mutex::new(None)), name2id_cache: Arc::new(std::sync::Mutex::new(None)),
media_db_paths: Arc::new(std::sync::Mutex::new(None)), media_db_paths: Arc::new(std::sync::Mutex::new(None)),
}); });
@@ -313,6 +317,7 @@ pub async fn cmd_serve(
eprintln!(" GET /api/v1/sessions"); eprintln!(" GET /api/v1/sessions");
eprintln!(" GET /api/v1/contacts"); eprintln!(" GET /api/v1/contacts");
eprintln!(" GET /api/v1/messages?contact=<name_or_wxid>"); eprintln!(" GET /api/v1/messages?contact=<name_or_wxid>");
eprintln!(" GET /api/v1/timeline?since=<unix>&until=<unix>");
eprintln!(" GET /api/v1/media?server_id=<id>&talker=<wxid>[&format=ogg|mp3]"); eprintln!(" GET /api/v1/media?server_id=<id>&talker=<wxid>[&format=ogg|mp3]");
eprintln!(" GET /api/v1/search?q=<keyword>"); eprintln!(" GET /api/v1/search?q=<keyword>");
eprintln!(" Auth: {auth_status}"); eprintln!(" Auth: {auth_status}");
+4 -2
View File
@@ -11,6 +11,8 @@ use wx_context::{
}; };
use wx_db::WechatDb; use wx_db::WechatDb;
type Name2IdCache = Arc<std::sync::Mutex<Option<HashMap<i64, String>>>>;
/// Signal sent to the refresh task. /// Signal sent to the refresh task.
pub enum RefreshTrigger { pub enum RefreshTrigger {
Refresh, Refresh,
@@ -36,7 +38,7 @@ pub struct RefreshTask {
/// Path to FTS DB for reopening. /// Path to FTS DB for reopening.
fts_path: Option<PathBuf>, fts_path: Option<PathBuf>,
/// Cache of name2id mapping — cleared when FTS is reopened. /// Cache of name2id mapping — cleared when FTS is reopened.
name2id_cache: Option<Arc<std::sync::Mutex<Option<HashMap<i64, String>>>>>, name2id_cache: Option<Name2IdCache>,
/// Cache of media DB paths — cleared on every refresh. /// Cache of media DB paths — cleared on every refresh.
media_db_paths: Option<Arc<std::sync::Mutex<Option<Vec<PathBuf>>>>>, media_db_paths: Option<Arc<std::sync::Mutex<Option<Vec<PathBuf>>>>>,
/// Cached hardlink.db connection — cleared on refresh so it is reopened lazily. /// Cached hardlink.db connection — cleared on refresh so it is reopened lazily.
@@ -79,7 +81,7 @@ impl RefreshTask {
/// Set the caches that should be invalidated on refresh. /// Set the caches that should be invalidated on refresh.
pub fn with_caches( pub fn with_caches(
mut self, mut self,
name2id_cache: Option<Arc<std::sync::Mutex<Option<HashMap<i64, String>>>>>, name2id_cache: Option<Name2IdCache>,
media_db_paths: Option<Arc<std::sync::Mutex<Option<Vec<PathBuf>>>>>, media_db_paths: Option<Arc<std::sync::Mutex<Option<Vec<PathBuf>>>>>,
hardlink_db_conn: Option<Arc<std::sync::Mutex<Option<Connection>>>>, hardlink_db_conn: Option<Arc<std::sync::Mutex<Option<Connection>>>>,
) -> Self { ) -> Self {
+1
View File
@@ -15,6 +15,7 @@ pub fn build_router(state: Arc<AppState>) -> Router {
.route("/api/v1/sessions", get(handlers::handler_sessions)) .route("/api/v1/sessions", get(handlers::handler_sessions))
.route("/api/v1/contacts", get(handlers::handler_contacts)) .route("/api/v1/contacts", get(handlers::handler_contacts))
.route("/api/v1/messages", get(handlers::handler_messages)) .route("/api/v1/messages", get(handlers::handler_messages))
.route("/api/v1/timeline", get(handlers::handler_timeline))
.route("/api/v1/media", get(handlers::handler_media)) .route("/api/v1/media", get(handlers::handler_media))
.route("/api/v1/search", get(handlers::handler_search)) .route("/api/v1/search", get(handlers::handler_search))
.route("/api/v1/events", get(handlers::handler_sse)) .route("/api/v1/events", get(handlers::handler_sse))
+6 -7
View File
@@ -20,7 +20,9 @@ use crate::OutputFormat;
const START_TIMEOUT: Duration = Duration::from_secs(10); const START_TIMEOUT: Duration = Duration::from_secs(10);
const STOP_TIMEOUT: Duration = Duration::from_secs(5); const STOP_TIMEOUT: Duration = Duration::from_secs(5);
fn resolve_app_paths(runtime_root: Option<PathBuf>) -> Result<AppPaths, Box<dyn std::error::Error>> { fn resolve_app_paths(
runtime_root: Option<PathBuf>,
) -> Result<AppPaths, Box<dyn std::error::Error>> {
match runtime_root { match runtime_root {
Some(root) => Ok(AppPaths::with_runtime_root(root)?), Some(root) => Ok(AppPaths::with_runtime_root(root)?),
None => Ok(AppPaths::new()?), None => Ok(AppPaths::new()?),
@@ -120,9 +122,8 @@ pub async fn cmd_server_stop(args: ServerStopArgs) -> Result<(), Box<dyn std::er
pub async fn cmd_server_restart(args: ServerRestartArgs) -> Result<(), Box<dyn std::error::Error>> { pub async fn cmd_server_restart(args: ServerRestartArgs) -> Result<(), Box<dyn std::error::Error>> {
let ap = resolve_app_paths(args.runtime_root.clone())?; let ap = resolve_app_paths(args.runtime_root.clone())?;
let config = load_launch_config(&ap)?.ok_or( let config = load_launch_config(&ap)?
"no persisted server launch configuration found; run `wx-cli server run` first", .ok_or("no persisted server launch configuration found; run `wx-cli server run` first")?;
)?;
let stop_args = ServerStopArgs { let stop_args = ServerStopArgs {
runtime_root: args.runtime_root.clone(), runtime_root: args.runtime_root.clone(),
@@ -286,9 +287,7 @@ fn spawn_worker(
command.arg("--runtime-root").arg(root); command.arg("--runtime-root").arg(root);
} }
command command.arg("--worker-id").arg(worker_id);
.arg("--worker-id")
.arg(worker_id);
if let Some(key) = &config.key { if let Some(key) = &config.key {
command.arg("--key").arg(key); command.arg("--key").arg(key);
+3 -1
View File
@@ -6,7 +6,9 @@ use super::contacts::build_visibility;
use super::thin_client::{ThinClientCliArgs, ThinClientOptions}; use super::thin_client::{ThinClientCliArgs, ThinClientOptions};
use crate::output::JsonEnvelope; use crate::output::JsonEnvelope;
use crate::schema::{enrich_session, EnrichedSession}; use crate::schema::{enrich_session, EnrichedSession};
use crate::util::{effective_limit_all, open_db_core, print_cache_stats, print_detection_note, try_remote_or_local}; use crate::util::{
effective_limit_all, open_db_core, print_cache_stats, print_detection_note, try_remote_or_local,
};
use crate::visibility_projection::project_sessions_envelope_enriched; use crate::visibility_projection::project_sessions_envelope_enriched;
use crate::{OutputFormat, SortOrderArg}; use crate::{OutputFormat, SortOrderArg};
+11 -5
View File
@@ -197,15 +197,20 @@ mod tests {
#[test] #[test]
fn watch_text_hidden_sender_shows_placeholder() { fn watch_text_hidden_sender_shows_placeholder() {
use crate::schema::project_session_sender; use crate::schema::project_session_sender;
let visibility = VisibilityIndex::build( let visibility =
&["wxid_spam".to_string()], &[], &ContactResolver::empty(), VisibilityIndex::build(&["wxid_spam".to_string()], &[], &ContactResolver::empty());
);
let mut enriched = make_enriched_session("group@chatroom", "spam msg", Some("wxid_spam")); let mut enriched = make_enriched_session("group@chatroom", "spam msg", Some("wxid_spam"));
project_session_sender(&mut enriched, &visibility); project_session_sender(&mut enriched, &visibility);
let line = format_watch_line_from_enriched(&enriched, &ContactResolver::empty(), "wxid_me"); let line = format_watch_line_from_enriched(&enriched, &ContactResolver::empty(), "wxid_me");
assert!(line.contains("[消息已隐藏]"), "should show placeholder: {line}"); assert!(
assert!(!line.contains("wxid_spam"), "should not leak sender wxid: {line}"); line.contains("[消息已隐藏]"),
"should show placeholder: {line}"
);
assert!(
!line.contains("wxid_spam"),
"should not leak sender wxid: {line}"
);
} }
#[test] #[test]
@@ -281,6 +286,7 @@ fn should_emit_event(
show_hidden || !visibility.is_hidden_talker(&event.username) show_hidden || !visibility.is_hidden_talker(&event.username)
} }
#[allow(clippy::too_many_arguments)]
pub async fn cmd_watch( pub async fn cmd_watch(
key_hex: Option<String>, key_hex: Option<String>,
data_dir: Option<PathBuf>, data_dir: Option<PathBuf>,
+44 -9
View File
@@ -540,7 +540,11 @@ mod tests {
} }
} }
fn make_enriched(sender: &str, talker: &str, content: wx_db::MessageContent) -> EnrichedMessage { fn make_enriched(
sender: &str,
talker: &str,
content: wx_db::MessageContent,
) -> EnrichedMessage {
let msg = make_message(sender, talker, content); let msg = make_message(sender, talker, content);
let snippet = format_content(&msg); let snippet = format_content(&msg);
EnrichedMessage { EnrichedMessage {
@@ -562,21 +566,33 @@ mod tests {
#[test] #[test]
fn project_message_item_non_group_does_not_filter() { fn project_message_item_non_group_does_not_filter() {
let vis = vis_with_hidden_persons(&["wxid_spam"]); let vis = vis_with_hidden_persons(&["wxid_spam"]);
let msg = make_enriched("wxid_spam", "wxid_spam", wx_db::MessageContent::Text("hi".into())); let msg = make_enriched(
"wxid_spam",
"wxid_spam",
wx_db::MessageContent::Text("hi".into()),
);
assert!(project_message_item(msg, "wxid_spam", &vis).is_some()); assert!(project_message_item(msg, "wxid_spam", &vis).is_some());
} }
#[test] #[test]
fn project_message_item_group_hidden_sender_filtered() { fn project_message_item_group_hidden_sender_filtered() {
let vis = vis_with_hidden_persons(&["wxid_spam"]); let vis = vis_with_hidden_persons(&["wxid_spam"]);
let msg = make_enriched("wxid_spam", "group@chatroom", wx_db::MessageContent::Text("spam".into())); let msg = make_enriched(
"wxid_spam",
"group@chatroom",
wx_db::MessageContent::Text("spam".into()),
);
assert!(project_message_item(msg, "group@chatroom", &vis).is_none()); assert!(project_message_item(msg, "group@chatroom", &vis).is_none());
} }
#[test] #[test]
fn project_message_item_group_visible_sender_kept() { fn project_message_item_group_visible_sender_kept() {
let vis = vis_with_hidden_persons(&["wxid_spam"]); let vis = vis_with_hidden_persons(&["wxid_spam"]);
let msg = make_enriched("wxid_normal", "group@chatroom", wx_db::MessageContent::Text("hi".into())); let msg = make_enriched(
"wxid_normal",
"group@chatroom",
wx_db::MessageContent::Text("hi".into()),
);
assert!(project_message_item(msg, "group@chatroom", &vis).is_some()); assert!(project_message_item(msg, "group@chatroom", &vis).is_some());
} }
@@ -679,8 +695,16 @@ mod tests {
fn project_message_items_show_hidden_bypasses() { fn project_message_items_show_hidden_bypasses() {
let vis = vis_with_hidden_persons(&["wxid_spam"]); let vis = vis_with_hidden_persons(&["wxid_spam"]);
let items = vec![ let items = vec![
make_enriched("wxid_spam", "group@chatroom", wx_db::MessageContent::Text("spam".into())), make_enriched(
make_enriched("wxid_normal", "group@chatroom", wx_db::MessageContent::Text("hi".into())), "wxid_spam",
"group@chatroom",
wx_db::MessageContent::Text("spam".into()),
),
make_enriched(
"wxid_normal",
"group@chatroom",
wx_db::MessageContent::Text("hi".into()),
),
]; ];
let result = project_message_items(items, "group@chatroom", &vis, true); let result = project_message_items(items, "group@chatroom", &vis, true);
assert_eq!(result.len(), 2); assert_eq!(result.len(), 2);
@@ -690,8 +714,16 @@ mod tests {
fn project_message_items_filters_hidden_sender() { fn project_message_items_filters_hidden_sender() {
let vis = vis_with_hidden_persons(&["wxid_spam"]); let vis = vis_with_hidden_persons(&["wxid_spam"]);
let items = vec![ let items = vec![
make_enriched("wxid_spam", "group@chatroom", wx_db::MessageContent::Text("spam".into())), make_enriched(
make_enriched("wxid_normal", "group@chatroom", wx_db::MessageContent::Text("hi".into())), "wxid_spam",
"group@chatroom",
wx_db::MessageContent::Text("spam".into()),
),
make_enriched(
"wxid_normal",
"group@chatroom",
wx_db::MessageContent::Text("hi".into()),
),
]; ];
let result = project_message_items(items, "group@chatroom", &vis, false); let result = project_message_items(items, "group@chatroom", &vis, false);
assert_eq!(result.len(), 1); assert_eq!(result.len(), 1);
@@ -760,6 +792,9 @@ mod tests {
}; };
project_session_sender(&mut session, &vis); project_session_sender(&mut session, &vis);
assert_eq!(session.session.summary, "normal message"); assert_eq!(session.session.summary, "normal message");
assert_eq!(session.session.last_msg_sender.as_deref(), Some("wxid_normal")); assert_eq!(
session.session.last_msg_sender.as_deref(),
Some("wxid_normal")
);
} }
} }
+9 -16
View File
@@ -3,6 +3,12 @@ use std::path::PathBuf;
use crate::cmd::thin_client::{ThinClient, ThinClientError, ThinClientOptions}; use crate::cmd::thin_client::{ThinClient, ThinClientError, ThinClientOptions};
use wx_context::{AccountContext, DecryptRequest, DecryptStats, PersistentCache}; use wx_context::{AccountContext, DecryptRequest, DecryptStats, PersistentCache};
type OpenDbAllResult = (
wx_db::WechatDb,
Option<PersistentCache>,
Option<DecryptStats>,
);
/// Open a WechatDb: direct encrypted open if raw_key available, else decrypt+cache (core only). /// Open a WechatDb: direct encrypted open if raw_key available, else decrypt+cache (core only).
pub fn open_db_core( pub fn open_db_core(
acct: &AccountContext, acct: &AccountContext,
@@ -27,14 +33,7 @@ pub fn open_db_core(
pub fn open_db_all( pub fn open_db_all(
acct: &AccountContext, acct: &AccountContext,
progress: impl Fn(wx_context::DecryptProgress) + Send + Sync, progress: impl Fn(wx_context::DecryptProgress) + Send + Sync,
) -> Result< ) -> Result<OpenDbAllResult, Box<dyn std::error::Error>> {
(
wx_db::WechatDb,
Option<PersistentCache>,
Option<DecryptStats>,
),
Box<dyn std::error::Error>,
> {
if acct.raw_key.is_some() { if acct.raw_key.is_some() {
eprintln!("Direct encrypted open (SQLCipher)"); eprintln!("Direct encrypted open (SQLCipher)");
let db = wx_context::open_encrypted_db(acct)?; let db = wx_context::open_encrypted_db(acct)?;
@@ -249,14 +248,8 @@ mod tests {
#[test] #[test]
fn effective_limit_all_true_returns_max() { fn effective_limit_all_true_returns_max() {
assert_eq!( assert_eq!(effective_limit_all(true, 0), wx_db::MAX_QUERY_LIMIT);
effective_limit_all(true, 0), assert_eq!(effective_limit_all(true, 50), wx_db::MAX_QUERY_LIMIT);
wx_db::MAX_QUERY_LIMIT
);
assert_eq!(
effective_limit_all(true, 50),
wx_db::MAX_QUERY_LIMIT
);
} }
#[test] #[test]
+171 -66
View File
@@ -95,9 +95,15 @@ fn ignore_tags_hide_matching_contact_at_both_talker_and_sender_level() {
"json", "json",
], ],
); );
let items = group_messages["items"].as_array().expect("query items array"); let items = group_messages["items"]
.as_array()
.expect("query items array");
// The only group message is from wxid_hidden_tagged; it should be filtered out // The only group message is from wxid_hidden_tagged; it should be filtered out
assert_eq!(items.len(), 0, "tagged contact's group messages should be sender-level filtered: {group_messages}"); assert_eq!(
items.len(),
0,
"tagged contact's group messages should be sender-level filtered: {group_messages}"
);
// Session should show placeholder for group where last sender is tagged // Session should show placeholder for group where last sender is tagged
let sessions = run_json( let sessions = run_json(
@@ -112,9 +118,7 @@ fn ignore_tags_hide_matching_contact_at_both_talker_and_sender_level() {
"json", "json",
], ],
); );
let items = sessions["items"] let items = sessions["items"].as_array().expect("sessions items array");
.as_array()
.expect("sessions items array");
let group_session = items let group_session = items
.iter() .iter()
.find(|item| item["username"].as_str() == Some(TALKER_GROUP)); .find(|item| item["username"].as_str() == Some(TALKER_GROUP));
@@ -331,16 +335,8 @@ fn create_encrypted_contact_db(path: &Path, raw_key: &[u8; 32]) {
) )
.expect("insert bob"); .expect("insert bob");
let tagged_extra = encode_extra_buffer_for_test( let tagged_extra =
None, encode_extra_buffer_for_test(None, None, None, None, None, None, None, Some("1"));
None,
None,
None,
None,
None,
None,
Some("1"),
);
conn.execute( conn.execute(
"INSERT INTO contact (username, nick_name, extra_buffer) VALUES (?1, ?2, ?3)", "INSERT INTO contact (username, nick_name, extra_buffer) VALUES (?1, ?2, ?3)",
params![TALKER_TAGGED, "Sensitive Person", tagged_extra], params![TALKER_TAGGED, "Sensitive Person", tagged_extra],
@@ -451,7 +447,10 @@ fn create_encrypted_message_db(path: &Path, raw_key: &[u8; 32]) {
group = TABLE_GROUP, group = TABLE_GROUP,
), ),
|conn| { |conn| {
conn.execute("INSERT INTO Timestamp VALUES (?1)", params![1_700_000_000_i64]) conn.execute(
"INSERT INTO Timestamp VALUES (?1)",
params![1_700_000_000_i64],
)
.expect("insert timestamp"); .expect("insert timestamp");
conn.execute( conn.execute(
"INSERT INTO Name2Id VALUES (?1, ?2)", "INSERT INTO Name2Id VALUES (?1, ?2)",
@@ -729,7 +728,10 @@ fn create_sender_account(root: &Path) {
group = TABLE_GROUP_SENDER, group = TABLE_GROUP_SENDER,
), ),
|conn| { |conn| {
conn.execute("INSERT INTO Timestamp VALUES (?1)", params![1_700_000_000_i64]) conn.execute(
"INSERT INTO Timestamp VALUES (?1)",
params![1_700_000_000_i64],
)
.expect("insert timestamp"); .expect("insert timestamp");
conn.execute( conn.execute(
"INSERT INTO Name2Id VALUES (?1, ?2)", "INSERT INTO Name2Id VALUES (?1, ?2)",
@@ -754,8 +756,15 @@ fn create_sender_account(root: &Path) {
table = TABLE_ALICE table = TABLE_ALICE
), ),
params![ params![
100_i64, 3001_i64, 1_i64, 1_i64, 1_700_000_301_i64, 100_i64,
b"private hello" as &[u8], None::<Vec<u8>>, 0_i32, None::<i32>, 3001_i64,
1_i64,
1_i64,
1_700_000_301_i64,
b"private hello" as &[u8],
None::<Vec<u8>>,
0_i32,
None::<i32>,
], ],
) )
.expect("insert alice private message"); .expect("insert alice private message");
@@ -767,8 +776,15 @@ fn create_sender_account(root: &Path) {
table = TABLE_GROUP_SENDER table = TABLE_GROUP_SENDER
), ),
params![ params![
200_i64, 4001_i64, 1_i64, 1_i64, 1_700_000_101_i64, 200_i64,
b"alice says hello in group" as &[u8], None::<Vec<u8>>, 0_i32, None::<i32>, 4001_i64,
1_i64,
1_i64,
1_700_000_101_i64,
b"alice says hello in group" as &[u8],
None::<Vec<u8>>,
0_i32,
None::<i32>,
], ],
) )
.expect("insert alice group message"); .expect("insert alice group message");
@@ -780,8 +796,15 @@ fn create_sender_account(root: &Path) {
table = TABLE_GROUP_SENDER table = TABLE_GROUP_SENDER
), ),
params![ params![
210_i64, 4002_i64, 1_i64, 5_i64, 1_700_000_102_i64, 210_i64,
b"spam content" as &[u8], None::<Vec<u8>>, 0_i32, None::<i32>, 4002_i64,
1_i64,
5_i64,
1_700_000_102_i64,
b"spam content" as &[u8],
None::<Vec<u8>>,
0_i32,
None::<i32>,
], ],
) )
.expect("insert spam group message"); .expect("insert spam group message");
@@ -795,10 +818,15 @@ fn create_sender_account(root: &Path) {
table = TABLE_GROUP_SENDER table = TABLE_GROUP_SENDER
), ),
params![ params![
220_i64, 4003_i64, 220_i64,
4003_i64,
quote_local_type, quote_local_type,
1_i64, 1_700_000_103_i64, 1_i64,
quote_xml.as_bytes(), None::<Vec<u8>>, 0_i32, None::<i32>, 1_700_000_103_i64,
quote_xml.as_bytes(),
None::<Vec<u8>>,
0_i32,
None::<i32>,
], ],
) )
.expect("insert quote message"); .expect("insert quote message");
@@ -814,22 +842,38 @@ fn sender_hiding_filters_hidden_sender_messages_in_group() {
let result = run_json( let result = run_json(
fixture.path(), fixture.path(),
&[ &[
"query", TALKER_GROUP, "query",
"--data-dir", senders_dir.as_str(), TALKER_GROUP,
"--key", TEST_KEY_HEX, "--data-dir",
"--format", "json", senders_dir.as_str(),
"--key",
TEST_KEY_HEX,
"--format",
"json",
], ],
); );
let items = result["items"].as_array().expect("query items array"); let items = result["items"].as_array().expect("query items array");
// spam message (server_id=4002) should be filtered out // spam message (server_id=4002) should be filtered out
let senders: Vec<&str> = items.iter().map(|i| i["sender"].as_str().unwrap()).collect(); let senders: Vec<&str> = items
assert!(!senders.contains(&TALKER_SPAM), "hidden sender message should be filtered: {result}"); .iter()
assert!(senders.contains(&TALKER_ALICE), "visible sender should remain: {result}"); .map(|i| i["sender"].as_str().unwrap())
.collect();
assert!(
!senders.contains(&TALKER_SPAM),
"hidden sender message should be filtered: {result}"
);
assert!(
senders.contains(&TALKER_ALICE),
"visible sender should remain: {result}"
);
// paging.total should NOT change (DB-level count) // paging.total should NOT change (DB-level count)
// paging.returned should reflect filtered items // paging.returned should reflect filtered items
assert_eq!(result["paging"]["returned"].as_u64().unwrap(), items.len() as u64); assert_eq!(
result["paging"]["returned"].as_u64().unwrap(),
items.len() as u64
);
} }
#[test] #[test]
@@ -841,14 +885,22 @@ fn sender_hiding_does_not_affect_private_chat() {
let result = run_json( let result = run_json(
fixture.path(), fixture.path(),
&[ &[
"query", TALKER_ALICE, "query",
"--data-dir", senders_dir.as_str(), TALKER_ALICE,
"--key", TEST_KEY_HEX, "--data-dir",
"--format", "json", senders_dir.as_str(),
"--key",
TEST_KEY_HEX,
"--format",
"json",
], ],
); );
let items = result["items"].as_array().expect("query items array"); let items = result["items"].as_array().expect("query items array");
assert_eq!(items.len(), 1, "private chat should not be filtered: {result}"); assert_eq!(
items.len(),
1,
"private chat should not be filtered: {result}"
);
} }
#[test] #[test]
@@ -859,27 +911,51 @@ fn sender_hiding_redacts_quote_referring_hidden_sender() {
let result = run_json( let result = run_json(
fixture.path(), fixture.path(),
&[ &[
"query", TALKER_GROUP, "query",
"--data-dir", senders_dir.as_str(), TALKER_GROUP,
"--key", TEST_KEY_HEX, "--data-dir",
"--format", "json", senders_dir.as_str(),
"--key",
TEST_KEY_HEX,
"--format",
"json",
], ],
); );
let items = result["items"].as_array().expect("query items array"); let items = result["items"].as_array().expect("query items array");
// Find the quote message (server_id=4003) // Find the quote message (server_id=4003)
let quote = items.iter().find(|i| i["server_id"].as_i64() == Some(4003)); let quote = items.iter().find(|i| i["server_id"].as_i64() == Some(4003));
assert!(quote.is_some(), "quote message should be present (alice's reply): items={:?}", items.iter().map(|i| i["server_id"].as_i64()).collect::<Vec<_>>()); assert!(
quote.is_some(),
"quote message should be present (alice's reply): items={:?}",
items
.iter()
.map(|i| i["server_id"].as_i64())
.collect::<Vec<_>>()
);
let quote = quote.unwrap(); let quote = quote.unwrap();
// refer_sender and refer_content should be null (redacted) // refer_sender and refer_content should be null (redacted)
let q = &quote["content"]["Quote"]; let q = &quote["content"]["Quote"];
assert!(q["refer_sender"].is_null(), "refer_sender should be redacted: {quote}"); assert!(
assert!(q["refer_content"].is_null(), "refer_content should be redacted: {quote}"); q["refer_sender"].is_null(),
"refer_sender should be redacted: {quote}"
);
assert!(
q["refer_content"].is_null(),
"refer_content should be redacted: {quote}"
);
// reply_text should be preserved // reply_text should be preserved
assert!(q["reply_text"].as_str().unwrap().contains("my reply"), "reply_text should be preserved: {quote}"); assert!(
q["reply_text"].as_str().unwrap().contains("my reply"),
"reply_text should be preserved: {quote}"
);
// raw_xml should be cleared // raw_xml should be cleared
assert_eq!(q["raw_xml"].as_str(), Some(""), "raw_xml should be empty: {quote}"); assert_eq!(
q["raw_xml"].as_str(),
Some(""),
"raw_xml should be empty: {quote}"
);
} }
#[test] #[test]
@@ -890,19 +966,33 @@ fn sender_hiding_show_hidden_restores_all_messages_and_quotes() {
let result = run_json( let result = run_json(
fixture.path(), fixture.path(),
&[ &[
"query", TALKER_GROUP, "query",
"--data-dir", senders_dir.as_str(), TALKER_GROUP,
"--key", TEST_KEY_HEX, "--data-dir",
senders_dir.as_str(),
"--key",
TEST_KEY_HEX,
"--show-hidden", "--show-hidden",
"--format", "json", "--format",
"json",
], ],
); );
let items = result["items"].as_array().expect("query items array"); let items = result["items"].as_array().expect("query items array");
assert_eq!(items.len(), 3, "show_hidden should restore all 3 messages: {result}"); assert_eq!(
items.len(),
3,
"show_hidden should restore all 3 messages: {result}"
);
// Quote should have refer_sender intact // Quote should have refer_sender intact
let quote = items.iter().find(|i| i["server_id"].as_i64() == Some(4003)).unwrap(); let quote = items
assert!(!quote["content"]["Quote"]["refer_sender"].is_null(), "refer_sender should be intact with show_hidden: {quote}"); .iter()
.find(|i| i["server_id"].as_i64() == Some(4003))
.unwrap();
assert!(
!quote["content"]["Quote"]["refer_sender"].is_null(),
"refer_sender should be intact with show_hidden: {quote}"
);
} }
#[test] #[test]
@@ -914,22 +1004,37 @@ fn sender_hiding_session_placeholder_when_last_sender_hidden() {
fixture.path(), fixture.path(),
&[ &[
"sessions", "sessions",
"--data-dir", senders_dir.as_str(), "--data-dir",
"--key", TEST_KEY_HEX, senders_dir.as_str(),
"--format", "json", "--key",
TEST_KEY_HEX,
"--format",
"json",
], ],
); );
let items = sessions["items"].as_array().expect("sessions items array"); let items = sessions["items"].as_array().expect("sessions items array");
let group_session = items.iter().find(|i| i["username"].as_str() == Some(TALKER_GROUP)); let group_session = items
assert!(group_session.is_some(), "group session should be visible: {sessions}"); .iter()
.find(|i| i["username"].as_str() == Some(TALKER_GROUP));
assert!(
group_session.is_some(),
"group session should be visible: {sessions}"
);
let group_session = group_session.unwrap(); let group_session = group_session.unwrap();
// Summary should be placeholder, sender fields should be null // Summary should be placeholder, sender fields should be null
assert_eq!(group_session["summary"].as_str(), Some("[消息已隐藏]"), assert_eq!(
"summary should be placeholder: {group_session}"); group_session["summary"].as_str(),
assert!(group_session["last_msg_sender"].is_null(), Some("[消息已隐藏]"),
"last_msg_sender should be null: {group_session}"); "summary should be placeholder: {group_session}"
assert!(group_session["direction"].is_null(), );
"direction should be null: {group_session}"); assert!(
group_session["last_msg_sender"].is_null(),
"last_msg_sender should be null: {group_session}"
);
assert!(
group_session["direction"].is_null(),
"direction should be null: {group_session}"
);
} }
+5 -6
View File
@@ -98,8 +98,10 @@ fn serve_media_visible_sender_in_group_with_hidden_persons_not_visibility_blocke
let body = String::from_utf8_lossy(&response.body); let body = String::from_utf8_lossy(&response.body);
// The media endpoint was reached (not blocked by visibility) but the asset // The media endpoint was reached (not blocked by visibility) but the asset
// isn't on disk. Acceptable. // isn't on disk. Acceptable.
assert!(!body.contains("message not found"), assert!(
"visible sender should NOT get visibility 404: {body}"); !body.contains("message not found"),
"visible sender should NOT get visibility 404: {body}"
);
} }
// If 200, even better — means asset resolution succeeded // If 200, even better — means asset resolution succeeded
} }
@@ -317,10 +319,7 @@ fn spawn_test_server_with_env(envs: &[(&str, &str)]) -> TestServer {
spawn_test_server_with_setup(envs, &[]) spawn_test_server_with_setup(envs, &[])
} }
fn spawn_test_server_with_setup( fn spawn_test_server_with_setup(envs: &[(&str, &str)], hidden_contacts: &[&str]) -> TestServer {
envs: &[(&str, &str)],
hidden_contacts: &[&str],
) -> TestServer {
let fixture = create_fixture(); let fixture = create_fixture();
if !hidden_contacts.is_empty() { if !hidden_contacts.is_empty() {
write_settings(fixture.path(), hidden_contacts); write_settings(fixture.path(), hidden_contacts);
+1 -4
View File
@@ -773,10 +773,7 @@ mod tests {
&tmp.path().join("cache").join("test.db"), &tmp.path().join("cache").join("test.db"),
) )
.unwrap_err(); .unwrap_err();
assert!(matches!( assert!(matches!(err, wx_decrypt::DecryptError::NoMatchingEncKey));
err,
wx_decrypt::DecryptError::NoMatchingEncKey
));
} }
#[test] #[test]
+2 -6
View File
@@ -77,8 +77,7 @@ impl VisibilityIndex {
/// ///
/// Hidden talkers OR hidden senders in visible groups cannot access media. /// Hidden talkers OR hidden senders in visible groups cannot access media.
pub fn allows_media_for_sender(&self, talker: &str, sender: &str) -> bool { pub fn allows_media_for_sender(&self, talker: &str, sender: &str) -> bool {
!self.hidden_persons.contains(talker) !self.hidden_persons.contains(talker) && !self.is_hidden_sender_in_group(talker, sender)
&& !self.is_hidden_sender_in_group(talker, sender)
} }
} }
@@ -230,10 +229,7 @@ mod tests {
#[test] #[test]
fn allows_media_for_sender_covers_both_levels() { fn allows_media_for_sender_covers_both_levels() {
let idx = VisibilityIndex { let idx = VisibilityIndex {
hidden_persons: vec![ hidden_persons: vec!["hidden_group@chatroom".to_string(), "wxid_spam".to_string()]
"hidden_group@chatroom".to_string(),
"wxid_spam".to_string(),
]
.into_iter() .into_iter()
.collect(), .collect(),
}; };
+6 -15
View File
@@ -302,11 +302,8 @@ mod tests {
#[test] #[test]
fn non_group_returns_fallback_unchanged() { fn non_group_returns_fallback_unchanged() {
let (sender, content) = parse_group_sender( let (sender, content) =
false, parse_group_sender(false, "hello world".to_string(), "fallback".to_string());
"hello world".to_string(),
"fallback".to_string(),
);
assert_eq!(sender, "fallback"); assert_eq!(sender, "fallback");
assert_eq!(content, "hello world"); assert_eq!(content, "hello world");
} }
@@ -335,22 +332,16 @@ mod tests {
#[test] #[test]
fn group_empty_content_after_separator() { fn group_empty_content_after_separator() {
let (sender, content) = parse_group_sender( let (sender, content) =
true, parse_group_sender(true, "wxid_abc:\n".to_string(), "fallback".to_string());
"wxid_abc:\n".to_string(),
"fallback".to_string(),
);
assert_eq!(sender, "wxid_abc"); assert_eq!(sender, "wxid_abc");
assert_eq!(content, ""); assert_eq!(content, "");
} }
#[test] #[test]
fn group_only_colon_before_newline() { fn group_only_colon_before_newline() {
let (sender, content) = parse_group_sender( let (sender, content) =
true, parse_group_sender(true, ":\nsome content".to_string(), "fallback".to_string());
":\nsome content".to_string(),
"fallback".to_string(),
);
assert_eq!(sender, ""); assert_eq!(sender, "");
assert_eq!(content, "some content"); assert_eq!(content, "some content");
} }
+6 -4
View File
@@ -6,7 +6,9 @@ use rusqlite::types::ValueRef;
use rusqlite::Connection; use rusqlite::Connection;
use serde::Serialize; use serde::Serialize;
use crate::decode::{check_column_exists, decode_content, msg_table_name, parse_content, parse_group_sender}; use crate::decode::{
check_column_exists, decode_content, msg_table_name, parse_content, parse_group_sender,
};
use crate::error::DbError; use crate::error::DbError;
use crate::model::{split_local_type, MessageContent}; use crate::model::{split_local_type, MessageContent};
use crate::open::WechatDb; use crate::open::WechatDb;
@@ -414,8 +416,7 @@ impl WechatDb {
)?; )?;
for shard in &self.shards { for shard in &self.shards {
let shard_conn = let shard_conn = WechatDb::open_shard_with_key(shard, self.sqlcipher_key.as_ref())?;
WechatDb::open_shard_with_key(shard, self.sqlcipher_key.as_ref())?;
// List Msg_* tables in this shard // List Msg_* tables in this shard
let mut table_stmt = shard_conn.prepare( let mut table_stmt = shard_conn.prepare(
@@ -574,7 +575,8 @@ impl WechatDb {
let decoded_text = decode_content(&raw_content, wcdb_ct)?; let decoded_text = decode_content(&raw_content, wcdb_ct)?;
// Group sender parsing // Group sender parsing
let (sender, content_text) = parse_group_sender(is_group, decoded_text, sender_from_name2id); let (sender, content_text) =
parse_group_sender(is_group, decoded_text, sender_from_name2id);
let (msg_type, sub_type) = split_local_type(local_type as i64); let (msg_type, sub_type) = split_local_type(local_type as i64);
+5 -3
View File
@@ -323,9 +323,11 @@ impl WechatDb {
msg_type_filter: Option<u32>, msg_type_filter: Option<u32>,
) -> usize { ) -> usize {
let result = if let Some(mt) = msg_type_filter { let result = if let Some(mt) = msg_type_filter {
conn.query_row(sql, [start_time, end_time, mt as i64], |row: &rusqlite::Row<'_>| { conn.query_row(
row.get::<_, i64>(0) sql,
}) [start_time, end_time, mt as i64],
|row: &rusqlite::Row<'_>| row.get::<_, i64>(0),
)
} else { } else {
conn.query_row(sql, [start_time, end_time], |row: &rusqlite::Row<'_>| { conn.query_row(sql, [start_time, end_time], |row: &rusqlite::Row<'_>| {
row.get::<_, i64>(0) row.get::<_, i64>(0)
+3 -12
View File
@@ -530,10 +530,7 @@ mod tests {
#[test] #[test]
fn extract_quote_fromusr_normal() { fn extract_quote_fromusr_normal() {
let xml = r#"<msg><appmsg><title>reply</title><refermsg><fromusr>wxid_alice</fromusr><content>hi</content></refermsg></appmsg></msg>"#; let xml = r#"<msg><appmsg><title>reply</title><refermsg><fromusr>wxid_alice</fromusr><content>hi</content></refermsg></appmsg></msg>"#;
assert_eq!( assert_eq!(extract_quote_fromusr(xml), Some("wxid_alice".to_string()));
extract_quote_fromusr(xml),
Some("wxid_alice".to_string())
);
} }
#[test] #[test]
@@ -552,19 +549,13 @@ mod tests {
fn extract_quote_fromusr_prefers_chatusr_in_group() { fn extract_quote_fromusr_prefers_chatusr_in_group() {
// In group chats, <fromusr> is the chatroom ID, <chatusr> is the actual sender // In group chats, <fromusr> is the chatroom ID, <chatusr> is the actual sender
let xml = r#"<msg><appmsg><title>reply</title><refermsg><fromusr>group@chatroom</fromusr><chatusr>wxid_sender</chatusr><displayname>Sender</displayname><content>hi</content></refermsg></appmsg></msg>"#; let xml = r#"<msg><appmsg><title>reply</title><refermsg><fromusr>group@chatroom</fromusr><chatusr>wxid_sender</chatusr><displayname>Sender</displayname><content>hi</content></refermsg></appmsg></msg>"#;
assert_eq!( assert_eq!(extract_quote_fromusr(xml), Some("wxid_sender".to_string()));
extract_quote_fromusr(xml),
Some("wxid_sender".to_string())
);
} }
#[test] #[test]
fn extract_quote_fromusr_falls_back_to_fromusr_without_chatusr() { fn extract_quote_fromusr_falls_back_to_fromusr_without_chatusr() {
// In private chats, only <fromusr> exists (no <chatusr>) // In private chats, only <fromusr> exists (no <chatusr>)
let xml = r#"<msg><appmsg><title>reply</title><refermsg><fromusr>wxid_bob</fromusr><displayname>Bob</displayname><content>hi</content></refermsg></appmsg></msg>"#; let xml = r#"<msg><appmsg><title>reply</title><refermsg><fromusr>wxid_bob</fromusr><displayname>Bob</displayname><content>hi</content></refermsg></appmsg></msg>"#;
assert_eq!( assert_eq!(extract_quote_fromusr(xml), Some("wxid_bob".to_string()));
extract_quote_fromusr(xml),
Some("wxid_bob".to_string())
);
} }
} }
+3 -3
View File
@@ -17,11 +17,11 @@ pub use process::config_dir;
pub use process::detect_active_account; pub use process::detect_active_account;
pub use process::{ pub use process::{
ensure_supported_wechat_version, extract_base_wxid, find_account_dirs, find_account_dirs_under, ensure_supported_wechat_version, extract_base_wxid, find_account_dirs, find_account_dirs_under,
find_wechat_pid, is_xwechat_files_root, AccountDirInfo, ActiveAccount, find_wechat_pid, is_xwechat_files_root, AccountDirInfo, ActiveAccount, DetectionSource,
DetectionSource, SUPPORTED_VERSION, SUPPORTED_VERSION,
}; };
pub use wx_decrypt::read_db_salt;
pub use store::{AccountKey, EncKeyEntry, KeyStore}; pub use store::{AccountKey, EncKeyEntry, KeyStore};
pub use wx_decrypt::read_db_salt;
use std::process::Command; use std::process::Command;
+5 -1
View File
@@ -46,7 +46,11 @@ pub async fn capture_key(
// Pre-read salts from all accounts. Skip unreadable DBs. // Pre-read salts from all accounts. Skip unreadable DBs.
let account_salts: Vec<([u8; 16], &AccountDirInfo)> = accounts let account_salts: Vec<([u8; 16], &AccountDirInfo)> = accounts
.iter() .iter()
.filter_map(|a| wx_decrypt::read_db_salt(&a.message_db_path).ok().map(|salt| (salt, a))) .filter_map(|a| {
wx_decrypt::read_db_salt(&a.message_db_path)
.ok()
.map(|salt| (salt, a))
})
.collect(); .collect();
if account_salts.is_empty() { if account_salts.is_empty() {
+5 -6
View File
@@ -138,16 +138,16 @@ fn get_wechat_version() -> Result<String, KeychainError> {
/// Shared config directory resolved by `AppPaths`. /// Shared config directory resolved by `AppPaths`.
pub fn config_dir() -> Result<PathBuf, KeychainError> { pub fn config_dir() -> Result<PathBuf, KeychainError> {
let ap = wx_paths::AppPaths::new() let ap = wx_paths::AppPaths::new().map_err(|e| KeychainError::Other(e.to_string()))?;
.map_err(|e| KeychainError::Other(e.to_string()))?;
Ok(ap.config_dir()) Ok(ap.config_dir())
} }
/// Default xwechat_files base path. /// Default xwechat_files base path.
fn default_xwechat_files_base() -> Result<PathBuf, KeychainError> { fn default_xwechat_files_base() -> Result<PathBuf, KeychainError> {
let ap = wx_paths::AppPaths::new() let ap = wx_paths::AppPaths::new().map_err(|e| KeychainError::Other(e.to_string()))?;
.map_err(|e| KeychainError::Other(e.to_string()))?; Ok(ap
Ok(ap.home().join("Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files")) .home()
.join("Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files"))
} }
/// Detect account directories from the filesystem (without WeChat running). /// Detect account directories from the filesystem (without WeChat running).
@@ -350,7 +350,6 @@ fn tiebreak_by_wal_mtime<'a>(accounts: &[&'a AccountDirInfo]) -> Option<&'a Acco
best best
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+2 -5
View File
@@ -57,15 +57,13 @@ pub struct KeyStore {
impl KeyStore { impl KeyStore {
/// Default path: `<config_dir>/keys.toml` /// Default path: `<config_dir>/keys.toml`
pub fn default_path() -> Result<PathBuf, KeychainError> { pub fn default_path() -> Result<PathBuf, KeychainError> {
let ap = wx_paths::AppPaths::new() let ap = wx_paths::AppPaths::new().map_err(|e| KeychainError::Other(e.to_string()))?;
.map_err(|e| KeychainError::Other(e.to_string()))?;
Ok(ap.keys_file()) Ok(ap.keys_file())
} }
/// Load from the default path, creating an empty store if the file doesn't exist. /// Load from the default path, creating an empty store if the file doesn't exist.
pub fn load_default() -> Result<Self, KeychainError> { pub fn load_default() -> Result<Self, KeychainError> {
let ap = wx_paths::AppPaths::new() let ap = wx_paths::AppPaths::new().map_err(|e| KeychainError::Other(e.to_string()))?;
.map_err(|e| KeychainError::Other(e.to_string()))?;
ap.migrate_config() ap.migrate_config()
.map_err(|e| KeychainError::Other(format!("config migration failed: {}", e)))?; .map_err(|e| KeychainError::Other(format!("config migration failed: {}", e)))?;
let path = ap.keys_file(); let path = ap.keys_file();
@@ -354,7 +352,6 @@ impl KeyStore {
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+8 -2
View File
@@ -58,7 +58,11 @@ pub fn reset_ffmpeg_cache() {
FFPROBE_CACHED.store(false, Ordering::Release); FFPROBE_CACHED.store(false, Ordering::Release);
} }
fn run_command_with_piped_input(bin: String, input: &[u8], args: &[&str]) -> Result<Output, MediaError> { fn run_command_with_piped_input(
bin: String,
input: &[u8],
args: &[&str],
) -> Result<Output, MediaError> {
let mut child = Command::new(&bin) let mut child = Command::new(&bin)
.args(args) .args(args)
.stdin(Stdio::piped()) .stdin(Stdio::piped())
@@ -76,7 +80,9 @@ fn run_command_with_piped_input(bin: String, input: &[u8], args: &[&str]) -> Res
}) })
}); });
let output = child.wait_with_output().map_err(|e| MediaError::FfmpegFailed { let output = child
.wait_with_output()
.map_err(|e| MediaError::FfmpegFailed {
status: -1, status: -1,
stderr: e.to_string(), stderr: e.to_string(),
})?; })?;
+1 -1
View File
@@ -22,7 +22,7 @@ pub fn query_hardlink_with_conn(
media_type: &str, media_type: &str,
key: &str, key: &str,
) -> Result<Vec<HardlinkEntry>, MediaError> { ) -> Result<Vec<HardlinkEntry>, MediaError> {
let table = resolve_table(&conn, media_type)?; let table = resolve_table(conn, media_type)?;
let query = format!( let query = format!(
"SELECT f.md5, f.file_name, f.file_size, f.modify_time, "SELECT f.md5, f.file_name, f.file_size, f.modify_time,
+1 -3
View File
@@ -37,9 +37,7 @@ fn extract_wxid_from_data_dir(data_dir: &Path) -> Result<String, MediaError> {
Ok(data_dir.parent().map_or_else( Ok(data_dir.parent().map_or_else(
|| extract_wxid(dir_name), || extract_wxid(dir_name),
|root| { |root| wx_keychain::process::extract_base_wxid_for_account_dir_under_root(root, dir_name),
wx_keychain::process::extract_base_wxid_for_account_dir_under_root(root, dir_name)
},
)) ))
} }
+6 -2
View File
@@ -85,7 +85,9 @@ pub use audio_transcode::{transcode_silk_to_mp3, transcode_silk_to_ogg_opus};
pub use dat::{decrypt_dat, detect_dat_format, detect_image_type, detect_xor_key}; pub use dat::{decrypt_dat, detect_dat_format, detect_image_type, detect_xor_key};
pub use error::MediaError; pub use error::MediaError;
pub use fallback::{find_file_by_name, find_video_by_md5}; pub use fallback::{find_file_by_name, find_video_by_md5};
pub use ffmpeg::{ffmpeg_available, ffprobe_available, reset_ffmpeg_cache, run_ffmpeg, run_ffprobe}; pub use ffmpeg::{
ffmpeg_available, ffprobe_available, reset_ffmpeg_cache, run_ffmpeg, run_ffprobe,
};
pub use hardlink::{query_hardlink, query_hardlink_with_conn}; pub use hardlink::{query_hardlink, query_hardlink_with_conn};
pub use image_resolver::{resolve_image, resolve_image_by_md5}; pub use image_resolver::{resolve_image, resolve_image_by_md5};
pub use image_transcode::transcode_wxgf; pub use image_transcode::transcode_wxgf;
@@ -97,7 +99,9 @@ pub use types::{
MediaLookupResult, TranscodeAudioResult, TranscodeImageResult, VoiceBlob, MediaLookupResult, TranscodeAudioResult, TranscodeImageResult, VoiceBlob,
}; };
pub use video_decrypt::{decrypt_video, decrypt_video_with_keystream}; pub use video_decrypt::{decrypt_video, decrypt_video_with_keystream};
pub use voice::{extract_voice, extract_voice_with_conn, extract_voice_with_conn_hint, find_media_dbs}; pub use voice::{
extract_voice, extract_voice_with_conn, extract_voice_with_conn_hint, find_media_dbs,
};
pub use wxgf::{parse_wxgf, WxgfContent}; pub use wxgf::{parse_wxgf, WxgfContent};
/// Compute MD5 hash of bytes, returning the `md5::Digest` (displays as hex). /// Compute MD5 hash of bytes, returning the `md5::Digest` (displays as hex).
+6 -1
View File
@@ -12,7 +12,12 @@ fn sample_silk() -> Vec<u8> {
#[cfg(feature = "audio")] #[cfg(feature = "audio")]
fn long_sample_silk() -> Vec<u8> { fn long_sample_silk() -> Vec<u8> {
silk_rs::encode_silk(vec![0_u8; silent_pcm_frame().len() * 250], 24_000, 24_000, true) silk_rs::encode_silk(
vec![0_u8; silent_pcm_frame().len() * 250],
24_000,
24_000,
true,
)
.unwrap() .unwrap()
} }
+1 -4
View File
@@ -184,10 +184,7 @@ fn voice_query_with_conn_returns_stable_not_found_errors() {
fn voice_query_with_conn_hint_returns_chat_name_id_from_indexed_lookup() { fn voice_query_with_conn_hint_returns_chat_name_id_from_indexed_lookup() {
let tmp = TempDir::new().unwrap(); let tmp = TempDir::new().unwrap();
let db_path = tmp.path().join("media.db"); let db_path = tmp.path().join("media.db");
create_indexed_media_db( create_indexed_media_db(&db_path, &[(55, 1000, 1, "srv_hint_1", b"hinted_blob")]);
&db_path,
&[(55, 1000, 1, "srv_hint_1", b"hinted_blob")],
);
let conn = rusqlite::Connection::open(&db_path).unwrap(); let conn = rusqlite::Connection::open(&db_path).unwrap();
let blob = wx_media::extract_voice_with_conn_hint(&conn, "srv_hint_1", Some(55)).unwrap(); let blob = wx_media::extract_voice_with_conn_hint(&conn, "srv_hint_1", Some(55)).unwrap();
+1 -5
View File
@@ -118,11 +118,7 @@ impl DecryptCache {
wx_decrypt::dispatch_decrypt_db(src, dst, &self.key_material, self.params) wx_decrypt::dispatch_decrypt_db(src, dst, &self.key_material, self.params)
} }
fn do_decrypt_wal( fn do_decrypt_wal(&self, wal: &Path, dst: &Path) -> Result<usize, wx_decrypt::DecryptError> {
&self,
wal: &Path,
dst: &Path,
) -> Result<usize, wx_decrypt::DecryptError> {
wx_decrypt::dispatch_decrypt_wal(wal, dst, &self.key_material, self.params) wx_decrypt::dispatch_decrypt_wal(wal, dst, &self.key_material, self.params)
} }
} }
+1 -4
View File
@@ -217,10 +217,7 @@ async fn monitor_detects_session_change() {
// Should be an Updated event for wxid_bob (the new session) // Should be an Updated event for wxid_bob (the new session)
assert_eq!(event.username, "wxid_bob"); assert_eq!(event.username, "wxid_bob");
assert!(matches!( assert!(matches!(event.kind, wx_monitor::SessionEventKind::Updated));
event.kind,
wx_monitor::SessionEventKind::Updated
));
// Stop monitor and assert clean exit // Stop monitor and assert clean exit
monitor.stop(); monitor.stop();
+23 -7
View File
@@ -211,7 +211,9 @@ impl AppPaths {
/// `<temp_root>/lldb/wechat_lldb_output.txt` /// `<temp_root>/lldb/wechat_lldb_output.txt`
pub fn lldb_output_file() -> PathBuf { pub fn lldb_output_file() -> PathBuf {
Self::temp_root().join("lldb").join("wechat_lldb_output.txt") Self::temp_root()
.join("lldb")
.join("wechat_lldb_output.txt")
} }
/// `<temp_root>/nickname/<pid>_<nanos>.db` /// `<temp_root>/nickname/<pid>_<nanos>.db`
@@ -242,13 +244,21 @@ impl AppPaths {
/// Current platform identifier. /// Current platform identifier.
pub fn platform() -> &'static str { pub fn platform() -> &'static str {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
{ "macos" } {
"macos"
}
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
{ "linux" } {
"linux"
}
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
{ "windows" } {
"windows"
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
{ "unknown" } {
"unknown"
}
} }
/// Build a summary of all paths. /// Build a summary of all paths.
@@ -442,7 +452,10 @@ mod tests {
let ap = AppPaths::new().unwrap(); let ap = AppPaths::new().unwrap();
let config = ap.config_dir(); let config = ap.config_dir();
assert!( assert!(
config.to_str().unwrap().contains("Application Support/wx-cli/config"), config
.to_str()
.unwrap()
.contains("Application Support/wx-cli/config"),
"macOS config should be under Application Support: {:?}", "macOS config should be under Application Support: {:?}",
config config
); );
@@ -464,7 +477,10 @@ mod tests {
let ap = AppPaths::new().unwrap(); let ap = AppPaths::new().unwrap();
let state = ap.state_root(); let state = ap.state_root();
assert!( assert!(
state.to_str().unwrap().contains("Application Support/wx-cli/state"), state
.to_str()
.unwrap()
.contains("Application Support/wx-cli/state"),
"macOS state should be under Application Support: {:?}", "macOS state should be under Application Support: {:?}",
state state
); );
+12 -3
View File
@@ -120,8 +120,14 @@ mod tests {
} }
// New files exist // New files exist
assert_eq!(fs::read_to_string(new_config.join("keys.toml")).unwrap(), "test-keys"); assert_eq!(
assert_eq!(fs::read_to_string(new_config.join("settings.toml")).unwrap(), "test-settings"); fs::read_to_string(new_config.join("keys.toml")).unwrap(),
"test-keys"
);
assert_eq!(
fs::read_to_string(new_config.join("settings.toml")).unwrap(),
"test-settings"
);
// Old files deleted // Old files deleted
assert!(!old_config.join("keys.toml").exists()); assert!(!old_config.join("keys.toml").exists());
@@ -153,7 +159,10 @@ mod tests {
} }
// New file unchanged // New file unchanged
assert_eq!(fs::read_to_string(new_config.join("keys.toml")).unwrap(), "new-keys"); assert_eq!(
fs::read_to_string(new_config.join("keys.toml")).unwrap(),
"new-keys"
);
// Old file still exists (wasn't migrated because dst exists) // Old file still exists (wasn't migrated because dst exists)
assert!(old_config.join("keys.toml").exists()); assert!(old_config.join("keys.toml").exists());
} }
+3 -9
View File
@@ -26,9 +26,7 @@ impl PlatformBaseDirs {
let config_root = dirs::config_dir() let config_root = dirs::config_dir()
.ok_or(PathsError::NoConfig)? .ok_or(PathsError::NoConfig)?
.join("wx-cli"); .join("wx-cli");
let cache_root = dirs::cache_dir() let cache_root = dirs::cache_dir().ok_or(PathsError::NoCache)?.join("wx-cli");
.ok_or(PathsError::NoCache)?
.join("wx-cli");
let state_root = dirs::state_dir() let state_root = dirs::state_dir()
.or_else(dirs::data_local_dir) .or_else(dirs::data_local_dir)
.or_else(dirs::data_dir) .or_else(dirs::data_dir)
@@ -48,9 +46,7 @@ impl PlatformBaseDirs {
let config_root = dirs::config_dir() let config_root = dirs::config_dir()
.ok_or(PathsError::NoConfig)? .ok_or(PathsError::NoConfig)?
.join("wx-cli"); .join("wx-cli");
let cache_root = dirs::cache_dir() let cache_root = dirs::cache_dir().ok_or(PathsError::NoCache)?.join("wx-cli");
.ok_or(PathsError::NoCache)?
.join("wx-cli");
let local_data = dirs::data_local_dir() let local_data = dirs::data_local_dir()
.or_else(dirs::data_dir) .or_else(dirs::data_dir)
.ok_or(PathsError::NoState)? .ok_or(PathsError::NoState)?
@@ -71,9 +67,7 @@ impl PlatformBaseDirs {
let config_root = dirs::config_dir() let config_root = dirs::config_dir()
.ok_or(PathsError::NoConfig)? .ok_or(PathsError::NoConfig)?
.join("wx-cli"); .join("wx-cli");
let cache_root = dirs::cache_dir() let cache_root = dirs::cache_dir().ok_or(PathsError::NoCache)?.join("wx-cli");
.ok_or(PathsError::NoCache)?
.join("wx-cli");
let state_root = dirs::state_dir() let state_root = dirs::state_dir()
.or_else(dirs::data_local_dir) .or_else(dirs::data_local_dir)
.or_else(dirs::data_dir) .or_else(dirs::data_dir)