Author SHA1 Message Date
dependabot[bot] eb0c8db964 chore(deps): bump sha2 from 0.10.9 to 0.11.0
Bumps [sha2](https://github.com/RustCrypto/hashes) from 0.10.9 to 0.11.0.
- [Commits](https://github.com/RustCrypto/hashes/compare/sha2-v0.10.9...sha2-v0.11.0)

---
updated-dependencies:
- dependency-name: sha2
  dependency-version: 0.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-10 10:52:15 +00:00
pandorafuture 26bae79fc4 perf: eliminate repeated SQLCipher key derivation 2026-07-10 18:49:15 +08:00
10 changed files with 73 additions and 344 deletions
Generated
+46 -9
View File
@@ -245,6 +245,15 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block-buffer"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
dependencies = [
"hybrid-array",
]
[[package]]
name = "block-padding"
version = "0.3.3"
@@ -428,6 +437,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "const-oid"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
@@ -606,11 +621,22 @@ version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"block-buffer 0.10.4",
"crypto-common 0.1.7",
"subtle",
]
[[package]]
name = "digest"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer 0.12.1",
"const-oid",
"crypto-common 0.2.2",
]
[[package]]
name = "dirs"
version = "6.0.0"
@@ -944,7 +970,7 @@ version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
"digest 0.10.7",
]
[[package]]
@@ -1604,9 +1630,9 @@ version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
dependencies = [
"digest",
"digest 0.10.7",
"hmac",
"sha2",
"sha2 0.10.9",
]
[[package]]
@@ -2012,7 +2038,18 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest",
"digest 0.10.7",
]
[[package]]
name = "sha2"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"digest 0.11.3",
]
[[package]]
@@ -3181,7 +3218,7 @@ dependencies = [
"rusqlite",
"rust-stemmers",
"serde",
"sha2",
"sha2 0.11.0",
"tempfile",
"thiserror 2.0.18",
"wx-db",
@@ -3215,7 +3252,7 @@ dependencies = [
"cbc",
"hmac",
"pbkdf2",
"sha2",
"sha2 0.11.0",
"tempfile",
"thiserror 2.0.18",
]
@@ -3233,7 +3270,7 @@ dependencies = [
"mach2",
"regex",
"serde",
"sha2",
"sha2 0.11.0",
"tempfile",
"thiserror 2.0.18",
"tokio",
@@ -3272,7 +3309,7 @@ dependencies = [
"pbkdf2",
"rusqlite",
"serde",
"sha2",
"sha2 0.11.0",
"tempfile",
"thiserror 2.0.18",
"tokio",
+5 -39
View File
@@ -1,39 +1,6 @@
# wx-cli
> 把微信变成 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 和实时事件订阅覆盖查询与持续运行两类任务;
- 长驻服务可复用已打开的数据库,适合高频查询和定时记忆任务;
- 所有能力都以本地数据为中心,便于控制隐私边界。
WeChat macOS 数据库解密与查询工具。支持通过 `key extract`(LLDB hook)提取密钥,解密并查询 WeChat 4.1.7.x / 4.1.8.x 的 Apple SEE 加密 SQLite 数据
## 支持范围
@@ -93,9 +60,10 @@ source ~/.zshrc
wx-cli --version
```
### 让 Agent 直接使用
本项目提供 [Agent Skill](https://skills.sh)。安装后,Claude Code、Codex、Cursor 等 Agent 可以直接理解 wx-cli 的能力,并帮你读取历史消息、搜索聊天和订阅新消息:
### AI 编程助手集成
本项目提供 [Agent Skill](https://skills.sh),安装后 Claude Code、Codex、Cursor 等 AI 编程助手可直接协助查询微信数据:
```bash
npx skills add pandorafuture/wx-cli
@@ -173,9 +141,7 @@ wx-cli server stop # 停止
wx-cli server restart # 重启
```
REST 端点:`/api/v1/health``/api/v1/sessions``/api/v1/contacts``/api/v1/messages``/api/v1/timeline``/api/v1/search``/api/v1/media``/api/v1/events`SSE)。
其中 `/api/v1/timeline?since=<unix>&until=<unix>` 可在一次请求中读取时间范围内所有会话的消息,适合 Agent 记忆补全、归档和批处理,避免逐会话反复调用。
REST 端点:`/api/v1/health``/api/v1/sessions``/api/v1/contacts``/api/v1/messages``/api/v1/search``/api/v1/media``/api/v1/events`SSE)。
所有查询命令加 `--format json` 可获取 JSON 格式输出。
-2
View File
@@ -258,7 +258,6 @@ wx-cli server status / stop / restart # 管理服务
| `GET /api/v1/sessions` | 会话列表 | `limit`, `offset`, `order`, `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/timeline` | 跨全部会话按时间批量读取消息 | `since`(必填), `until`(必填), `limit`, `offset`, `type`, `order`, `show_hidden` |
| `GET /api/v1/media` | 媒体内容直出 | `server_id`(必填), `talker`(必填), `format=ogg\|mp3`(仅语音) |
| `GET /api/v1/search` | 全文搜索 | `q`(必填), `limit`, `offset` |
| `GET /api/v1/events` | SSE 事件流 | 无 |
@@ -314,7 +313,6 @@ wx-cli sessions # 4. 重试查询
|------|-------------|
| sessions | `username`, `display_name`, `summary`, `sort_timestamp`, `direction?` |
| query | `sort_seq`, `server_id`, `msg_type`, `sender`, `content`, `direction` |
| timeline API | `sort_seq`, `server_id`, `msg_type`, `sender`, `talker`, `talker_display_name`, `create_time`, `direction`, `snippet`;统一按时间跨会话排序 |
| contacts | `user_name`, `alias`, `remark`, `nick_name`, `phone`, `labels` |
| search | `server_id`, `talker`, `sender`, `snippet`, `hit_type` |
+18 -288
View File
@@ -8,18 +8,20 @@ use axum::http::StatusCode;
use axum::response::sse::{Event, KeepAlive, Sse};
use axum::response::IntoResponse;
use axum::Json;
use serde::{Deserialize, Serialize};
use serde::Deserialize;
use serde_json::json;
use tokio_stream::wrappers::BroadcastStream;
use tokio_stream::StreamExt;
use crate::cmd::server::types::{RuntimeAccountState, ServerHealthPayload};
use crate::output::{JsonEnvelope, PagingMeta, StatsMeta};
use crate::output::JsonEnvelope;
use crate::schema::{
enrich_message, enrich_message_as_hit, enrich_native_fts_hit, enrich_session,
project_message_items,
};
use crate::visibility_projection::{project_contacts_envelope, project_sessions_envelope_enriched};
use crate::visibility_projection::{
project_contacts_envelope, project_sessions_envelope_enriched,
};
use super::error::ServeError;
use super::event::SseEvent;
@@ -90,68 +92,6 @@ 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)]
@@ -281,7 +221,8 @@ pub async fn handler_contacts(
.query_contacts(&query)
.map_err(|e| ServeError::Db(e.to_string()))?;
let envelope = JsonEnvelope::from_query_result(result, wx_db::MAX_QUERY_LIMIT, 0, |c| c);
let envelope =
JsonEnvelope::from_query_result(result, wx_db::MAX_QUERY_LIMIT, 0, |c| c);
Ok::<_, ServeError>(project_contacts_envelope(
envelope.items,
&visibility,
@@ -438,7 +379,9 @@ pub async fn handler_messages(
// When limit pushdown was used (non-anchor), total_rows only reflects the
// scanned window. Use a lightweight COUNT(*) query for accurate DB-level total.
if !has_anchor {
let mt_filter = msg_type.as_ref().and_then(|s| wx_db::parse_msg_type(s));
let mt_filter = msg_type
.as_ref()
.and_then(|s| wx_db::parse_msg_type(s));
let db_total = guard.count_messages(
&talker,
since.unwrap_or(0),
@@ -462,219 +405,6 @@ pub async fn handler_messages(
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
// ---------------------------------------------------------------------------
@@ -717,9 +447,7 @@ pub async fn handler_search(
.map_err(|e: std::sync::PoisonError<_>| ServeError::Internal(e.to_string()))?;
// Lazy-init name2id cache with proper error propagation.
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()))?;
match cache_guard.as_ref() {
Some(map) => map.clone(),
@@ -796,7 +524,9 @@ pub async fn handler_search(
let first_attempt = guard
.pool()
.and_then(|pool| pool.fts_conn())
.map(|fts_conn| wx_db::native_fts::search_message_fts(fts_conn, &q, limit, offset));
.map(|fts_conn| {
wx_db::native_fts::search_message_fts(fts_conn, &q, limit, offset)
});
match first_attempt {
Some(Ok(r)) => Some(r),
@@ -809,8 +539,8 @@ pub async fn handler_search(
guard
.pool()
.and_then(|pool| pool.fts_conn())
.and_then(|fts_conn| {
match wx_db::native_fts::search_message_fts(
.and_then(
|fts_conn| match wx_db::native_fts::search_message_fts(
fts_conn, &q, limit, offset,
) {
Ok(r) => Some(r),
@@ -821,8 +551,8 @@ pub async fn handler_search(
);
None
}
}
})
},
)
}
Err(reopen_err) => {
eprintln!(
-1
View File
@@ -313,7 +313,6 @@ pub async fn cmd_serve(
eprintln!(" GET /api/v1/sessions");
eprintln!(" GET /api/v1/contacts");
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/search?q=<keyword>");
eprintln!(" Auth: {auth_status}");
-1
View File
@@ -15,7 +15,6 @@ pub fn build_router(state: Arc<AppState>) -> Router {
.route("/api/v1/sessions", get(handlers::handler_sessions))
.route("/api/v1/contacts", get(handlers::handler_contacts))
.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/search", get(handlers::handler_search))
.route("/api/v1/events", get(handlers::handler_sse))
+1 -1
View File
@@ -22,6 +22,6 @@ filetime = "0.2"
aes = "0.9"
cbc = "0.2"
hmac = "0.12"
sha2 = "0.10"
sha2 = "0.11"
pbkdf2 = { version = "0.12", features = ["hmac"] }
hex = "0.4"
+1 -1
View File
@@ -7,7 +7,7 @@ edition.workspace = true
aes = "0.9"
cbc = "0.2"
pbkdf2 = { version = "0.12", features = ["sha2"] }
sha2 = "0.10"
sha2 = "0.11"
hmac = "0.12"
thiserror = "2"
+1 -1
View File
@@ -24,5 +24,5 @@ libc = "0.2"
aes = "0.9"
cbc = "0.2"
hmac = "0.12"
sha2 = "0.10"
sha2 = "0.11"
tempfile = "3"
+1 -1
View File
@@ -18,7 +18,7 @@ serde = { version = "1", features = ["derive"] }
aes = "0.9"
cbc = "0.2"
hmac = "0.12"
sha2 = "0.10"
sha2 = "0.11"
pbkdf2 = { version = "0.12", features = ["sha2"] }
rusqlite = { version = "0.40", features = ["bundled"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] }