diff --git a/.gitignore b/.gitignore index 056b740..cf83214 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md index c53f866..4ffc99a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/SKILL.md b/SKILL.md index 487fc24..7092a1d 100644 --- a/SKILL.md +++ b/SKILL.md @@ -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 diff --git a/src/cli/mod.rs b/src/cli/mod.rs index b6d0dd4..85d985a 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -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, /// 输出 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 } => { diff --git a/src/cli/unread.rs b/src/cli/unread.rs index 595081a..031700c 100644 --- a/src/cli/unread.rs +++ b/src/cli/unread.rs @@ -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, 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![])); diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs index 63e1fe5..a9c4d3b 100644 --- a/src/daemon/mod.rs +++ b/src/daemon/mod.rs @@ -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; diff --git a/src/daemon/query.rs b/src/daemon/query.rs index da42bba..f875d0f 100644 --- a/src/daemon/query.rs +++ b/src/daemon/query.rs @@ -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, /// 消息 DB 的相对路径列表(message/message_N.db) pub msg_db_keys: Vec, + /// username -> contact.verify_flag(0=真人,非 0 通常为公众号/服务号/认证账号) + pub verify_flags: HashMap, } 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 { let path = db.get("contact/contact.db").await?; let mut map = HashMap::new(); + let mut verify_flags: HashMap = 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::>>()?; 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 { .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 Result String { // ─── 新增命令查询函数 ────────────────────────────────────────────────────────── /// 查询有未读消息的会话 -pub async fn q_unread(db: &DbCache, names: &Names, limit: usize) -> Result { +/// +/// `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>, +) -> Result { let path = db.get("session/session.db").await? .context("无法解密 session.db")?; + // 归一化 filter:小写 + 去除别名。返回 None 代表"不过滤"。 + let filter_set: Option> = 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, 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::>>()?; + }; + 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::>>()? + } else { + stmt.query_map([limit_val as i64], map_row)?.collect::>>()? + }; 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 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) -> Result { 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(), }) } diff --git a/src/ipc.rs b/src/ipc.rs index cf21993..37da4c0 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -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>, }, Members { chat: String,