mirror of https://github.com/jackwener/wx-cli.git
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
parent
bfb7048cf0
commit
e977007306
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
14
SKILL.md
14
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
|
||||
|
|
|
|||
|
|
@ -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 } => {
|
||||
|
|
|
|||
|
|
@ -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![]));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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_flag(0=真人,非 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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue