From 2d88c9542dcba52c21405416c57b97ab47febe8b Mon Sep 17 00:00:00 2001 From: jackwener Date: Thu, 14 May 2026 18:40:57 +0800 Subject: [PATCH] feat(attachment): wire wx attachments / wx extract end-to-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把 V1 (legacy XOR + V1 fixed-AES) + 平台相关 V2 (macOS / Windows) image 解 密能力一路接到 CLI: - ipc: 新增 Attachments / Extract 两个 Request variant - daemon/server: dispatch 路由到 query::q_attachments / q_extract - daemon/cache: DbCache::db_dir() 公开,让 resolver 推 wxchat_base - daemon/query: q_attachments 走 Msg_ 表按 (local_type & 0xFFFFFFFF) IN (...) 过滤、按 ts DESC 全局排序后分页,返回不透明 attachment_id; q_extract 解码 attachment_id → 查 message_resource.db → 找本地 .dat → 按 magic 分发 v1/v2 解码 → 写盘。bridge 用 ImageKeyMaterial.{aes_key, xor_key}(codex 实测真实账号 xor_key=0xa2,不能硬编码 0x88) - cli: 新增 wx attachments / wx extract 两个子命令,flag 风格与现有 history / biz-articles 对齐 - README + SKILL: 加附件提取章节,含三档解码档位与 V2 image key 派生说明 --- README.md | 29 ++++ SKILL.md | 28 ++++ src/cli/attachments.rs | 42 ++++++ src/cli/extract.rs | 25 ++++ src/cli/mod.rs | 46 ++++++ src/daemon/cache.rs | 6 + src/daemon/query.rs | 312 +++++++++++++++++++++++++++++++++++++++++ src/daemon/server.rs | 12 ++ src/ipc.rs | 26 ++++ 9 files changed, 526 insertions(+) create mode 100644 src/cli/attachments.rs create mode 100644 src/cli/extract.rs diff --git a/README.md b/README.md index 8a8e23b..35589cd 100644 --- a/README.md +++ b/README.md @@ -211,6 +211,35 @@ wx biz-articles --json | jq '.[].url' # 下游消费 URL 每条返回:`account` / `account_username` / `title` / `url` / `digest` / `cover_url` / `time` / `timestamp` / `recv_time_str`。多图文推送会展开成多行。 +### 附件提取(图片 / 视频 / 文件 / 语音) + +聊天里的附件本体存在 `xwechat_files//msg/attach/...` 下的 `.dat` 文件,需要按消息所在 `message_resource.db` 的 md5 + 平台相关 image key 解码才能拿到原图。 + +```bash +# 1) 列出会话里的附件,先拿到不透明的 attachment_id(默认 image,可多选) +wx attachments "张三" +wx attachments "AI群" --kind image --kind video -n 100 +wx attachments "AI群" --since 2026-04-01 --until 2026-04-15 + +# 2) 把单个 attachment_id 解密写出去(扩展名建议保留 .jpg / .mp4 等) +wx extract -o ~/Desktop/photo.jpg +wx extract -o /tmp/x.jpg --overwrite +``` + +`attachments` 输出每条带:`attachment_id` / `kind` / `type` / `local_id` / `timestamp` / `time`,群聊里还有 `sender`。 + +`extract` 输出报告里带:`md5` / `dat_path` / `dat_size` / `output` / `output_size` / `format`(实际识别出的图片格式:jpg / png / gif / webp / hevc 等)/ `decoder`(实际选用的解码器:`legacy_xor` / `v1_aes` / `v2`)。 + +支持的解码档位: +- **legacy XOR**:早期单字节 XOR,无 magic(按文件首字节探测格式自动反推) +- **V1 fixed-AES**(`07 08 V1 08 07`):AES-128-ECB + 固定 key `cfcd208495d565ef` +- **V2 AES + XOR**(`07 08 V2 08 07`):AES-128-ECB + raw + XOR;AES key 平台派生 + +V2 image key 提取: +- **macOS**:`kvcomm` cache(`key__*.statistic` 文件名取 uin → `md5(str(uin) + wxid)[:16]`)+ brute-force fallback(`md5(str(uin))[:4] == wxid_suffix` 枚举 2^24);xor_key = `uin & 0xff`,**不是硬编码 0x88** +- **Windows**:扫 `Weixin.exe` 内存匹配 `[A-Za-z0-9]{32|16}` 候选,按 V2 template ciphertext-block 反验 +- **Linux**:上游空白,遇到 V2 .dat 会报 unsupported + ### 联系人 & 群组 ```bash diff --git a/SKILL.md b/SKILL.md index fe7418c..ddf02e1 100644 --- a/SKILL.md +++ b/SKILL.md @@ -242,6 +242,34 @@ wx biz-articles --since 2026-05-10 --json | jq '.[].url' 每条返回的字段:`account` / `account_username`(`gh_*`)/ `title` / `url`(`mp.weixin.qq.com` 链接)/ `digest` / `cover_url` / `time` + `timestamp`(文章发布时间)/ `recv_time_str` + `recv_time`(微信接收推送的时间)。多图文推送会展开为多行。 +### 附件提取(图片 / 视频 / 文件 / 语音) + +聊天里的图片/视频/文件本体在 `xwechat_files//msg/attach/...` 下加密存储(`.dat`),需要按消息所在 `message_resource.db` 的 md5 + 平台相关 image key 才能解码。两步走: + +```bash +# 1) 先列出附件,拿到不透明的 attachment_id(默认 image,可多选) +wx attachments "张三" +wx attachments "AI群" --kind image --kind video -n 100 +wx attachments "AI群" --since 2026-04-01 --until 2026-04-15 + +# 2) 用 attachment_id 把单个资源解密写到指定路径 +wx extract -o ~/Desktop/photo.jpg +wx extract -o /tmp/x.jpg --overwrite +``` + +`attachments` 输出每条带:`attachment_id` / `kind`(image/voice/video/file)/ `type` / `local_id` / `timestamp` / `time`,群聊里另带 `sender`。 + +`extract` 报告里带:`md5` / `dat_path` / `dat_size` / `output` / `output_size` / `format`(实际识别出的图片格式:jpg / png / gif / webp / hevc 等)/ `decoder`(实际选用的解码器:`legacy_xor` / `v1_aes` / `v2`)。 + +支持的解码档位: +- **legacy XOR**:早期单字节 XOR,无 magic(按文件首字节探测格式自动反推) +- **V1 fixed-AES**(`07 08 V1 08 07`):AES-128-ECB + 固定 key `cfcd208495d565ef` +- **V2 AES + XOR**(`07 08 V2 08 07`):AES-128-ECB + raw + XOR;AES key 平台派生 + +V2 image key 提取(macOS / Windows 自动;Linux 暂不支持): +- macOS:`kvcomm` cache(`key__*.statistic` 文件名取 uin → `md5(str(uin) + wxid)[:16]`)+ brute-force fallback;`xor_key = uin & 0xff` +- Windows:扫 `Weixin.exe` 内存匹配 `[A-Za-z0-9]{32|16}` 候选,按 V2 template ciphertext-block 反验 + ### 收藏与统计 ```bash diff --git a/src/cli/attachments.rs b/src/cli/attachments.rs new file mode 100644 index 0000000..662c256 --- /dev/null +++ b/src/cli/attachments.rs @@ -0,0 +1,42 @@ +use anyhow::Result; + +use crate::ipc::Request; +use super::history::{parse_time, parse_time_end}; +use super::output::{print_value, resolve}; +use super::transport; + +/// `wx attachments` — 列出指定会话的附件消息(默认 image,可多选)。 +/// +/// 输出每条 `attachment_id`,再传给 `wx extract` 才真正读 message_resource.db +/// 与本地 .dat 解码。这一步只查 `Msg_` 表,几千条群聊也能秒返。 +pub fn cmd_attachments( + chat: String, + kinds: Vec, + limit: usize, + offset: usize, + since: Option, + until: 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()?; + + // CLI 收上来的 Vec 为空时按默认(image)走,让 daemon 决定 fallback。 + let kinds_param = if kinds.is_empty() { None } else { Some(kinds) }; + + let req = Request::Attachments { + chat, + kinds: kinds_param, + limit, + offset, + since: since_ts, + until: until_ts, + }; + let resp = transport::send(req)?; + let data = resp + .data + .get("attachments") + .cloned() + .unwrap_or(serde_json::Value::Array(vec![])); + print_value(&data, &resolve(json)) +} diff --git a/src/cli/extract.rs b/src/cli/extract.rs new file mode 100644 index 0000000..a0eba0d --- /dev/null +++ b/src/cli/extract.rs @@ -0,0 +1,25 @@ +use anyhow::Result; + +use crate::ipc::Request; +use super::output::{print_value, resolve}; +use super::transport; + +/// `wx extract` — 把单个 `attachment_id` 对应的资源解密写到指定路径。 +/// +/// daemon 端:解析 `attachment_id` → 查 `message_resource.db` 拿 file md5 → +/// 在 `/msg/attach/...` 找 .dat → 按 magic 分发到 v1/v2 解码器 → +/// 写出真实图片/文件。 +pub fn cmd_extract( + attachment_id: String, + output: String, + overwrite: bool, + json: bool, +) -> Result<()> { + let req = Request::Extract { + attachment_id, + output, + overwrite, + }; + let resp = transport::send(req)?; + print_value(&resp.data, &resolve(json)) +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index b9e71fd..5fe4e8c 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,5 +1,7 @@ mod init; +pub mod attachments; pub mod biz_articles; +pub mod extract; pub mod sessions; pub mod history; pub mod search; @@ -262,6 +264,44 @@ enum Commands { #[arg(long)] json: bool, }, + /// 列出某会话的附件(图片 / 视频 / 文件 / 语音),返回不透明 attachment_id + Attachments { + /// 会话名称(联系人显示名 / wxid / @chatroom username 都可以) + chat: String, + /// 类型(多选,默认 image)。可选:image / voice / video / file + #[arg(long = "kind", value_name = "KIND", + value_parser = ["image", "voice", "video", "file", "audio", "img"])] + kinds: Vec, + /// 显示数量 + #[arg(short = 'n', long, default_value = "50")] + limit: usize, + /// 分页偏移 + #[arg(long, default_value = "0")] + offset: usize, + /// 起始时间 YYYY-MM-DD + #[arg(long)] + since: Option, + /// 结束时间 YYYY-MM-DD + #[arg(long)] + until: Option, + /// 输出 JSON(默认 YAML) + #[arg(long)] + json: bool, + }, + /// 把单个 attachment_id 对应的资源解密写到指定文件路径 + Extract { + /// 由 `wx attachments` 输出的不透明 ID(base64url 字符串) + attachment_id: String, + /// 输出文件路径(绝对或相对当前工作目录均可;扩展名建议保留为 .jpg 等) + #[arg(short = 'o', long)] + output: String, + /// 目标已存在时覆盖 + #[arg(long)] + overwrite: bool, + /// 输出 JSON(默认 YAML) + #[arg(long)] + json: bool, + }, /// 管理 wx-daemon Daemon { #[command(subcommand)] @@ -329,6 +369,12 @@ fn dispatch(cli: Cli) -> Result<()> { 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, json) + } + Commands::Extract { attachment_id, output, overwrite, json } => { + extract::cmd_extract(attachment_id, output, overwrite, json) + } Commands::Daemon { cmd } => daemon_cmd::cmd_daemon(cmd), } } diff --git a/src/daemon/cache.rs b/src/daemon/cache.rs index 9801396..56e307c 100644 --- a/src/daemon/cache.rs +++ b/src/daemon/cache.rs @@ -54,6 +54,12 @@ impl DbCache { Ok(cache) } + /// 数据库根目录(即 `/db_storage`)。 + /// 上层(attachment resolver)需要 `db_dir.parent()` 来定位 `msg/attach/...` 解密图片。 + pub fn db_dir(&self) -> &Path { + &self.db_dir + } + fn cache_file_path(&self, rel_key: &str) -> PathBuf { let hash = format!("{:x}", md5::compute(rel_key.as_bytes())); self.cache_dir.join(format!("{}.db", hash)) diff --git a/src/daemon/query.rs b/src/daemon/query.rs index 167d88a..5a5d1b9 100644 --- a/src/daemon/query.rs +++ b/src/daemon/query.rs @@ -3285,6 +3285,318 @@ pub async fn q_biz_articles( Ok(json!({ "count": results.len(), "articles": results })) } +// ─── 附件(图片 / 视频 / 文件 / 语音)查询与提取 ───────────────────────────────── +// +// 设计要点: +// - `q_attachments` 只走 `Msg_` 表,按 `local_type & 0xFFFFFFFF IN (...)` 过滤 +// 出附件消息行,再编出 `attachment_id`。**不**去翻 `message_resource.db`,因为列出动作 +// 要可枚举几千条;resource lookup 留到 `q_extract` 才做。 +// - `q_extract` 走完整链:`AttachmentId` → `message_resource.db` 查 md5 → +// `/msg/attach/...` 找 .dat → 按 magic 分发到 v1/v2 decoder → 写盘。 +// - V2 image AES key 通过 `image_key::default_provider()` 拿(codex 后续填实现)。 +// 缺 key 时 V2 解码会返回明确错误,CLI 直接抛给用户。 + +/// 列出某会话内的附件消息(默认 image,可多选)。返回每条的 `attachment_id`, +/// 后续传给 `Extract` 才真正读 message_resource.db + 解密 .dat。 +pub async fn q_attachments( + db: &DbCache, + names: &Names, + chat: &str, + kinds: Option>, + limit: usize, + offset: usize, + since: Option, + until: Option, +) -> Result { + use crate::attachment::{AttachmentId, AttachmentKind}; + + let username = resolve_username(chat, names) + .with_context(|| format!("找不到联系人: {}", chat))?; + let display = names.display(&username); + let chat_type = chat_type_of(&username, names); + let is_group = chat_type == "group"; + + // 解析 kinds → 低 32 bit local_type 集合 + let kind_filters: Vec<(AttachmentKind, i64)> = parse_attachment_kinds(kinds.as_deref())?; + if kind_filters.is_empty() { + anyhow::bail!("kinds 为空 — 至少传一种 image/video/file/voice"); + } + let lo32_types: Vec = kind_filters.iter().map(|(_, t)| *t).collect(); + // local_type → AttachmentKind 反查(mask 完后定 kind) + let type_to_kind: HashMap = kind_filters.iter() + .map(|(k, t)| (*t, *k)) + .collect(); + + let tables = find_msg_tables(db, names, &username).await?; + if tables.is_empty() { + anyhow::bail!("找不到 {} 的消息记录", display); + } + + // 群聊需要 sender 显示名 + let group_nicknames = if is_group { + load_group_nicknames(db, &username).await.unwrap_or_default() + } else { + HashMap::new() + }; + + let mut all_rows: Vec<(i64, i64, i64, i64, String, i64, i64)> = Vec::new(); + // 元组:(local_id, local_type_lo32, create_time, real_sender_id, sender_label, ts_for_sort, db_idx) + for (db_idx, (db_path, table_name)) in tables.iter().enumerate() { + let path = db_path.clone(); + let tname = table_name.clone(); + let uname = username.clone(); + let is_group2 = is_group; + let names_map = names.map.clone(); + let group_nicknames2 = group_nicknames.clone(); + let lo32_types2 = lo32_types.clone(); + let since2 = since; + let until2 = until; + // per-DB 软上限避免巨群全量加载 + let per_db_cap = (offset + limit).max(limit) * 2; + let db_idx2 = db_idx as i64; + + let rows: Vec<(i64, i64, i64, i64, String, i64, i64)> = + tokio::task::spawn_blocking(move || { + let conn = Connection::open(&path)?; + let id2u = load_id2u(&conn); + + // local_type 在 DB 里可能带高位 flag,过滤要 mask 低 32 bit + let placeholders = lo32_types2.iter().map(|_| "?").collect::>().join(","); + let mut clauses: Vec = vec![ + format!("(local_type & 4294967295) IN ({})", placeholders), + ]; + let mut params: Vec> = lo32_types2.iter() + .map(|t| Box::new(*t) as Box) + .collect(); + if let Some(s) = since2 { + clauses.push("create_time >= ?".into()); + params.push(Box::new(s)); + } + if let Some(u) = until2 { + clauses.push("create_time <= ?".into()); + params.push(Box::new(u)); + } + let where_clause = format!("WHERE {}", clauses.join(" AND ")); + + let sql = format!( + "SELECT local_id, local_type, create_time, real_sender_id, + message_content, WCDB_CT_message_content + FROM [{}] {} ORDER BY create_time DESC LIMIT ?", + tname, where_clause + ); + params.push(Box::new(per_db_cap 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: Vec<(i64, i64, i64, i64, String, i64, i64)> = stmt + .query_map(params_ref.as_slice(), |row| { + let local_id: i64 = row.get(0)?; + let raw_type: i64 = row.get(1)?; + let lo32 = (raw_type as u64 & 0xFFFFFFFF) as i64; + let ts: i64 = row.get(2)?; + let real_sender_id: i64 = row.get(3)?; + let content_bytes = get_content_bytes(row, 4); + let ct: i64 = row.get::<_, i64>(5).unwrap_or(0); + let content = decompress_message(&content_bytes, ct); + let sender = if is_group2 { + sender_label(real_sender_id, &content, true, &uname, + &id2u, &names_map, &group_nicknames2) + } else { + String::new() + }; + Ok((local_id, lo32, ts, real_sender_id, sender, ts, db_idx2)) + })? + .filter_map(|r| r.ok()) + .collect(); + Ok::<_, anyhow::Error>(rows) + }) + .await??; + all_rows.extend(rows); + } + + // 全局按 ts DESC 排序后分页 + all_rows.sort_by_key(|r| std::cmp::Reverse(r.5)); + let paged: Vec<_> = all_rows.into_iter().skip(offset).take(limit).collect(); + + // 翻成 JSON + let mut results: Vec = Vec::with_capacity(paged.len()); + for (local_id, lo32, ts, _real_sender_id, sender, _ts2, _db_idx) in paged { + let kind = type_to_kind.get(&lo32).copied() + .unwrap_or(AttachmentKind::Image); // 理论不会 fallthrough + let id = AttachmentId { + v: 1, + chat: username.clone(), + local_id, + create_time: ts, + kind, + db: None, + }; + let id_str = id.encode()?; + + let mut row = json!({ + "attachment_id": id_str, + "kind": kind.as_str(), + "type": fmt_type(lo32), + "local_id": local_id, + "timestamp": ts, + "time": fmt_time(ts, "%Y-%m-%d %H:%M"), + }); + if is_group && !sender.is_empty() { + row["sender"] = Value::String(sender); + } + results.push(row); + } + + Ok(json!({ + "chat": display, + "username": username, + "is_group": is_group, + "chat_type": chat_type, + "count": results.len(), + "attachments": results, + })) +} + +/// 解码 attachment_id → 查 message_resource.db → 找本地 .dat → 解密 → 写盘。 +pub async fn q_extract( + db: &DbCache, + _names: &Names, + attachment_id: &str, + output: &str, + overwrite: bool, +) -> Result { + use crate::attachment::{ + attachment_id::AttachmentId, + decoder::{self, V2KeyMaterial}, + image_key, + resolver, + }; + + let id = AttachmentId::decode(attachment_id) + .context("解析 attachment_id 失败(不是合法 base64url(json)?)")?; + + let output_path = std::path::PathBuf::from(output); + if output_path.exists() && !overwrite { + anyhow::bail!( + "目标已存在:{}(加 --overwrite 覆盖)", + output_path.display() + ); + } + if let Some(parent) = output_path.parent() { + if !parent.as_os_str().is_empty() { + tokio::fs::create_dir_all(parent).await + .with_context(|| format!("创建输出目录失败:{}", parent.display()))?; + } + } + + // 1) 拿 message_resource.db + let resource_path = db.get("message/message_resource.db").await? + .context("无法解密 message_resource.db(请确认 all_keys.json 包含该 DB 的密钥)")?; + + // 2) 推 wxchat_base = db_dir.parent(),再拼 attach_root + let wxchat_base = db.db_dir().parent() + .ok_or_else(|| anyhow::anyhow!("db_dir 没有 parent,无法推断 xwechat_files 根目录"))? + .to_path_buf(); + let attach_root = resolver::attach_root_for(&wxchat_base); + + // 3) blocking pool 跑 resolver + 读盘 + 解码 + let id_for_task = id.clone(); + let resource_path2 = resource_path.clone(); + let attach_root2 = attach_root.clone(); + let wxchat_base2 = wxchat_base.clone(); + let output_path2 = output_path.clone(); + + let report: Value = tokio::task::spawn_blocking(move || -> Result { + let resolved = resolver::resolve_blocking(&id_for_task, &resource_path2, &attach_root2)?; + + let dat_bytes = std::fs::read(&resolved.dat_path) + .with_context(|| format!("读取 .dat 失败:{}", resolved.dat_path.display()))?; + + // V2 image key — 平台相关。`ImageKeyMaterial` 同时给 aes_key + xor_key。 + // xor_key 不能硬编码 0x88:实测 macOS 真实账号上是 `uin & 0xff` 派生的(0xa2 等), + // 所以这里桥接时必须把 provider 的 xor_key 透传给 V2KeyMaterial。 + // 缺 key 时让 decoder 自己抛带诊断的错。 + let provider = image_key::default_provider(); + let key_material = if let Some(p) = provider.as_ref() { + // 从 wxchat_base 末段拿 wxid + let wxid = wxchat_base2.file_name() + .and_then(|s| s.to_str()) + .unwrap_or_default() + .to_string(); + if wxid.is_empty() { + None + } else { + match p.get_key(&wxid) { + Ok(km) => Some(km), + Err(e) => { + eprintln!("[extract] image key 提取失败 (wxid={}): {} — V2 文件将无法解码", wxid, e); + None + } + } + } + } else { + None + }; + let v2_key = match key_material.as_ref() { + Some(km) => V2KeyMaterial { aes_key: Some(&km.aes_key), xor_key: km.xor_key }, + None => V2KeyMaterial::default(), + }; + + let decoded = decoder::dispatch(&dat_bytes, v2_key)?; + + // 写盘 + std::fs::write(&output_path2, &decoded.data) + .with_context(|| format!("写出文件失败:{}", output_path2.display()))?; + + Ok(json!({ + "ok": true, + "attachment_id": attachment_id_str(&id_for_task)?, + "kind": id_for_task.kind.as_str(), + "md5": resolved.md5, + "dat_path": resolved.dat_path.display().to_string(), + "dat_size": resolved.size, + "output": output_path2.display().to_string(), + "output_size": decoded.data.len(), + "format": decoded.format, + "decoder": decoded.decoder, + })) + }).await??; + + Ok(report) +} + +/// 解析 `kinds` 参数到 `(AttachmentKind, lo32_local_type)` 列表。 +/// 缺省(None / 空)按 image 处理。 +fn parse_attachment_kinds( + kinds: Option<&[String]>, +) -> Result> { + use crate::attachment::AttachmentKind; + let raw = kinds.unwrap_or(&[]); + if raw.is_empty() { + return Ok(vec![(AttachmentKind::Image, 3)]); + } + let mut out: Vec<(AttachmentKind, i64)> = Vec::with_capacity(raw.len()); + let mut seen = HashSet::<&'static str>::new(); + for k in raw { + let (kind, t): (AttachmentKind, i64) = match k.to_ascii_lowercase().as_str() { + "image" | "img" => (AttachmentKind::Image, 3), + "voice" | "audio" => (AttachmentKind::Voice, 34), + "video" => (AttachmentKind::Video, 43), + "file" => (AttachmentKind::File, 49), + other => anyhow::bail!("未知附件类型:{}(支持 image/voice/video/file)", other), + }; + if seen.insert(kind.as_str()) { + out.push((kind, t)); + } + } + Ok(out) +} + +fn attachment_id_str(id: &crate::attachment::AttachmentId) -> Result { + id.encode() +} + #[cfg(test)] mod biz_tests { use super::*; diff --git a/src/daemon/server.rs b/src/daemon/server.rs index 3b06727..9f54076 100644 --- a/src/daemon/server.rs +++ b/src/daemon/server.rs @@ -240,5 +240,17 @@ async fn dispatch( 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), + Err(e) => Response::err(e.to_string()), + } + } } } diff --git a/src/ipc.rs b/src/ipc.rs index c478ee4..78d6278 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -131,6 +131,32 @@ pub enum Request { }, /// 重新加载配置和密钥(init --force 后 daemon 不会自动重读) ReloadConfig, + /// 列出某个会话里的附件(图片 / 视频 / 文件 / 语音) + /// 输出每条带 `attachment_id`(不透明 base64url 句柄),传给 `Extract` 时取回本体 + Attachments { + chat: String, + /// 类型过滤:image / video / file / voice,多选;缺省返回 image + #[serde(default, skip_serializing_if = "Option::is_none")] + kinds: Option>, + #[serde(default = "default_limit_50")] + limit: usize, + #[serde(default)] + offset: usize, + #[serde(skip_serializing_if = "Option::is_none")] + since: Option, + #[serde(skip_serializing_if = "Option::is_none")] + until: Option, + }, + /// 提取(解密)单个附件的本体到指定路径 + Extract { + /// `Attachments` 返回的不透明 ID + attachment_id: String, + /// 写入的绝对路径(daemon 直接写盘,不经 socket 传 binary) + output: String, + /// 已存在时是否覆盖 + #[serde(default)] + overwrite: bool, + }, }