feat(unread): 按 chat_type 分类会话,新增 --filter (#9)

Before: wx unread / sessions / history 把公众号、订阅号折叠入口
(brandsessionholder)、折叠群聊(@placeholder_foldgroup)、认证服务号
全归为 is_group=false,与真私聊混在一起。甚至 username 形如 wxid_* 但
实为公众号的条目也完全分不出来。

改动:
- 新增 chat_type_of(username, names) helper,输出固定为
  group / official_account / folded / private。
- 判据依次:@chatroom → group;brandsessionholder / @placeholder_foldgroup
  → folded;contact.verify_flag != 0 → official_account(覆盖 wxid_*
  伪装为公众号的情况,以及银行/品牌服务号、qqsafe / mphelper 等认证账号);
  gh_* / biz_* / @* 前缀兜底;其余为 private。
- load_names 顺带读 contact.verify_flag,Names::is_verified 封装查询。
- q_sessions / q_unread / q_history / q_new_messages / q_stats 输出
  新增 chat_type 字段,is_group 保留向后兼容并统一由 chat_type 派生。
- wx unread 新增 --filter,clap value_parser 限制可选值为
  all / private / group / official / folded,逗号分隔多选,默认 all。
  例:wx unread --filter private,group 可过滤公众号与折叠入口。
- SKILL.md / README.md 补充新字段与用法说明。
- .gitignore 补 target/(Rust 项目标配)。

性能:默认 wx unread 的 SQL 与改动前相同(保留 LIMIT)。仅当传入
--filter 时改为全表扫再 Rust 侧过滤,否则 SQL LIMIT 会先把匹配
filter 的条目截断导致漏召。
pull/13/head
JL 2026-04-18 01:59:35 +08:00 committed by GitHub
parent bfb7048cf0
commit e977007306
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 158 additions and 33 deletions

7
.gitignore vendored
View File

@ -15,6 +15,9 @@ hook_start_output.txt
hook_stderr.txt
run_hook.bat
# Rust
target/
# Python
__pycache__/
*.py[cod]
@ -23,5 +26,5 @@ __pycache__/
# OS
.DS_Store
Thumbs.db
find_all_keys_macos
.claude/worktrees/
find_all_keys_macos
.claude/worktrees/

View File

@ -142,6 +142,7 @@ wx sessions
```bash
wx sessions # 最近 20 个会话
wx unread # 有未读消息的会话
wx unread --filter private,group # 只看真人未读(过滤公众号/折叠入口)
wx new-messages # 上次检查后的新消息(增量)
wx history "张三" # 最近 50 条记录
wx history "AI群" --since 2026-04-01 --until 2026-04-15
@ -149,6 +150,8 @@ wx search "关键词" # 全库搜索
wx search "会议" --in "工作群" --since 2026-01-01
```
会话/消息输出里都带 `chat_type` 字段,取值为 `private` / `group` / `official_account` / `folded`。`official_account` 涵盖公众号、订阅号、服务号及 `mphelper` / `qqsafe` 等系统通知;`folded` 对应微信里的"订阅号折叠"和"折叠群聊"两个聚合入口。
### 联系人 & 群组
```bash

View File

@ -106,6 +106,9 @@ wx sessions
# 有未读消息的会话
wx unread
# 只看真人(私聊 + 群聊)的未读,过滤公众号与折叠入口
wx unread --filter private,group
# 上次检查后的新消息(增量)
wx new-messages
wx new-messages --json # JSON 输出,适合 agent 解析
@ -119,6 +122,17 @@ wx search "关键词"
wx search "会议" --in "工作群" --since 2026-01-01
```
`sessions` / `unread` / `history` / `new-messages` / `stats` 的输出都带 `chat_type` 字段agent 可据此分流:
| 取值 | 含义 | username 特征 |
|------|------|--------------|
| `private` | 真人私聊 | `wxid_*` 或自定义短号 |
| `group` | 群聊 | `*@chatroom` |
| `official_account` | 公众号 / 订阅号 / 服务号 / 系统通知 | `gh_*`、`biz_*`、`mphelper`、`qqsafe`、`@opencustomerservicemsg` |
| `folded` | 折叠入口(订阅号折叠、折叠群聊的聚合条目) | `brandsessionholder`、`@placeholder_foldgroup` |
`wx unread --filter` 支持 `private` / `group` / `official` / `folded` / `all`,逗号分隔多选。默认 `all`
### 联系人与群组
```bash

View File

@ -126,6 +126,10 @@ enum Commands {
/// 显示数量
#[arg(short = 'n', long, default_value = "20")]
limit: usize,
/// 按会话类型过滤,逗号分隔。示例:--filter private,group 只看真人的未读
#[arg(long, value_name = "TYPES", value_delimiter = ',',
value_parser = ["all", "private", "group", "official", "folded"])]
filter: Vec<String>,
/// 输出 JSON默认 YAML
#[arg(long)]
json: bool,
@ -223,7 +227,7 @@ fn dispatch(cli: Cli) -> Result<()> {
Commands::Export { chat, since, until, limit, format, output } => {
export::cmd_export(chat, since, until, limit, format, output)
}
Commands::Unread { limit, json } => unread::cmd_unread(limit, json),
Commands::Unread { limit, filter, json } => unread::cmd_unread(limit, filter, json),
Commands::Members { chat, json } => members::cmd_members(chat, json),
Commands::NewMessages { limit, json } => new_messages::cmd_new_messages(limit, json),
Commands::Stats { chat, since, until, json } => {

View File

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

View File

@ -66,6 +66,7 @@ async fn async_run() -> Result<()> {
map: HashMap::new(),
md5_to_uname: HashMap::new(),
msg_db_keys: Vec::new(),
verify_flags: HashMap::new(),
}
});
let mut names = names_raw;

View File

@ -14,6 +14,36 @@ fn msg_table_re() -> &'static Regex {
RE.get_or_init(|| Regex::new(r"^Msg_[0-9a-f]{32}$").unwrap())
}
/// 判定会话类型。返回值固定为 `group` / `official_account` / `folded` / `private` 之一。
///
/// 判据次序:
/// 1. `@chatroom` / 折叠入口特殊 username
/// 2. `contact.verify_flag` 非 0 —— 覆盖所有被微信官方打了认证标的账号,
/// 包括 username 为 `wxid_*` 但实为公众号的情况(如"人物"
/// 以及品牌服务号 `cmb4008205555`、系统号 `qqsafe` / `mphelper` 等
/// 3. username 前缀兜底(`gh_*` / `biz_*` / `@*` 等)—— 在 contact 表未加载或没记录时
/// 仍能给出正确结果
pub fn chat_type_of(username: &str, names: &Names) -> &'static str {
if username.contains("@chatroom") {
return "group";
}
if username == "brandsessionholder" || username == "@placeholder_foldgroup" {
return "folded";
}
if names.is_verified(username) {
return "official_account";
}
if username.starts_with("gh_") || username.starts_with("biz_") {
return "official_account";
}
// `@` 开头的剩余 username如 `@opencustomerservicemsg`)是微信内部系统账号,
// 通常不落在 contact 表里verify_flag 兜不住,按前缀兜底。
if username.starts_with('@') {
return "official_account";
}
"private"
}
/// 联系人名称缓存
#[derive(Clone)]
pub struct Names {
@ -23,40 +53,50 @@ pub struct Names {
pub md5_to_uname: HashMap<String, String>,
/// 消息 DB 的相对路径列表message/message_N.db
pub msg_db_keys: Vec<String>,
/// username -> contact.verify_flag0=真人,非 0 通常为公众号/服务号/认证账号)
pub verify_flags: HashMap<String, i64>,
}
impl Names {
pub fn display(&self, username: &str) -> String {
self.map.get(username).cloned().unwrap_or_else(|| username.to_string())
}
/// 是否被微信官方标了认证/服务号 flag。未在 contact 表中的 username 返回 false。
pub fn is_verified(&self, username: &str) -> bool {
self.verify_flags.get(username).copied().unwrap_or(0) != 0
}
}
/// 加载联系人缓存(从 contact/contact.db
pub async fn load_names(db: &DbCache) -> Result<Names> {
let path = db.get("contact/contact.db").await?;
let mut map = HashMap::new();
let mut verify_flags: HashMap<String, i64> = HashMap::new();
if let Some(p) = path {
let p2 = p.clone();
let rows: Vec<(String, String, String)> = tokio::task::spawn_blocking(move || {
let rows: Vec<(String, String, String, i64)> = tokio::task::spawn_blocking(move || {
let conn = Connection::open(&p2).context("打开 contact.db 失败")?;
let mut stmt = conn.prepare(
"SELECT username, nick_name, remark FROM contact"
"SELECT username, nick_name, remark, verify_flag FROM contact"
)?;
let rows = stmt.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1).unwrap_or_default(),
row.get::<_, String>(2).unwrap_or_default(),
row.get::<_, i64>(3).unwrap_or(0),
))
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok::<_, anyhow::Error>(rows)
}).await??;
for (uname, nick, remark) in rows {
for (uname, nick, remark, vf) in rows {
let display = if !remark.is_empty() { remark }
else if !nick.is_empty() { nick }
else { uname.clone() };
verify_flags.insert(uname.clone(), vf);
map.insert(uname, display);
}
}
@ -65,7 +105,7 @@ pub async fn load_names(db: &DbCache) -> Result<Names> {
.map(|u| (format!("{:x}", md5::compute(u.as_bytes())), u.clone()))
.collect();
Ok(Names { map, md5_to_uname, msg_db_keys: Vec::new() })
Ok(Names { map, md5_to_uname, msg_db_keys: Vec::new(), verify_flags })
}
/// 查询最近会话列表
@ -102,7 +142,8 @@ pub async fn q_sessions(db: &DbCache, names: &Names, limit: usize) -> Result<Val
let mut results = Vec::new();
for (username, unread, summary_bytes, ts, msg_type, sender, sender_name) in rows {
let display = names.display(&username);
let is_group = username.contains("@chatroom");
let chat_type = chat_type_of(&username, names);
let is_group = chat_type == "group";
// 尝试 zstd 解压 summary
let summary = decompress_or_str(&summary_bytes);
@ -120,6 +161,7 @@ pub async fn q_sessions(db: &DbCache, names: &Names, limit: usize) -> Result<Val
"chat": display,
"username": username,
"is_group": is_group,
"chat_type": chat_type,
"unread": unread,
"last_msg_type": fmt_type(msg_type),
"last_sender": sender_display,
@ -145,7 +187,8 @@ pub async fn q_history(
let username = resolve_username(chat, names)
.with_context(|| format!("找不到联系人: {}", chat))?;
let display = names.display(&username);
let is_group = username.contains("@chatroom");
let chat_type = chat_type_of(&username, names);
let is_group = chat_type == "group";
let tables = find_msg_tables(db, names, &username).await?;
if tables.is_empty() {
@ -182,6 +225,7 @@ pub async fn q_history(
"chat": display,
"username": username,
"is_group": is_group,
"chat_type": chat_type,
"count": paged.len(),
"messages": paged,
}))
@ -779,39 +823,80 @@ fn fmt_time(ts: i64, fmt: &str) -> String {
// ─── 新增命令查询函数 ──────────────────────────────────────────────────────────
/// 查询有未读消息的会话
pub async fn q_unread(db: &DbCache, names: &Names, limit: usize) -> Result<Value> {
///
/// `filter`:按 chat_type 过滤None 或空 Vec 等价于 "all"。
/// 可选值:`private` / `group` / `official` / `folded` / `all`。
/// 多选支持在 CLI 层用逗号分隔后传入多个元素。
pub async fn q_unread(
db: &DbCache,
names: &Names,
limit: usize,
filter: Option<Vec<String>>,
) -> Result<Value> {
let path = db.get("session/session.db").await?
.context("无法解密 session.db")?;
// 归一化 filter小写 + 去除别名。返回 None 代表"不过滤"。
let filter_set: Option<std::collections::HashSet<&'static str>> = filter.and_then(|v| {
let mut set = std::collections::HashSet::new();
for raw in v {
match raw.trim().to_lowercase().as_str() {
"" | "all" => return None,
"private" => { set.insert("private"); }
"group" => { set.insert("group"); }
"official" | "official_account" => { set.insert("official_account"); }
"folded" | "fold" => { set.insert("folded"); }
_ => {} // 未知值忽略,避免拼错导致什么都不返回
}
}
if set.is_empty() { None } else { Some(set) }
});
// 有 filter 时必须全表扫SQL LIMIT 会把想要的公众号先筛掉。
// 无 filter 时保留 LIMIT避免重度用户的大量未读会话拖慢默认路径。
let has_filter = filter_set.is_some();
let limit_val = limit;
let rows: Vec<(String, i64, Vec<u8>, i64, i64, String, String)> = tokio::task::spawn_blocking(move || {
let conn = Connection::open(&path)?;
let mut stmt = conn.prepare(
let sql = if has_filter {
"SELECT username, unread_count, summary, last_timestamp,
last_msg_type, last_msg_sender, last_sender_display_name
FROM SessionTable
WHERE unread_count > 0
FROM SessionTable WHERE unread_count > 0
ORDER BY last_timestamp DESC"
} else {
"SELECT username, unread_count, summary, last_timestamp,
last_msg_type, last_msg_sender, last_sender_display_name
FROM SessionTable WHERE unread_count > 0
ORDER BY last_timestamp DESC LIMIT ?"
)?;
let rows = stmt.query_map([limit_val as i64], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, i64>(1).unwrap_or(0),
get_content_bytes(row, 2),
row.get::<_, i64>(3).unwrap_or(0),
row.get::<_, i64>(4).unwrap_or(0),
row.get::<_, String>(5).unwrap_or_default(),
row.get::<_, String>(6).unwrap_or_default(),
))
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
};
let mut stmt = conn.prepare(sql)?;
let map_row = |row: &rusqlite::Row<'_>| Ok((
row.get::<_, String>(0)?,
row.get::<_, i64>(1).unwrap_or(0),
get_content_bytes(row, 2),
row.get::<_, i64>(3).unwrap_or(0),
row.get::<_, i64>(4).unwrap_or(0),
row.get::<_, String>(5).unwrap_or_default(),
row.get::<_, String>(6).unwrap_or_default(),
));
let rows = if has_filter {
stmt.query_map([], map_row)?.collect::<rusqlite::Result<Vec<_>>>()?
} else {
stmt.query_map([limit_val as i64], map_row)?.collect::<rusqlite::Result<Vec<_>>>()?
};
Ok::<_, anyhow::Error>(rows)
}).await??;
let mut results = Vec::new();
for (username, unread, summary_bytes, ts, msg_type, sender, sender_name) in rows {
let chat_type = chat_type_of(&username, names);
if let Some(ref set) = filter_set {
if !set.contains(chat_type) { continue; }
}
if results.len() >= limit { break; }
let display = names.display(&username);
let is_group = username.contains("@chatroom");
let is_group = chat_type == "group";
let summary = decompress_or_str(&summary_bytes);
let summary = strip_group_prefix(&summary);
let sender_display = if is_group && !sender.is_empty() {
@ -825,6 +910,7 @@ pub async fn q_unread(db: &DbCache, names: &Names, limit: usize) -> Result<Value
"chat": display,
"username": username,
"is_group": is_group,
"chat_type": chat_type,
"unread": unread,
"last_msg_type": fmt_type(msg_type),
"last_sender": sender_display,
@ -1059,7 +1145,8 @@ pub async fn q_new_messages(
if tables.is_empty() { continue; }
let display = names.display(uname);
let is_group = uname.contains("@chatroom");
let chat_type = chat_type_of(uname, names);
let is_group = chat_type == "group";
for (db_path, table_name) in &tables {
let path = db_path.clone();
@ -1104,6 +1191,7 @@ pub async fn q_new_messages(
"chat": display2,
"username": uname2,
"is_group": is_group,
"chat_type": chat_type,
"timestamp": ts,
"time": fmt_time(ts, "%Y-%m-%d %H:%M"),
"sender": sender,
@ -1259,7 +1347,8 @@ pub async fn q_stats(
let username = resolve_username(chat, names)
.with_context(|| format!("找不到联系人: {}", chat))?;
let display = names.display(&username);
let is_group = username.contains("@chatroom");
let chat_type = chat_type_of(&username, names);
let is_group = chat_type == "group";
let tables = find_msg_tables(db, names, &username).await?;
if tables.is_empty() {
@ -1405,6 +1494,7 @@ pub async fn q_stats(
"chat": display,
"username": username,
"is_group": is_group,
"chat_type": chat_type,
"total": total,
"by_type": by_type,
"top_senders": top_senders,

View File

@ -191,12 +191,12 @@ async fn dispatch(
Err(e) => Response::err(e.to_string()),
}
}
Unread { limit } => {
Unread { limit, filter } => {
let names_snapshot = match clone_names(names) {
Ok(n) => n,
Err(e) => return Response::err(e),
};
match query::q_unread(db, &names_snapshot, limit).await {
match query::q_unread(db, &names_snapshot, limit, filter).await {
Ok(v) => Response::ok(v),
Err(e) => Response::err(e.to_string()),
}
@ -247,5 +247,6 @@ fn clone_names(names: &std::sync::RwLock<Names>) -> Result<Names, String> {
map: guard.map.clone(),
md5_to_uname: guard.md5_to_uname.clone(),
msg_db_keys: guard.msg_db_keys.clone(),
verify_flags: guard.verify_flags.clone(),
})
}

View File

@ -46,6 +46,9 @@ pub enum Request {
Unread {
#[serde(default = "default_limit_20")]
limit: usize,
/// 按会话类型过滤private / group / official / folded / all支持多选
#[serde(default, skip_serializing_if = "Option::is_none")]
filter: Option<Vec<String>>,
},
Members {
chat: String,