Compare commits

..

15 Commits
v0.2.0 ... main

Author SHA1 Message Date
jakevin 08af894594
fix(biz-articles): read all biz_message shards (#81) 2026-05-19 14:19:02 +08:00
jakevin 94fcc36ffe
feat(attachments): expose stable group sender identity (#77)
`q_attachments` 群聊场景下两个昵称同名的成员,原本只输出
`sender` 字段(取群名片),无法在 JSON 消费侧区分谁发的图。

跟 #68 把 `sender_username / sender_contact_display /
sender_group_nickname` 一起追加到 attachment row 上,复用
PR68 引入的 `add_sender_identity` / `sender_username` helper,
保持 4 处出口 (history / search / new-messages / stats.top_senders)
+ attachments 的字段语义完全一致。

调整:
- `q_attachments` 元组从 7 字段扩到 8 字段(多带一个稳定 wxid)
- spawn_blocking 内部多算一次 `sender_username`,per-row 复杂度 O(1)
- JSON build 处调用 `add_sender_identity`,行为对齐:非群 / 解析不到
  wxid 时三字段不输出

测试 / 文档:
- 新增 `attachment_row_gets_stable_group_sender_identity_via_helper`,
  锁住"两同名成员可被 sender_username 区分" + "非群 / 未知 sender
  不追加伪字段"
- README + SKILL.md 在 `attachments` 段和顶部 "sender 选择策略" 段
  同时记录新字段,标明 wxid 解析不到时的不输出语义

closes #23
2026-05-19 01:44:03 +08:00
jackwener 0612789d19 Merge pull request #68 from t0m1sacat/kael/sender-identity
fix: expose stable group sender identity
2026-05-19 01:14:58 +08:00
jackwener f8550ae74d Merge pull request #63 from Icy-Cat/feat/windows-mydocument-keyword
feat(windows): resolve MyDocument: token in Weixin data-root ini
2026-05-19 01:14:58 +08:00
jackwener 5f87ce6348 Merge pull request #62 from Icy-Cat/fix/init-error-shows-config-path
fix(init): show config.json path in auto-detect error
2026-05-19 01:14:58 +08:00
jackwener ed95812332 Merge pull request #76 from Suda202/fix/group-nickname-field
fix(members): ignore non-card fields for group nicknames
2026-05-19 01:14:58 +08:00
suda be1a174226 fix(members): ignore non-card fields for group nicknames 2026-05-18 23:18:20 +08:00
kael c34f5f8fe2 fix: expose stable group sender identity 2026-05-16 08:46:37 +08:00
jackwener 739b66a4b1 chore(release): bump version to 0.3.0 2026-05-16 02:23:46 +08:00
jackwener b5edaf7177 feat(meta): expose freshness coverage in query output 2026-05-16 02:22:03 +08:00
jackwener 9f6a2cfba3 review: restore cache mode coverage and rationale comments 2026-05-15 22:33:32 +08:00
jackwener 76024901e9 feat(meta): expose freshness coverage in query output 2026-05-15 22:08:46 +08:00
jakevin 12740afb53
docs(macos): document codesign side-effect popup (#64)
* docs(macos): document codesign side-effect popup ("微信" 想访问其他 App 的数据)

After `codesign --force --deep --sign - /Applications/WeChat.app`, macOS
treats the re-signed WeChat as a different code identity from the
original. When WeChat then accesses its own container / cache / app-group
data (notably triggered when opening 公众号 articles), macOS fires the
"'微信' 想访问其他 App 的数据" popup.

This is a known side-effect of the current macOS invasive init path,
not a "wx-cli is reading other apps' data" issue and not a 公众号-only
problem — 公众号 is just a high-frequency trigger surface because of
WebView / cache access.

Document this in 3 places per agreed scope:
- README.md macOS init: add "副作用提示" callout linking to the guide
- docs/macos-permission-guide.md: new §六 with first-principles
  explanation, mitigation options, and long-term direction
- src/cli/init.rs: print a short macOS-only warning at the end of
  `wx init` so users see it right when they finish the invasive setup

* review: stop overstating the trade-off and condition the init warning

Per codex review on PR #64:

1. src/cli/init.rs warning was unconditional but the wording presumed
   the user had taken the ad-hoc re-sign path. If init goes through the
   tier 2 path (Apple-signed WeChat + GUI Terminal + Developer Tools TCC
   authorization), the warning would mis-fire. Reword conditionally and
   point to the GitHub URL of the doc instead of a relative path that
   release-binary / npm-installed users won't have on disk.

2. docs/macos-permission-guide.md §六 and the matching README callout
   said "restoring official WeChat = giving up macOS memory-scan". This
   contradicts the same guide's §一 实测表 which shows
   "Apple 签名 + 本机 Terminal sudo = ". Restoring the official
   signature only gives up the default re-sign path; the local-Terminal
   + Developer-Tools route still works on Apple-signed WeChat. Only
   SSH + Apple-signed WeChat actually requires re-signing.

* review (round 2): caveat empirical gap + drop emoji

Self-review found two issues both LGTMs missed:

1. The "tier 2 仍走通" claim (README + §六) leans on §一 实测表 row
   "Apple 签名 + 本机 Terminal sudo = ". But that data only covers
   macOS 10.15 (Catalina) and 11.1 (Big Sur). macOS 14/15 — the exact
   versions where the popup behavior originates — were never tested
   for that path in this project. Add an explicit caveat instead of
   silently extrapolating across major macOS versions.

2. `init.rs` warning used a ⚠️ emoji prefix, which violates the
   project + global "no emojis in files unless requested" rule. README
   and the rest of init.rs have no emoji. Replace with `[macOS]`.
2026-05-15 15:47:15 +08:00
Icy-Cat b58ae5468d feat(windows): resolve MyDocument: token in Weixin data-root ini
The data-root ini under %APPDATA%\Tencent\xwechat\config\*.ini is
observed to contain either a plain absolute path (e.g. D:\WeChatFiles)
or the literal token 'MyDocument:'. The token form is not a real
filesystem path, so detect_db_dir_impl() — which previously did
PathBuf::from(content).is_dir() — silently failed on it, even though
the user's Weixin data was sitting in their (possibly relocated)
Documents folder.

Empirically the token denotes 'the calling user's Documents folder'.
We resolve it via SHGetKnownFolderPath(FOLDERID_Documents), which
honours the standard Windows shell-folder redirect (HKCU User Shell
Folders\Personal), so users who moved Documents to e.g. D:\Documents
now auto-detect correctly.

Plain absolute paths still pass through unchanged.

Adds Win32_UI_Shell + Win32_System_Com features to the windows crate
(needed for SHGetKnownFolderPath and CoTaskMemFree).
2026-05-15 11:53:35 +08:00
Icy-Cat 7451ce5684 fix(init): show config.json path in auto-detect error
When auto_detect_db_dir() fails, the error told the user to edit
config.json without saying where that file lives. On Windows that is
%USERPROFILE%\.wx-cli\config.json, which is non-obvious.

Use the config_path already computed at the top of cmd_init() so the
error message includes the absolute path, plus a concrete example of
the db_dir shape.
2026-05-15 11:49:40 +08:00
29 changed files with 3153 additions and 925 deletions

2
Cargo.lock generated
View File

@ -1313,7 +1313,7 @@ checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
[[package]] [[package]]
name = "wx-cli" name = "wx-cli"
version = "0.2.0" version = "0.3.0"
dependencies = [ dependencies = [
"aes", "aes",
"anyhow", "anyhow",

View File

@ -1,6 +1,6 @@
[package] [package]
name = "wx-cli" name = "wx-cli"
version = "0.2.0" version = "0.3.0"
edition = "2021" edition = "2021"
description = "WeChat 4.x (macOS/Linux) local data CLI — decrypt SQLCipher DBs, query chat history, watch new messages" description = "WeChat 4.x (macOS/Linux) local data CLI — decrypt SQLCipher DBs, query chat history, watch new messages"
license = "Apache-2.0" license = "Apache-2.0"
@ -71,6 +71,8 @@ windows = { version = "0.58", features = [
"Win32_System_Threading", "Win32_System_Threading",
"Win32_Foundation", "Win32_Foundation",
"Win32_System_Memory", "Win32_System_Memory",
"Win32_System_Com",
"Win32_UI_Shell",
] } ] }
[profile.release] [profile.release]

View File

@ -36,7 +36,7 @@ npx skills add jackwener/wx-cli -g
- **零依赖安装** — 单一 Rust 二进制,一行命令装完 - **零依赖安装** — 单一 Rust 二进制,一行命令装完
- **毫秒级响应** — 后台 daemon 持久缓存解密数据库mtime 不变则复用 - **毫秒级响应** — 后台 daemon 持久缓存解密数据库mtime 不变则复用
- **AI 友好**默认 YAML 输出,更省 token & 易读;`--json` 可切换为 JSON方便 `jq` 处理等) - **AI 友好**`history` / `search` / `sessions` / `new-messages` / `stats` / `attachments` 默认返回 `{..., meta}` wrapperagent 能直接消费 freshness / source 信息
- **完全本地** — 数据不出本机,实时解密,无需全量预解密 - **完全本地** — 数据不出本机,实时解密,无需全量预解密
--- ---
@ -121,6 +121,8 @@ sudo wx init
> >
> 重签名后 macOS 的 TCC 隐私授权按新 code signature 重新校验,旧记录会失效。如果跳过 `tccutil reset`,微信截图/视频通话/麦克风等权限可能"看起来已开启但实际拒绝"。详见 [macOS 权限与签名指南](docs/macos-permission-guide.md#五重签名后微信权限-silent-失效)。 > 重签名后 macOS 的 TCC 隐私授权按新 code signature 重新校验,旧记录会失效。如果跳过 `tccutil reset`,微信截图/视频通话/麦克风等权限可能"看起来已开启但实际拒绝"。详见 [macOS 权限与签名指南](docs/macos-permission-guide.md#五重签名后微信权限-silent-失效)。
> **副作用提示**:完成上面的 ad-hoc 重签后macOS 会比较频繁地弹 `"微信" 想访问其他 App 的数据`(在微信里打开公众号文章时尤其容易触发)。这是当前 macOS invasive init 路径的已知副作用:重签后 WeChat 的 code identity 变了,它再访问自己原来的 container / 缓存数据会被系统识别为"跨 App 访问"。点"允许"通常只是放行当前 WeChat 进程;想彻底不弹得恢复官方 WeChat——这只放弃**当前依赖重签的默认路径****不等于放弃 memory-scan**:在本机 GUI Terminal 下、Terminal.app 拿到「开发者工具」TCC 授权后,对 Apple 官方签名的 WeChat 应当仍可以走通(实证覆盖只有 Catalina / Big SurmacOS 14+ 未在本项目内实测);只有 SSH 远程 + Apple 签名 WeChat 这种组合才必须重签。详见 [macOS 权限与签名指南 §六](docs/macos-permission-guide.md#六微信-想访问其他-app-的数据-弹窗)。
**Linux** **Linux**
```bash ```bash
@ -166,6 +168,23 @@ wx search "会议" --in "工作群" --since 2026-01-01
群聊里的 `last_sender`、`sender` 和 `stats``top_senders` 会优先使用群昵称(群名片)。如果本地数据库里没有对应群昵称,则回退到联系人备注、微信昵称或 username。 群聊里的 `last_sender`、`sender` 和 `stats``top_senders` 会优先使用群昵称(群名片)。如果本地数据库里没有对应群昵称,则回退到联系人备注、微信昵称或 username。
`history` / `search` / `new-messages` / `attachments` 以及 `stats.top_senders`,在群聊上下文里还会附带稳定身份三件套:
- `sender_username`:稳定 wxid用来区分两个昵称同名的成员
- `sender_contact_display`:通讯录里的显示名(备注 > 昵称 > wxid 兜底)
- `sender_group_nickname`:群名片本身(同 `sender` 的来源,方便机器读取时不必再解析)
解析不到 wxid 时id2u 没命中且老格式 `wxid_xxx:\n...` 前缀也不存在)这三字段不会输出,避免伪造空字段污染下游过滤。
`history` / `search` / `sessions` / `unread` / `new-messages` / `stats` / `attachments` 现在都会附带 `meta`
- `status`: `ok` / `possibly_stale` / `possibly_stale_unknown_shards` / `windowed`
- `unknown_shards`: 磁盘上存在、但 daemon 当前没有 key 的 `message_N.db` 分片;非空时应先跑 `wx init --force`
- `chat_latest_timestamp` / `chat_latest_db`: 当前命中数据里最新一条消息的时间和分片来源
- `session_last_timestamp`: `session.db` 里 WeChat 自己记录的最新时间;如果明显领先于 `chat_latest_timestamp`,说明结果可能漏了消息
默认情况下,人类用户会在 stderr 看到可执行的 warningagent / 脚本可直接读 stdout 里的 `meta`。传 `--with-meta` 会额外返回 `per_shard_latest` / `cache_mode_per_shard`,传隐藏 flag `--debug-source` 还会带真实 `shard_paths`
引用消息会在 `history` / `search` / `new-messages` 输出中显示当前回复和被引用原文: 引用消息会在 `history` / `search` / `new-messages` 输出中显示当前回复和被引用原文:
```text ```text
@ -198,7 +217,7 @@ wx sns-search "婚礼" --user "李四" --since 2023-01-01
### 公众号文章 ### 公众号文章
公众号文章推送存在独立的 `biz_message_0.db`,用 `biz-articles` 单独查: 公众号文章推送存在独立的 `biz_message_*.db` 分片,用 `biz-articles` 单独查:
```bash ```bash
wx biz-articles # 最近 50 篇 wx biz-articles # 最近 50 篇
@ -226,7 +245,7 @@ wx extract <attachment_id> -o ~/Desktop/photo.jpg
wx extract <attachment_id> -o /tmp/x.jpg --overwrite wx extract <attachment_id> -o /tmp/x.jpg --overwrite
``` ```
`attachments` 输出每条带:`attachment_id` / `kind` / `type` / `local_id` / `timestamp` / `time`,群聊里还有 `sender`。当前 `kind` 固定为 `image`;命令名保留成 `attachments` 是为了后续扩到其他附件类型时不 break CLI。 `attachments` 输出每条带:`attachment_id` / `kind` / `type` / `local_id` / `timestamp` / `time`,群聊里还有 `sender` 以及稳定身份三件套 `sender_username` / `sender_contact_display` / `sender_group_nickname`(语义同 `history` / `search` / `new-messages``sender_username` 是 wxid用于两个同名成员之间的稳定区分解析不到 wxid 时这三字段不输出)。当前 `kind` 固定为 `image`;命令名保留成 `attachments` 是为了后续扩到其他附件类型时不 break CLI。
`extract` 输出报告里带:`md5` / `dat_path` / `dat_size` / `output` / `output_size` / `format`实际识别出的图片格式jpg / png / gif / webp / hevc 等)/ `decoder`(实际选用的解码器:`legacy_xor` / `v1_aes` / `v2`)。 `extract` 输出报告里带:`md5` / `dat_path` / `dat_size` / `output` / `output_size` / `format`实际识别出的图片格式jpg / png / gif / webp / hevc 等)/ `decoder`(实际选用的解码器:`legacy_xor` / `v1_aes` / `v2`)。
@ -276,12 +295,14 @@ wx export "AI群" --since 2026-01-01 --format json
### 输出格式 ### 输出格式
默认输出 YAML,更省 token & 易读;`--json` 可切换为 JSON方便 `jq` 处理等) 默认输出 YAML`--json` 可切换为 JSON。对 agent 而言,`history` / `search` / `sessions` / `new-messages` / `stats` / `attachments` 的 stdout 现在是 wrapper而不是裸数组
```bash ```bash
wx sessions --json wx sessions --json
wx search "关键词" --json | jq '.[0].content' wx search "关键词" --json | jq '.results[0].content'
wx new-messages --json wx new-messages --json
wx history "张三" --json | jq '.meta'
wx history "张三" --json --with-meta | jq '.meta.cache_mode_per_shard'
``` ```
### Daemon 管理 ### Daemon 管理

View File

@ -159,6 +159,31 @@ wx search "会议" --in "工作群" --since 2026-01-01
群聊消息里的 `last_sender`、`sender` 和 `stats.top_senders` 会优先显示群昵称(群名片)。如果本地数据库没有群昵称,再回退到联系人备注、微信昵称或 username。 群聊消息里的 `last_sender`、`sender` 和 `stats.top_senders` 会优先显示群昵称(群名片)。如果本地数据库没有群昵称,再回退到联系人备注、微信昵称或 username。
`history` / `search` / `new-messages` / `attachments``stats.top_senders` 在群上下文里同时输出稳定身份三件套:`sender_username`(稳定 wxid用来区分同名成员/ `sender_contact_display`(备注 > 昵称 > wxid 兜底)/ `sender_group_nickname`(群名片,等价于 `sender` 的来源,免去再做字符串解析)。当 wxid 解析不到时,这三字段不会输出,避免空字符串污染下游过滤。
`sessions` / `unread` / `history` / `search` / `new-messages` / `stats` / `attachments` 的 stdout 现在统一是 wrapper
```json
{
"messages": [...],
"meta": {
"status": "ok",
"unknown_shards": [],
"chat_latest_timestamp": 1715750400,
"chat_latest_db": "message/message_2.db",
"session_last_timestamp": 1715760000
}
}
```
其中:
- `status = possibly_stale_unknown_shards`:磁盘上出现 daemon 不认识的新 `message_N.db`,先跑 `wx init --force`
- `status = possibly_stale``session.db` 记录的最新时间明显领先于本次查到的最新消息,结果可能漏消息
- `status = windowed`:这次查询本来就是窗口化/过滤后的局部视图,不应把它当作"全量最新状态"
- `--with-meta`:额外返回 `per_shard_latest` / `cache_mode_per_shard`
- `--debug-source`:在 `--with-meta` 基础上再暴露真实 `shard_paths`
引用消息appmsg `type=57`)在 `history` / `search` / `new-messages` 输出里会展开为两行:第一行是当前回复,第二行以 `↳` 开头显示被引用原文,例如: 引用消息appmsg `type=57`)在 `history` / `search` / `new-messages` 输出里会展开为两行:第一行是当前回复,第二行以 `↳` 开头显示被引用原文,例如:
```text ```text
@ -217,7 +242,7 @@ wx sns-search "婚礼" --user "李四" --since 2023-01-01 -n 50
### 公众号文章 ### 公众号文章
公众号的文章推送存在独立的 `biz_message_0.db`,与普通 `message_0.db` 分开: 公众号的文章推送存在独立的 `biz_message_*.db` 分片,与普通 `message_0.db` 分开:
```bash ```bash
# 最近 50 篇(默认) # 最近 50 篇(默认)
@ -257,7 +282,7 @@ wx extract <attachment_id> -o ~/Desktop/photo.jpg
wx extract <attachment_id> -o /tmp/x.jpg --overwrite wx extract <attachment_id> -o /tmp/x.jpg --overwrite
``` ```
`attachments` 输出每条带:`attachment_id` / `kind`(当前固定 `image`/ `type` / `local_id` / `timestamp` / `time`,群聊里另带 `sender`。命令名保留成 `attachments` 是为了后续扩到其他附件类型时不 break CLI。 `attachments` 输出每条带:`attachment_id` / `kind`(当前固定 `image`/ `type` / `local_id` / `timestamp` / `time`,群聊里另带 `sender` 和稳定身份三件套(同上文)。命令名保留成 `attachments` 是为了后续扩到其他附件类型时不 break CLI。
`extract` 报告里带:`md5` / `dat_path` / `dat_size` / `output` / `output_size` / `format`实际识别出的图片格式jpg / png / gif / webp / hevc 等)/ `decoder`(实际选用的解码器:`legacy_xor` / `v1_aes` / `v2`)。 `extract` 报告里带:`md5` / `dat_path` / `dat_size` / `output` / `output_size` / `format`实际识别出的图片格式jpg / png / gif / webp / hevc 等)/ `decoder`(实际选用的解码器:`legacy_xor` / `v1_aes` / `v2`)。
@ -315,8 +340,10 @@ wx daemon logs --follow
```bash ```bash
wx sessions --json wx sessions --json
wx new-messages --json wx new-messages --json
wx search "关键词" --json wx search "关键词" --json | jq '.results[0]'
wx history "张三" --json -n 50 wx history "张三" --json -n 50 | jq '.messages[0]'
wx history "张三" --json | jq '.meta'
wx history "张三" --json --with-meta | jq '.meta.cache_mode_per_shard'
``` ```
CHAT 参数支持昵称、备注名、微信 ID模糊匹配。不确定准确名称时先用 `wx contacts --query` 搜索。 CHAT 参数支持昵称、备注名、微信 ID模糊匹配。不确定准确名称时先用 `wx contacts --query` 搜索。

View File

@ -272,3 +272,50 @@ TeamIdentifier=not set
``` ```
最直接的功能验证:在微信里使用截图、视频通话、麦克风等功能,按 GUI 弹窗的"允许"重新授权一次,之后正常工作。 最直接的功能验证:在微信里使用截图、视频通话、麦克风等功能,按 GUI 弹窗的"允许"重新授权一次,之后正常工作。
---
## 六、`"微信" 想访问其他 App 的数据` 弹窗
### 现象
执行过 `wx init`、对 `/Applications/WeChat.app` 做过 ad-hoc 重签名之后,再使用微信时会比较频繁地看到 macOS 弹出:
```
"微信" 想访问其他 App 的数据。
单独存放 App 数据可让你更容易管理隐私和安全。
[ 不允许 ] [ 允许 ]
```
最常见的触发面是**在微信里打开公众号文章**,但这只是高频触发面,不是根因。
### 根因(第一性原理)
这弹窗是 macOS Ventura+ / 14 / 15 对 **app data container 跨身份访问** 的保护:当前进程("微信")正在读取另一个 code identity 的 app 留下的数据。
我们当前 macOS 方案为了让 `task_for_pid` 能拿到 WeChat 的 task port、读取进程内存里的 raw key要求用户执行
```bash
codesign --force --deep --sign - /Applications/WeChat.app
```
这一步把 WeChat 从 Apple 官方签名换成 ad-hoc 身份。对用户来说它仍然是"微信";对 macOS 安全模型来说,**重签前的 WeChat** 和 **重签后的 WeChat** 已经不是同一个 app identity。
之后当(重签后的)微信访问它原本的 `~/Library/Containers/com.tencent.xinWeChat/...`、缓存、app group 等数据时,系统看到的是"一个新身份在读旧身份留下的 container 数据",于是按隐私保护策略弹这个对话框。公众号文章里的 webview / cookie / 缓存路径刚好踩到了这条访问路径,所以"打开公众号就弹"会非常容易复现,但**本质不是公众号页面的问题**,而是 code identity + container access。
> 注意:这**不是** "wx-cli 在偷偷读别的 App 的数据"wx-cli 进程本身对 WeChat container 是只读访问;但**要求用户重签 WeChat** 这一步本身就是这类弹窗的直接诱因。所以这是当前 macOS invasive init 路径的已知副作用,不是与 wx-cli 无关的系统行为。
### 应对
短期缓解:
- 点"允许"通常只是放行**当前这次** WeChat 进程;下一次 WeChat 启动权限会 reset可能还会再弹
- 该授权一般不会在 System Settings 里留下显式开关,因为它绑定的是动态的 code identity
彻底不弹:
- 把 `/Applications/WeChat.app` 恢复成官方签名(重装官方 WeChat 包),不再执行 `codesign --force --deep --sign -`
- 这一步只是放弃**当前依赖 ad-hoc 重签的默认路径**,并不等于放弃 macOS memory-scan在本机 GUI Terminal 下、对 Terminal.app 授予「开发者工具」TCC 权限后,`task_for_pid` 对 Apple 官方签名hardened runtime的 WeChat 应当仍能走通——参考 §一 实测表里的"Apple 签名 + 本机 Terminal sudo = ✅"
- ⚠️ 实测覆盖范围说明:§一 实测表里 "Apple 签名 + 本机 Terminal sudo ✅" 的两条实证只覆盖 macOS 10.15 (Catalina) 与 11.1 (Big Sur)macOS 14 (Sonoma) / 15 (Sequoia) 上是否仍走通**未在本项目内实测**。如果你按这条路恢复官方签名后发现 init 走不通,请回到重签路径并接受本节描述的弹窗副作用
- 真正受限的场景是 SSH 远程 + Apple 签名 WeChat`sshd` 拿不到 TCC 开发者工具授权,这时才必须走重签路径
长期方向:
- 这条副作用的真正修复是把 `wx init` 重新设计成 `safe → assisted → invasive fallback` 三层:默认不动 WeChat只有在前两条都不可行时才走 ad-hoc 重签,并先打出完整副作用清单让用户显式确认。在那之前,这是已知 trade-off。

View File

@ -1,6 +1,6 @@
{ {
"name": "@jackwener/wx-cli-darwin-arm64", "name": "@jackwener/wx-cli-darwin-arm64",
"version": "0.2.0", "version": "0.3.0",
"description": "wx-cli binary for macOS arm64", "description": "wx-cli binary for macOS arm64",
"os": ["darwin"], "os": ["darwin"],
"cpu": ["arm64"], "cpu": ["arm64"],

View File

@ -1,6 +1,6 @@
{ {
"name": "@jackwener/wx-cli-darwin-x64", "name": "@jackwener/wx-cli-darwin-x64",
"version": "0.2.0", "version": "0.3.0",
"description": "wx-cli binary for macOS x64", "description": "wx-cli binary for macOS x64",
"os": ["darwin"], "os": ["darwin"],
"cpu": ["x64"], "cpu": ["x64"],

View File

@ -1,6 +1,6 @@
{ {
"name": "@jackwener/wx-cli-linux-arm64", "name": "@jackwener/wx-cli-linux-arm64",
"version": "0.2.0", "version": "0.3.0",
"description": "wx-cli binary for Linux arm64", "description": "wx-cli binary for Linux arm64",
"os": ["linux"], "os": ["linux"],
"cpu": ["arm64"], "cpu": ["arm64"],

View File

@ -1,6 +1,6 @@
{ {
"name": "@jackwener/wx-cli-linux-x64", "name": "@jackwener/wx-cli-linux-x64",
"version": "0.2.0", "version": "0.3.0",
"description": "wx-cli binary for Linux x64", "description": "wx-cli binary for Linux x64",
"os": ["linux"], "os": ["linux"],
"cpu": ["x64"], "cpu": ["x64"],

View File

@ -1,6 +1,6 @@
{ {
"name": "@jackwener/wx-cli-win32-x64", "name": "@jackwener/wx-cli-win32-x64",
"version": "0.2.0", "version": "0.3.0",
"description": "wx-cli binary for Windows x64", "description": "wx-cli binary for Windows x64",
"os": ["win32"], "os": ["win32"],
"cpu": ["x64"], "cpu": ["x64"],

View File

@ -1,6 +1,6 @@
{ {
"name": "@jackwener/wx-cli", "name": "@jackwener/wx-cli",
"version": "0.2.0", "version": "0.3.0",
"description": "Query your local WeChat data from the command line. Designed for LLM agent tool calls.", "description": "Query your local WeChat data from the command line. Designed for LLM agent tool calls.",
"bin": { "bin": {
"wx": "bin/wx.js" "wx": "bin/wx.js"
@ -13,11 +13,11 @@
"install.js" "install.js"
], ],
"optionalDependencies": { "optionalDependencies": {
"@jackwener/wx-cli-darwin-arm64": "0.2.0", "@jackwener/wx-cli-darwin-arm64": "0.3.0",
"@jackwener/wx-cli-darwin-x64": "0.2.0", "@jackwener/wx-cli-darwin-x64": "0.3.0",
"@jackwener/wx-cli-linux-x64": "0.2.0", "@jackwener/wx-cli-linux-x64": "0.3.0",
"@jackwener/wx-cli-linux-arm64": "0.2.0", "@jackwener/wx-cli-linux-arm64": "0.3.0",
"@jackwener/wx-cli-win32-x64": "0.2.0" "@jackwener/wx-cli-win32-x64": "0.3.0"
}, },
"engines": { "node": ">=14" }, "engines": { "node": ">=14" },
"keywords": ["wechat", "cli", "wx", "llm", "ai", "sqlite", "sqlcipher"], "keywords": ["wechat", "cli", "wx", "llm", "ai", "sqlite", "sqlcipher"],

View File

@ -1,9 +1,9 @@
use anyhow::Result; use anyhow::Result;
use crate::ipc::Request;
use super::history::{parse_time, parse_time_end}; use super::history::{parse_time, parse_time_end};
use super::output::{print_value, resolve}; use super::output::{emit_warnings, print_response, OutputOpts};
use super::transport; use super::transport;
use crate::ipc::Request;
/// `wx attachments` — 列出指定会话的附件消息(默认 image可多选 /// `wx attachments` — 列出指定会话的附件消息(默认 image可多选
/// ///
@ -16,10 +16,11 @@ pub fn cmd_attachments(
offset: usize, offset: usize,
since: Option<String>, since: Option<String>,
until: Option<String>, until: Option<String>,
json: bool, opts: OutputOpts,
) -> Result<()> { ) -> Result<()> {
let since_ts = since.as_deref().map(parse_time).transpose()?; let since_ts = since.as_deref().map(parse_time).transpose()?;
let until_ts = until.as_deref().map(parse_time_end).transpose()?; let until_ts = until.as_deref().map(parse_time_end).transpose()?;
let (with_meta, debug_source) = opts.request_flags();
// CLI 收上来的 Vec<String> 为空时按默认image让 daemon 决定 fallback。 // CLI 收上来的 Vec<String> 为空时按默认image让 daemon 决定 fallback。
let kinds_param = if kinds.is_empty() { None } else { Some(kinds) }; let kinds_param = if kinds.is_empty() { None } else { Some(kinds) };
@ -31,12 +32,10 @@ pub fn cmd_attachments(
offset, offset,
since: since_ts, since: since_ts,
until: until_ts, until: until_ts,
with_meta,
debug_source,
}; };
let resp = transport::send(req)?; let resp = transport::send(req)?;
let data = resp emit_warnings(&resp.data);
.data print_response(&resp.data, &opts)
.get("attachments")
.cloned()
.unwrap_or(serde_json::Value::Array(vec![]));
print_value(&data, &resolve(json))
} }

View File

@ -1,7 +1,8 @@
use anyhow::Result;
use crate::ipc::Request;
use super::transport;
use super::history::{parse_time, parse_time_end}; use super::history::{parse_time, parse_time_end};
use super::output::{emit_warnings, warning_block_markdown, warning_block_text, OutputOpts};
use super::transport;
use crate::ipc::Request;
use anyhow::Result;
pub fn cmd_export( pub fn cmd_export(
chat: String, chat: String,
@ -10,9 +11,11 @@ pub fn cmd_export(
limit: usize, limit: usize,
format: String, format: String,
output: Option<String>, output: Option<String>,
opts: OutputOpts,
) -> Result<()> { ) -> Result<()> {
let since_ts = since.as_deref().map(parse_time).transpose()?; let since_ts = since.as_deref().map(parse_time).transpose()?;
let until_ts = until.as_deref().map(parse_time_end).transpose()?; let until_ts = until.as_deref().map(parse_time_end).transpose()?;
let (with_meta, debug_source) = opts.request_flags();
let req = Request::History { let req = Request::History {
chat, chat,
@ -21,24 +24,42 @@ pub fn cmd_export(
since: since_ts, since: since_ts,
until: until_ts, until: until_ts,
msg_type: None, msg_type: None,
with_meta,
debug_source,
}; };
let resp = transport::send(req)?; let resp = transport::send(req)?;
let messages = resp.data["messages"].as_array().cloned().unwrap_or_default(); emit_warnings(&resp.data);
let messages = resp.data["messages"]
.as_array()
.cloned()
.unwrap_or_default();
let chat_name = resp.data["chat"].as_str().unwrap_or("").to_string(); let chat_name = resp.data["chat"].as_str().unwrap_or("").to_string();
let is_group = resp.data["is_group"].as_bool().unwrap_or(false); let is_group = resp.data["is_group"].as_bool().unwrap_or(false);
let count = messages.len(); let count = messages.len();
let text = match format.as_str() { let text = match format.as_str() {
"json" => serde_json::to_string_pretty(&resp.data)?, "json" => serde_json::to_string_pretty(&resp.data)?,
"yaml" => serde_yaml::to_string(&resp.data)?,
"txt" => { "txt" => {
let group_str = if is_group { "[群]" } else { "" }; let group_str = if is_group { "[群]" } else { "" };
let mut lines = vec![format!("=== {}{} ({} 条) ===\n", chat_name, group_str, count)]; let mut lines = vec![format!(
"=== {}{} ({} 条) ===\n",
chat_name, group_str, count
)];
if let Some(warn) = warning_block_text(&resp.data) {
lines.push(warn);
lines.push(String::new());
}
for m in &messages { for m in &messages {
let time = m["time"].as_str().unwrap_or(""); let time = m["time"].as_str().unwrap_or("");
let sender = m["sender"].as_str().unwrap_or(""); let sender = m["sender"].as_str().unwrap_or("");
let content = m["content"].as_str().unwrap_or(""); let content = m["content"].as_str().unwrap_or("");
let sender_str = if !sender.is_empty() { format!("{}: ", sender) } else { String::new() }; let sender_str = if !sender.is_empty() {
format!("{}: ", sender)
} else {
String::new()
};
lines.push(format!("[{}] {}{}", time, sender_str, content)); lines.push(format!("[{}] {}{}", time, sender_str, content));
} }
lines.join("\n") lines.join("\n")
@ -50,11 +71,18 @@ pub fn cmd_export(
format!("# {}{}", chat_name, group_str), format!("# {}{}", chat_name, group_str),
format!("\n> 导出 {} 条消息\n", count), format!("\n> 导出 {} 条消息\n", count),
]; ];
if let Some(warn) = warning_block_markdown(&resp.data) {
lines.push(warn);
}
for m in &messages { for m in &messages {
let time = m["time"].as_str().unwrap_or(""); let time = m["time"].as_str().unwrap_or("");
let sender = m["sender"].as_str().unwrap_or(""); let sender = m["sender"].as_str().unwrap_or("");
let content = m["content"].as_str().unwrap_or("").replace('\n', "\n> "); let content = m["content"].as_str().unwrap_or("").replace('\n', "\n> ");
let sender_md = if !sender.is_empty() { format!("**{}**: ", sender) } else { String::new() }; let sender_md = if !sender.is_empty() {
format!("**{}**: ", sender)
} else {
String::new()
};
lines.push(format!("### {}\n\n{}{}\n", time, sender_md, content)); lines.push(format!("### {}\n\n{}{}\n", time, sender_md, content));
} }
lines.join("\n") lines.join("\n")

View File

@ -1,7 +1,7 @@
use anyhow::Result; use super::output::{emit_warnings, print_response, OutputOpts};
use crate::ipc::Request;
use super::transport; use super::transport;
use super::output::{resolve, print_value}; use crate::ipc::Request;
use anyhow::Result;
pub fn cmd_history( pub fn cmd_history(
chat: String, chat: String,
@ -10,37 +10,51 @@ pub fn cmd_history(
since: Option<String>, since: Option<String>,
until: Option<String>, until: Option<String>,
msg_type: Option<String>, msg_type: Option<String>,
json: bool, opts: OutputOpts,
) -> Result<()> { ) -> Result<()> {
let since_ts = since.as_deref().map(parse_time).transpose()?; let since_ts = since.as_deref().map(parse_time).transpose()?;
let until_ts = until.as_deref().map(parse_time_end).transpose()?; let until_ts = until.as_deref().map(parse_time_end).transpose()?;
let type_val = msg_type.as_deref().and_then(parse_msg_type); let type_val = msg_type.as_deref().and_then(parse_msg_type);
let (with_meta, debug_source) = opts.request_flags();
let req = Request::History { chat, limit, offset, since: since_ts, until: until_ts, msg_type: type_val }; let req = Request::History {
chat,
limit,
offset,
since: since_ts,
until: until_ts,
msg_type: type_val,
with_meta,
debug_source,
};
let resp = transport::send(req)?; let resp = transport::send(req)?;
emit_warnings(&resp.data);
let msgs = resp.data.get("messages") print_response(&resp.data, &opts)
.cloned()
.unwrap_or(serde_json::Value::Array(vec![]));
print_value(&msgs, &resolve(json))
} }
pub fn parse_time(s: &str) -> Result<i64> { pub fn parse_time(s: &str) -> Result<i64> {
use chrono::{Local, TimeZone}; use chrono::{Local, TimeZone};
for fmt in &["%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"] { for fmt in &["%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"] {
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, fmt) { if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, fmt) {
return Local.from_local_datetime(&dt).single() return Local
.from_local_datetime(&dt)
.single()
.map(|d| d.timestamp()) .map(|d| d.timestamp())
.ok_or_else(|| anyhow::anyhow!("本地时间歧义: {}", s)); .ok_or_else(|| anyhow::anyhow!("本地时间歧义: {}", s));
} }
} }
if let Ok(d) = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") { if let Ok(d) = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") {
let dt = d.and_hms_opt(0, 0, 0).unwrap(); let dt = d.and_hms_opt(0, 0, 0).unwrap();
return Local.from_local_datetime(&dt).single() return Local
.from_local_datetime(&dt)
.single()
.map(|d| d.timestamp()) .map(|d| d.timestamp())
.ok_or_else(|| anyhow::anyhow!("本地时间歧义: {}", s)); .ok_or_else(|| anyhow::anyhow!("本地时间歧义: {}", s));
} }
anyhow::bail!("无法解析时间 '{}',支持 YYYY-MM-DD / YYYY-MM-DD HH:MM / YYYY-MM-DD HH:MM:SS", s) anyhow::bail!(
"无法解析时间 '{}',支持 YYYY-MM-DD / YYYY-MM-DD HH:MM / YYYY-MM-DD HH:MM:SS",
s
)
} }
pub fn parse_time_end(s: &str) -> Result<i64> { pub fn parse_time_end(s: &str) -> Result<i64> {
@ -48,7 +62,9 @@ pub fn parse_time_end(s: &str) -> Result<i64> {
if s.len() == 10 { if s.len() == 10 {
if let Ok(d) = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") { if let Ok(d) = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") {
let dt = d.and_hms_opt(23, 59, 59).unwrap(); let dt = d.and_hms_opt(23, 59, 59).unwrap();
return Local.from_local_datetime(&dt).single() return Local
.from_local_datetime(&dt)
.single()
.map(|d| d.timestamp()) .map(|d| d.timestamp())
.ok_or_else(|| anyhow::anyhow!("本地时间歧义: {}", s)); .ok_or_else(|| anyhow::anyhow!("本地时间歧义: {}", s));
} }
@ -59,15 +75,15 @@ pub fn parse_time_end(s: &str) -> Result<i64> {
/// 将消息类型字符串转为 local_type 整数,未知类型返回 None /// 将消息类型字符串转为 local_type 整数,未知类型返回 None
pub fn parse_msg_type(s: &str) -> Option<i64> { pub fn parse_msg_type(s: &str) -> Option<i64> {
match s { match s {
"text" => Some(1), "text" => Some(1),
"image" => Some(3), "image" => Some(3),
"voice" => Some(34), "voice" => Some(34),
"video" => Some(43), "video" => Some(43),
"sticker" => Some(47), "sticker" => Some(47),
"location" => Some(48), "location" => Some(48),
"link" | "file" => Some(49), "link" | "file" => Some(49),
"call" => Some(50), "call" => Some(50),
"system" => Some(10000), "system" => Some(10000),
_ => None, _ => None,
} }
} }

View File

@ -35,8 +35,13 @@ pub fn cmd_init(force: bool) -> Result<()> {
// Step 1: 检测 db_dir // Step 1: 检测 db_dir
println!("检测微信数据目录..."); println!("检测微信数据目录...");
let db_dir = config::auto_detect_db_dir() let db_dir = config::auto_detect_db_dir().with_context(|| format!(
.context("未能自动检测到微信数据目录\n请手动编辑 config.json 中的 db_dir 字段")?; "未能自动检测到微信数据目录\n\
db_dir :\n \
{}\n\
db_dir : <data_root>\\xwechat_files\\<wxid>\\db_storage",
config_path.display()
))?;
println!("找到数据目录: {}", db_dir.display()); println!("找到数据目录: {}", db_dir.display());
// Step 2: 扫描密钥(需要 root/sudo // Step 2: 扫描密钥(需要 root/sudo
@ -97,6 +102,19 @@ pub fn cmd_init(force: bool) -> Result<()> {
println!("初始化完成,可以使用 wx sessions / wx history 等命令了"); println!("初始化完成,可以使用 wx sessions / wx history 等命令了");
#[cfg(target_os = "macos")]
{
eprintln!();
eprintln!("[macOS] 副作用提示:");
eprintln!(" 如果你是通过对 /Applications/WeChat.app 做 ad-hoc 重签来让 init 走通的,");
eprintln!(" 之后 macOS 可能弹 \"微信\" 想访问其他 App 的数据(在微信里打开公众号文章");
eprintln!(" 时尤其常见)。这是 ad-hoc 重签后 WeChat 的 code identity 变了导致的,");
eprintln!(" 不是 wx-cli 在读其他 App 数据。");
eprintln!(" 完整说明https://github.com/jackwener/wx-cli/blob/main/docs/macos-permission-guide.md#六微信-想访问其他-app-的数据-弹窗");
eprintln!(" (如果你的 WeChat 仍是 Apple 官方签名、init 是靠 GUI Terminal + 开发者工具");
eprintln!(" 授权走通的,则不会出现这个弹窗,可以忽略本提示。)");
}
Ok(()) Ok(())
} }

View File

@ -1,24 +1,25 @@
mod init;
pub mod attachments; pub mod attachments;
pub mod biz_articles; pub mod biz_articles;
pub mod extract;
pub mod sessions;
pub mod history;
pub mod search;
pub mod contacts; pub mod contacts;
pub mod export;
pub mod daemon_cmd; pub mod daemon_cmd;
pub mod transport; pub mod export;
pub mod output; pub mod extract;
pub mod unread; pub mod favorites;
pub mod history;
mod init;
pub mod members; pub mod members;
pub mod new_messages; pub mod new_messages;
pub mod stats; pub mod output;
pub mod favorites; pub mod search;
pub mod sns_notifications; pub mod sessions;
pub mod sns_feed; pub mod sns_feed;
pub mod sns_notifications;
pub mod sns_search; pub mod sns_search;
pub mod stats;
pub mod transport;
pub mod unread;
use self::output::OutputOpts;
use anyhow::Result; use anyhow::Result;
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
@ -26,6 +27,12 @@ use clap::{Parser, Subcommand};
#[derive(Parser)] #[derive(Parser)]
#[command(name = "wx", version = env!("CARGO_PKG_VERSION"), about = "wx — 微信本地数据 CLI")] #[command(name = "wx", version = env!("CARGO_PKG_VERSION"), about = "wx — 微信本地数据 CLI")]
pub struct Cli { pub struct Cli {
/// 返回更重的 freshness/source 元数据(如 per-shard latest、cache modes
#[arg(long, global = true)]
with_meta: bool,
/// 在 meta 里暴露真实 shard 路径(调试用)
#[arg(long, global = true, hide = true)]
debug_source: bool,
#[command(subcommand)] #[command(subcommand)]
command: Commands, command: Commands,
} }
@ -335,46 +342,184 @@ pub fn run() {
} }
fn dispatch(cli: Cli) -> Result<()> { fn dispatch(cli: Cli) -> Result<()> {
let base_with_meta = cli.with_meta;
let base_debug_source = cli.debug_source;
match cli.command { match cli.command {
Commands::Init { force } => init::cmd_init(force), Commands::Init { force } => init::cmd_init(force),
Commands::Sessions { limit, json } => sessions::cmd_sessions(limit, json), Commands::Sessions { limit, json } => sessions::cmd_sessions(
Commands::History { chat, limit, offset, since, until, msg_type, json } => { limit,
history::cmd_history(chat, limit, offset, since, until, msg_type, json) OutputOpts {
} json,
Commands::Search { keyword, chats, limit, since, until, msg_type, json } => { with_meta: base_with_meta,
search::cmd_search(keyword, chats, limit, since, until, msg_type, json) debug_source: base_debug_source,
} },
),
Commands::History {
chat,
limit,
offset,
since,
until,
msg_type,
json,
} => history::cmd_history(
chat,
limit,
offset,
since,
until,
msg_type,
OutputOpts {
json,
with_meta: base_with_meta,
debug_source: base_debug_source,
},
),
Commands::Search {
keyword,
chats,
limit,
since,
until,
msg_type,
json,
} => search::cmd_search(
keyword,
chats,
limit,
since,
until,
msg_type,
OutputOpts {
json,
with_meta: base_with_meta,
debug_source: base_debug_source,
},
),
Commands::Contacts { query, limit, json } => contacts::cmd_contacts(query, limit, json), Commands::Contacts { query, limit, json } => contacts::cmd_contacts(query, limit, json),
Commands::Export { chat, since, until, limit, format, output } => { Commands::Export {
export::cmd_export(chat, since, until, limit, format, output) chat,
since,
until,
limit,
format,
output,
} => {
let export_json = format == "json";
export::cmd_export(
chat,
since,
until,
limit,
format,
output,
OutputOpts {
json: export_json,
with_meta: base_with_meta,
debug_source: base_debug_source,
},
)
} }
Commands::Unread { limit, filter, json } => unread::cmd_unread(limit, filter, json), Commands::Unread {
limit,
filter,
json,
} => unread::cmd_unread(
limit,
filter,
OutputOpts {
json,
with_meta: base_with_meta,
debug_source: base_debug_source,
},
),
Commands::Members { chat, json } => members::cmd_members(chat, json), Commands::Members { chat, json } => members::cmd_members(chat, json),
Commands::NewMessages { limit, json } => new_messages::cmd_new_messages(limit, json), Commands::NewMessages { limit, json } => new_messages::cmd_new_messages(
Commands::Stats { chat, since, until, json } => { limit,
stats::cmd_stats(chat, since, until, json) OutputOpts {
} json,
Commands::Favorites { limit, fav_type, query, json } => { with_meta: base_with_meta,
favorites::cmd_favorites(limit, fav_type, query, json) debug_source: base_debug_source,
} },
Commands::SnsNotifications { limit, since, until, include_read, json } => { ),
sns_notifications::cmd_sns_notifications(limit, since, until, include_read, json) Commands::Stats {
} chat,
Commands::SnsFeed { limit, since, until, user, json } => { since,
sns_feed::cmd_sns_feed(limit, since, until, user, json) until,
} json,
Commands::SnsSearch { keyword, limit, since, until, user, json } => { } => stats::cmd_stats(
sns_search::cmd_sns_search(keyword, limit, since, until, user, json) chat,
} since,
Commands::BizArticles { limit, account, since, until, unread, json } => { until,
biz_articles::cmd_biz_articles(limit, account, since, until, unread, json) OutputOpts {
} json,
Commands::Attachments { chat, kinds, limit, offset, since, until, json } => { with_meta: base_with_meta,
attachments::cmd_attachments(chat, kinds, limit, offset, since, until, json) debug_source: base_debug_source,
} },
Commands::Extract { attachment_id, output, overwrite, json } => { ),
extract::cmd_extract(attachment_id, output, overwrite, json) Commands::Favorites {
} limit,
fav_type,
query,
json,
} => favorites::cmd_favorites(limit, fav_type, query, json),
Commands::SnsNotifications {
limit,
since,
until,
include_read,
json,
} => sns_notifications::cmd_sns_notifications(limit, since, until, include_read, json),
Commands::SnsFeed {
limit,
since,
until,
user,
json,
} => sns_feed::cmd_sns_feed(limit, since, until, user, json),
Commands::SnsSearch {
keyword,
limit,
since,
until,
user,
json,
} => sns_search::cmd_sns_search(keyword, limit, since, until, user, json),
Commands::BizArticles {
limit,
account,
since,
until,
unread,
json,
} => biz_articles::cmd_biz_articles(limit, account, since, until, unread, json),
Commands::Attachments {
chat,
kinds,
limit,
offset,
since,
until,
json,
} => attachments::cmd_attachments(
chat,
kinds,
limit,
offset,
since,
until,
OutputOpts {
json,
with_meta: base_with_meta,
debug_source: base_debug_source,
},
),
Commands::Extract {
attachment_id,
output,
overwrite,
json,
} => extract::cmd_extract(attachment_id, output, overwrite, json),
Commands::Daemon { cmd } => daemon_cmd::cmd_daemon(cmd), Commands::Daemon { cmd } => daemon_cmd::cmd_daemon(cmd),
} }
} }

View File

@ -1,8 +1,8 @@
use super::output::{emit_warnings, print_response, OutputOpts};
use super::transport;
use crate::ipc::Request;
use anyhow::Result; use anyhow::Result;
use std::collections::HashMap; use std::collections::HashMap;
use crate::ipc::Request;
use super::transport;
use super::output::{resolve, print_value};
fn state_file() -> std::path::PathBuf { fn state_file() -> std::path::PathBuf {
dirs::home_dir() dirs::home_dir()
@ -18,7 +18,8 @@ fn load_state() -> Option<HashMap<String, i64>> {
let data = std::fs::read_to_string(state_file()).ok()?; let data = std::fs::read_to_string(state_file()).ok()?;
let v: serde_json::Value = serde_json::from_str(&data).ok()?; let v: serde_json::Value = serde_json::from_str(&data).ok()?;
// 旧格式(只有 timestamp 字段)没有 sessions key → 返回 None 触发首次运行逻辑 // 旧格式(只有 timestamp 字段)没有 sessions key → 返回 None 触发首次运行逻辑
let map: HashMap<String, i64> = v.get("sessions")? let map: HashMap<String, i64> = v
.get("sessions")?
.as_object()? .as_object()?
.iter() .iter()
.filter_map(|(k, v)| v.as_i64().map(|ts| (k.clone(), ts))) .filter_map(|(k, v)| v.as_i64().map(|ts| (k.clone(), ts)))
@ -33,17 +34,27 @@ fn save_state(new_state: &HashMap<String, i64>) -> Result<()> {
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?; std::fs::create_dir_all(parent)?;
} }
std::fs::write(&path, serde_json::to_string(&serde_json::json!({ "sessions": new_state }))?)?; std::fs::write(
&path,
serde_json::to_string(&serde_json::json!({ "sessions": new_state }))?,
)?;
Ok(()) Ok(())
} }
pub fn cmd_new_messages(limit: usize, json: bool) -> Result<()> { pub fn cmd_new_messages(limit: usize, opts: OutputOpts) -> Result<()> {
let state = load_state(); let state = load_state();
let resp = transport::send(Request::NewMessages { state, limit })?; let (with_meta, debug_source) = opts.request_flags();
let resp = transport::send(Request::NewMessages {
state,
limit,
with_meta,
debug_source,
})?;
// 保存 daemon 返回的 new_state // 保存 daemon 返回的 new_state
if let Some(obj) = resp.data.get("new_state").and_then(|v| v.as_object()) { if let Some(obj) = resp.data.get("new_state").and_then(|v| v.as_object()) {
let map: HashMap<String, i64> = obj.iter() let map: HashMap<String, i64> = obj
.iter()
.filter_map(|(k, v)| v.as_i64().map(|ts| (k.clone(), ts))) .filter_map(|(k, v)| v.as_i64().map(|ts| (k.clone(), ts)))
.collect(); .collect();
if !map.is_empty() { if !map.is_empty() {
@ -51,8 +62,6 @@ pub fn cmd_new_messages(limit: usize, json: bool) -> Result<()> {
} }
} }
let messages = resp.data.get("messages") emit_warnings(&resp.data);
.cloned() print_response(&resp.data, &opts)
.unwrap_or(serde_json::Value::Array(vec![]));
print_value(&messages, &resolve(json))
} }

View File

@ -1,12 +1,31 @@
use chrono::{Local, TimeZone};
/// 输出格式 /// 输出格式
pub enum Fmt { pub enum Fmt {
Yaml, Yaml,
Json, Json,
} }
#[derive(Clone, Copy, Debug)]
pub struct OutputOpts {
pub json: bool,
pub with_meta: bool,
pub debug_source: bool,
}
impl OutputOpts {
pub fn request_flags(self) -> (bool, bool) {
(self.with_meta || self.debug_source, self.debug_source)
}
}
/// 默认 YAML--json 时输出 JSON /// 默认 YAML--json 时输出 JSON
pub fn resolve(json: bool) -> Fmt { pub fn resolve(json: bool) -> Fmt {
if json { Fmt::Json } else { Fmt::Yaml } if json {
Fmt::Json
} else {
Fmt::Yaml
}
} }
pub fn print_value(value: &serde_json::Value, fmt: &Fmt) -> anyhow::Result<()> { pub fn print_value(value: &serde_json::Value, fmt: &Fmt) -> anyhow::Result<()> {
@ -16,3 +35,95 @@ pub fn print_value(value: &serde_json::Value, fmt: &Fmt) -> anyhow::Result<()> {
} }
Ok(()) Ok(())
} }
pub fn print_response(data: &serde_json::Value, opts: &OutputOpts) -> anyhow::Result<()> {
print_value(data, &resolve(opts.json))
}
pub fn emit_warnings(data: &serde_json::Value) {
for line in warning_lines(data) {
eprintln!("[wx] 警告:{}", line);
}
}
pub fn warning_lines(data: &serde_json::Value) -> Vec<String> {
let mut lines = Vec::new();
let meta = match data.get("meta") {
Some(v) if v.is_object() => v,
_ => return lines,
};
let unknown_shards: Vec<String> = meta
.get("unknown_shards")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
if !unknown_shards.is_empty() {
lines.push(format!(
"磁盘上发现 daemon 不认识的分片 {},结果可能不完整;运行 `wx init --force` 重新提取密钥。",
unknown_shards.join(", ")
));
}
let status = meta.get("status").and_then(|v| v.as_str()).unwrap_or("");
if status == "possibly_stale" || status == "possibly_stale_unknown_shards" {
let session_ts = meta.get("session_last_timestamp").and_then(|v| v.as_i64());
let chat_ts = meta.get("chat_latest_timestamp").and_then(|v| v.as_i64());
if let (Some(session_ts), Some(chat_ts)) = (session_ts, chat_ts) {
let subject = data
.get("chat")
.and_then(|v| v.as_str())
.or_else(|| data.get("username").and_then(|v| v.as_str()))
.unwrap_or("当前查询");
lines.push(format!(
"session.db 显示 '{}' 最新到 {},但本次扫描只到 {},结果可能过期或不完整。",
subject,
fmt_meta_ts(session_ts),
fmt_meta_ts(chat_ts),
));
}
}
lines
}
pub fn warning_block_text(data: &serde_json::Value) -> Option<String> {
let lines = warning_lines(data);
if lines.is_empty() {
return None;
}
Some(
lines
.into_iter()
.map(|line| format!("[wx] 警告:{}", line))
.collect::<Vec<_>>()
.join("\n"),
)
}
pub fn warning_block_markdown(data: &serde_json::Value) -> Option<String> {
let lines = warning_lines(data);
if lines.is_empty() {
return None;
}
let mut out = String::from("> [!WARNING]\n");
for line in lines {
out.push_str("> ");
out.push_str(&line);
out.push('\n');
}
Some(out)
}
fn fmt_meta_ts(ts: i64) -> String {
Local
.timestamp_opt(ts, 0)
.single()
.map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string())
.unwrap_or_else(|| ts.to_string())
}

View File

@ -1,8 +1,8 @@
use anyhow::Result; use super::history::{parse_msg_type, parse_time, parse_time_end};
use crate::ipc::Request; use super::output::{emit_warnings, print_response, OutputOpts};
use super::transport; use super::transport;
use super::history::{parse_time, parse_time_end, parse_msg_type}; use crate::ipc::Request;
use super::output::{resolve, print_value}; use anyhow::Result;
pub fn cmd_search( pub fn cmd_search(
keyword: String, keyword: String,
@ -11,12 +11,13 @@ pub fn cmd_search(
since: Option<String>, since: Option<String>,
until: Option<String>, until: Option<String>,
msg_type: Option<String>, msg_type: Option<String>,
json: bool, opts: OutputOpts,
) -> Result<()> { ) -> Result<()> {
let since_ts = since.as_deref().map(parse_time).transpose()?; let since_ts = since.as_deref().map(parse_time).transpose()?;
let until_ts = until.as_deref().map(parse_time_end).transpose()?; let until_ts = until.as_deref().map(parse_time_end).transpose()?;
let type_val = msg_type.as_deref().and_then(parse_msg_type); let type_val = msg_type.as_deref().and_then(parse_msg_type);
let chats_opt = if chats.is_empty() { None } else { Some(chats) }; let chats_opt = if chats.is_empty() { None } else { Some(chats) };
let (with_meta, debug_source) = opts.request_flags();
let req = Request::Search { let req = Request::Search {
keyword, keyword,
@ -25,11 +26,11 @@ pub fn cmd_search(
since: since_ts, since: since_ts,
until: until_ts, until: until_ts,
msg_type: type_val, msg_type: type_val,
with_meta,
debug_source,
}; };
let resp = transport::send(req)?; let resp = transport::send(req)?;
let results = resp.data.get("results") emit_warnings(&resp.data);
.cloned() print_response(&resp.data, &opts)
.unwrap_or(serde_json::Value::Array(vec![]));
print_value(&results, &resolve(json))
} }

View File

@ -1,12 +1,15 @@
use anyhow::Result; use super::output::{emit_warnings, print_response, OutputOpts};
use crate::ipc::Request;
use super::transport; use super::transport;
use super::output::{resolve, print_value}; use crate::ipc::Request;
use anyhow::Result;
pub fn cmd_sessions(limit: usize, json: bool) -> Result<()> { pub fn cmd_sessions(limit: usize, opts: OutputOpts) -> Result<()> {
let resp = transport::send(Request::Sessions { limit })?; let (with_meta, debug_source) = opts.request_flags();
let data = resp.data.get("sessions") let resp = transport::send(Request::Sessions {
.cloned() limit,
.unwrap_or(serde_json::Value::Array(vec![])); with_meta,
print_value(&data, &resolve(json)) debug_source,
})?;
emit_warnings(&resp.data);
print_response(&resp.data, &opts)
} }

View File

@ -1,18 +1,25 @@
use anyhow::Result;
use crate::ipc::Request;
use super::transport;
use super::history::{parse_time, parse_time_end}; use super::history::{parse_time, parse_time_end};
use super::output::{resolve, print_value}; use super::output::{emit_warnings, print_response, OutputOpts};
use super::transport;
use crate::ipc::Request;
use anyhow::Result;
pub fn cmd_stats( pub fn cmd_stats(
chat: String, chat: String,
since: Option<String>, since: Option<String>,
until: Option<String>, until: Option<String>,
json: bool, opts: OutputOpts,
) -> Result<()> { ) -> Result<()> {
let since_ts = since.as_deref().map(parse_time).transpose()?; let since_ts = since.as_deref().map(parse_time).transpose()?;
let until_ts = until.as_deref().map(parse_time_end).transpose()?; let until_ts = until.as_deref().map(parse_time_end).transpose()?;
let (with_meta, debug_source) = opts.request_flags();
let resp = transport::send(Request::Stats { chat, since: since_ts, until: until_ts })?; let resp = transport::send(Request::Stats {
print_value(&resp.data, &resolve(json)) chat,
since: since_ts,
until: until_ts,
with_meta,
debug_source,
})?;
emit_warnings(&resp.data);
print_response(&resp.data, &opts)
} }

View File

@ -1,18 +1,22 @@
use anyhow::Result; use super::output::{emit_warnings, print_response, OutputOpts};
use crate::ipc::Request;
use super::transport; use super::transport;
use super::output::{resolve, print_value}; use crate::ipc::Request;
use anyhow::Result;
pub fn cmd_unread(limit: usize, filter: Vec<String>, json: bool) -> Result<()> { pub fn cmd_unread(limit: usize, filter: Vec<String>, opts: OutputOpts) -> Result<()> {
// 空或含 "all" 视为不过滤;其他值已被 clap value_parser 验证过,直接透传给 daemon。 // 空或含 "all" 视为不过滤;其他值已被 clap value_parser 验证过,直接透传给 daemon。
let filter_vec = if filter.is_empty() || filter.iter().any(|s| s == "all") { let filter_vec = if filter.is_empty() || filter.iter().any(|s| s == "all") {
None None
} else { } else {
Some(filter) Some(filter)
}; };
let resp = transport::send(Request::Unread { limit, filter: filter_vec })?; let (with_meta, debug_source) = opts.request_flags();
let data = resp.data.get("sessions") let resp = transport::send(Request::Unread {
.cloned() limit,
.unwrap_or(serde_json::Value::Array(vec![])); filter: filter_vec,
print_value(&data, &resolve(json)) with_meta,
debug_source,
})?;
emit_warnings(&resp.data);
print_response(&resp.data, &opts)
} }

View File

@ -320,9 +320,11 @@ fn detect_db_dir_impl() -> Option<PathBuf> {
let path = entry.path(); let path = entry.path();
if path.extension().map(|e| e == "ini").unwrap_or(false) { if path.extension().map(|e| e == "ini").unwrap_or(false) {
if let Ok(content) = std::fs::read_to_string(&path) { if let Ok(content) = std::fs::read_to_string(&path) {
let data_root = content.trim().to_string(); let Some(data_root) = resolve_windows_data_root(content.trim()) else {
if PathBuf::from(&data_root).is_dir() { continue;
let pattern = PathBuf::from(&data_root).join("xwechat_files"); };
if data_root.is_dir() {
let pattern = data_root.join("xwechat_files");
if let Ok(entries2) = std::fs::read_dir(&pattern) { if let Ok(entries2) = std::fs::read_dir(&pattern) {
for entry2 in entries2.flatten() { for entry2 in entries2.flatten() {
let storage = entry2.path().join("db_storage"); let storage = entry2.path().join("db_storage");
@ -340,6 +342,72 @@ fn detect_db_dir_impl() -> Option<PathBuf> {
candidates.into_iter().next_back() candidates.into_iter().next_back()
} }
/// Resolve the data-root path that Weixin writes to its `*.ini` file under
/// `%APPDATA%\Tencent\xwechat\config\`.
///
/// Observed forms in the wild:
/// - A plain absolute path, e.g. `D:\WeChatFiles`.
/// - The literal token `MyDocument:` (sometimes with a trailing slash),
/// which is not a real filesystem path. Empirically this denotes
/// "the current user's Documents folder"; users who relocated
/// Documents to e.g. `D:\Documents` saw auto-detect fail silently
/// because `PathBuf::from("MyDocument:").is_dir()` is false.
///
/// We accept either form. For the `MyDocument:` token we resolve via
/// `SHGetKnownFolderPath(FOLDERID_Documents)`, which respects the standard
/// shell-folder redirect at
/// `HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\User Shell Folders\Personal`.
#[cfg(target_os = "windows")]
fn resolve_windows_data_root(content: &str) -> Option<PathBuf> {
let trimmed = content.trim();
// Strip an optional trailing slash so `MyDocument:\` and `MyDocument:/` also match.
let stripped = trimmed
.strip_suffix(['\\', '/'])
.unwrap_or(trimmed);
if stripped.eq_ignore_ascii_case("MyDocument:") {
return known_documents_dir();
}
Some(PathBuf::from(trimmed))
}
#[cfg(target_os = "windows")]
fn known_documents_dir() -> Option<PathBuf> {
use std::ffi::OsString;
use std::os::windows::ffi::OsStringExt;
use windows::Win32::Foundation::HANDLE;
use windows::Win32::System::Com::CoTaskMemFree;
use windows::Win32::UI::Shell::{
FOLDERID_Documents, SHGetKnownFolderPath, KF_FLAG_DEFAULT,
};
// SAFETY: standard Win32 known-folder API. SHGetKnownFolderPath either returns
// a heap-allocated PWSTR that the caller must free with CoTaskMemFree, or an
// error — in which case the out-pointer is not allocated. We free on every
// success path. Passing a null token (HANDLE::default()) means "the calling
// user", which is exactly what we want.
unsafe {
let pwstr =
SHGetKnownFolderPath(&FOLDERID_Documents, KF_FLAG_DEFAULT, HANDLE::default()).ok()?;
if pwstr.0.is_null() {
return None;
}
// Walk the NUL-terminated wide string to compute its length.
let mut len = 0usize;
while *pwstr.0.add(len) != 0 {
len += 1;
}
let slice = std::slice::from_raw_parts(pwstr.0, len);
let os_str = OsString::from_wide(slice);
CoTaskMemFree(Some(pwstr.0 as *const _));
let path = PathBuf::from(os_str);
if path.as_os_str().is_empty() {
None
} else {
Some(path)
}
}
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
fn detect_db_dir_impl() -> Option<PathBuf> { fn detect_db_dir_impl() -> Option<PathBuf> {
None None
@ -351,6 +419,8 @@ mod tests {
config_path_in_dir, default_config_path, find_existing_config_path, home_config_path, config_path_in_dir, default_config_path, find_existing_config_path, home_config_path,
resolve_cli_home, resolve_cli_home,
}; };
#[cfg(target_os = "windows")]
use super::{known_documents_dir, resolve_windows_data_root};
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
@ -409,4 +479,24 @@ mod tests {
let path = default_config_path(Some(&cwd), Some(&exe), Some(&home)); let path = default_config_path(Some(&cwd), Some(&exe), Some(&home));
assert_eq!(path, cwd.join("config.json")); assert_eq!(path, cwd.join("config.json"));
} }
#[cfg(target_os = "windows")]
#[test]
fn resolve_windows_data_root_passes_through_absolute_path() {
let p = resolve_windows_data_root("D:\\WeChatFiles").unwrap();
assert_eq!(p, PathBuf::from("D:\\WeChatFiles"));
}
#[cfg(target_os = "windows")]
#[test]
fn resolve_windows_data_root_recognises_mydocument_keyword() {
// Should match the keyword exactly (case-insensitive, with or without trailing slash)
// and resolve to a non-empty Documents path via SHGetKnownFolderPath.
let docs = known_documents_dir().expect("Documents known folder must resolve");
for keyword in ["MyDocument:", "mydocument:", "MyDocument:\\", "MyDocument:/"] {
let resolved = resolve_windows_data_root(keyword)
.unwrap_or_else(|| panic!("keyword {keyword:?} should resolve"));
assert_eq!(resolved, docs, "keyword {keyword:?}");
}
}
} }

View File

@ -23,6 +23,40 @@ struct CacheEntry {
decrypted_path: PathBuf, decrypted_path: PathBuf,
} }
/// `DbCache::get_with_mode()` 本次解析 rel_key 时实际走了哪条路径。
///
/// latency tier:
/// - `CacheHit`~0ms只返回已有解密产物
/// - `WalIncremental`:典型 <10s只在 cached DB 上增量 apply WAL
/// - `FullDecrypt`:最慢路径,大库上可能到 ~120s
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CacheMode {
/// Path 1主 `.db` 和 WAL 都没变,直接命中缓存。
CacheHit,
/// Path 2主 `.db` 没变、只有 WAL 变了,在 cached DB 上增量 apply。
WalIncremental,
/// Path 3主 `.db` 变了或缓存 miss重新 full decrypt。
FullDecrypt,
}
impl CacheMode {
/// 手工固定为 snake_case 字符串,避免未来给 enum 直接 derive `Serialize`
/// 时静默改变 wire 形态。
pub fn as_str(self) -> &'static str {
match self {
CacheMode::CacheHit => "cache_hit",
CacheMode::WalIncremental => "wal_incremental",
CacheMode::FullDecrypt => "full_decrypt",
}
}
}
#[derive(Debug, Clone)]
pub struct CacheResolve {
pub path: PathBuf,
pub mode: CacheMode,
}
/// 解密后数据库的 mtime-aware 缓存 /// 解密后数据库的 mtime-aware 缓存
/// ///
/// 当数据库文件(.db或 WAL 文件(.db-wal的 mtime 发生变化时, /// 当数据库文件(.db或 WAL 文件(.db-wal的 mtime 发生变化时,
@ -36,10 +70,7 @@ pub struct DbCache {
} }
impl DbCache { impl DbCache {
pub async fn new( pub async fn new(db_dir: PathBuf, all_keys: HashMap<String, String>) -> Result<Self> {
db_dir: PathBuf,
all_keys: HashMap<String, String>,
) -> Result<Self> {
Self::with_dirs(db_dir, config::cache_dir(), config::mtime_file(), all_keys).await Self::with_dirs(db_dir, config::cache_dir(), config::mtime_file(), all_keys).await
} }
@ -94,23 +125,34 @@ impl DbCache {
if !dec_path.exists() { if !dec_path.exists() {
continue; continue;
} }
let db_path = self.db_dir.join(rel_key.replace('\\', std::path::MAIN_SEPARATOR_STR).replace('/', std::path::MAIN_SEPARATOR_STR)); let db_path = self.db_dir.join(
rel_key
.replace('\\', std::path::MAIN_SEPARATOR_STR)
.replace('/', std::path::MAIN_SEPARATOR_STR),
);
let wal_path = wal_path_for(&db_path); let wal_path = wal_path_for(&db_path);
let db_mt = mtime_nanos(&db_path); let db_mt = mtime_nanos(&db_path);
let _wal_mt = if wal_path.exists() { mtime_nanos(&wal_path) } else { 0 }; let _wal_mt = if wal_path.exists() {
mtime_nanos(&wal_path)
} else {
0
};
// 只要主 .db 没变,就把 cached 产物载回来。 // 只要主 .db 没变,就把 cached 产物载回来。
// 如果 WAL mtime 变了,后续 `get()` 会自动走 Path 2在已有 cached DB 上增量 apply_wal // 如果 WAL mtime 变了,后续 `get()` 会自动走 Path 2在已有 cached DB 上增量 apply_wal
// 而不是 daemon 重启后第一条请求又退回全量解密。 // 而不是 daemon 重启后第一条请求又退回全量解密。
if db_mt == entry.db_mt { if db_mt == entry.db_mt {
inner.insert(rel_key.clone(), CacheEntry { inner.insert(
db_mtime: db_mt, rel_key.clone(),
// 保留"cached 产物构建时看到的 wal_mtime",让 `get()` 去比较当前 WAL CacheEntry {
// 是否发生了变化,从而决定 exact-hit 还是 WAL 增量。 db_mtime: db_mt,
wal_mtime: entry.wal_mt, // 保留"cached 产物构建时看到的 wal_mtime",让 `get()` 去比较当前 WAL
decrypted_path: dec_path, // 是否发生了变化,从而决定 exact-hit 还是 WAL 增量。
}); wal_mtime: entry.wal_mt,
decrypted_path: dec_path,
},
);
reused += 1; reused += 1;
} }
} }
@ -123,13 +165,19 @@ impl DbCache {
async fn save_persistent(&self) { async fn save_persistent(&self) {
let mtime_file = &self.mtime_file; let mtime_file = &self.mtime_file;
let inner = self.inner.lock().await; let inner = self.inner.lock().await;
let data: HashMap<String, MtimeEntry> = inner.iter().map(|(k, v)| { let data: HashMap<String, MtimeEntry> = inner
(k.clone(), MtimeEntry { .iter()
db_mt: v.db_mtime, .map(|(k, v)| {
wal_mt: v.wal_mtime, (
path: v.decrypted_path.to_string_lossy().into_owned(), k.clone(),
MtimeEntry {
db_mt: v.db_mtime,
wal_mt: v.wal_mtime,
path: v.decrypted_path.to_string_lossy().into_owned(),
},
)
}) })
}).collect(); .collect();
drop(inner); drop(inner);
if let Ok(json) = serde_json::to_string_pretty(&data) { if let Ok(json) = serde_json::to_string_pretty(&data) {
@ -148,14 +196,19 @@ impl DbCache {
/// WeChat 在写消息时只 append WAL除非触发 checkpoint因此 path 2 是常态; /// WeChat 在写消息时只 append WAL除非触发 checkpoint因此 path 2 是常态;
/// 这条路径把"每次请求都全量解密 ~1.8GB DB~120s"压到"只解 WAL 帧(典型 < 10s"。 /// 这条路径把"每次请求都全量解密 ~1.8GB DB~120s"压到"只解 WAL 帧(典型 < 10s"。
pub async fn get(&self, rel_key: &str) -> Result<Option<PathBuf>> { pub async fn get(&self, rel_key: &str) -> Result<Option<PathBuf>> {
Ok(self.get_with_mode(rel_key).await?.map(|r| r.path))
}
pub async fn get_with_mode(&self, rel_key: &str) -> Result<Option<CacheResolve>> {
let enc_key_hex = match self.all_keys.get(rel_key) { let enc_key_hex = match self.all_keys.get(rel_key) {
Some(k) => k.clone(), Some(k) => k.clone(),
None => return Ok(None), None => return Ok(None),
}; };
let db_path = self.db_dir.join( let db_path = self.db_dir.join(
rel_key.replace('\\', std::path::MAIN_SEPARATOR_STR) rel_key
.replace('/', std::path::MAIN_SEPARATOR_STR) .replace('\\', std::path::MAIN_SEPARATOR_STR)
.replace('/', std::path::MAIN_SEPARATOR_STR),
); );
if !db_path.exists() { if !db_path.exists() {
return Ok(None); return Ok(None);
@ -163,21 +216,28 @@ impl DbCache {
let wal_path = wal_path_for(&db_path); let wal_path = wal_path_for(&db_path);
let db_mt = mtime_nanos(&db_path); let db_mt = mtime_nanos(&db_path);
let wal_mt = if wal_path.exists() { mtime_nanos(&wal_path) } else { 0 }; let wal_mt = if wal_path.exists() {
mtime_nanos(&wal_path)
} else {
0
};
let cached = { let cached = {
let inner = self.inner.lock().await; let inner = self.inner.lock().await;
inner.get(rel_key).cloned() inner.get(rel_key).cloned()
}; };
let enc_key_bytes = hex_to_32bytes(&enc_key_hex) let enc_key_bytes =
.with_context(|| format!("密钥格式错误: {}", rel_key))?; hex_to_32bytes(&enc_key_hex).with_context(|| format!("密钥格式错误: {}", rel_key))?;
// Path 1 / Path 2主 .db mtime 未变且 cached 产物仍在 // Path 1 / Path 2主 .db mtime 未变且 cached 产物仍在
if let Some(entry) = cached.as_ref() { if let Some(entry) = cached.as_ref() {
if entry.db_mtime == db_mt && entry.decrypted_path.exists() { if entry.db_mtime == db_mt && entry.decrypted_path.exists() {
if entry.wal_mtime == wal_mt { if entry.wal_mtime == wal_mt {
return Ok(Some(entry.decrypted_path.clone())); return Ok(Some(CacheResolve {
path: entry.decrypted_path.clone(),
mode: CacheMode::CacheHit,
}));
} }
// Path 2: WAL-only 变化 → 在 cached 产物上重新 apply_wal // Path 2: WAL-only 变化 → 在 cached 产物上重新 apply_wal
@ -190,20 +250,31 @@ impl DbCache {
let key_copy = enc_key_bytes; let key_copy = enc_key_bytes;
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
wal::apply_wal(&wal_path2, &out_path2, &key_copy) wal::apply_wal(&wal_path2, &out_path2, &key_copy)
}).await??; })
.await??;
} }
eprintln!("[cache] WAL 增量 {} ({}ms)", rel_key, t0.elapsed().as_millis()); eprintln!(
"[cache] WAL 增量 {} ({}ms)",
rel_key,
t0.elapsed().as_millis()
);
{ {
let mut inner = self.inner.lock().await; let mut inner = self.inner.lock().await;
inner.insert(rel_key.to_string(), CacheEntry { inner.insert(
db_mtime: db_mt, rel_key.to_string(),
wal_mtime: wal_mt, CacheEntry {
decrypted_path: out_path.clone(), db_mtime: db_mt,
}); wal_mtime: wal_mt,
decrypted_path: out_path.clone(),
},
);
} }
self.save_persistent().await; self.save_persistent().await;
return Ok(Some(out_path)); return Ok(Some(CacheResolve {
path: out_path,
mode: CacheMode::WalIncremental,
}));
} }
} }
@ -213,39 +284,51 @@ impl DbCache {
let db_path2 = db_path.clone(); let db_path2 = db_path.clone();
let out_path2 = out_path.clone(); let out_path2 = out_path.clone();
let key_copy = enc_key_bytes; let key_copy = enc_key_bytes;
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || crypto::full_decrypt(&db_path2, &out_path2, &key_copy))
crypto::full_decrypt(&db_path2, &out_path2, &key_copy) .await??;
}).await??;
if wal_path.exists() { if wal_path.exists() {
let out_path3 = out_path.clone(); let out_path3 = out_path.clone();
let wal_path3 = wal_path.clone(); let wal_path3 = wal_path.clone();
let key_copy2 = enc_key_bytes; let key_copy2 = enc_key_bytes;
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || wal::apply_wal(&wal_path3, &out_path3, &key_copy2))
wal::apply_wal(&wal_path3, &out_path3, &key_copy2) .await??;
}).await??;
} }
eprintln!("[cache] 全量解密 {} ({}ms)", rel_key, t0.elapsed().as_millis()); eprintln!(
"[cache] 全量解密 {} ({}ms)",
rel_key,
t0.elapsed().as_millis()
);
{ {
let mut inner = self.inner.lock().await; let mut inner = self.inner.lock().await;
inner.insert(rel_key.to_string(), CacheEntry { inner.insert(
db_mtime: db_mt, rel_key.to_string(),
wal_mtime: wal_mt, CacheEntry {
decrypted_path: out_path.clone(), db_mtime: db_mt,
}); wal_mtime: wal_mt,
decrypted_path: out_path.clone(),
},
);
} }
self.save_persistent().await; self.save_persistent().await;
Ok(Some(out_path)) Ok(Some(CacheResolve {
path: out_path,
mode: CacheMode::FullDecrypt,
}))
} }
} }
pub(super) fn mtime_nanos(path: &Path) -> u64 { pub(super) fn mtime_nanos(path: &Path) -> u64 {
std::fs::metadata(path) std::fs::metadata(path)
.and_then(|m| m.modified()) .and_then(|m| m.modified())
.map(|t| t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos() as u64) .map(|t| {
t.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos() as u64
})
.unwrap_or(0) .unwrap_or(0)
} }
@ -273,8 +356,7 @@ mod tests {
use super::*; use super::*;
/// 64 字符 hex不需要是真 SQLCipher key — 仅用来证明"是否触发了 full_decrypt" /// 64 字符 hex不需要是真 SQLCipher key — 仅用来证明"是否触发了 full_decrypt"
const FAKE_KEY_HEX: &str = const FAKE_KEY_HEX: &str = "0000000000000000000000000000000000000000000000000000000000000000";
"0000000000000000000000000000000000000000000000000000000000000000";
/// 路径区分约定: /// 路径区分约定:
/// - 完全 hit / WAL 增量 → `decrypted_path` **内容不变** /// - 完全 hit / WAL 增量 → `decrypted_path` **内容不变**
@ -337,7 +419,11 @@ mod tests {
let (cache, _db_path, decrypted_path, _mtime_file, rel_key) = let (cache, _db_path, decrypted_path, _mtime_file, rel_key) =
setup_seeded_cache("exact").await; setup_seeded_cache("exact").await;
let p = cache.get(&rel_key).await.unwrap().expect("cache should hit"); let p = cache
.get(&rel_key)
.await
.unwrap()
.expect("cache should hit");
assert_eq!(p, decrypted_path); assert_eq!(p, decrypted_path);
// 完全 hit → cached file 内容不应被改 // 完全 hit → cached file 内容不应被改
@ -387,7 +473,10 @@ mod tests {
// 第一次:完全 hit // 第一次:完全 hit
let p1 = cache.get(&rel_key).await.unwrap().expect("first get hits"); let p1 = cache.get(&rel_key).await.unwrap().expect("first get hits");
assert_eq!(p1, decrypted_path); assert_eq!(p1, decrypted_path);
assert_eq!(std::fs::read(&decrypted_path).unwrap(), ORIGINAL_CACHED_BYTES); assert_eq!(
std::fs::read(&decrypted_path).unwrap(),
ORIGINAL_CACHED_BYTES
);
// bump WAL mtime重写仍 31 bytesapply_wal 仍 noop // bump WAL mtime重写仍 31 bytesapply_wal 仍 noop
std::thread::sleep(std::time::Duration::from_millis(20)); std::thread::sleep(std::time::Duration::from_millis(20));
@ -442,6 +531,72 @@ mod tests {
); );
} }
#[tokio::test]
async fn get_with_mode_reports_each_path() {
let root = unique_tmpdir("getwithmode");
let db_dir = root.join("db_storage");
let cache_dir = root.join("cache");
std::fs::create_dir_all(&db_dir).unwrap();
std::fs::create_dir_all(&cache_dir).unwrap();
let rel_key = "message_0.db".to_string();
let db_path = db_dir.join(&rel_key);
std::fs::write(&db_path, b"fake encrypted db").unwrap();
let wal_path = wal_path_for(&db_path);
std::fs::write(&wal_path, [0u8; 31]).unwrap();
let cached_hash = format!("{:x}", md5::compute(rel_key.as_bytes()));
let decrypted_path = cache_dir.join(format!("{}.db", cached_hash));
std::fs::write(&decrypted_path, ORIGINAL_CACHED_BYTES).unwrap();
let db_mt = mtime_nanos(&db_path);
let wal_mt0 = mtime_nanos(&wal_path);
let mtime_file = cache_dir.join("_mtimes.json");
let payload = serde_json::to_string(&serde_json::json!({
&rel_key: {
"db_mt": db_mt,
"wal_mt": wal_mt0,
"path": decrypted_path.display().to_string(),
}
}))
.unwrap();
std::fs::write(&mtime_file, payload).unwrap();
let mut all_keys = HashMap::new();
all_keys.insert(rel_key.clone(), FAKE_KEY_HEX.to_string());
let cache = DbCache::with_dirs(db_dir, cache_dir, mtime_file, all_keys)
.await
.unwrap();
let hit = cache
.get_with_mode(&rel_key)
.await
.unwrap()
.expect("cache should hit");
assert_eq!(hit.path, decrypted_path);
assert_eq!(hit.mode, CacheMode::CacheHit);
std::thread::sleep(std::time::Duration::from_millis(20));
std::fs::write(&wal_path, [0xffu8; 31]).unwrap();
let wal = cache
.get_with_mode(&rel_key)
.await
.unwrap()
.expect("WAL-only change should stay incremental");
assert_eq!(wal.path, decrypted_path);
assert_eq!(wal.mode, CacheMode::WalIncremental);
std::thread::sleep(std::time::Duration::from_millis(20));
std::fs::write(&db_path, b"different bytes").unwrap();
let full = cache
.get_with_mode(&rel_key)
.await
.unwrap()
.expect("db mtime change should trigger full decrypt");
assert_eq!(full.path, decrypted_path);
assert_eq!(full.mode, CacheMode::FullDecrypt);
}
#[tokio::test] #[tokio::test]
async fn restart_with_wal_change_still_reuses_cached_db_then_applies_wal() { async fn restart_with_wal_change_still_reuses_cached_db_then_applies_wal() {
let root = unique_tmpdir("restart-wal"); let root = unique_tmpdir("restart-wal");
@ -486,7 +641,11 @@ mod tests {
.await .await
.unwrap(); .unwrap();
let p = cache.get(&rel_key).await.unwrap().expect("cache should reuse persisted DB"); let p = cache
.get(&rel_key)
.await
.unwrap()
.expect("cache should reuse persisted DB");
assert_eq!(p, decrypted_path); assert_eq!(p, decrypted_path);
let body = std::fs::read(&decrypted_path).unwrap(); let body = std::fs::read(&decrypted_path).unwrap();
assert_eq!( assert_eq!(

269
src/daemon/meta.rs 100644
View File

@ -0,0 +1,269 @@
//! Freshness metadata appended to every q_* response.
//!
//! 背景:`all_keys.json` 是 `wx init` 时的快照。WeChat 在 daemon 启动后随时可能创建
//! 新的 `message_N.db` 分片;如果只信任 init 时收到的 `msg_db_keys` 列表,新分片里
//! 的数据对 daemon 完全不可见 → 调用方拿到的是看似正常但缺数据的结果("stale")。
//!
//! 本模块的职责:
//! 1. 提供 `Meta` 结构体,由各 `q_*` 函数填充后塞进 response顶层 `meta` 字段)。
//! 2. 提供 `discover_unknown_shards(db_dir, msg_db_keys)`:扫描磁盘上当前真实存在的
//! `message/message_*.db` 文件diff 出 daemon 未持有 enc_key 的"未知分片"列表。
//! 3. 集中 `MetaStatus` 的判定规则,避免 8 个 q_* 各自判,规则漂移。
use serde::Serialize;
use std::collections::HashMap;
use std::path::Path;
/// 每条 q_* 响应附带的"新鲜度元数据"。
///
/// 序列化为 JSON 时,所有 `Option` 字段在 `None` 时省略,让最常见的命令调用
/// 输出尽量短重负载字段per_shard_*、shard_paths默认不填由 CLI 层
/// 通过 `--debug-source` 等开关显式请求时才放进来。
#[derive(Debug, Clone, Serialize, Default)]
pub struct Meta {
/// 命中数据中最新一条的 create_timeunix 秒)。
/// `q_history` / `q_search` / `q_new_messages` 等基于 Msg_ 表的查询都应填。
/// `q_sessions` / `q_unread` 这类基于 SessionTable 的查询填会话维度的最新 ts。
#[serde(skip_serializing_if = "Option::is_none")]
pub chat_latest_timestamp: Option<i64>,
/// 上面那条最新消息所在的分片 rel_key`message/message_3.db`)。
/// 让 agent 一眼看出"当前命中的最新数据来自哪个分片"。
#[serde(skip_serializing_if = "Option::is_none")]
pub chat_latest_db: Option<String>,
/// 该 chat 在 `session.db.SessionTable.last_timestamp` 里的值(如果可读)。
/// 这是 WeChat 自己写的"最近一条消息时间",与上面 `chat_latest_timestamp` 比较
/// 即可发现"session 说有更新但 history 没读到" → 漏分片。
#[serde(skip_serializing_if = "Option::is_none")]
pub session_last_timestamp: Option<i64>,
/// 本次查询实际遍历的分片数(即 `names.msg_db_keys.len()` 的子集;包括命中 0 行的)。
pub shards_scanned: usize,
/// 本次查询里至少返回了 1 行的分片数。
pub shards_hit: usize,
/// 磁盘上存在但 daemon 没有 enc_key 的分片 rel_key 列表。
/// 非空 ⇒ `wx init` 之后 WeChat 又分裂了新分片 → 必须重跑 `wx init`。
pub unknown_shards: Vec<String>,
/// 由上述字段派生出的总体状态CLI / agent 主要看这一个。
pub status: MetaStatus,
// 重负载/调试字段默认不填CLI 层显式开启
#[serde(skip_serializing_if = "Option::is_none")]
pub per_shard_latest: Option<HashMap<String, i64>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cache_mode_per_shard: Option<HashMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub shard_paths: Option<HashMap<String, String>>,
}
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum MetaStatus {
#[default]
Ok,
/// `session.db` 的最新时间明显领先于本次消息查询结果,说明数据可能过期或不完整。
PossiblyStale,
/// 最强信号:磁盘上出现 daemon 不认识的新分片,通常必须重跑 `wx init --force`。
PossiblyStaleUnknownShards,
/// 调用方主动传了 `since` / `until` / `offset` 等窗口条件,结果天然是局部视图。
Windowed,
}
/// session 领先 history 多少秒就报 `PossiblyStale`。
///
/// 24h 的取值是故意保守的:活跃群聊/私聊很少会整整一天没有新消息,
/// 超过这个窗口就值得显式提醒 agent 不要把结果当成“当前最新状态”。
pub const STALE_THRESHOLD_SECS: i64 = 24 * 3600;
/// 统一 freshness status 的优先级:
/// 1. `unknown_shards` 非空daemon 整体视图已经过期,优先返回 `PossiblyStaleUnknownShards`
/// 2. `windowed=true`:调用方本来就在看局部窗口,不参与 stale 推导
/// 3. `session_last - chat_latest > STALE_THRESHOLD_SECS`:返回 `PossiblyStale`
/// 4. 其他情况:`Ok`
pub fn derive_status(
chat_latest: Option<i64>,
session_last: Option<i64>,
unknown_shards: &[String],
windowed: bool,
) -> MetaStatus {
if !unknown_shards.is_empty() {
return MetaStatus::PossiblyStaleUnknownShards;
}
if windowed {
return MetaStatus::Windowed;
}
match (chat_latest, session_last) {
(Some(c), Some(s)) if s - c > STALE_THRESHOLD_SECS => MetaStatus::PossiblyStale,
_ => MetaStatus::Ok,
}
}
/// 扫描 `<db_dir>/message/` 下真实存在的 `message_*.db`diff 出 daemon 当前没有 key
/// 的未知分片。
///
/// 契约:
/// - 返回值一律是 `/` 分隔的 rel_key如 `message/message_3.db`),与 `all_keys.json` 对齐
/// - 结果按字典序排序,方便测试和 CLI 稳定显示
/// - 排除 `_fts*` / `_resource*`,因为它们是索引/附件库,不属于消息分片真相
pub fn discover_unknown_shards(db_dir: &Path, known: &[String]) -> Vec<String> {
let known_set: std::collections::HashSet<String> =
known.iter().map(|k| k.replace('\\', "/")).collect();
let msg_dir = db_dir.join("message");
let entries = match std::fs::read_dir(&msg_dir) {
Ok(it) => it,
Err(_) => return Vec::new(),
};
let mut unknown: Vec<String> = Vec::new();
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name_str) = name.to_str() else {
continue;
};
if !is_message_shard(name_str) {
continue;
}
let rel = format!("message/{}", name_str);
if !known_set.contains(&rel) {
unknown.push(rel);
}
}
unknown.sort();
unknown
}
fn is_message_shard(file_name: &str) -> bool {
if !file_name.starts_with("message_") || !file_name.ends_with(".db") {
return false;
}
if file_name.contains("_fts") || file_name.contains("_resource") {
return false;
}
let stem = &file_name["message_".len()..file_name.len() - ".db".len()];
!stem.is_empty() && stem.chars().all(|c| c.is_ascii_digit())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_message_shard_accepts_normal_shards() {
assert!(is_message_shard("message_0.db"));
assert!(is_message_shard("message_12.db"));
}
#[test]
fn is_message_shard_rejects_fts_and_resource() {
assert!(!is_message_shard("message_0_fts.db"));
assert!(!is_message_shard("message_fts.db"));
assert!(!is_message_shard("message_0_resource.db"));
assert!(!is_message_shard("message_resource.db"));
}
#[test]
fn is_message_shard_rejects_non_digits() {
assert!(!is_message_shard("message_a.db"));
assert!(!is_message_shard("message_.db"));
assert!(!is_message_shard("session.db"));
assert!(!is_message_shard("message_0.db.bak"));
}
#[test]
fn discover_unknown_shards_finds_disk_only_shards() {
let dir = tempdir();
let msg_dir = dir.join("message");
std::fs::create_dir_all(&msg_dir).unwrap();
for f in [
"message_0.db",
"message_1.db",
"message_2.db",
"message_0_fts.db",
] {
std::fs::write(msg_dir.join(f), b"").unwrap();
}
let known = vec![
"message/message_0.db".to_string(),
"message/message_1.db".to_string(),
];
let unknown = discover_unknown_shards(&dir, &known);
assert_eq!(unknown, vec!["message/message_2.db".to_string()]);
}
#[test]
fn discover_unknown_shards_normalizes_backslash_in_known_keys() {
let dir = tempdir();
let msg_dir = dir.join("message");
std::fs::create_dir_all(&msg_dir).unwrap();
std::fs::write(msg_dir.join("message_0.db"), b"").unwrap();
let known = vec!["message\\message_0.db".to_string()];
assert!(discover_unknown_shards(&dir, &known).is_empty());
}
#[test]
fn discover_unknown_shards_returns_empty_when_message_dir_missing() {
let dir = tempdir();
assert!(discover_unknown_shards(&dir, &[]).is_empty());
}
#[test]
fn derive_status_unknown_shards_overrides_windowed() {
let unknown = vec!["message/message_3.db".to_string()];
assert_eq!(
derive_status(Some(100), Some(100), &unknown, true),
MetaStatus::PossiblyStaleUnknownShards
);
}
#[test]
fn derive_status_windowed_when_user_paginates() {
assert_eq!(
derive_status(Some(100), Some(999_999), &[], true),
MetaStatus::Windowed,
);
}
#[test]
fn derive_status_possibly_stale_when_session_far_ahead() {
let chat = Some(1_000_000);
let session = Some(1_000_000 + STALE_THRESHOLD_SECS + 1);
assert_eq!(
derive_status(chat, session, &[], false),
MetaStatus::PossiblyStale
);
}
#[test]
fn derive_status_ok_when_within_threshold() {
let chat = Some(1_000_000);
let session = Some(1_000_000 + STALE_THRESHOLD_SECS - 1);
assert_eq!(derive_status(chat, session, &[], false), MetaStatus::Ok);
}
#[test]
fn derive_status_ok_when_either_side_unknown() {
assert_eq!(
derive_status(None, Some(999_999_999), &[], false),
MetaStatus::Ok
);
assert_eq!(derive_status(Some(1), None, &[], false), MetaStatus::Ok);
assert_eq!(derive_status(None, None, &[], false), MetaStatus::Ok);
}
fn tempdir() -> std::path::PathBuf {
let pid = std::process::id();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let p = std::env::temp_dir().join(format!("wx-cli-meta-test-{}-{}", pid, nanos));
std::fs::create_dir_all(&p).unwrap();
p
}
}

View File

@ -1,4 +1,5 @@
pub mod cache; pub mod cache;
pub mod meta;
pub mod query; pub mod query;
pub mod server; pub mod server;
@ -8,6 +9,39 @@ use std::sync::Arc;
use crate::config; use crate::config;
fn normalized_rel_key(rel_key: &str) -> String {
rel_key.replace('\\', "/")
}
fn is_msg_db_key(rel_key: &str) -> bool {
let rel_key = normalized_rel_key(rel_key);
rel_key.starts_with("message/message_")
&& rel_key.ends_with(".db")
&& !rel_key.contains("_fts")
&& !rel_key.contains("_resource")
}
fn is_biz_msg_db_key(rel_key: &str) -> bool {
let rel_key = normalized_rel_key(rel_key);
rel_key.starts_with("message/biz_message_")
&& rel_key.ends_with(".db")
&& !rel_key.contains("_fts")
&& !rel_key.contains("_resource")
}
fn collect_db_keys(
all_keys: &HashMap<String, String>,
predicate: fn(&str) -> bool,
) -> Vec<String> {
let mut keys: Vec<String> = all_keys
.keys()
.filter(|k| predicate(k))
.cloned()
.collect();
keys.sort();
keys
}
/// daemon 入口 /// daemon 入口
/// ///
/// 当 WX_DAEMON_MODE 环境变量设置时main() 调用此函数 /// 当 WX_DAEMON_MODE 环境变量设置时main() 调用此函数
@ -48,17 +82,8 @@ async fn async_run() -> Result<()> {
let db = Arc::new(cache::DbCache::new(cfg.db_dir.clone(), all_keys.clone()).await?); let db = Arc::new(cache::DbCache::new(cfg.db_dir.clone(), all_keys.clone()).await?);
// 收集消息 DB 列表 // 收集消息 DB 列表
let msg_db_keys: Vec<String> = all_keys let msg_db_keys = collect_db_keys(&all_keys, is_msg_db_key);
.keys() let biz_msg_db_keys = collect_db_keys(&all_keys, is_biz_msg_db_key);
.filter(|k| {
let k = k.replace('\\', "/");
k.contains("message/message_")
&& k.ends_with(".db")
&& !k.contains("_fts")
&& !k.contains("_resource")
})
.cloned()
.collect();
// 预热:加载联系人 + 解密 session.db // 预热:加载联系人 + 解密 session.db
eprintln!("[daemon] 预热..."); eprintln!("[daemon] 预热...");
@ -68,11 +93,13 @@ async fn async_run() -> Result<()> {
map: HashMap::new(), map: HashMap::new(),
md5_to_uname: HashMap::new(), md5_to_uname: HashMap::new(),
msg_db_keys: Vec::new(), msg_db_keys: Vec::new(),
biz_msg_db_keys: Vec::new(),
verify_flags: HashMap::new(), verify_flags: HashMap::new(),
} }
}); });
let mut names = names_raw; let mut names = names_raw;
names.msg_db_keys = msg_db_keys; names.msg_db_keys = msg_db_keys;
names.biz_msg_db_keys = biz_msg_db_keys;
let _ = db.get("session/session.db").await; let _ = db.get("session/session.db").await;
let _ = db.get("sns/sns.db").await; let _ = db.get("sns/sns.db").await;
@ -148,3 +175,28 @@ fn cleanup_ipc_files() {
let _ = std::fs::remove_file(config::sock_path()); let _ = std::fs::remove_file(config::sock_path());
let _ = std::fs::remove_file(config::pid_path()); let _ = std::fs::remove_file(config::pid_path());
} }
#[cfg(test)]
mod tests {
use super::{is_biz_msg_db_key, is_msg_db_key};
#[test]
fn message_db_key_filter_ignores_biz_and_auxiliary_files() {
assert!(is_msg_db_key("message/message_0.db"));
assert!(is_msg_db_key("message\\message_12.db"));
assert!(!is_msg_db_key("message/biz_message_0.db"));
assert!(!is_msg_db_key("message/message_0.db-wal"));
assert!(!is_msg_db_key("message/message_0_fts.db"));
assert!(!is_msg_db_key("message/message_0_resource.db"));
}
#[test]
fn biz_message_db_key_filter_matches_only_biz_shards() {
assert!(is_biz_msg_db_key("message/biz_message_0.db"));
assert!(is_biz_msg_db_key("message\\biz_message_3.db"));
assert!(!is_biz_msg_db_key("message/message_0.db"));
assert!(!is_biz_msg_db_key("message/biz_message_0.db-wal"));
assert!(!is_biz_msg_db_key("message/biz_message_0_fts.db"));
assert!(!is_biz_msg_db_key("message/biz_message_0_resource.db"));
}
}

File diff suppressed because it is too large Load Diff

View File

@ -2,15 +2,12 @@ use anyhow::Result;
use std::sync::Arc; use std::sync::Arc;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use crate::ipc::{Request, Response};
use super::cache::DbCache; use super::cache::DbCache;
use super::query::Names; use super::query::Names;
use crate::ipc::{Request, Response};
/// 启动 IPC serverUnix socket / Windows named pipe /// 启动 IPC serverUnix socket / Windows named pipe
pub async fn serve( pub async fn serve(db: Arc<DbCache>, names: Arc<tokio::sync::RwLock<Arc<Names>>>) -> Result<()> {
db: Arc<DbCache>,
names: Arc<tokio::sync::RwLock<Arc<Names>>>,
) -> Result<()> {
#[cfg(unix)] #[cfg(unix)]
serve_unix(db, names).await?; serve_unix(db, names).await?;
#[cfg(windows)] #[cfg(windows)]
@ -19,10 +16,7 @@ pub async fn serve(
} }
#[cfg(unix)] #[cfg(unix)]
async fn serve_unix( async fn serve_unix(db: Arc<DbCache>, names: Arc<tokio::sync::RwLock<Arc<Names>>>) -> Result<()> {
db: Arc<DbCache>,
names: Arc<tokio::sync::RwLock<Arc<Names>>>,
) -> Result<()> {
use tokio::net::UnixListener; use tokio::net::UnixListener;
let sock_path = crate::config::sock_path(); let sock_path = crate::config::sock_path();
@ -88,9 +82,7 @@ async fn serve_windows(
db: Arc<DbCache>, db: Arc<DbCache>,
names: Arc<tokio::sync::RwLock<Arc<Names>>>, names: Arc<tokio::sync::RwLock<Arc<Names>>>,
) -> Result<()> { ) -> Result<()> {
use interprocess::local_socket::{ use interprocess::local_socket::{tokio::prelude::*, GenericNamespaced, ListenerOptions};
tokio::prelude::*, GenericNamespaced, ListenerOptions,
};
// interprocess 的 GenericNamespaced 在 Windows 上会自动拼接 `\\.\pipe\` 前缀, // interprocess 的 GenericNamespaced 在 Windows 上会自动拼接 `\\.\pipe\` 前缀,
// 这里必须传相对名client 端用 `\\.\pipe\wx-cli-daemon` 直接打开可以对上 // 这里必须传相对名client 端用 `\\.\pipe\wx-cli-daemon` 直接打开可以对上
@ -141,13 +133,9 @@ async fn handle_connection_windows(
Ok(()) Ok(())
} }
async fn dispatch( async fn dispatch(req: Request, db: &DbCache, names: &tokio::sync::RwLock<Arc<Names>>) -> Response {
req: Request,
db: &DbCache,
names: &tokio::sync::RwLock<Arc<Names>>,
) -> Response {
use crate::ipc::Request::*;
use super::query; use super::query;
use crate::ipc::Request::*;
// 取 guard → O(1) clone Arc → 立即 drop 锁。后续 await 期间不持有锁, // 取 guard → O(1) clone Arc → 立即 drop 锁。后续 await 期间不持有锁,
// 多个并发 IPC 请求可以真正并行。Names 本身不可变(由 daemon 启动时 // 多个并发 IPC 请求可以真正并行。Names 本身不可变(由 daemon 启动时
@ -159,20 +147,66 @@ async fn dispatch(
match req { match req {
Ping => Response::ok(serde_json::json!({ "pong": true })), Ping => Response::ok(serde_json::json!({ "pong": true })),
Sessions { limit } => { Sessions {
match query::q_sessions(db, &names_arc, limit).await { limit,
with_meta,
debug_source,
} => match query::q_sessions(db, &names_arc, limit, with_meta, debug_source).await {
Ok(v) => Response::ok(v),
Err(e) => Response::err(e.to_string()),
},
History {
chat,
limit,
offset,
since,
until,
msg_type,
with_meta,
debug_source,
} => {
match query::q_history(
db,
&names_arc,
&chat,
limit,
offset,
since,
until,
msg_type,
with_meta,
debug_source,
)
.await
{
Ok(v) => Response::ok(v), Ok(v) => Response::ok(v),
Err(e) => Response::err(e.to_string()), Err(e) => Response::err(e.to_string()),
} }
} }
History { chat, limit, offset, since, until, msg_type } => { Search {
match query::q_history(db, &names_arc, &chat, limit, offset, since, until, msg_type).await { keyword,
Ok(v) => Response::ok(v), chats,
Err(e) => Response::err(e.to_string()), limit,
} since,
} until,
Search { keyword, chats, limit, since, until, msg_type } => { msg_type,
match query::q_search(db, &names_arc, &keyword, chats, limit, since, until, msg_type).await { with_meta,
debug_source,
} => {
match query::q_search(
db,
&names_arc,
&keyword,
chats,
limit,
since,
until,
msg_type,
with_meta,
debug_source,
)
.await
{
Ok(v) => Response::ok(v), Ok(v) => Response::ok(v),
Err(e) => Response::err(e.to_string()), Err(e) => Response::err(e.to_string()),
} }
@ -183,74 +217,145 @@ async fn dispatch(
Err(e) => Response::err(e.to_string()), Err(e) => Response::err(e.to_string()),
} }
} }
Unread { limit, filter } => { Unread {
match query::q_unread(db, &names_arc, limit, filter).await { limit,
filter,
with_meta,
debug_source,
} => match query::q_unread(db, &names_arc, limit, filter, with_meta, debug_source).await {
Ok(v) => Response::ok(v),
Err(e) => Response::err(e.to_string()),
},
Members { chat } => match query::q_members(db, &names_arc, &chat).await {
Ok(v) => Response::ok(v),
Err(e) => Response::err(e.to_string()),
},
NewMessages {
state,
limit,
with_meta,
debug_source,
} => {
match query::q_new_messages(db, &names_arc, state, limit, with_meta, debug_source).await
{
Ok(v) => Response::ok(v), Ok(v) => Response::ok(v),
Err(e) => Response::err(e.to_string()), Err(e) => Response::err(e.to_string()),
} }
} }
Members { chat } => { Favorites {
match query::q_members(db, &names_arc, &chat).await { limit,
fav_type,
query,
} => match query::q_favorites(db, limit, fav_type, query).await {
Ok(v) => Response::ok(v),
Err(e) => Response::err(e.to_string()),
},
Stats {
chat,
since,
until,
with_meta,
debug_source,
} => {
match query::q_stats(db, &names_arc, &chat, since, until, with_meta, debug_source).await
{
Ok(v) => Response::ok(v), Ok(v) => Response::ok(v),
Err(e) => Response::err(e.to_string()), Err(e) => Response::err(e.to_string()),
} }
} }
NewMessages { state, limit } => { SnsNotifications {
match query::q_new_messages(db, &names_arc, state, limit).await { limit,
since,
until,
include_read,
} => {
match query::q_sns_notifications(db, &names_arc, limit, since, until, include_read)
.await
{
Ok(v) => Response::ok(v), Ok(v) => Response::ok(v),
Err(e) => Response::err(e.to_string()), Err(e) => Response::err(e.to_string()),
} }
} }
Favorites { limit, fav_type, query } => { SnsFeed {
match query::q_favorites(db, limit, fav_type, query).await { limit,
since,
until,
user,
} => match query::q_sns_feed(db, &names_arc, limit, since, until, user.as_deref()).await {
Ok(v) => Response::ok(v),
Err(e) => Response::err(e.to_string()),
},
SnsSearch {
keyword,
limit,
since,
until,
user,
} => {
match query::q_sns_search(
db,
&names_arc,
&keyword,
limit,
since,
until,
user.as_deref(),
)
.await
{
Ok(v) => Response::ok(v), Ok(v) => Response::ok(v),
Err(e) => Response::err(e.to_string()), Err(e) => Response::err(e.to_string()),
} }
} }
Stats { chat, since, until } => { ReloadConfig => Response::ok(serde_json::json!({ "reloading": true })),
match query::q_stats(db, &names_arc, &chat, since, until).await { BizArticles {
limit,
account,
since,
until,
unread,
} => {
match query::q_biz_articles(db, &names_arc, limit, account, since, until, unread).await
{
Ok(v) => Response::ok(v), Ok(v) => Response::ok(v),
Err(e) => Response::err(e.to_string()), Err(e) => Response::err(e.to_string()),
} }
} }
SnsNotifications { limit, since, until, include_read } => { Attachments {
match query::q_sns_notifications(db, &names_arc, limit, since, until, include_read).await { chat,
Ok(v) => Response::ok(v), kinds,
Err(e) => Response::err(e.to_string()), limit,
} offset,
} since,
SnsFeed { limit, since, until, user } => { until,
match query::q_sns_feed(db, &names_arc, limit, since, until, user.as_deref()).await { with_meta,
Ok(v) => Response::ok(v), debug_source,
Err(e) => Response::err(e.to_string()), } => {
} match query::q_attachments(
} db,
SnsSearch { keyword, limit, since, until, user } => { &names_arc,
match query::q_sns_search(db, &names_arc, &keyword, limit, since, until, user.as_deref()).await { &chat,
Ok(v) => Response::ok(v), kinds,
Err(e) => Response::err(e.to_string()), limit,
} offset,
} since,
ReloadConfig => { until,
Response::ok(serde_json::json!({ "reloading": true })) with_meta,
} debug_source,
BizArticles { limit, account, since, until, unread } => { )
match query::q_biz_articles(db, &names_arc, limit, account, since, until, unread).await { .await
Ok(v) => Response::ok(v), {
Err(e) => Response::err(e.to_string()),
}
}
Attachments { chat, kinds, limit, offset, since, until } => {
match query::q_attachments(db, &names_arc, &chat, kinds, limit, offset, since, until).await {
Ok(v) => Response::ok(v),
Err(e) => Response::err(e.to_string()),
}
}
Extract { attachment_id, output, overwrite } => {
match query::q_extract(db, &names_arc, &attachment_id, &output, overwrite).await {
Ok(v) => Response::ok(v), Ok(v) => Response::ok(v),
Err(e) => Response::err(e.to_string()), Err(e) => Response::err(e.to_string()),
} }
} }
Extract {
attachment_id,
output,
overwrite,
} => match query::q_extract(db, &names_arc, &attachment_id, &output, overwrite).await {
Ok(v) => Response::ok(v),
Err(e) => Response::err(e.to_string()),
},
} }
} }

View File

@ -1,6 +1,6 @@
use std::collections::HashMap;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use std::collections::HashMap;
/// CLI 向 daemon 发送的请求(换行符分隔 JSON与 Python 版兼容) /// CLI 向 daemon 发送的请求(换行符分隔 JSON与 Python 版兼容)
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -10,6 +10,10 @@ pub enum Request {
Sessions { Sessions {
#[serde(default = "default_limit_20")] #[serde(default = "default_limit_20")]
limit: usize, limit: usize,
#[serde(default, skip_serializing_if = "is_false")]
with_meta: bool,
#[serde(default, skip_serializing_if = "is_false")]
debug_source: bool,
}, },
History { History {
chat: String, chat: String,
@ -23,6 +27,10 @@ pub enum Request {
until: Option<i64>, until: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
msg_type: Option<i64>, msg_type: Option<i64>,
#[serde(default, skip_serializing_if = "is_false")]
with_meta: bool,
#[serde(default, skip_serializing_if = "is_false")]
debug_source: bool,
}, },
Search { Search {
keyword: String, keyword: String,
@ -36,6 +44,10 @@ pub enum Request {
until: Option<i64>, until: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
msg_type: Option<i64>, msg_type: Option<i64>,
#[serde(default, skip_serializing_if = "is_false")]
with_meta: bool,
#[serde(default, skip_serializing_if = "is_false")]
debug_source: bool,
}, },
Contacts { Contacts {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@ -49,6 +61,10 @@ pub enum Request {
/// 按会话类型过滤private / group / official / folded / all支持多选 /// 按会话类型过滤private / group / official / folded / all支持多选
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
filter: Option<Vec<String>>, filter: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "is_false")]
with_meta: bool,
#[serde(default, skip_serializing_if = "is_false")]
debug_source: bool,
}, },
Members { Members {
chat: String, chat: String,
@ -60,6 +76,10 @@ pub enum Request {
state: Option<HashMap<String, i64>>, state: Option<HashMap<String, i64>>,
#[serde(default = "default_limit_200")] #[serde(default = "default_limit_200")]
limit: usize, limit: usize,
#[serde(default, skip_serializing_if = "is_false")]
with_meta: bool,
#[serde(default, skip_serializing_if = "is_false")]
debug_source: bool,
}, },
Stats { Stats {
chat: String, chat: String,
@ -67,6 +87,10 @@ pub enum Request {
since: Option<i64>, since: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
until: Option<i64>, until: Option<i64>,
#[serde(default, skip_serializing_if = "is_false")]
with_meta: bool,
#[serde(default, skip_serializing_if = "is_false")]
debug_source: bool,
}, },
Favorites { Favorites {
#[serde(default = "default_limit_50")] #[serde(default = "default_limit_50")]
@ -102,7 +126,7 @@ pub enum Request {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
user: Option<String>, user: Option<String>,
}, },
/// 查询公众号文章推送biz_message_0.db /// 查询公众号文章推送biz_message_*.db 分片
BizArticles { BizArticles {
#[serde(default = "default_limit_50")] #[serde(default = "default_limit_50")]
limit: usize, limit: usize,
@ -146,6 +170,10 @@ pub enum Request {
since: Option<i64>, since: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
until: Option<i64>, until: Option<i64>,
#[serde(default, skip_serializing_if = "is_false")]
with_meta: bool,
#[serde(default, skip_serializing_if = "is_false")]
debug_source: bool,
}, },
/// 提取(解密)单个附件的本体到指定路径 /// 提取(解密)单个附件的本体到指定路径
Extract { Extract {
@ -159,7 +187,6 @@ pub enum Request {
}, },
} }
/// daemon 的响应 /// daemon 的响应
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Response { pub struct Response {
@ -172,11 +199,19 @@ pub struct Response {
impl Response { impl Response {
pub fn ok(data: Value) -> Self { pub fn ok(data: Value) -> Self {
Self { ok: true, error: None, data } Self {
ok: true,
error: None,
data,
}
} }
pub fn err(msg: impl Into<String>) -> Self { pub fn err(msg: impl Into<String>) -> Self {
Self { ok: false, error: Some(msg.into()), data: Value::Null } Self {
ok: false,
error: Some(msg.into()),
data: Value::Null,
}
} }
pub fn to_json_line(&self) -> anyhow::Result<String> { pub fn to_json_line(&self) -> anyhow::Result<String> {
@ -185,6 +220,15 @@ impl Response {
} }
} }
fn default_limit_20() -> usize { 20 } fn default_limit_20() -> usize {
fn default_limit_50() -> usize { 50 } 20
fn default_limit_200() -> usize { 200 } }
fn default_limit_50() -> usize {
50
}
fn default_limit_200() -> usize {
200
}
fn is_false(v: &bool) -> bool {
!*v
}