diff --git a/README.md b/README.md index a0076a3..50b7394 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,27 @@ wx search "会议" --in "工作群" --since 2026-01-01 会话/消息输出里都带 `chat_type` 字段,取值为 `private` / `group` / `official_account` / `folded`。`official_account` 涵盖公众号、订阅号、服务号及 `mphelper` / `qqsafe` 等系统通知;`folded` 对应微信里的"订阅号折叠"和"折叠群聊"两个聚合入口。 +### 朋友圈(SNS) + +三个独立命令,区分"通知"和"帖子": + +```bash +wx sns-notifications # 点赞/评论通知(默认仅未读) +wx sns-notifications --include-read -n 100 # 含已读 + +wx sns-feed # 近 20 条朋友圈(时间线) +wx sns-feed --user "张三" # 限定作者 +wx sns-feed --since 2026-04-01 -n 100 # 按时间 + +wx sns-search "关键词" # 全文搜索朋友圈正文 +wx sns-search "婚礼" --user "李四" --since 2023-01-01 +``` + +- **sns-notifications** 返回互动通知:`type`(`like`/`comment`)、`from_nickname`、`content`(评论正文)、`feed_preview` + `feed_author`(对应原帖) +- **sns-feed** / **sns-search** 返回朋友圈帖子:`author`、`content`(正文)、`media_count`、`location`、`timestamp` + +朋友圈数据只覆盖你本地刷到过的帖子(微信 app 按需下载)。 + ### 联系人 & 群组 ```bash diff --git a/SKILL.md b/SKILL.md index fc74e81..9a2f605 100644 --- a/SKILL.md +++ b/SKILL.md @@ -148,6 +148,32 @@ wx contacts --query "李" wx members "AI交流群" ``` +### 朋友圈(SNS) + +三个命令,作用各不同: + +```bash +# 1) 互动通知(点赞 / 评论,默认仅未读) +wx sns-notifications +wx sns-notifications --include-read --since 2026-04-01 -n 100 + +# 2) 时间线:浏览本地缓存的朋友圈帖子 +wx sns-feed # 近 20 条 +wx sns-feed --user "张三" # 只看某人 +wx sns-feed --since 2026-04-01 --until 2026-04-18 -n 100 + +# 3) 全文搜索:在正文里找关键词 +wx sns-search "关键词" +wx sns-search "婚礼" --user "李四" --since 2023-01-01 -n 50 +``` + +**字段区分**: + +- `sns-notifications` 返回"通知"条目:`type`(`like`/`comment`)、`from_nickname`、`content`(评论正文,点赞为空)、`feed_preview` + `feed_author`(对应的原帖) +- `sns-feed` / `sns-search` 返回"帖子"条目:`author`、`content`(朋友圈正文)、`media_count`(图片/视频数)、`location`、`timestamp` + +> 只保存你本地刷到过的朋友圈(微信 app 按需下载)。没刷到过的帖子不在本地,任何命令都拿不到。 + ### 收藏与统计 ```bash diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 85d985a..3a28060 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -12,6 +12,9 @@ pub mod members; pub mod new_messages; pub mod stats; pub mod favorites; +pub mod sns_notifications; +pub mod sns_feed; +pub mod sns_search; use anyhow::Result; use clap::{Parser, Subcommand}; @@ -181,6 +184,62 @@ enum Commands { #[arg(long)] json: bool, }, + /// 朋友圈互动通知:别人对我的朋友圈点赞/评论 + 我评过的帖子下的跟帖 + SnsNotifications { + /// 显示数量 + #[arg(short = 'n', long, default_value = "50")] + limit: usize, + /// 起始时间 YYYY-MM-DD + #[arg(long)] + since: Option, + /// 结束时间 YYYY-MM-DD + #[arg(long)] + until: Option, + /// 包含已读通知(默认仅未读) + #[arg(long)] + include_read: bool, + /// 输出 JSON(默认 YAML) + #[arg(long)] + json: bool, + }, + /// 朋友圈时间线:按时间/作者筛选本地缓存的朋友圈 + SnsFeed { + /// 显示数量 + #[arg(short = 'n', long, default_value = "20")] + limit: usize, + /// 起始时间 YYYY-MM-DD + #[arg(long)] + since: Option, + /// 结束时间 YYYY-MM-DD + #[arg(long)] + until: Option, + /// 只看指定作者(昵称 / 备注名 / 微信 ID,模糊匹配) + #[arg(long)] + user: Option, + /// 输出 JSON(默认 YAML) + #[arg(long)] + json: bool, + }, + /// 朋友圈全文搜索:匹配正文关键词 + SnsSearch { + /// 关键词 + keyword: String, + /// 结果数量 + #[arg(short = 'n', long, default_value = "20")] + limit: usize, + /// 起始时间 YYYY-MM-DD + #[arg(long)] + since: Option, + /// 结束时间 YYYY-MM-DD + #[arg(long)] + until: Option, + /// 限定作者(昵称 / 备注名 / 微信 ID) + #[arg(long)] + user: Option, + /// 输出 JSON(默认 YAML) + #[arg(long)] + json: bool, + }, /// 管理 wx-daemon Daemon { #[command(subcommand)] @@ -236,6 +295,15 @@ fn dispatch(cli: Cli) -> Result<()> { 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::Daemon { cmd } => daemon_cmd::cmd_daemon(cmd), } } diff --git a/src/cli/sns_feed.rs b/src/cli/sns_feed.rs new file mode 100644 index 0000000..afb30a5 --- /dev/null +++ b/src/cli/sns_feed.rs @@ -0,0 +1,28 @@ +use anyhow::Result; +use crate::ipc::Request; +use super::history::{parse_time, parse_time_end}; +use super::transport; +use super::output::{resolve, print_value}; + +pub fn cmd_sns_feed( + limit: usize, + since: Option, + until: Option, + user: Option, + json: bool, +) -> Result<()> { + let since_ts = since.as_deref().map(parse_time).transpose()?; + let until_ts = until.as_deref().map(parse_time_end).transpose()?; + + let req = Request::SnsFeed { + limit, + since: since_ts, + until: until_ts, + user, + }; + let resp = transport::send(req)?; + let data = resp.data.get("posts") + .cloned() + .unwrap_or(serde_json::Value::Array(vec![])); + print_value(&data, &resolve(json)) +} diff --git a/src/cli/sns_notifications.rs b/src/cli/sns_notifications.rs new file mode 100644 index 0000000..42fa30f --- /dev/null +++ b/src/cli/sns_notifications.rs @@ -0,0 +1,28 @@ +use anyhow::Result; +use crate::ipc::Request; +use super::history::{parse_time, parse_time_end}; +use super::transport; +use super::output::{resolve, print_value}; + +pub fn cmd_sns_notifications( + limit: usize, + since: Option, + until: Option, + include_read: bool, + json: bool, +) -> Result<()> { + let since_ts = since.as_deref().map(parse_time).transpose()?; + let until_ts = until.as_deref().map(parse_time_end).transpose()?; + + let req = Request::SnsNotifications { + limit, + since: since_ts, + until: until_ts, + include_read, + }; + let resp = transport::send(req)?; + let data = resp.data.get("notifications") + .cloned() + .unwrap_or(serde_json::Value::Array(vec![])); + print_value(&data, &resolve(json)) +} diff --git a/src/cli/sns_search.rs b/src/cli/sns_search.rs new file mode 100644 index 0000000..1ed4bda --- /dev/null +++ b/src/cli/sns_search.rs @@ -0,0 +1,30 @@ +use anyhow::Result; +use crate::ipc::Request; +use super::history::{parse_time, parse_time_end}; +use super::transport; +use super::output::{resolve, print_value}; + +pub fn cmd_sns_search( + keyword: String, + limit: usize, + since: Option, + until: Option, + user: Option, + json: bool, +) -> Result<()> { + let since_ts = since.as_deref().map(parse_time).transpose()?; + let until_ts = until.as_deref().map(parse_time_end).transpose()?; + + let req = Request::SnsSearch { + keyword, + limit, + since: since_ts, + until: until_ts, + user, + }; + let resp = transport::send(req)?; + let data = resp.data.get("posts") + .cloned() + .unwrap_or(serde_json::Value::Array(vec![])); + print_value(&data, &resolve(json)) +} diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs index 4f2913c..02dc99f 100644 --- a/src/daemon/mod.rs +++ b/src/daemon/mod.rs @@ -73,6 +73,7 @@ async fn async_run() -> Result<()> { names.msg_db_keys = msg_db_keys; let _ = db.get("session/session.db").await; + let _ = db.get("sns/sns.db").await; eprintln!("[daemon] 预热完成,联系人 {} 个", names.map.len()); // 包一层内部 Arc:IPC 请求取 guard 后只做 Arc::clone(O(1)), diff --git a/src/daemon/query.rs b/src/daemon/query.rs index f875d0f..b3994c3 100644 --- a/src/daemon/query.rs +++ b/src/daemon/query.rs @@ -1502,3 +1502,431 @@ pub async fn q_stats( })) } + +/// 查询朋友圈互动通知(点赞 + 评论),对应微信 app 右上角的红点入口。 +/// 空 `content` 是点赞,非空是评论正文。 +pub async fn q_sns_notifications( + db: &DbCache, + names: &Names, + limit: usize, + since: Option, + until: Option, + include_read: bool, +) -> Result { + let path = db.get("sns/sns.db").await? + .context("无法解密 sns.db")?; + + let path2 = path.clone(); + type Row = (i64, i64, i64, i64, String, String, String); + let rows: Vec = tokio::task::spawn_blocking(move || { + let conn = Connection::open(&path2)?; + let mut clauses: Vec<&str> = Vec::new(); + let mut params: Vec> = Vec::new(); + if !include_read { + clauses.push("is_unread = 1"); + } + if let Some(s) = since { + clauses.push("create_time >= ?"); + params.push(Box::new(s)); + } + if let Some(u) = until { + clauses.push("create_time <= ?"); + params.push(Box::new(u)); + } + let where_clause = if clauses.is_empty() { + String::new() + } else { + format!("WHERE {}", clauses.join(" AND ")) + }; + let sql = format!( + "SELECT local_id, create_time, type, feed_id, from_username, from_nickname, content + FROM SnsMessage_tmp3 {} ORDER BY create_time DESC LIMIT ?", + where_clause + ); + params.push(Box::new(limit as i64)); + let params_ref: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map(params_ref.as_slice(), |row| Ok(( + row.get::<_, i64>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, i64>(2).unwrap_or(0), + row.get::<_, i64>(3).unwrap_or(0), + row.get::<_, String>(4).unwrap_or_default(), + row.get::<_, String>(5).unwrap_or_default(), + row.get::<_, String>(6).unwrap_or_default(), + )))? + .collect::>>()?; + Ok::<_, anyhow::Error>(rows) + }).await??; + + // 一次性取出涉及的 feed 原帖,避免 N+1 查询 + let feed_ids: Vec = { + let mut v: Vec = rows.iter().map(|r| r.3).collect(); + v.sort_unstable(); + v.dedup(); + v + }; + let path3 = path.clone(); + let feed_ids_clone = feed_ids.clone(); + let feeds: HashMap = tokio::task::spawn_blocking(move || { + if feed_ids_clone.is_empty() { + return Ok::<_, anyhow::Error>(HashMap::new()); + } + let conn = Connection::open(&path3)?; + let placeholders = std::iter::repeat("?").take(feed_ids_clone.len()).collect::>().join(","); + let sql = format!( + "SELECT tid, user_name, content FROM SnsTimeLine WHERE tid IN ({})", + placeholders + ); + let params: Vec<&dyn rusqlite::types::ToSql> = + feed_ids_clone.iter().map(|id| id as &dyn rusqlite::types::ToSql).collect(); + let mut stmt = conn.prepare(&sql)?; + let mut map = HashMap::new(); + let mut rows2 = stmt.query(params.as_slice())?; + while let Some(row) = rows2.next()? { + let tid: i64 = row.get(0)?; + let author: String = row.get::<_, String>(1).unwrap_or_default(); + let content: String = row.get::<_, String>(2).unwrap_or_default(); + let preview = extract_xml_text(&content, "contentDesc") + .map(|s| s.chars().take(60).collect::()) + .unwrap_or_default(); + // 原帖 user_name 偶尔为空(转发帖),再从 XML 兜一下 + let author = if author.is_empty() { + extract_xml_text(&content, "username").unwrap_or_default() + } else { + author + }; + map.insert(tid, (author, preview)); + } + Ok(map) + }).await??; + + let mut out = Vec::with_capacity(rows.len()); + for (_local_id, ct, _typ, fid, from_u, from_nick, content) in rows { + let kind = if content.trim().is_empty() { "like" } else { "comment" }; + let display = if !from_nick.is_empty() { + from_nick.clone() + } else { + names.display(&from_u) + }; + let (feed_author_u, feed_preview) = feeds.get(&fid) + .cloned() + .unwrap_or_default(); + let feed_author_display = if feed_author_u.is_empty() { + String::new() + } else { + names.display(&feed_author_u) + }; + out.push(json!({ + "type": kind, + "time": fmt_time(ct, "%m-%d %H:%M"), + "timestamp": ct, + "from_username": from_u, + "from_nickname": display, + "content": content, + "feed_id": fid, + "feed_author_username": feed_author_u, + "feed_author": feed_author_display, + "feed_preview": feed_preview, + })); + } + let total = out.len(); + Ok(json!({ "notifications": out, "total": total })) +} + +fn sns_media_count_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + // 只在 里数 开标签,避免匹配到嵌套的其他 字段 + RE.get_or_init(|| Regex::new(r"").unwrap()) +} + +fn sns_location_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + // location 是自闭合标签,poiName 在属性里 + RE.get_or_init(|| Regex::new(r#"]*poiName="([^"]*)""#).unwrap()) +} + +// 朋友圈扫描的硬上限:单次查询最多解析这么多行 SnsTimeLine, +// 防止用户传超大 limit 或者底层数据异常时把 daemon 卡住。 +// 当前账号 ~10k+ 帖子,5w 上限留足缓冲。 +const SNS_MAX_LIMIT: usize = 10_000; +const SNS_MAX_SCAN: usize = 50_000; + +/// 转义 SQL LIKE 模式中的元字符。配合 `ESCAPE '\\'` 使用。 +/// 反斜杠必须最先转义,否则后续替换出的 `\%` / `\_` 会被再次吞掉。 +fn escape_like_pattern(s: &str) -> String { + s.replace('\\', r"\\") + .replace('%', r"\%") + .replace('_', r"\_") +} + +/// SnsTimeLine 行解析产物。不含 display name(依赖 Names,需要出 spawn_blocking 再补)。 +struct ParsedPost { + tid: i64, + create_time: i64, + author_username: String, + content: String, + media_count: i64, + location: String, +} + +/// 纯 XML 解析,无 Names 依赖,可以在 spawn_blocking 里跑。 +/// user_name_column 为空时从 TimelineObject/ 兜底(转发帖)。 +fn parse_post_xml(tid: i64, user_name_column: &str, content: &str) -> ParsedPost { + let create_time = extract_xml_text(content, "createTime") + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + let text = extract_xml_text(content, "contentDesc").unwrap_or_default(); + let author_username = if user_name_column.is_empty() { + extract_xml_text(content, "username").unwrap_or_default() + } else { + user_name_column.to_string() + }; + let media_count = sns_media_count_re().find_iter(content).count() as i64; + let location = sns_location_re() + .captures(content) + .and_then(|c| c.get(1)) + .map(|m| m.as_str().to_string()) + .unwrap_or_default(); + ParsedPost { tid, create_time, author_username, content: text, media_count, location } +} + +fn post_to_value(p: ParsedPost, names: &Names) -> Value { + let author = if p.author_username.is_empty() { + String::new() + } else { + names.display(&p.author_username) + }; + json!({ + "tid": p.tid, + "timestamp": p.create_time, + "time": fmt_time(p.create_time, "%Y-%m-%d %H:%M"), + "author_username": p.author_username, + "author": author, + "content": p.content, + "media_count": p.media_count, + "location": p.location, + }) +} + +/// 查询朋友圈时间线:按时间/作者筛选。用于浏览自己或好友的朋友圈。 +pub async fn q_sns_feed( + db: &DbCache, + names: &Names, + limit: usize, + since: Option, + until: Option, + user: Option<&str>, +) -> Result { + let path = db.get("sns/sns.db").await? + .context("无法解密 sns.db")?; + + let limit = limit.min(SNS_MAX_LIMIT); + let user_uname = match user { + Some(q) => Some( + resolve_username(q, names) + .with_context(|| format!("找不到联系人: {}", q))?, + ), + None => None, + }; + + // user 过滤不在 SQL 层做:SnsTimeLine.user_name 列对部分(转发)帖子是空, + // 真正作者只在 XML 里。SQL 层 `user_name = ?` 会把这部分提前漏掉, + // 让 parse_post_xml 的 fallback 失效。所以扫全表 → parse → 用 ParsedPost.author_username 过滤。 + // (createTime 也不是列,本来就要扫全表 parse XML 才能正确按时间排序。) + let path2 = path.clone(); + let parsed: Vec = tokio::task::spawn_blocking(move || { + let conn = Connection::open(&path2)?; + let sql = "SELECT tid, user_name, content FROM SnsTimeLine ORDER BY tid DESC"; + let mut stmt = conn.prepare(sql)?; + let rows = stmt.query_map([], |row| Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1).unwrap_or_default(), + row.get::<_, String>(2).unwrap_or_default(), + )))?; + + let mut scanned = 0usize; + let mut out: Vec = Vec::new(); + for row in rows { + scanned += 1; + if scanned > SNS_MAX_SCAN { + eprintln!( + "[sns_feed] scan 超过硬上限 {},结果可能不完整。建议加 --user / --since 缩小范围。", + SNS_MAX_SCAN + ); + break; + } + let (tid, uname, content) = row?; + let p = parse_post_xml(tid, &uname, &content); + if let Some(u) = user_uname.as_ref() { if &p.author_username != u { continue; } } + if let Some(s) = since { if p.create_time < s { continue; } } + if let Some(u) = until { if p.create_time > u { continue; } } + out.push(p); + } + // tid DESC 不严格等于 createTime DESC(不同账号 tid 生成算法不同), + // 所以要先收齐全部匹配的、按 create_time 排序,再 truncate —— 否则会丢帖。 + out.sort_by_key(|p| std::cmp::Reverse(p.create_time)); + out.truncate(limit); + Ok::<_, anyhow::Error>(out) + }).await??; + + let posts: Vec = parsed.into_iter().map(|p| post_to_value(p, names)).collect(); + let total = posts.len(); + Ok(json!({ "posts": posts, "total": total })) +} + +/// 搜索朋友圈全文:在 contentDesc(正文)里匹配 keyword,可叠加时间 / 作者过滤。 +pub async fn q_sns_search( + db: &DbCache, + names: &Names, + keyword: &str, + limit: usize, + since: Option, + until: Option, + user: Option<&str>, +) -> Result { + if keyword.trim().is_empty() { + anyhow::bail!("搜索关键词不能为空"); + } + let path = db.get("sns/sns.db").await? + .context("无法解密 sns.db")?; + + let limit = limit.min(SNS_MAX_LIMIT); + let user_uname = match user { + Some(q) => Some( + resolve_username(q, names) + .with_context(|| format!("找不到联系人: {}", q))?, + ), + None => None, + }; + + // SQL LIKE 在 content 上粗筛 keyword(这步省掉绝大多数行的 XML parse 开销)。 + // user 不在 SQL 层过滤,原因同 q_sns_feed:SnsTimeLine.user_name 列对部分(转发) + // 帖子为空,真实作者只在 XML 里。 + let like_pattern = format!("%{}%", escape_like_pattern(keyword)); + let keyword_owned = keyword.to_string(); + + let path2 = path.clone(); + let parsed: Vec = tokio::task::spawn_blocking(move || { + let conn = Connection::open(&path2)?; + let sql = "SELECT tid, user_name, content FROM SnsTimeLine \ + WHERE content LIKE ? ESCAPE '\\' ORDER BY tid DESC"; + let mut stmt = conn.prepare(sql)?; + let rows = stmt.query_map([&like_pattern], |row| Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1).unwrap_or_default(), + row.get::<_, String>(2).unwrap_or_default(), + )))?; + + let needle = keyword_owned.to_lowercase(); + let mut scanned = 0usize; + let mut out: Vec = Vec::new(); + for row in rows { + scanned += 1; + if scanned > SNS_MAX_SCAN { + eprintln!( + "[sns_search] scan 超过硬上限 {},结果可能不完整。建议缩小 keyword 或加 --user / --since。", + SNS_MAX_SCAN + ); + break; + } + let (tid, uname, content) = row?; + let desc = extract_xml_text(&content, "contentDesc").unwrap_or_default(); + if !desc.to_lowercase().contains(&needle) { continue; } + + let p = parse_post_xml(tid, &uname, &content); + if let Some(u) = user_uname.as_ref() { if &p.author_username != u { continue; } } + if let Some(s) = since { if p.create_time < s { continue; } } + if let Some(u) = until { if p.create_time > u { continue; } } + out.push(p); + } + out.sort_by_key(|p| std::cmp::Reverse(p.create_time)); + out.truncate(limit); + Ok::<_, anyhow::Error>(out) + }).await??; + + let posts: Vec = parsed.into_iter().map(|p| post_to_value(p, names)).collect(); + let total = posts.len(); + Ok(json!({ "keyword": keyword, "posts": posts, "total": total })) +} + +#[cfg(test)] +mod sns_tests { + use super::*; + + fn make_post_xml(create_time: &str, desc: &str, username_tag: Option<&str>, media: usize, location: Option<&str>) -> String { + let username = username_tag.map(|u| format!("{}", u)).unwrap_or_default(); + let media_tags = "...".repeat(media); + let media_list = if media > 0 { format!("{}", media_tags) } else { String::new() }; + let loc = location + .map(|p| format!(r#""#, p)) + .unwrap_or_default(); + format!( + "{}{}{}{}{}", + username, create_time, desc, media_list, loc + ) + } + + #[test] + fn parse_uses_user_name_column_when_present() { + let xml = make_post_xml("1700000000", "hello", Some("wxid_xml"), 0, None); + let p = parse_post_xml(1, "wxid_column", &xml); + assert_eq!(p.author_username, "wxid_column"); + assert_eq!(p.create_time, 1700000000); + assert_eq!(p.content, "hello"); + assert_eq!(p.media_count, 0); + assert_eq!(p.location, ""); + } + + #[test] + fn parse_falls_back_to_xml_username_when_column_empty() { + let xml = make_post_xml("1700000001", "world", Some("wxid_xml_only"), 0, None); + let p = parse_post_xml(2, "", &xml); + assert_eq!(p.author_username, "wxid_xml_only"); + } + + #[test] + fn parse_handles_missing_create_time() { + let xml = "x"; + let p = parse_post_xml(3, "wxid", xml); + assert_eq!(p.create_time, 0); + assert_eq!(p.content, "x"); + } + + #[test] + fn parse_counts_media_and_extracts_location() { + let xml = make_post_xml("1700000002", "post", None, 3, Some("Wuxi")); + let p = parse_post_xml(4, "wxid", &xml); + assert_eq!(p.media_count, 3); + assert_eq!(p.location, "Wuxi"); + } + + #[test] + fn parse_when_both_column_and_xml_username_empty_returns_empty_author() { + let xml = "1700000003orphan"; + let p = parse_post_xml(5, "", xml); + assert_eq!(p.author_username, ""); + } + + #[test] + fn escape_like_pattern_escapes_backslash_first() { + // 反斜杠必须在 % / _ 之前转义;否则后面塞进去的 \% / \_ 会被再次双转义吃掉 + assert_eq!(escape_like_pattern("a\\b"), "a\\\\b"); + assert_eq!(escape_like_pattern("100%"), "100\\%"); + assert_eq!(escape_like_pattern("foo_bar"), "foo\\_bar"); + } + + #[test] + fn escape_like_pattern_combined() { + // \%_ 三个元字符同时出现 + let escaped = escape_like_pattern("a\\b%c_d"); + assert_eq!(escaped, "a\\\\b\\%c\\_d"); + } + + #[test] + fn escape_like_pattern_no_special_chars_unchanged() { + assert_eq!(escape_like_pattern("hello world"), "hello world"); + assert_eq!(escape_like_pattern("中文关键词"), "中文关键词"); + assert_eq!(escape_like_pattern(""), ""); + } +} diff --git a/src/daemon/server.rs b/src/daemon/server.rs index 2c05711..896a08e 100644 --- a/src/daemon/server.rs +++ b/src/daemon/server.rs @@ -213,5 +213,23 @@ async fn dispatch( Err(e) => Response::err(e.to_string()), } } + SnsNotifications { limit, since, until, include_read } => { + match query::q_sns_notifications(db, &names_arc, limit, since, until, include_read).await { + Ok(v) => Response::ok(v), + Err(e) => Response::err(e.to_string()), + } + } + SnsFeed { 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), + Err(e) => Response::err(e.to_string()), + } + } } } diff --git a/src/ipc.rs b/src/ipc.rs index 37da4c0..873e2d4 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -78,6 +78,42 @@ pub enum Request { #[serde(skip_serializing_if = "Option::is_none")] query: Option, }, + /// 朋友圈互动通知(点赞 + 评论) + SnsNotifications { + #[serde(default = "default_limit_50")] + limit: usize, + #[serde(skip_serializing_if = "Option::is_none")] + since: Option, + #[serde(skip_serializing_if = "Option::is_none")] + until: Option, + /// 包含已读通知(默认仅未读) + #[serde(default)] + include_read: bool, + }, + /// 朋友圈时间线(按时间 / 作者筛选帖子) + SnsFeed { + #[serde(default = "default_limit_20")] + limit: usize, + #[serde(skip_serializing_if = "Option::is_none")] + since: Option, + #[serde(skip_serializing_if = "Option::is_none")] + until: Option, + /// 作者昵称 / 备注名 / 微信 username,模糊匹配 + #[serde(skip_serializing_if = "Option::is_none")] + user: Option, + }, + /// 朋友圈全文搜索(匹配 contentDesc) + SnsSearch { + keyword: String, + #[serde(default = "default_limit_20")] + limit: usize, + #[serde(skip_serializing_if = "Option::is_none")] + since: Option, + #[serde(skip_serializing_if = "Option::is_none")] + until: Option, + #[serde(skip_serializing_if = "Option::is_none")] + user: Option, + }, }