mirror of https://github.com/jackwener/wx-cli.git
Compare commits
15 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
08af894594 | |
|
|
94fcc36ffe | |
|
|
0612789d19 | |
|
|
f8550ae74d | |
|
|
5f87ce6348 | |
|
|
ed95812332 | |
|
|
be1a174226 | |
|
|
c34f5f8fe2 | |
|
|
739b66a4b1 | |
|
|
b5edaf7177 | |
|
|
9f6a2cfba3 | |
|
|
76024901e9 | |
|
|
12740afb53 | |
|
|
b58ae5468d | |
|
|
7451ce5684 |
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
31
README.md
31
README.md
|
|
@ -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}` wrapper,agent 能直接消费 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 Sur,macOS 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 看到可执行的 warning;agent / 脚本可直接读 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 管理
|
||||||
|
|
|
||||||
35
SKILL.md
35
SKILL.md
|
|
@ -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` 搜索。
|
||||||
|
|
|
||||||
|
|
@ -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。
|
||||||
|
|
|
||||||
|
|
@ -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"],
|
||||||
|
|
|
||||||
|
|
@ -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"],
|
||||||
|
|
|
||||||
|
|
@ -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"],
|
||||||
|
|
|
||||||
|
|
@ -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"],
|
||||||
|
|
|
||||||
|
|
@ -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"],
|
||||||
|
|
|
||||||
|
|
@ -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"],
|
||||||
|
|
|
||||||
|
|
@ -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))
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
239
src/cli/mod.rs
239
src/cli/mod.rs
|
|
@ -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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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))
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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))
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 bytes,apply_wal 仍 noop)
|
// bump WAL mtime(重写仍 31 bytes,apply_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!(
|
||||||
|
|
|
||||||
|
|
@ -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_time(unix 秒)。
|
||||||
|
/// `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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
2313
src/daemon/query.rs
2313
src/daemon/query.rs
File diff suppressed because it is too large
Load Diff
|
|
@ -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 server(Unix socket / Windows named pipe)
|
/// 启动 IPC server(Unix 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()),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
60
src/ipc.rs
60
src/ipc.rs
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue