mirror of https://github.com/jackwener/wx-cli.git
feat(meta): expose freshness coverage in query output
commit
b5edaf7177
17
README.md
17
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 信息
|
||||||
- **完全本地** — 数据不出本机,实时解密,无需全量预解密
|
- **完全本地** — 数据不出本机,实时解密,无需全量预解密
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -168,6 +168,15 @@ wx search "会议" --in "工作群" --since 2026-01-01
|
||||||
|
|
||||||
群聊里的 `last_sender`、`sender` 和 `stats` 的 `top_senders` 会优先使用群昵称(群名片)。如果本地数据库里没有对应群昵称,则回退到联系人备注、微信昵称或 username。
|
群聊里的 `last_sender`、`sender` 和 `stats` 的 `top_senders` 会优先使用群昵称(群名片)。如果本地数据库里没有对应群昵称,则回退到联系人备注、微信昵称或 username。
|
||||||
|
|
||||||
|
`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
|
||||||
|
|
@ -278,12 +287,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 管理
|
||||||
|
|
|
||||||
29
SKILL.md
29
SKILL.md
|
|
@ -159,6 +159,29 @@ wx search "会议" --in "工作群" --since 2026-01-01
|
||||||
|
|
||||||
群聊消息里的 `last_sender`、`sender` 和 `stats.top_senders` 会优先显示群昵称(群名片)。如果本地数据库没有群昵称,再回退到联系人备注、微信昵称或 username。
|
群聊消息里的 `last_sender`、`sender` 和 `stats.top_senders` 会优先显示群昵称(群名片)。如果本地数据库没有群昵称,再回退到联系人备注、微信昵称或 username。
|
||||||
|
|
||||||
|
`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
|
||||||
|
|
@ -315,8 +338,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` 搜索。
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
1883
src/daemon/query.rs
1883
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()),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
58
src/ipc.rs
58
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")]
|
||||||
|
|
@ -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