diff --git a/.gitignore b/.gitignore index 088c647..e65e66d 100644 --- a/.gitignore +++ b/.gitignore @@ -310,6 +310,15 @@ paket-files/ # FAKE - F# Make .fake/ +# Chat export/transcription output files (contain private message data) +*_export*.json +*_transcribed*.json + +# Hook outputs +hook_output.txt +hook_start_output.txt +hook_stderr.txt +run_hook.bat # CodeRush personal settings .cr/personal @@ -380,6 +389,14 @@ docs/.vitepress/dist # Temporary build artifacts x64/ +# OS +.DS_Store +Thumbs.db + +# Compiled binaries and output +find_all_keys_macos +decoded_voices/ +voice_transcriptions.json data/ export/ build/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a47a09e --- /dev/null +++ b/Makefile @@ -0,0 +1,46 @@ +.PHONY: setup build keys decrypt web export all status clean help + +PYTHON ?= .venv/bin/python3 +export SHELL := /bin/bash + +help: + @echo "WeChat Decrypt — Makefile" + @echo "" + @echo " make setup 一键安装所有依赖 + 编译 + 初始配置" + @echo " make build 编译 macOS 密钥扫描器" + @echo " make keys 提取密钥(需要 root)" + @echo " make decrypt 提取密钥 + 解密全部数据库" + @echo " make web 启动 Web UI(实时消息监听)" + @echo " make export 解密 + 批量导出聊天记录" + @echo " make all 从零到完成:setup → keys → decrypt → export" + @echo " make status 显示当前数据状态和磁盘用量" + @echo " make clean 交互式清理临时数据(解密库/导出/缓存)" + @echo "" + +setup: + @bash setup.sh + +build: + cc -O2 -o find_all_keys_macos find_all_keys_macos.c -framework Foundation + codesign -s - find_all_keys_macos + +keys: + sudo ./find_all_keys_macos + +decrypt: + $(PYTHON) main.py decrypt + +web: + $(PYTHON) main.py + +export: + $(PYTHON) main.py export + +all: + $(PYTHON) main.py all + +status: + $(PYTHON) main.py status + +clean: + $(PYTHON) cleanup.py \ No newline at end of file diff --git a/README.md b/README.md index 38567b0..6c5e3fe 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,93 @@ # WeChat 4.x Database Decryptor -微信 4.0 (Windows、MacOS、Linux) 本地数据库解密工具。从运行中的微信进程内存提取加密密钥,解密所有 SQLCipher 4 加密数据库,并提供实时消息监听。 +微信 4.0 (Windows / macOS / Linux) 本地数据库解密工具。从运行中的微信进程内存提取加密密钥,解密所有 SQLCipher 4 加密数据库,并提供实时消息监听、MCP Server、批量导出和语音转录。 -## 更新日志 +--- -## 防失联tg: https://t.me/wechat_decrypt +## ⭐ 快速开始 -### 2025-03-03 — 富媒体内容 & 组合消息修复 +
+macOS — 最小路径(展开查看) -- **表情包内联显示**: 自动从 emoticon.db 构建 MD5→CDN 映射,支持自定义表情(NonStore)和商店表情(Store),CDN 下载后本地缓存 -- **富媒体内容解析**: 链接卡片(type 49)、文件、视频号、小程序、引用回复、位置分享等在 Web UI 中完整渲染 -- **文字+图片组合消息不再丢失**: 修复同时发送文字和图片时只显示最后一条的问题(前端去重 key 增加消息类型) -- **隐藏消息检测**: 新增 `_check_hidden_messages` 机制,session.db 只保存最后一条消息摘要,现在会异步查 message DB 找回同一秒内的其他消息 -- **MonitorDBCache 线程安全**: 引入 per-key 锁,防止多线程并发解密同一数据库导致文件损坏 -- **Web UI 改进**: 消息气泡样式优化、群聊发送者显示、图片缩略图点击放大 +```bash +# 1. 安装依赖 +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +brew install whisper-cpp # 语音转录加速(可选,推荐) -## 原理 +# 2. 密钥提取(退出微信后先重签名) +killall WeChat +sudo codesign --force --deep --sign - /Applications/WeChat.app +cc -O2 -o find_all_keys_macos find_all_keys_macos.c -framework Foundation +sudo ./find_all_keys_macos # 扫描内存提取密钥 -微信 4.0 使用 SQLCipher 4 加密本地数据库: -- **加密算法**: AES-256-CBC + HMAC-SHA512 -- **KDF**: PBKDF2-HMAC-SHA512, 256,000 iterations -- **页面大小**: 4096 bytes, reserve = 80 (IV 16 + HMAC 64) -- **每个数据库有独立的 salt 和 enc_key** +# 3. 解密 + 导出 + 转录 +python3 decrypt_db.py # 解密所有数据库 +python3 export_all_chats.py -t # 导出全部聊天并转录语音 -WCDB (微信的 SQLCipher 封装) 会在进程内存中缓存派生后的 raw key,格式为 `x'<64hex_enc_key><32hex_salt>'`。三个平台(Windows / Linux / macOS)均可通过扫描进程内存匹配此模式,再通过 HMAC 校验 page 1 确认密钥正确性。 +# 或一条命令从零到完成: +make all +``` -## 使用方法 +
+ +
+Windows — 最小路径 + +```bash +# 1. 以管理员身份打开终端 +# 2. 安装依赖 +py -m pip install -r requirements.txt + +# 3. 提取密钥 + 解密 +python main.py decrypt + +# 4. 批量导出 +python export_all_chats.py +``` + +
+ +
+Linux — 最小路径 + +```bash +# 1. 安装依赖 +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt + +# 2. 提取密钥(需要 root 或 CAP_SYS_PTRACE) +sudo python3 main.py decrypt + +# 3. 批量导出 +python3 export_all_chats.py +``` + +
+ +--- + +## 📖 详细指南 ### 环境要求 - Python 3.10+ -- 微信 4.x -- `pip install -r requirements.txt` +- 微信 4.x 正在运行 -Windows: +**macOS**: +- Xcode Command Line Tools: `xcode-select --install` +- 需要对 `/Applications/WeChat.app` 做 ad-hoc 重签名(允许进程内存读取) +- 需要 root 权限运行扫描器 -- Windows 10/11 +**Windows**: +- 管理员权限(读取进程内存) - 微信正在运行 -- 需要管理员权限(读取进程内存) -Linux: - -- 64-bit Linux -- 需要 root 权限或 `CAP_SYS_PTRACE`(读取 `/proc//mem`) -- `db_dir` 默认类似 `~/Documents/xwechat_files//db_storage` +**Linux**: +- root 权限或 `CAP_SYS_PTRACE` +- 微信正在运行 ### 安装依赖 @@ -51,124 +95,180 @@ Linux: pip install -r requirements.txt ``` -Windows 如果遇到权限不足或全局环境不可写,可以改用: +
+⚠️ 安装失败? 点击展开 + +**问题:`error: externally-managed-environment` (PEP 668)** + +Homebrew Python (3.12+) 和部分 Linux 发行版禁止 `pip install` 直接写入系统 Python 环境。 + +**解决:使用虚拟环境** + +```bash +python3 -m venv .venv +source .venv/bin/activate # 激活虚拟环境 +pip install -r requirements.txt + +# 后续运行脚本时使用 .venv 中的 Python +.venv/bin/python3 main.py +.venv/bin/python3 decrypt_db.py +``` + +或使用 Makefile(已配置 `.venv/bin/python3`): + +```bash +make setup # 一键安装所有依赖 + 编译扫描器 +make decrypt # 等价于 .venv/bin/python3 main.py decrypt +make all # 从密钥提取到导出全部完成 +``` + +Windows 可改用: ```bash py -m pip install --user -r requirements.txt ``` -如果需要读取受保护的进程或把依赖安装到系统 Python,也可能需要以管理员身份打开终端。 +
-### 快速开始 +### 配置 -Windows: - -```bash -python main.py -python main.py decrypt -``` - -Linux: - -```bash -python3 main.py decrypt -``` - -程序会自动完成:配置检测 → 内存扫描提取密钥 → 解密。首次运行会自动检测微信数据目录并生成 `config.json`。微信只要在运行中即可,无需重启或重新登录。 - -如果自动检测失败(例如微信安装在非默认位置),手动创建 `config.json`: -```json -{ - "db_dir": "D:\\xwechat_files\\你的微信ID\\db_storage", - "keys_file": "all_keys.json", - "decrypted_dir": "decrypted", - "wechat_process": "Weixin.exe" -} -``` - -Linux 版 `config.json` 示例: +程序会自动检测微信数据目录并生成 `config.json`。如果自动检测失败,手动创建: ```json { - "db_dir": "/home/yourname/Documents/xwechat_files/your_wxid/db_storage", + "db_dir": "/path/to/your/wxid/db_storage", "keys_file": "all_keys.json", "decrypted_dir": "decrypted", - "wechat_process": "wechat" + "wechat_process": "WeChat" } ``` -`db_dir` 路径:Windows 可在微信设置 → 文件管理中找到;Linux 默认在 `~/Documents/xwechat_files//db_storage`。 +各平台默认路径: +- macOS: `~/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files//db_storage` +- Windows: 微信设置 → 文件管理中查看 +- Linux: `~/Documents/xwechat_files//db_storage` -### Web UI 说明 +### 常用命令 + +| 用途 | 命令 | +|------|------| +| 提取密钥(macOS) | `sudo ./find_all_keys_macos` | +| 提取密钥(Windows/Linux) | `python find_all_keys.py` | +| 解密全部数据库 | `python decrypt_db.py` | +| 启动 Web UI(实时消息) | `python main.py` | +| 批量导出聊天记录 | `python export_all_chats.py` | +| 批量导出 + 语音转录 | `python export_all_chats.py --with-transcriptions` | +| 转录单个文件语音 | `python transcribe_chat.py input.json [output.json]` | +| 注册 MCP Server(Claude) | `claude mcp add wechat -- python /path/to/mcp_server.py` | + +### Web UI `python main.py` 启动后打开 http://localhost:5678 查看实时消息流。 -- 30ms 轮询 WAL 文件变化 (mtime) -- 检测到变化后全量解密 + WAL patch (~70ms) +- 30ms 轮询 WAL 文件变化 - SSE 实时推送到浏览器 -- 总延迟约 100ms -- **图片消息内联预览**(支持旧 XOR / V1 / V2 三种 .dat 加密格式) +- 图片消息内联预览 -### MCP Server (Claude AI 集成) +#### HTTP API -将微信数据查询能力接入 [Claude Code](https://claude.ai/claude-code),让 AI 直接读取你的微信消息。 +| 端点 | 说明 | +|------|------| +| `GET /api/history` | 最近消息列表 | +| `GET /api/history?chat=群名` | 按会话过滤 | +| `GET /api/history?since=1712000000` | 增量拉取 | +| `GET /api/tags` | 联系人标签 | +| `GET /stream` | SSE 实时消息推送 | + +### MCP Server(Claude AI 集成) + +将微信数据查询能力接入 Claude Code,让 AI 直接读取你的微信消息。 + +**注册:** ```bash -pip install -r requirements.txt +claude mcp add wechat -- python /path/to/mcp_server.py ``` -注册到 Claude Code: +**可用工具:** -```bash -claude mcp add wechat -- python C:\Users\你的用户名\wechat-decrypt\mcp_server.py -``` +| 工具 | 功能 | +|------|------| +| `get_recent_sessions(limit)` | 最近会话列表 | +| `get_chat_history(chat_name, limit, offset, start_time, end_time)` | 聊天记录 | +| `search_messages(keyword, chat_name, limit, offset, ...)` | 搜索消息 | +| `get_contacts(query, limit)` | 联系人搜索 | +| `get_contact_tags()` | 联系人标签 | +| `get_voice_messages(chat_name)` | 语音消息列表 | +| `decode_voice(chat_name, local_id)` | 解码语音为 WAV | +| `transcribe_voice(chat_name, local_id)` | 转录语音为文字 | -或手动编辑 `~/.claude.json`: +### ⚠️ 语音转录 + +`export_all_chats.py -t`、`transcribe_chat.py` 和 `transcribe_voice` MCP 工具共享同一套转录配置。 + +**后端对比:** + +| 后端 | 速度 | 隐私 | 依赖 | 配置 | +|------|------|------|------|------| +| `local`(默认) | CPU,较慢 | 数据不出本机 | `pip install -r requirements.txt` | 无需配置 | +| `openai` | API,最快 | 语音上传至 OpenAI | `pip install openai` | 需 `openai_api_key` | +| `whisper_cpp` | Metal GPU,3-5x | 数据不出本机 | `brew install whisper-cpp` + 模型 | 自动检测 | + +**配置方式(config.json):** ```json { - "mcpServers": { - "wechat": { - "type": "stdio", - "command": "python", - "args": ["C:\\Users\\你的用户名\\wechat-decrypt\\mcp_server.py"] - } - } + "transcription_backend": "whisper_cpp" } ``` -注册后在 Claude Code 中即可使用以下工具: - -| Tool | 功能 | -|------|------| -| `get_recent_sessions(limit)` | 最近会话列表(含消息摘要、未读数) | -| `get_chat_history(chat_name, limit, offset, start_time, end_time)` | 指定聊天的消息记录,支持时间范围和分页 | -| `search_messages(keyword, chat_name, start_time, end_time, limit, offset)` | 统一搜索消息;支持全库、单个聊天对象、多个聊天对象、时间范围和分页 | -| `get_contacts(query, limit)` | 搜索/列出联系人 | -| `get_new_messages()` | 获取自上次调用以来的新消息 | - -前置条件:需要先运行 `python main.py` 或 `python find_all_keys.py` 完成密钥提取。 - -说明:`search_messages` 的 `limit` 最大为 `500`;`get_chat_history` 支持更大的 `limit`,但消息很多时仍建议配合 `offset` 分页读取。 - -**[查看使用案例 →](USAGE.md)** - -### 图片解密 (V2 格式) - -微信 4.0 (2025-08+) 的 .dat 图片文件使用 AES-128-ECB + XOR 混合加密 (V2 格式)。AES 密钥需要从运行中的微信进程内存中提取: +启用 whisper_cpp 前需安装: ```bash -# 1. 在微信中打开查看 2-3 张图片(点击看大图) -# 2. 立即运行密钥提取(持续监控版): -python find_image_key_monitor.py - -# 或单次扫描版: -python find_image_key.py +brew install whisper-cpp +# 模型自动检测常见路径,或手动下载: +# curl -L -o ~/whisper-models/ggml-base.bin https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin ``` -密钥会自动保存到 `config.json` 的 `image_aes_key` 字段。之后 `monitor_web.py` 启动时会自动加载密钥,图片消息将显示内联预览。 +**注意事项:** +- 首次启用 openai 或 whisper_cpp 时会打印一行提示 +- openai 缺 key 时静默回退 local +- whisper_cpp 二进制未找到时静默回退 local +- 切换后端后旧缓存自动失效并重新转录 -> **注意**: AES 密钥仅在微信查看图片时临时加载到内存中。如果扫描未找到密钥,请先在微信中查看几张图片,然后立即重新运行脚本。 +### 图片解密 + +微信 4.0 的 .dat 图片文件使用三种加密格式之一: + +| 格式 | 时期 | 加密方式 | +|------|------|---------| +| 旧 XOR | ~2025-07 | 单字节 XOR | +| V1 | 过渡期 | AES-ECB + XOR | +| V2 | 2025-08+ | AES-128-ECB + XOR | + +macOS 图片密钥从磁盘 kvcomm 缓存派生,无需扫描进程内存: + +```bash +python find_image_key_macos.py +``` + +密钥自动保存到 `config.json`,之后 Web UI 自动显示图片预览。 + +### Makefile 命令 + +```bash +make setup # 全自动:venv → pip install → brew install → 编译扫描器 → 配置 +make build # 编译 macOS 密钥扫描器 +make keys # 提取密钥(需要 root) +make decrypt # 解密全部数据库 +make web # 启动 Web UI +make all # 从零到完成:setup → keys → decrypt → export +make status # 显示当前数据状态 +make clean # 交互式清理:选择删除 decrypted / exported_chats / 临时文件 +make help # 列出所有命令 +``` + +--- ## 文件说明 @@ -184,19 +284,36 @@ python find_image_key.py | `find_all_keys_windows.py` | Windows 版内存扫描提 key | | `find_all_keys_linux.py` | Linux 版内存扫描提 key | | `decrypt_db.py` | 全量解密所有数据库 | +| `export_all_chats.py` | 批量导出所有聊天为 JSON(支持 `-t` 附带语音转录) | +| `export_chat.py` | 单会话导出(供 export_all_chats.py 内部调用) | +| `chat_export_helpers.py` | 导出格式化共享函数(两脚本共用,避免代码漂移) | +| `transcribe_chat.py` | 语音消息转录(共享 config.json 配置的 backend) | | `find_wxwork_keys.py` | 企业微信 Windows 版内存扫描提 key | | `decrypt_wxwork_db.py` | 企业微信 wxSQLite3 AES-128 数据库解密 | | `export_wxwork_messages.py` | 企业微信聊天记录导出(按个人/群筛选,CSV / HTML / JSON) | | `mcp_server.py` | MCP Server,让 Claude AI 查询微信数据 | -| `monitor_web.py` | 实时消息监听 (Web UI + SSE + 图片预览) | +| `monitor_web.py` | 实时消息监听 (Web UI + SSE) | | `monitor.py` | 实时消息监听 (命令行) | -| `decode_image.py` | 图片 .dat 文件解密模块 (XOR / V1 / V2) | -| `find_image_key.py` | 从微信进程内存提取图片 AES 密钥 | -| `find_image_key_monitor.py` | 持续监控版密钥提取(推荐) | -| `latency_test.py` | 延迟测量诊断工具 | +| `find_all_keys.py` | 平台分发入口(Windows / Linux) | | `find_all_keys_macos.c` | macOS 版内存密钥扫描器 (C, Mach VM API) | +| `find_image_key.py` | 从进程内存提取图片 AES 密钥(Windows / Linux) | +| `find_image_key_macos.py` | macOS 版图片密钥派生(从磁盘 kvcomm 缓存推算) | +| `decode_image.py` | 图片 .dat 文件解密模块 (XOR / V1 / V2) | +| `config.json` | 配置文件(自动生成,手动编辑) | +| `setup.sh` | 一键安装脚本 | -## 技术细节 +--- + +## 🔧 技术细节 + +### 原理 + +微信 4.0 使用 SQLCipher 4 加密本地数据库: +- **加密算法**: AES-256-CBC + HMAC-SHA512 +- **KDF**: PBKDF2-HMAC-SHA512, 256,000 iterations +- **每个数据库有独立的 salt 和 enc_key** + +WCDB (微信的 SQLCipher 封装) 会在进程内存中缓存派生后的 raw key,格式为 `x'<64hex_enc_key><32hex_salt>'`。三个平台均可通过扫描进程内存匹配此模式,再通过 HMAC 校验 page 1 确认密钥正确性。 ### GUI 工具箱 & 单 exe 打包 @@ -230,7 +347,7 @@ build.bat ### WAL 处理 微信使用 SQLite WAL 模式,WAL 文件是**预分配固定大小** (4MB)。检测变化时: -- 不能用文件大小 (永远不变) +- 不能用文件大小(永远不变) - 使用 mtime 检测写入 - 解密 WAL frame 时需校验 salt 值,跳过旧周期遗留的 frame @@ -310,16 +427,20 @@ cc -O2 -o find_all_keys_macos find_all_keys_macos.c -framework Foundation # 运行(自动查找微信进程、扫描内存、匹配 DB salt) sudo ./find_all_keys_macos -# 或指定 PID -sudo ./find_all_keys_macos -``` +
+点击展开 -输出 `all_keys.json`,格式兼容 `decrypt_db.py`,可直接用于解密: +#### 2025-03-03 — 富媒体内容 & 组合消息修复 +- 表情包内联显示 +- 富媒体内容解析(链接卡片、文件、视频号、小程序等) +- 文字+图片组合消息不再丢失 +- 隐藏消息检测机制 +- Web UI 改进 -```bash -python3 decrypt_db.py -``` +
-## 免责声明 +### 免责声明 本工具仅用于学习和研究目的,用于解密**自己的**微信数据。请遵守相关法律法规,不要用于未经授权的数据访问。 + +防失联 TG: https://t.me/wechat_decrypt diff --git a/chat_export_helpers.py b/chat_export_helpers.py new file mode 100644 index 0000000..dfbca46 --- /dev/null +++ b/chat_export_helpers.py @@ -0,0 +1,201 @@ +"""聊天导出共享工具函数。 + +本模块包含 export_chat.py 和 export_all_chats.py 共用的消息格式化函数。 +统一维护,避免两处代码漂移。 +""" + +import base64 + +import mcp_server + + +MSG_TYPE_MAP = { + 1: "text", + 3: "image", + 34: "voice", + 42: "contact_card", + 43: "video", + 47: "sticker", + 48: "location", + 49: "link_or_file", + 50: "call", + 10000: "system", + 10002: "recall", +} + + +def _msg_type_str(local_type): + base, _ = mcp_server._split_msg_type(local_type) + return MSG_TYPE_MAP.get(base, f"type_{local_type}") + + +def _resolve_sender(row, ctx, names, id_to_username): + """Resolve the sender of a message. + + Returns "me" for the logged-in user, or the sender's display name otherwise + (the contact's name in 1-on-1 chats, the member's name in groups). Empty + string for unattributable messages (e.g. system notifications). + """ + local_id, local_type, create_time, real_sender_id, content, ct = row + decoded = mcp_server._decompress_content(content, ct) + sender_from_content, _ = mcp_server._format_message_text( + local_id, local_type, decoded, ctx["is_group"], ctx["username"], ctx["display_name"], names + ) + label = mcp_server._resolve_sender_label( + real_sender_id, + sender_from_content, + ctx["is_group"], + ctx["username"], + ctx["display_name"], + names, + id_to_username, + ) + return label or "" + + +def _decode_sticker_desc(b64_desc): + """WeChat encodes sticker labels as base64 protobuf: repeated (lang, text) pairs. + Returns the 'default' language label (usually Chinese), or None. + + Limitation: treats the length byte as a single octet rather than a real protobuf + varint — labels >127 bytes would be misread. In practice sticker descriptions are + short (<30 chars), so this is adequate. Also sensitive to the bytes b"default" + appearing inside a preceding value; no such cases observed. + """ + try: + raw = base64.b64decode(b64_desc) + except Exception: + return None + # Find the 'default' marker; text follows as: \x12 + i = raw.find(b"default") + if i < 0 or i + 7 >= len(raw) or raw[i + 7] != 0x12: + return None + try: + text_len = raw[i + 8] + text_bytes = raw[i + 9 : i + 9 + text_len] + return text_bytes.decode("utf-8") or None + except (IndexError, UnicodeDecodeError): + return None + + +def _format_sticker_message(content): + root = mcp_server._parse_xml_root(content) if content else None + if root is None: + return "[表情]" + emoji = root.find(".//emoji") + if emoji is None: + return "[表情]" + desc = emoji.get("desc") or "" + label = _decode_sticker_desc(desc) if desc else None + return f"[表情] {label}" if label else "[表情]" + + +def _format_system_message(content): + if not content: + return "[系统消息]" + if "'。Issue #88: 之前直接把 + # 带前缀的字符串喂给 XML 解析器,群里的引用回复 / 卡片 / 视频等都因 + # 解析失败导致 type 渲染成 link_or_file 且 content 为空。 + is_group = bool(chat_username) and chat_username.endswith('@chatroom') + if is_group: + _, content = mcp_server._parse_message_content(content, local_type, True) + + # names 用于群引用回复的发送者名解析(_resolve_quote_sender_label)。 + # 1-on-1 场景也能用到(按 wxid 查显示名)。 + names = mcp_server.get_contact_names() + + base, _ = mcp_server._split_msg_type(local_type) + if base == 1: + return (content or ""), None + if base == 43: + return _format_video_message(content), None + if base == 47: + return _format_sticker_message(content), None + if base == 49: + rendered = mcp_server._format_app_message_text( + content, local_type, is_group, chat_username, chat_display_name, names + ) + transfer = _extract_transfer_extras(content) + extras = {'type': 'transfer', 'transfer': transfer} if transfer else None + return rendered, extras + if base == 50: + return mcp_server._format_voip_message_text(content), None + if base == 10000: + return _format_system_message(content), None + if base == 10002: + return "[撤回消息]", None + return None, None diff --git a/cleanup.py b/cleanup.py new file mode 100644 index 0000000..2d25876 --- /dev/null +++ b/cleanup.py @@ -0,0 +1,240 @@ +#!/usr/bin/env python3 +""" +WeChat Decrypt — 数据清理工具 + +安全地查看和解密导出数据占用的磁盘空间,交互式清理。 + +用法: + python3 cleanup.py # 交互式清理 + python3 cleanup.py status # 仅显示磁盘用量 + python3 cleanup.py --dry-run # 显示将删除的内容但不实际操作 +""" + +import argparse +import glob +import json +import os +import shutil +import sys + + +def format_size(size_bytes): + """格式化文件大小""" + if size_bytes > 1024 * 1024 * 1024: + return f"{size_bytes / 1024 / 1024 / 1024:.1f} GB" + elif size_bytes > 1024 * 1024: + return f"{size_bytes / 1024 / 1024:.0f} MB" + elif size_bytes > 1024: + return f"{size_bytes / 1024:.0f} KB" + else: + return f"{size_bytes} B" + + +class CleanupItem: + def __init__(self, name, path, is_dir=True, pattern=None, description=""): + self.name = name + self.path = path + self.is_dir = is_dir + self.pattern = pattern + self.description = description + + def size(self): + if not self.exists(): + return 0 + if self.is_dir: + if self.pattern: + files = glob.glob(os.path.join(self.path, self.pattern), recursive=True) + files = [f for f in files if os.path.isfile(f)] + else: + files = [] + for root, dirs, fnames in os.walk(self.path): + for fname in fnames: + files.append(os.path.join(root, fname)) + return sum(os.path.getsize(f) for f in files) + else: + return os.path.getsize(self.path) if os.path.isfile(self.path) else 0 + + def exists(self): + if self.is_dir: + return os.path.isdir(self.path) + return os.path.isfile(self.path) + + def delete(self): + if not self.exists(): + return + if self.is_dir: + shutil.rmtree(self.path) + else: + os.unlink(self.path) + + +def get_items(): + """返回所有可清理项的列表""" + items = [] + + # 解密数据库 + cfg = {} + if os.path.exists("config.json"): + with open("config.json") as f: + cfg = json.load(f) + decrypted_dir = cfg.get("decrypted_dir", "decrypted") + items.append(CleanupItem( + "解密数据库", decrypted_dir, + description="解密后的 SQLite 数据库文件(可重新解密恢复)" + )) + + # WAV 解码缓存 + items.append(CleanupItem( + "语音 WAV 缓存", "decoded_voices", + description="SILK 解码后的临时 WAV 文件(可重新解码)" + )) + + # 图片解码缓存 + items.append(CleanupItem( + "图片解码缓存", "decoded_images", + description="解密后的图片缓存" + )) + + # 导出 JSON + items.append(CleanupItem( + "导出聊天记录", "exported_chats", + description="export_all_chats.py 导出的 JSON 文件(可重新导出)" + )) + + # 旧格式导出 + items.append(CleanupItem( + "旧格式导出", "exports", + description="旧版本导出的数据" + )) + + # 密钥文件 + for kf in sorted(glob.glob("all_keys*.json")): + items.append(CleanupItem( + os.path.basename(kf), kf, is_dir=False, + description="密钥缓存文件(可重新提取)" + )) + + return items + + +def show_status(items): + """显示各项目的磁盘用量""" + total = 0 + rows = [] + for item in items: + sz = item.size() + if sz > 0: + total += sz + rows.append((item.name, sz, item.description)) + + if not rows: + print("没有需要清理的数据。") + return 0 + + # 找最长的名称 + name_width = max(len(r[0]) for r in rows) + 2 + print(f"{'项目':<{name_width}}{'大小':>10} 说明") + print("-" * (name_width + 45)) + for name, sz, desc in rows: + print(f"{name:<{name_width}}{format_size(sz):>10} {desc}") + print("-" * (name_width + 45)) + print(f"{'总计':<{name_width}}{format_size(total):>10}") + return total + + +def cleanup(dry_run=False): + """交互式清理""" + items = get_items() + + print("=" * 60) + print(" 磁盘用量分析") + print("=" * 60) + print() + total = show_status(items) + if total == 0: + print() + print("没有需要清理的数据。") + return + + print() + print("选择要删除的项目(逗号分隔,如: 1,3,5):") + print(" 输入 a 选择全部") + print(" 输入 n 取消") + choice = input("> ").strip().lower() + + if choice in ("", "n"): + print("已取消。") + return + + # 解析选择 + indices = [] + if choice == "a": + indices = list(range(len(items))) + else: + for part in choice.split(","): + part = part.strip() + try: + idx = int(part) - 1 + if 0 <= idx < len(items): + indices.append(idx) + except ValueError: + pass + + if not indices: + print("未选择任何项目。") + return + + # 确认 + total_saved = 0 + print() + for idx in indices: + item = items[idx] + if item.exists(): + sz = item.size() + total_saved += sz + print(f" [{idx+1}] {item.name} ({format_size(sz)})") + + print(f"\n将释放 {format_size(total_saved)} 磁盘空间") + if dry_run: + print("(dry-run 模式,未实际删除)") + return + + confirm = input("确认删除?(y/N): ").strip().lower() + if confirm != "y": + print("已取消。") + return + + # 执行删除 + for idx in indices: + item = items[idx] + if item.exists(): + sz = item.size() + item.delete() + print(f" 已删除: {item.name} ({format_size(sz)})") + + print() + # 显示剩余 + remaining = sum(item.size() for item in get_items()) + print(f"剩余: {format_size(remaining)}") + print("清理完成。") + + +def main(): + parser = argparse.ArgumentParser( + description="WeChat Decrypt — 数据清理工具", + ) + parser.add_argument("mode", nargs="?", default="interactive", + choices=["interactive", "status"], + help="interactive(默认)或 status(仅显示)") + parser.add_argument("--dry-run", action="store_true", + help="预览模式,不实际删除") + args = parser.parse_args() + + if args.mode == "status": + show_status(get_items()) + else: + cleanup(dry_run=args.dry_run) + + +if __name__ == "__main__": + main() diff --git a/config.py b/config.py index 00cc3b0..bbd050e 100644 --- a/config.py +++ b/config.py @@ -29,7 +29,9 @@ if _SYSTEM == "linux": _DEFAULT_PROCESS = "wechat" elif _SYSTEM == "darwin": # macOS 使用独立的 C 扫描器 (find_all_keys_macos.c),此处仅提供 config 默认值 - _DEFAULT_TEMPLATE_DIR = os.path.expanduser("~/Documents/xwechat_files/your_wxid/db_storage") + _DEFAULT_TEMPLATE_DIR = os.path.expanduser( + "~/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files/your_wxid/db_storage" + ) _DEFAULT_PROCESS = "WeChat" else: _DEFAULT_TEMPLATE_DIR = r"D:\xwechat_files\your_wxid\db_storage" @@ -46,6 +48,11 @@ _DEFAULT = { "wxwork_decrypted_dir": "wxwork_decrypted", "wxwork_export_dir": "wxwork_export", "wxwork_process": "WXWork.exe", + # 语音转录后端: "local" (默认, 本地 Whisper) 或 "openai" (OpenAI API) + # 切到 openai 时语音将上传至 OpenAI 服务器, 详见 README "语音转录隐私" 章节 + "transcription_backend": "local", + "local_whisper_model": "base", + "openai_api_key": "", } @@ -178,11 +185,47 @@ def _auto_detect_db_dir_linux(): return _choose_candidate(candidates) +def _auto_detect_db_dir_macos(): + """自动检测 macOS 微信 db_storage 路径。 + + 微信 4.x 数据目录位于 ~/Library/Containers/com.tencent.xinWeChat/.../xwechat_files//db_storage, + 路径中包含随机 hash,需要搜索定位。 + """ + base = os.path.expanduser( + "~/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files" + ) + if not os.path.isdir(base): + return None + + seen = set() + candidates = [] + pattern = os.path.join(base, "*", "db_storage") + for match in glob.glob(pattern): + normalized = os.path.normcase(os.path.normpath(match)) + if os.path.isdir(match) and normalized not in seen: + seen.add(normalized) + candidates.append(match) + + # 优先使用最近活跃账号:按 message 目录 mtime 降序 + def _mtime(path): + msg_dir = os.path.join(path, "message") + target = msg_dir if os.path.isdir(msg_dir) else path + try: + return os.path.getmtime(target) + except OSError: + return 0 + + candidates.sort(key=_mtime, reverse=True) + return _choose_candidate(candidates) + + def auto_detect_db_dir(): if _SYSTEM == "windows": return _auto_detect_db_dir_windows() if _SYSTEM == "linux": return _auto_detect_db_dir_linux() + if _SYSTEM == "darwin": + return _auto_detect_db_dir_macos() return None @@ -214,6 +257,8 @@ def load_config(): print(f" 请手动编辑 {config_file} 中的 db_dir 字段") if _SYSTEM == "linux": print(" Linux 默认路径类似: ~/Documents/xwechat_files//db_storage") + elif _SYSTEM == "darwin": + print(" macOS 默认路径类似: ~/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files//db_storage") else: print(f" 路径可在 微信设置 → 文件管理 中找到") sys.exit(1) @@ -228,6 +273,19 @@ def load_config(): ): if key in cfg and cfg[key] and not os.path.isabs(cfg[key]): cfg[key] = os.path.join(base, cfg[key]) + # 路径展开:先 expanduser(~ 展开)+ expandvars($HOME / %USERPROFILE% 展开), + # 再判 isabs;还相对就 join 项目根。这样 config 里既能写 + # "all_keys.json"(项目根相对),也能写 "~/Documents/wechat_decrypted" / + # "$HOME/wechat" / "%USERPROFILE%\\wechat"(跨用户便携)。 + # 空字串 / null 不再触发 TypeError(用 cfg.get 而非 in)。 + base = os.path.dirname(os.path.abspath(__file__)) + if cfg.get("db_dir"): + cfg["db_dir"] = os.path.expanduser(os.path.expandvars(cfg["db_dir"])) + for key in ("keys_file", "decrypted_dir", "decoded_image_dir"): + if cfg.get(key): + cfg[key] = os.path.expanduser(os.path.expandvars(cfg[key])) + if not os.path.isabs(cfg[key]): + cfg[key] = os.path.join(base, cfg[key]) # 自动推导微信数据根目录(db_dir 的上级目录) # db_dir 格式: D:\xwechat_files\\db_storage diff --git a/decode_image.py b/decode_image.py index f9edbc4..4a4442b 100644 --- a/decode_image.py +++ b/decode_image.py @@ -118,7 +118,7 @@ def v2_decrypt_file(dat_path, out_path=None, aes_key=None, xor_key=0x88): dat_path: V2 .dat 文件路径 out_path: 输出路径 (None 则自动命名) aes_key: 16 字节 AES key (bytes 或 str) - xor_key: XOR key (int, 默认 0x88) + xor_key: XOR key (int 或可被 int(_, 0) 解析的 str, 默认 0x88) Returns: (output_path, format) 或 (None, None) @@ -135,6 +135,10 @@ def v2_decrypt_file(dat_path, out_path=None, aes_key=None, xor_key=0x88): if len(aes_key) < 16: return None, None + # 与 aes_key 的 str→bytes 处理对称: 允许 config.json 写 "0x88" / "136" 等字符串形式 + if isinstance(xor_key, str): + xor_key = int(xor_key, 0) + with open(dat_path, 'rb') as f: data = f.read() @@ -185,6 +189,22 @@ def v2_decrypt_file(dat_path, out_path=None, aes_key=None, xor_key=0x88): # wxgf (HEVC 裸流) 格式 if decrypted[:4] == b'wxgf': fmt = 'hevc' + elif fmt == 'bin': + # detect_image_format 返回 'bin' = magic 不匹配任何已知图片格式, + # 通常说明 AES key 错(解密后产生随机字节)。拒绝写出无意义的 .bin + # 垃圾文件,让 caller 知道解密失败。 + return None, None + elif xor_size >= 2: + # XOR key 错时 AES/raw 段可能产生合法 magic(看似正常 jpg/png 头), + # 但 XOR 段乱码。用尾部 magic 验证 XOR key 正确性: + # - JPG 必须以 FF D9 (EOI marker) 收尾 + # - PNG 末尾 12 字节必须含 IEND chunk + # 其他格式 (gif/bmp/tif/webp/hevc) 缺乏强制 trailer signature, + # 不做校验以避免误杀。xor_size < 2 时无 XOR 段或样本过小,跳过。 + if fmt == 'jpg' and decrypted[-2:] != b'\xff\xd9': + return None, None + if fmt == 'png' and b'IEND' not in decrypted[-12:]: + return None, None if out_path is None: base = os.path.splitext(dat_path)[0] @@ -257,6 +277,140 @@ def decrypt_dat_file(dat_path, out_path=None, aes_key=None, xor_key=0x88): return xor_decrypt_file(dat_path, out_path) +def decode_all_dats(attach_dir, out_dir, aes_key=None, xor_key=0x88, + force=False, progress_every=200, on_file=None): + """批量解密 attach_dir 下所有 .dat 图片到 out_dir 的镜像目录树。 + + 输入路径形态(微信本地约定): + ///Img/[_t|_h].dat + + 其中 chat_hash = md5(username).hexdigest(),username 是 wxid 或 + @chatroom;_t/_h 分别是缩略图 / 高清缩略图后缀。 + + 输出路径形态(镜像 + 移除 _t/_h 缩略图后缀,平铺到原图 basename): + ///. + + 其中 由 magic 自动检测(jpg / png / gif / webp / hevc 等)。 + wxgf 容器输出 .hevc;不在 upstream 做 mp4 转换(scope 留给下游)。 + + 幂等性:目标存在(任何扩展名,基于 basename)时跳过,无需 mtime 比较 —— + .dat 是 content-hash 命名,实际上 write-once。force=True 强制重解。 + + 原子写:解密先写到 ..tmp(同目录),`os.replace` 重命名 + 到最终路径,中断不留半文件。 + + 错误隔离:单文件失败不阻塞批次。V2 文件遇到 aes_key=None 计入 + skipped_no_key(可恢复:跑 find_image_key_macos.py 提取 key 后重跑)。 + + Args: + attach_dir: 微信 msg/attach 根目录(含 chat_hash 子目录) + out_dir: 输出根目录 + aes_key: V2 AES key(16 字节 str/bytes);V1 / 老 XOR 不需要 + xor_key: V2 XOR key(默认 0x88) + force: True 时忽略已存在目标重新解密 + progress_every: 每解 N 个文件打一行进度到 stderr;None 关闭(测试用) + on_file: 可选回调 (i, total, dat_path, status, fmt) 每文件调用一次, + status ∈ {"decoded", "skipped", "skipped_no_key", "failed"} + + Returns: + dict {decoded, skipped, skipped_no_key, failed, total, formats} + formats: dict[ext, count] + """ + pattern = os.path.join(attach_dir, "*", "*", "Img", "*.dat") + dat_files = sorted(glob.glob(pattern)) + + decoded = 0 + skipped = 0 + skipped_no_key = 0 + failed = 0 + formats = {} + + for i, dat_path in enumerate(dat_files): + rel = os.path.relpath(dat_path, attach_dir) + parts = rel.split(os.sep) + if len(parts) != 4 or parts[2] != "Img": + failed += 1 + print(f"[WARN] 跳过非标准路径: {rel}", file=sys.stderr) + if on_file: + on_file(i, len(dat_files), dat_path, "failed", None) + continue + chat_hash, ym, _img, fname = parts + basename = os.path.splitext(fname)[0] # 去 .dat + for suffix in ("_t", "_h"): + if basename.endswith(suffix): + basename = basename[:-len(suffix)] + break + + target_dir = os.path.join(out_dir, chat_hash, ym) + + # 幂等性:目标 basename 已存在(任何 ext,排除 .tmp) + if not force: + existing = [ + p for p in glob.glob(os.path.join(target_dir, f"{basename}.*")) + if not p.endswith(".tmp") + ] + if existing: + skipped += 1 + if on_file: + on_file(i, len(dat_files), dat_path, "skipped", None) + continue + + # V2 文件需要 key;无 key 时计入 skipped_no_key + if is_v2_format(dat_path) and aes_key is None: + skipped_no_key += 1 + if on_file: + on_file(i, len(dat_files), dat_path, "skipped_no_key", None) + if progress_every and (i + 1) % progress_every == 0: + print( + f" ...扫描 {i+1}/{len(dat_files)} (解码 {decoded}, 跳过 {skipped}, " + f"无 key {skipped_no_key}, 失败 {failed})", + file=sys.stderr, + ) + continue + + os.makedirs(target_dir, exist_ok=True) + tmp_path = os.path.join(target_dir, f"{basename}.unknown.tmp") + fmt = None + try: + result_path, fmt = decrypt_dat_file(dat_path, tmp_path, aes_key, xor_key) + if result_path is None or fmt is None: + failed += 1 + if os.path.exists(tmp_path): + try: os.remove(tmp_path) + except OSError: pass + else: + final_path = os.path.join(target_dir, f"{basename}.{fmt}") + os.replace(result_path, final_path) + decoded += 1 + formats[fmt] = formats.get(fmt, 0) + 1 + except Exception as e: + failed += 1 + if os.path.exists(tmp_path): + try: os.remove(tmp_path) + except OSError: pass + print(f"[WARN] {rel}: {e}", file=sys.stderr) + + if on_file: + status = "decoded" if fmt else "failed" + on_file(i, len(dat_files), dat_path, status, fmt) + + if progress_every and (i + 1) % progress_every == 0: + print( + f" ...扫描 {i+1}/{len(dat_files)} (解码 {decoded}, 跳过 {skipped}, " + f"无 key {skipped_no_key}, 失败 {failed})", + file=sys.stderr, + ) + + return { + "decoded": decoded, + "skipped": skipped, + "skipped_no_key": skipped_no_key, + "failed": failed, + "total": len(dat_files), + "formats": formats, + } + + def extract_md5_from_packed_info(blob): """从 message_resource.db 的 packed_info (protobuf) 中提取文件 MD5 @@ -299,34 +453,58 @@ def extract_md5_from_packed_info(blob): class ImageResolver: """封装从 local_id 到图片文件的完整解析链""" - def __init__(self, wechat_base_dir, decoded_image_dir, cache): + def __init__(self, wechat_base_dir, decoded_image_dir, cache, aes_key=None, xor_key=0x88): """ Args: wechat_base_dir: 微信数据根目录 (如 D:\\xwechat_files\\) decoded_image_dir: 解密图片输出目录 cache: DBCache 实例,用于解密 message_resource.db + aes_key: V2 格式的 AES key (16 字节 str/bytes),None 表示不支持 V2 文件 + xor_key: XOR key (int, 默认 0x88),用于 V2 文件的 XOR 段 """ self.base_dir = wechat_base_dir self.attach_dir = os.path.join(wechat_base_dir, "msg", "attach") self.out_dir = decoded_image_dir self.cache = cache + self.aes_key = aes_key + self.xor_key = xor_key - def get_image_md5(self, local_id): - """通过 local_id 查 message_resource.db 获取图片文件 MD5""" + def get_image_md5(self, username, local_id): + """通过 (username, local_id) 查 message_resource.db 获取图片 MD5 + + message_local_id 在 MessageResourceInfo 中跨 chat 重复 (不全局唯一), + 必须用 chat_id 缩小范围;同一 chat 内活跃聊天也会复用 local_id + (实测最高同 chat 7 条同 local_id 的记录), 默认取最新一条。 + + message_local_type 上 32 bit 是版本/会话 flag, 用 % 2^32 取低位匹配 + 图片类型 3, 同 monitor_web.py 里 push 路径的写法。 + """ path = self.cache.get("message/message_resource.db") if not path: return None - conn = sqlite3.connect(path) + conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True) try: + chat_row = conn.execute( + "SELECT rowid FROM ChatName2Id WHERE user_name = ?", + (username,) + ).fetchone() + if not chat_row: + return None + chat_id = chat_row[0] + row = conn.execute( - "SELECT packed_info FROM MessageResourceInfo WHERE local_id = ?", - (local_id,) + "SELECT packed_info FROM MessageResourceInfo " + "WHERE chat_id = ? AND message_local_id = ? " + "AND (message_local_type = 3 OR message_local_type % 4294967296 = 3) " + "ORDER BY message_create_time DESC LIMIT 1", + (chat_id, local_id) ).fetchone() if row and row[0]: return extract_md5_from_packed_info(row[0]) - except Exception: - pass + except Exception as e: + print(f"[get_image_md5] {type(e).__name__}: {e}", + file=sys.stderr, flush=True) finally: conn.close() @@ -357,10 +535,10 @@ class ImageResolver: Returns: dict with keys: success, path, format, md5, error """ - # 1. 获取 MD5 - file_md5 = self.get_image_md5(local_id) + # 1. 获取 MD5 (chat-scoped: 同 local_id 跨 chat 重复) + file_md5 = self.get_image_md5(username, local_id) if not file_md5: - return {'success': False, 'error': f'无法从 message_resource.db 找到 local_id={local_id} 的图片信息'} + return {'success': False, 'error': f'无法从 message_resource.db 找到 {username} local_id={local_id} 的图片信息'} # 2. 找 .dat 文件 dat_files = self.find_dat_files(username, file_md5) @@ -379,13 +557,17 @@ class ImageResolver: selected = f break - # 3. 解密 + # 3. 解密 (decrypt_dat_file 会按 magic 自动分发 V2 / V1 / 老 XOR) out_name = f"{file_md5}" out_path_base = os.path.join(self.out_dir, out_name) - result_path, fmt = xor_decrypt_file(selected, f"{out_path_base}.tmp") + # 提前拦截以给出具体错误信息;否则会在 v2_decrypt_file 内 silent-fail 成笼统的"解密失败" + if is_v2_format(selected) and not self.aes_key: + return {'success': False, 'error': f'V2 格式 .dat 文件需要 AES key (文件: {selected})', 'md5': file_md5} + + result_path, fmt = decrypt_dat_file(selected, f"{out_path_base}.tmp", self.aes_key, self.xor_key) if not result_path: - return {'success': False, 'error': f'无法检测 XOR key (文件: {selected})', 'md5': file_md5} + return {'success': False, 'error': f'解密失败 (文件: {selected})', 'md5': file_md5} # 重命名为正确扩展名 final_path = f"{out_path_base}.{fmt}" @@ -402,17 +584,30 @@ class ImageResolver: 'size': os.path.getsize(final_path), } - def list_chat_images(self, db_path, table_name, username, limit=20): - """列出某个聊天中的所有图片消息""" + def list_chat_images(self, db_path, table_name, username, limit=20, start_ts=None, end_ts=None): + """列出某个聊天中的所有图片消息 + + 可选 start_ts / end_ts (unix 秒) 过滤时间范围。 + """ + clauses = ['local_type = 3'] + params = [] + if start_ts is not None: + clauses.append('create_time >= ?') + params.append(start_ts) + if end_ts is not None: + clauses.append('create_time <= ?') + params.append(end_ts) + params.append(limit) + where_sql = ' AND '.join(clauses) conn = sqlite3.connect(db_path) try: rows = conn.execute(f""" SELECT local_id, create_time FROM [{table_name}] - WHERE local_type = 3 + WHERE {where_sql} ORDER BY create_time DESC LIMIT ? - """, (limit,)).fetchall() + """, params).fetchall() except Exception as e: conn.close() return [] @@ -420,7 +615,7 @@ class ImageResolver: results = [] for local_id, create_time in rows: - file_md5 = self.get_image_md5(local_id) + file_md5 = self.get_image_md5(username, local_id) info = { 'local_id': local_id, 'create_time': create_time, diff --git a/decode_transfer.py b/decode_transfer.py new file mode 100644 index 0000000..fb20d73 --- /dev/null +++ b/decode_transfer.py @@ -0,0 +1,51 @@ +""" +读取微信转账消息(appmsg type=2000)的结构化字段。 + +用法: + python3 decode_transfer.py [] + +参数: + 联系人显示名、备注名或 wxid(仅 1v1 聊天有转账消息)。 + 转账消息的 local_id(从 export_chat 输出 / monitor_web 等地方获取)。 + [] 可选 unix 时间戳。当 local_id 在多个分片冲突时用它唯一定位。 + +输出: 多行可读文本,含方向(发起/收款/退还)、金额、备注、付款/收款 wxid、 + 交易号、发起/失效时间。 + +需先完成 WeChat DB 解密(详见 README)。本 CLI 是 mcp_server.decode_transfer +工具的命令行包装,输出格式与 MCP 工具一致。 +""" +import argparse +import sys + +import mcp_server + + +def main() -> int: + parser = argparse.ArgumentParser( + prog="python3 decode_transfer.py", + description="读取微信转账消息的结构化字段", + ) + parser.add_argument("chat_name", help="联系人名/备注/wxid") + parser.add_argument("local_id", type=int, help="转账消息的 local_id") + parser.add_argument( + "ts", + nargs="?", + type=int, + default=0, + help="消息的 unix 时间戳(跨分片唯一定位时需要,可省略)", + ) + args = parser.parse_args() + + result = mcp_server.decode_transfer(args.chat_name, args.local_id, args.ts) + print(result) + # 如果工具返回错误文案,退出码非 0 便于 shell 脚本判断 + if result.startswith(("错误:", "找不到", "不是转账消息", "无法解析", "消息中没有", "消息 content")): + return 1 + if "无法唯一定位" in result: + return 2 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/decrypt_db.py b/decrypt_db.py index ec1aad8..308acbb 100644 --- a/decrypt_db.py +++ b/decrypt_db.py @@ -7,8 +7,9 @@ WeChat 4.0 数据库解密器 """ import hashlib, struct, os, sys, json import hmac as hmac_mod -from Crypto.Cipher import AES +from Crypto.Cipher import AES +import argparse import functools print = functools.partial(print, flush=True) @@ -20,12 +21,12 @@ HMAC_SZ = 64 RESERVE_SZ = 80 # IV(16) + HMAC(64) SQLITE_HDR = b'SQLite format 3\x00' -from config import load_config -from key_utils import get_key_info, strip_key_metadata -_cfg = load_config() -DB_DIR = _cfg["db_dir"] -OUT_DIR = _cfg["decrypted_dir"] -KEYS_FILE = _cfg["keys_file"] +from config import load_config +from key_utils import get_key_info, strip_key_metadata +_cfg = load_config() +DB_DIR = _cfg["db_dir"] +OUT_DIR = _cfg["decrypted_dir"] +KEYS_FILE = _cfg["keys_file"] def derive_mac_key(enc_key, salt): @@ -106,6 +107,21 @@ def decrypt_database(db_path, out_path, enc_key): def main(): + parser = argparse.ArgumentParser( + description="WeChat 4.0 数据库解密器" + ) + parser.add_argument( + "-i", "--incremental", + action="store_true", + help="增量模式:仅当源 .db 更新于已解密文件时才重新解密", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="预览模式:显示将要解密的数据库列表", + ) + args = parser.parse_args() + print("=" * 60) print(" WeChat 4.0 数据库解密器") print("=" * 60) @@ -113,16 +129,19 @@ def main(): # 加载密钥 if not os.path.exists(KEYS_FILE): print(f"[ERROR] 密钥文件不存在: {KEYS_FILE}") - print("请先运行 find_all_keys.py") + print("请先运行 python main.py decrypt 提取密钥并解密") sys.exit(1) - with open(KEYS_FILE, encoding="utf-8") as f: - keys = json.load(f) - - keys = strip_key_metadata(keys) - print(f"\n加载 {len(keys)} 个数据库密钥") - print(f"输出目录: {OUT_DIR}") - os.makedirs(OUT_DIR, exist_ok=True) + + with open(KEYS_FILE, encoding="utf-8") as f: + keys = json.load(f) + + keys = strip_key_metadata(keys) + print(f"\n加载 {len(keys)} 个数据库密钥") + print(f"输出目录: {OUT_DIR}") + if args.incremental: + print(f"模式: 增量 (跳过未变更的数据库)") + os.makedirs(OUT_DIR, exist_ok=True) # 收集所有DB文件 db_files = [] @@ -140,20 +159,42 @@ def main(): success = 0 failed = 0 + skipped = 0 + skipped_unmodified = 0 total_bytes = 0 - for rel, path, sz in db_files: - key_info = get_key_info(keys, rel) - if not key_info: - print(f"SKIP: {rel} (无密钥)") - failed += 1 - continue - - enc_key = bytes.fromhex(key_info["enc_key"]) - out_path = os.path.join(OUT_DIR, rel) + for rel, path, sz in db_files: + key_info = get_key_info(keys, rel) + if not key_info: + print(f"SKIP: {rel} (无密钥,如已安装微信补丁可能需要重新运行密钥提取)") + skipped += 1 + continue - print(f"解密: {rel} ({sz/1024/1024:.1f}MB) ...", end=" ") + out_path = os.path.join(OUT_DIR, rel) + # 增量模式:检查 mtime + if args.incremental and os.path.exists(out_path): + src_mtime = os.path.getmtime(path) + dst_mtime = os.path.getmtime(out_path) + if src_mtime <= dst_mtime: + skipped_unmodified += 1 + if args.dry_run: + print(f"SKIP: {rel} (未修改)") + continue + elif args.dry_run: + print(f"NEW: {rel} (源较新)") + elif not args.dry_run: + print(f"更新: {rel} ({sz/1024/1024:.1f}MB) ...", end=" ") + elif args.dry_run: + print(f"NEW: {rel} ({sz/1024/1024:.1f}MB)") + else: + print(f"解密: {rel} ({sz/1024/1024:.1f}MB) ...", end=" ") + + if args.dry_run: + skipped_unmodified += 1 + continue + + enc_key = bytes.fromhex(key_info["enc_key"]) ok = decrypt_database(path, out_path, enc_key) if ok: # SQLite验证 @@ -175,8 +216,24 @@ def main(): else: failed += 1 + # 清理 sqlite3.connect() 验证遗留的 -shm/-wal 空文件 + # 避免后续工具打开 .db 时优先读旧 WAL 报 "database disk image is malformed" + for suffix in ("-shm", "-wal"): + residual = out_path + suffix + if os.path.exists(residual): + try: + os.remove(residual) + except OSError: + pass + + if args.dry_run: + print(f"\n{'='*60}") + print(f"预览: 需要解密 {skipped_unmodified} 个数据库") + return + print(f"\n{'='*60}") - print(f"结果: {success} 成功, {failed} 失败, 共 {len(db_files)} 个") + inc_note = f" (跳过 {skipped_unmodified} 个未变更)" if skipped_unmodified else "" + print(f"结果: {success} 成功, {failed} 失败, {skipped} 跳过(无密钥){inc_note}, 共 {len(db_files)} 个") print(f"解密数据量: {total_bytes/1024/1024/1024:.1f}GB") print(f"解密文件在: {OUT_DIR}") diff --git a/docs/bugfix/mac-deploy-issues.md b/docs/bugfix/mac-deploy-issues.md new file mode 100644 index 0000000..17cb896 --- /dev/null +++ b/docs/bugfix/mac-deploy-issues.md @@ -0,0 +1,259 @@ +# macOS 部署问题记录 + +> 环境:macOS (Apple Silicon / Intel), 微信 4.x, Python 3.14 (Homebrew) +> 日期:2026-05-12 + +--- + +## 问题 1: `task_for_pid failed: 5` — 微信进程内存读取被拒绝 + +### 现象 + +```bash +sudo ./find_all_keys_macos +# 输出: +# WeChat PID: 12276 +# task_for_pid failed: 5 +# Make sure: (1) running as root, (2) WeChat is ad-hoc signed +``` + +以 root 运行扫描器,但仍无法读取微信进程内存。 + +### 原因 + +微信 App 使用了 Apple **Hardened Runtime**(`flags=0x10000(runtime)`),即使以 root 身份运行,macOS 也会阻止对带有此标志的进程进行 `task_for_pid` 调用。 + +验证方法: + +```bash +codesign -dvvv /Applications/WeChat.app 2>&1 | grep flags +# 输出: flags=0x10000(runtime) ← 问题所在 +``` + +### 修复 + +1. **退出微信**(重签名需要进程不在运行) + + ```bash + killall WeChat + ``` + +2. **执行 ad-hoc 重签名**(移除 Hardened Runtime 标志) + + ```bash + sudo codesign --force --deep --sign - /Applications/WeChat.app + ``` + +3. **验证签名已变更** + + ```bash + codesign -dvvv /Applications/WeChat.app 2>&1 | grep -E "flags|Authority" + # 正确输出应类似: flags=0x2 + # 不应再出现 flags=0x10000(runtime) 或 Authority=Developer ID + ``` + +4. **重新打开微信并登录**,再运行扫描器 + + ```bash + sudo ./find_all_keys_macos + ``` + +### 注意事项 + +- 微信**每次更新**后签名会恢复为原始状态,需重新执行上述步骤 +- `--deep` 参数确保签名覆盖 App Bundle 内所有嵌套二进制文件 +- 重签名后必须重启微信,否则进程仍使用旧的签名凭证 + +--- + +## 问题 2: 自动检测微信数据目录失败 + +### 现象 + +```bash +.venv/bin/python3 decrypt_db.py +# 输出: +# [!] 未能自动检测微信数据目录 +# 请手动编辑 config.json 中的 db_dir 字段 +``` + +或 + +```bash +.venv/bin/python3 main.py +# 输出: +# [!] 未能自动检测微信数据目录 +``` + +### 原因 + +`config.py` 中的 `auto_detect_db_dir()` 函数仅实现了 Windows 和 Linux 的自动检测逻辑,macOS 分支直接返回 `None`: + +```python +def auto_detect_db_dir(): + if _SYSTEM == "windows": + return _auto_detect_db_dir_windows() + if _SYSTEM == "linux": + return _auto_detect_db_dir_linux() + return None # ← macOS 未实现 +``` + +macOS 微信数据目录位于 `~/Library/Containers/com.tencent.xinWeChat/...`,路径中包含随机 hash,需要搜索才能定位。 + +### 修复 + +已在 `config.py` 中实现 macOS 自动检测,同时改进了检测失败时的提示信息。 + +#### 代码改动 + +1. **新增 `_auto_detect_db_dir_macos()` 函数**(`config.py`) + + ```python + def _auto_detect_db_dir_macos(): + """自动检测 macOS 微信 db_storage 路径。""" + base = os.path.expanduser( + "~/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files" + ) + if not os.path.isdir(base): + return None + + seen = set() + candidates = [] + pattern = os.path.join(base, "*", "db_storage") + for match in glob.glob(pattern): + normalized = os.path.normcase(os.path.normpath(match)) + if os.path.isdir(match) and normalized not in seen: + seen.add(normalized) + candidates.append(match) + + # 优先使用最近活跃账号:按 message 目录 mtime 降序 + def _mtime(path): + msg_dir = os.path.join(path, "message") + target = msg_dir if os.path.isdir(msg_dir) else path + try: + return os.path.getmtime(target) + except OSError: + return 0 + + candidates.sort(key=_mtime, reverse=True) + return _choose_candidate(candidates) + ``` + +2. **在 `auto_detect_db_dir()` 中接入 macOS 分支** + + ```python + def auto_detect_db_dir(): + if _SYSTEM == "windows": + return _auto_detect_db_dir_windows() + if _SYSTEM == "linux": + return _auto_detect_db_dir_linux() + if _SYSTEM == "darwin": + return _auto_detect_db_dir_macos() # ← 新增 + return None + ``` + +3. **改进检测失败时的提示**:macOS 提示正确的默认路径格式 + + ``` + [!] 未能自动检测微信数据目录 + 请手动编辑 config.json 中的 db_dir 字段 + macOS 默认路径类似: ~/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files//db_storage + ``` + +#### 临时解决方案(如自动检测仍失败) + +手动查找并配置 `db_dir`: + +```bash +find ~/Library/Containers/com.tencent.xinWeChat -type d -name "db_storage" 2>/dev/null +``` + +如有多个账号,按修改时间判断当前活跃账号: + +```bash +stat -f "%m %N" /path/to/account1/db_storage /path/to/account2/db_storage +``` + +然后编辑 `config.json` 填入路径。 + +--- + +## 问题 3: Homebrew Python 拒绝全局 pip 安装 + +### 现象 + +```bash +pip3 install -r requirements.txt +# 报错: error: externally-managed-environment +# 提示: PEP 668 — 不能直接向系统 Python 安装包 +``` + +### 原因 + +Homebrew 的 Python 3.14 遵循 [PEP 668](https://peps.python.org/pep-0668/),禁止 `pip install` 直接写入系统 Python 环境,防止破坏包管理器的依赖关系。 + +### 修复 + +使用虚拟环境: + +```bash +cd /Users/drulu/Documents/GitHub/wechat-decrypt + +# 创建虚拟环境 +python3 -m venv .venv + +# 激活并安装依赖 +source .venv/bin/activate +pip install -r requirements.txt + +# 后续运行脚本时使用 .venv 中的 Python +.venv/bin/python3 main.py +.venv/bin/python3 decrypt_db.py +``` + +或使用 Makefile(已配置 `.venv/bin/python3`): + +```bash +make decrypt # 等价于 .venv/bin/python3 main.py decrypt +make web # 等价于 .venv/bin/python3 main.py +``` + +--- + +## 完整部署流程(macOS) + +将以上修复整合为正确的部署顺序: + +```bash +# 1. 安装 Xcode CLI 工具 +xcode-select --install + +# 2. 创建虚拟环境并安装依赖 +cd ~/Documents/GitHub/wechat-decrypt +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt + +# 3. 退出微信 → 重签名 → 重启微信 +killall WeChat +sudo codesign --force --deep --sign - /Applications/WeChat.app +# 然后手动打开微信并登录 + +# 4. 编译 C 扫描器 +cc -O2 -o find_all_keys_macos find_all_keys_macos.c -framework Foundation + +# 5. 提取密钥 +sudo ./find_all_keys_macos + +# 6. 启动 Web UI(db_dir 已自动检测,无需手动配置) +.venv/bin/python3 main.py # 启动 Web UI → http://localhost:5678 +.venv/bin/python3 decrypt_db.py # 或仅全量解密 +``` + +## 修复状态汇总 + +| 问题 | 代码修复 | 说明 | +|------|---------|------| +| `task_for_pid failed: 5` | ❌ 无法代码修复 | 系统级限制,需手动重签名微信 | +| 自动检测 `db_dir` 失败 | ✅ 已修复 | `config.py` 新增 `_auto_detect_db_dir_macos()`,自动搜索 `~/Library/Containers/` | +| Homebrew Python 拒绝 pip 安装 | ❌ 无法代码修复 | 环境限制,需使用虚拟环境 | diff --git a/docs/chat_export_format.md b/docs/chat_export_format.md new file mode 100644 index 0000000..03a30f5 --- /dev/null +++ b/docs/chat_export_format.md @@ -0,0 +1,97 @@ +# 聊天导出 JSON 数据格式 + +`export_chat.py` 与 `transcribe_chat.py` 生成的 JSON 文件采用紧凑格式: +默认值与空值会被省略。本文档说明如何加载和解读这类文件。 + +## 生成文件 + +```bash +.venv/bin/python3 export_chat.py [output.json] +.venv/bin/python3 transcribe_chat.py [output.json] +``` + +`export_chat.py` 负责原始导出;`transcribe_chat.py` 使用 Whisper(CPU) +为语音消息填充转录文本。`transcribe_chat.py` 可重复运行 —— 已转录的 +消息会被跳过。 + +## 顶层结构 + +```json +{ + "chat": "", + "username": "", + "exported_at": "YYYY-MM-DD HH:MM:SS", + "is_group": true, + "messages": [ ... ] +} +``` + +- `chat` —— 聊天的显示名(联系人名或群名)。 +- `username` —— 稳定的 WeChat 用户名(1-on-1 聊天为 `wxid_*`,群聊为 `*@chatroom`)。 + `transcribe_chat.py` 会优先读取本字段而非基于 `chat` 再次模糊匹配,避免同名联系人漂移。 +- `exported_at` —— 本地时间字符串,仅作溯源用途。 +- `is_group` —— **仅**群聊出现且为 `true`;1-on-1 聊天时省略。 +- `messages` —— 消息数组,跨所有 DB 分片按时间由旧到新排序。 + +消息条数 = `len(messages)`,没有 `total` 字段。 + +## 消息对象 + +每条消息必有三个字段:`local_id`、`timestamp`、`sender`。 +其余字段均为**可选**,当值为默认值或 null 时会被省略。 + +| 字段 | 类型 | 必填 | 含义 / 缺失时的默认值 | +| --------------- | ------ | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `local_id` | int | 是 | WeChat 内该聊天的稳定行 ID。用于重跑转录或对比导出时的消息匹配。 | +| `timestamp` | int | 是 | Unix 时间戳(秒级,本地时间已换算为秒)。通过 `datetime.fromtimestamp(ts)` 转换。 | +| `sender` | string | 是 | `"me"` 代表当前登录用户;否则为发送者的显示名 —— 1-on-1 聊天中是联系人名,群聊中是群成员名。对于无法归属的消息(如系统通知)为 `""`。 | +| `type` | string | 否 | 消息类型。**缺失时视为 `"text"`**。已知取值:`text`、`image`、`voice`、`sticker`、`video`、`link_or_file`、`call`、`system`、`recall`、`contact_card`、`location`。 | +| `content` | string | 否 | 消息的渲染文本。当没有可提取内容时省略(例如部分图片 / 通话 / 系统事件)。 | +| `transcription` | string | 否 | **仅**在 `type: "voice"` 且已完成转录的消息上出现。若 Whisper 未产出文本可能为空串 `""`。 | + +## 加载示例 + +带默认值的遍历: + +```python +import json +from datetime import datetime + +with open("chat_export_transcribed.json") as f: + data = json.load(f) + +is_group = data.get("is_group", False) + +for m in data["messages"]: + mtype = m.get("type", "text") + when = datetime.fromtimestamp(m["timestamp"]) + sender = m["sender"] # "me" | 联系人/群成员名 | "" + text = m.get("content", "") + if mtype == "voice": + text = m.get("transcription") or "[voice, untranscribed]" + print(f"[{when:%Y-%m-%d %H:%M}] {sender or '(system)'}: {text}") +``` + +判断消息是否由自己发出: + +```python +from_me = m["sender"] == "me" +``` + +筛选仍需转录的语音消息: + +```python +pending = [m for m in data["messages"] + if m.get("type") == "voice" and not m.get("transcription")] +``` + +## 解读注意事项 + +- **系统消息**(`type: "system"`)的 `sender` 为 `""` —— 不属于任何人。 + 常见内容:撤回通知("X 撤回了一条消息")、添加好友事件等。 +- **空转录**(`transcription: ""`)表示 Whisper 已经运行但未产出文本, + 通常是极短或静音片段。这与"尚未转录"(字段缺失)是不同的状态。 +- **非文本消息的 `content`** 是渲染摘要:`[视频] 12秒`、`[表情] 哈哈`、 + `[图片]` 等。原始媒体仍在 WeChat DB 中,可用 `mcp_server.py` 中的 + 辅助函数(`decode_image`、`decode_voice`)取出。 +- **群聊**中的 `sender` 是群成员解析后的显示名;当前登录用户仍为 `"me"`。 diff --git a/export_all_chats.py b/export_all_chats.py new file mode 100644 index 0000000..b2326b6 --- /dev/null +++ b/export_all_chats.py @@ -0,0 +1,418 @@ +#!/usr/bin/env python3 +"""批量导出所有微信聊天记录为 JSON 文件,可选附带语音转录。 + +此脚本将导出所有会话的聊天记录,输出格式与 export_chat.py 完全一致。 +支持导出到指定目录,默认输出到 ./exported_chats 目录。 + +语音转录通过 mcp_server 的 backend 配置驱动(config.json 中设置 +transcription_backend 为 whisper_cpp / openai / local)。未启用 backend +或缺少依赖时仅导出文本消息,不报错。 + +用法: + python3 export_all_chats.py # 全量导出所有会话 + python3 export_all_chats.py --with-transcriptions # 全量导出 + 转录语音 + python3 export_all_chats.py -i # 增量(只导出最新消息) + python3 export_all_chats.py --start 2025-01-01 # 按日期范围 + python3 export_all_chats.py --end 2025-01-31 + python3 export_all_chats.py --start 2025-01-01 --end 2025-01-31 -t +""" + +import argparse +import json +import os +import re +import sqlite3 +import sys +import time +from contextlib import closing +from datetime import datetime + +import mcp_server + +# 尝试导入 tqdm 作为进度条(可选) +try: + from tqdm import tqdm as _tqdm +except ImportError: + _tqdm = None + +from chat_export_helpers import _extract_content, _msg_type_str, _resolve_sender + + +def _parse_timestamp(ts_str): + """解析时间字符串返回 unix timestamp。 + 支持格式: '2025-01-01', '2025-01-01 14:30', '2025-01-01T14:30:00' + """ + for fmt in ("%Y-%m-%d", "%Y-%m-%d %H:%M", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S"): + try: + dt = datetime.strptime(ts_str.strip(), fmt) + return int(dt.timestamp()) + except ValueError: + pass + try: + return int(ts_str) + except ValueError: + return None + + +def _get_last_message_ts(json_path): + """读取已有 JSON 的最后一条消息时间戳""" + try: + with open(json_path, encoding="utf-8") as f: + data = json.load(f) + msgs = data.get("messages", []) + if msgs: + return msgs[-1].get("timestamp", 0) + except (json.JSONDecodeError, IOError, KeyError): + pass + return 0 + + +def _get_existing_messages(json_path): + """读取已有 JSON 的消息列表(增量合并用)""" + try: + with open(json_path, encoding="utf-8") as f: + data = json.load(f) + return data.get("messages", []) + except (json.JSONDecodeError, IOError, KeyError): + return [] + + +def export_one(username, output_dir, names, transcribe=False, + start_ts=None, end_ts=None, incremental=False): + """ + 导出单个会话。 + + 参数: + start_ts: 消息起始时间戳(None = 全部) + end_ts: 消息结束时间戳(None = 全部) + incremental: 增量模式(追加到已有消息,跳过重复) + + 返回: (成功标志, 总消息数, 新增消息数, 错误信息) + """ + ctx = mcp_server._resolve_chat_context(username) + if ctx is None: + return False, 0, 0, f"Cannot resolve: {username}" + + display_name = ctx["display_name"] + message_tables = ctx["message_tables"] + + if not message_tables: + return False, 0, 0, "no tables" + + # 构造输出路径 + prefix = "group" if ctx["is_group"] else "single" + safe = re.sub(r'[\\/:*?"<>|]', "_", f"{prefix}_{display_name}") + out_path = os.path.join(output_dir, f"{safe}.json") + + # 增量模式:读取已有消息和最后时间戳 + existing_msgs = [] + last_ts = 0 + if incremental and os.path.isfile(out_path): + existing_msgs = _get_existing_messages(out_path) + last_ts = _get_last_message_ts(out_path) + if last_ts and (start_ts is None or start_ts < last_ts): + start_ts = last_ts + + # 如果提供了 start_ts/end_ts 但没有增量数据,仍需查询 + if start_ts is not None and incremental and not existing_msgs: + # 无增量目标文件,退化为普通导出 + incremental = False + + new_rows = [] + for table_info in message_tables: + db_path = table_info["db_path"] + table_name = table_info["table_name"] + try: + with closing(sqlite3.connect(db_path)) as conn: + id_to_username = mcp_server._load_name2id_maps(conn) + + # 增量模式:只查 start_ts 之后的消息 + if start_ts is not None or end_ts is not None: + rows = mcp_server._query_messages( + conn, table_name, + start_ts=start_ts, end_ts=end_ts, + limit=None, oldest_first=True, + ) + else: + rows = mcp_server._query_messages( + conn, table_name, limit=None, oldest_first=True + ) + + for row in rows: + new_rows.append((row, id_to_username)) + except Exception as e: + return False, 0, 0, f"DB query error: {e}" + + new_rows.sort(key=lambda pair: pair[0][2] or 0) + + local_ids_existing = {m.get("local_id") for m in existing_msgs} + + # 构建已有消息的 local_id → message 映射(用于合并时保留 transcription) + existing_by_lid = {m.get("local_id"): m for m in existing_msgs} + + new_messages = [] + for row, id_to_username in new_rows: + local_id, local_type, create_time, real_sender_id, content, ct = row + + # 增量模式:跳过已存在的消息 + if incremental and local_id in local_ids_existing: + continue + + sender = _resolve_sender(row, ctx, names, id_to_username) + type_str = _msg_type_str(local_type) + rendered, extras = _extract_content( + local_id, local_type, content, ct, username, display_name + ) + + msg = {"local_id": local_id, "timestamp": create_time, "sender": sender} + effective_type = (extras or {}).get("type") or type_str + if effective_type != "text": + msg["type"] = effective_type + if rendered is not None: + msg["content"] = rendered + if extras: + for k, v in extras.items(): + if k == "type": + continue + msg[k] = v + new_messages.append(msg) + + # 合并消息 + messages = existing_msgs + new_messages + new_count = len(new_messages) + + if not messages: + return False, 0, 0, "empty" + + # ── 语音转录 ────────────────────────────────────────────── + if transcribe: + # 只需转录新消息中的语音 + voices_to_transcribe = new_messages if incremental else [ + m for m in messages + if m.get("type") == "voice" and not m.get("transcription") + ] + transcribed = 0 + failed = 0 + for msg in voices_to_transcribe: + if msg.get("type") != "voice": + continue + lid = msg["local_id"] + try: + row = mcp_server._fetch_voice_row(username, lid) + if row is None: + continue + voice_data, create_time = row + wav_path, _ = mcp_server._silk_to_wav( + voice_data, create_time, username, lid + ) + backend = _resolve_backend() + result = mcp_server._transcribe(wav_path, backend) + if result and result.get("text"): + msg["transcription"] = result["text"] + transcribed += 1 + os.unlink(wav_path) + except Exception: + failed += 1 + if transcribed or failed: + display = names.get(username, username) + voice_total = len(voices_to_transcribe) + print( + f" 转录: {transcribed}/{voice_total} 条语音" + + (f" ({failed} 失败)" if failed else "") + ) + + # ── 写文件 ──────────────────────────────────────────────── + output = { + "chat": display_name, + "username": username, + "exported_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "messages": messages, + } + if ctx["is_group"]: + output["is_group"] = True + + os.makedirs(os.path.dirname(out_path) if os.path.dirname(out_path) else ".", exist_ok=True) + with open(out_path, "w", encoding="utf-8") as f: + json.dump(output, f, ensure_ascii=False, indent=2) + + return True, len(messages), new_count, None + + +_BACKEND_CACHE = None + + +def _resolve_backend(): + """解析转录 backend,结果缓存以避免重复检测。""" + global _BACKEND_CACHE + if _BACKEND_CACHE is None: + try: + _BACKEND_CACHE = mcp_server._resolve_active_backend() + except Exception: + _BACKEND_CACHE = "local" + return _BACKEND_CACHE + + +def main(): + parser = argparse.ArgumentParser( + description="批量导出所有微信聊天记录为 JSON 文件,可选附带语音转录", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +示例: + python3 export_all_chats.py 全量导出所有会话 + python3 export_all_chats.py -t 全量导出 + 转录语音 + python3 export_all_chats.py -i 增量(追加新消息) + python3 export_all_chats.py --start 2025-01-01 按日期范围导出 + python3 export_all_chats.py --end 2025-01-31 按日期范围导出 + python3 export_all_chats.py --start 2025-01-01 --end 2025-01-31 -t +""", + ) + parser.add_argument( + "output_dir", + nargs="?", + default=None, + help="输出目录路径 (默认: ./exported_chats)", + ) + parser.add_argument( + "-t", + "--with-transcriptions", + action="store_true", + help="导出时一并转录语音消息(依赖 config.json 配置的 backend)", + ) + parser.add_argument( + "-i", + "--incremental", + action="store_true", + help="增量导出:只追加新消息到已有 JSON 文件", + ) + parser.add_argument( + "--start", + default=None, + help="起始日期 (如 2025-01-01 或 Unix 时间戳)", + ) + parser.add_argument( + "--end", + default=None, + help="结束日期 (如 2025-01-31 或 Unix 时间戳)", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="预览模式:显示将导出的会话数和新消息数,不实际写入", + ) + args = parser.parse_args() + + script_dir = os.path.dirname(os.path.abspath(__file__)) + output_dir = args.output_dir or os.path.join(script_dir, "exported_chats") + + start_ts = _parse_timestamp(args.start) if args.start else None + end_ts = _parse_timestamp(args.end) if args.end else None + if args.start and start_ts is None: + print(f"错误: 无法解析起始时间: {args.start}", file=sys.stderr) + print("支持格式: 2025-01-01, 2025-01-01 14:30, 2025-01-01T14:30:00", file=sys.stderr) + sys.exit(1) + if args.end and end_ts is None: + print(f"错误: 无法解析结束时间: {args.end}", file=sys.stderr) + print("支持格式: 2025-01-01, 2025-01-01 14:30, 2025-01-01T14:30:00", file=sys.stderr) + sys.exit(1) + + if args.with_transcriptions: + try: + backend = _resolve_backend() + print(f"语音转录: 启用 (backend={backend})") + except Exception as e: + print(f"语音转录: backend 解析失败: {e}", file=sys.stderr) + args.with_transcriptions = False + + if not os.path.exists(mcp_server.DECRYPTED_DIR): + print(f"错误: 解密目录不存在: {mcp_server.DECRYPTED_DIR}", file=sys.stderr) + sys.exit(1) + os.makedirs(output_dir, exist_ok=True) + + session_db = os.path.join(mcp_server.DECRYPTED_DIR, "session", "session.db") + try: + with closing(sqlite3.connect(session_db)) as conn: + sessions = [u for u, _ in conn.execute( + "SELECT username, type FROM SessionTable" + )] + except sqlite3.Error as e: + print(f"会话数据库查询失败: {e}", file=sys.stderr) + sys.exit(1) + + names = mcp_server.get_contact_names() + + # 显示模式信息 + mode = "" + if args.incremental: + mode = "增量模式" + if start_ts: + start_dt = datetime.fromtimestamp(start_ts).strftime("%Y-%m-%d %H:%M") + mode += f" 起始={start_dt}" + if end_ts: + end_dt = datetime.fromtimestamp(end_ts).strftime("%Y-%m-%d %H:%M") + mode += f" 结束={end_dt}" + if not mode: + mode = "全量模式" + if args.dry_run: + mode += " (预览)" + + print(f"会话总数: {len(sessions)}") + print(f"联系人映射: {len(names)}") + print(f"输出目录: {output_dir}") + print(f"模式: {mode}") + print("=" * 60) + + t0 = time.time() + ok, skip, err, total = 0, 0, 0, 0 + total_new = 0 + + iterable = _tqdm(sessions, desc="导出进度") if _tqdm else sessions + for i, username in enumerate(iterable, 1): + display = names.get(username, username) + success, total_msgs, new_msgs, reason = export_one( + username, output_dir, names, + transcribe=args.with_transcriptions, + start_ts=start_ts, + end_ts=end_ts, + incremental=args.incremental, + ) + if success: + ok += 1 + total += total_msgs + total_new += new_msgs + if new_msgs > 0 or args.incremental: + label = f"+{new_msgs} new" if args.incremental else f"{total_msgs} msgs" + else: + label = f"{total_msgs} msgs" + if not _tqdm: + if i <= 10 or i % 100 == 0 or new_msgs > 0: + elapsed = time.time() - t0 + eta = (elapsed / i) * (len(sessions) - i) if i > 0 else 0 + print( + f"[{i}/{len(sessions)}] {display} - {label}" + + (f" ETA {eta/60:.0f}分" if i > 1 else "") + ) + else: + if "no tables" in str(reason) or "empty" in str(reason): + skip += 1 + if not _tqdm: + if i <= 10 or i % 50 == 0: + print(f"[{i}/{len(sessions)}] {display} - 跳过({reason})") + else: + err += 1 + if not _tqdm: + print(f"[{i}/{len(sessions)}] {display} - 失败: {reason}") + elif _tqdm: + _tqdm.write(f"失败: {display} - {reason}") + + elapsed = time.time() - t0 + print() + print("=" * 60) + extra = f" (新增 {total_new} 条)" if args.incremental and total_new > 0 else "" + print( + f"完成! 成功={ok} 跳过={skip} 失败={err} " + f"总消息={total}{extra} 耗时={elapsed/60:.1f}分" + ) + + +if __name__ == "__main__": + main() diff --git a/export_chat.py b/export_chat.py new file mode 100644 index 0000000..4c7918f --- /dev/null +++ b/export_chat.py @@ -0,0 +1,134 @@ +""" +将单个聊天的全部消息导出为 JSON。 + +用法: + .venv/bin/python3 export_chat.py [output.json] + +参数: + 联系人显示名、备注名、群名或 wxid。 + [output.json] 可选输出路径,默认 "_export.json"。 + +示例: + .venv/bin/python3 export_chat.py + .venv/bin/python3 export_chat.py /tmp/out.json + +输出 JSON 的紧凑结构: + { + "chat": "", + "username": "", + "exported_at": "YYYY-MM-DD HH:MM:SS", + "is_group": true, // 仅群聊出现 + "messages": [ + {"local_id": 1, "timestamp": 1713..., "sender": "me", "content": "..."}, + {"local_id": 2, "timestamp": 1713..., "sender": "", "type": "voice"} + ] + } + +默认值/空值会被省略: text 消息省略 "type",无可提取内容时省略 "content", +1-on-1 聊天省略 "is_group"。 + +语音消息以 type "voice" 导出且不带 transcription 字段;运行 +transcribe_chat.py 可用 Whisper 补齐转录。 + +需先完成 WeChat DB 解密(详见 README)。 + +完整 schema、字段语义与加载示例: docs/chat_export_format.md +""" +import json +import sqlite3 +import sys +from contextlib import closing +from datetime import datetime + +import mcp_server +from chat_export_helpers import ( + _extract_content, + _msg_type_str, + _resolve_sender, +) + + +def export_chat(chat_name, output_path): + ctx = mcp_server._resolve_chat_context(chat_name) + if ctx is None: + print(f"Could not resolve chat: {chat_name}") + sys.exit(1) + + username = ctx["username"] + display_name = ctx["display_name"] + # resolve_username 对模糊匹配会静默选第一个命中,打印一下便于用户核对。 + print(f"Resolved to: {display_name} ({username})") + + if not ctx["message_tables"]: + print(f"No message tables found for {username}") + sys.exit(1) + + names = mcp_server.get_contact_names() + + # Each shard has its own Name2Id table, so we must pair rows with the + # id_to_username map from their source DB. + all_rows = [] + for table_info in ctx["message_tables"]: + db_path = table_info["db_path"] + table_name = table_info["table_name"] + with closing(sqlite3.connect(db_path)) as conn: + id_to_username = mcp_server._load_name2id_maps(conn) + rows = mcp_server._query_messages(conn, table_name, limit=None, oldest_first=True) + for row in rows: + all_rows.append((row, id_to_username)) + + # Sort across shards by create_time (defensive "or 0" in case a row has NULL). + all_rows.sort(key=lambda pair: pair[0][2] or 0) + + messages = [] + for row, id_to_username in all_rows: + local_id, local_type, create_time, real_sender_id, content, ct = row + sender = _resolve_sender(row, ctx, names, id_to_username) + type_str = _msg_type_str(local_type) + rendered, extras = _extract_content( + local_id, local_type, content, ct, username, display_name + ) + + # Compact format: omit defaults/nulls. type defaults to "text", transcription + # is added later by transcribe_chat.py only for voice messages. See CLAUDE.md. + msg = { + "local_id": local_id, + "timestamp": create_time, + "sender": sender, + } + # extras may override type with a more specific value (e.g. "transfer" + # narrower than the generic "link_or_file" base=49 maps to). + effective_type = (extras or {}).get("type") or type_str + if effective_type != "text": + msg["type"] = effective_type + if rendered is not None: + msg["content"] = rendered + if extras: + for k, v in extras.items(): + if k == "type": + continue + msg[k] = v + messages.append(msg) + + output = { + "chat": display_name, + "username": username, + "exported_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "messages": messages, + } + if ctx["is_group"]: + output["is_group"] = True + + with open(output_path, "w", encoding="utf-8") as f: + json.dump(output, f, ensure_ascii=False, indent=2) + + print(f"Exported {len(messages)} messages to {output_path}") + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("Usage: python3 export_chat.py [output.json]") + sys.exit(1) + chat = sys.argv[1] + out = sys.argv[2] if len(sys.argv) > 2 else f"{chat}_export.json" + export_chat(chat, out) diff --git a/find_all_keys.py b/find_all_keys.py index eba2191..d1465a9 100644 --- a/find_all_keys.py +++ b/find_all_keys.py @@ -1,6 +1,179 @@ import functools import platform import sys +import os +import glob +import json +import hashlib +import multiprocessing +import time +from config import load_config +from Crypto.Cipher import AES + + +def find_v2_ciphertext(attach_dir): + v2_magic = b'\x07\x08V2\x08\x07' + pattern = os.path.join(attach_dir, "*", "*", "Img", "*_t.dat") + dat_files = sorted(glob.glob(pattern), key=os.path.getmtime, reverse=True) + + for f in dat_files[:100]: + try: + with open(f, 'rb') as fp: + header = fp.read(31) + if header[:6] == v2_magic and len(header) >= 31: + return header[15:31], os.path.basename(f) + except Exception: + continue + return None, None + + +def find_xor_key(attach_dir): + v2_magic = b'\x07\x08V2\x08\x07' + pattern = os.path.join(attach_dir, "*", "*", "Img", "*_t.dat") + dat_files = sorted(glob.glob(pattern), key=os.path.getmtime, reverse=True) + + tail_counts = {} + for f in dat_files[:32]: + try: + sz = os.path.getsize(f) + with open(f, 'rb') as fp: + head = fp.read(6) + fp.seek(sz - 2) + tail = fp.read(2) + if head == v2_magic and len(tail) == 2: + key = (tail[0], tail[1]) + tail_counts[key] = tail_counts.get(key, 0) + 1 + except Exception: + continue + + if not tail_counts: + return None + + most_common = max(tail_counts, key=tail_counts.get) + x, y = most_common + xor_key = x ^ 0xFF + if (y ^ 0xD9) == xor_key: + return xor_key + return None + + +def try_key(key_bytes, ciphertext): + try: + cipher = AES.new(key_bytes, AES.MODE_ECB) + dec = cipher.decrypt(ciphertext) + if dec[:3] == b'\xFF\xD8\xFF': return 'JPEG' + if dec[:4] == b'\x89PNG': return 'PNG' + if dec[:4] == b'RIFF': return 'WEBP' + if dec[:4] == b'wxgf': return 'WXGF' + if dec[:3] == b'GIF': return 'GIF' + except Exception: + pass + return None + + +def _brute_worker(start_i, end_i, xor_key, bin_suffix, base_wxid_bytes, ciphertext_16, result_queue): + for i in range(start_i, end_i): + uin = (i << 8) | xor_key + uin_bytes = str(uin).encode('ascii') + + if hashlib.md5(uin_bytes).digest()[:2] == bin_suffix: + h_aes = hashlib.md5(uin_bytes + base_wxid_bytes).hexdigest() + aes_key_16 = h_aes[:16].encode('ascii') + + if try_key(aes_key_16, ciphertext_16): + result_queue.put((uin, aes_key_16.decode('ascii'))) + return + + +def find_image_key_offline(cfg): + print("\n" + "=" * 60) + print(" 尝试提取图片 AES 密钥") + print("=" * 60) + + db_dir = cfg.get("db_dir", "") + if not db_dir: + print("未配置 db_dir") + return + + base_dir = os.path.dirname(db_dir) + attach_dir = os.path.join(base_dir, 'msg', 'attach') + + folder = os.path.basename(base_dir) + base_wxid, suffix = "", "" + if '_' in folder: + parts = folder.rsplit('_', 1) + if len(parts) == 2 and len(parts[1]) == 4: + base_wxid, suffix = parts + + if not base_wxid or not suffix: + print(f"[!] 目录名不符合 wxid_..._suffix 格式: {folder},跳过爆破") + return + + print(f"[*] 解析到 wxid={base_wxid}, suffix={suffix}") + + xor_key = find_xor_key(attach_dir) + if xor_key is None: + print("[!] 找不到足够的 _t.dat 文件推导 XOR key,跳过爆破") + print(" 请先在微信中查看 2-3 张图片,让缩略图缓存到本地后再重试。") + return + print(f"[*] 找到 XOR key: 0x{xor_key:02x}") + + ciphertext, ct_file = find_v2_ciphertext(attach_dir) + if not ciphertext: + print("[!] 找不到 V2 加密的图片文件,跳过爆破") + print(" 请先在微信中查看 2-3 张图片,让缩略图缓存到本地后再重试。") + return + + print(f"[*] 启动多进程 UIN 空间爆破...") + t0 = time.time() + + bin_suffix = bytes.fromhex(suffix) + base_wxid_bytes = base_wxid.encode('ascii') + + cpu_count = multiprocessing.cpu_count() + total = 1 << 24 + chunk = total // cpu_count + + result_queue = multiprocessing.Queue() + processes = [] + + for i in range(cpu_count): + start, end = i * chunk, (i + 1) * chunk if i != cpu_count - 1 else total + p = multiprocessing.Process( + target=_brute_worker, + args=(start, end, xor_key, bin_suffix, base_wxid_bytes, ciphertext, result_queue) + ) + p.start() + processes.append(p) + + found = None + try: + while any(p.is_alive() for p in processes): + if not result_queue.empty(): + found = result_queue.get() + break + time.sleep(0.1) + finally: + for p in processes: + p.terminate() + for p in processes: + p.join(timeout=1) + + elapsed = time.time() - t0 + if found: + print(f"[+] 爆破成功! UIN={found[0]}, 耗时={elapsed:.1f}s") + aes_key = found[1] + print(f" image_aes_key = {aes_key}") + + cfg['image_aes_key'] = aes_key + cfg['image_xor_key'] = xor_key + config_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json") + with open(config_file, 'w', encoding='utf-8') as f: + json.dump(cfg, f, indent=4, ensure_ascii=False) + print(f"[+] 已保存到 config.json") + else: + print(f"[-] 未能在 UIN 空间找到有效密钥 (耗时={elapsed:.1f}s)") + print(" 可能原因: 目录名被重命名过,或者不是标准账号目录。") @functools.lru_cache(maxsize=1) @@ -12,9 +185,16 @@ def _load_impl(): if system == "linux": import find_all_keys_linux as impl return impl + if system == "darwin": + raise RuntimeError( + "macOS 请先运行 C 版扫描器提取数据库密钥:\n" + "\n" + " sudo ./find_all_keys_macos\n" + "\n" + " 完成后再运行 python main.py decrypt" + ) raise RuntimeError( - f"当前平台暂不支持通过 find_all_keys.py 提取密钥: {platform.system()}\n" - f"macOS 请使用 find_all_keys_macos.c (C 版扫描器)" + f"当前平台暂不支持通过 find_all_keys.py 提取内存数据库密钥: {platform.system()}" ) @@ -23,10 +203,15 @@ def get_pids(): def main(): + cfg = load_config() + + find_image_key_offline(cfg) + return _load_impl().main() if __name__ == "__main__": + multiprocessing.freeze_support() try: main() except RuntimeError as exc: diff --git a/find_image_key.py b/find_image_key.py index b034660..f170306 100644 --- a/find_image_key.py +++ b/find_image_key.py @@ -343,7 +343,7 @@ def main(): except (FileNotFoundError, json.JSONDecodeError): config_raw = {} - db_dir = config['db_dir'] + db_dir = os.path.expanduser(os.path.expandvars(config['db_dir'])) base_dir = os.path.dirname(db_dir) attach_dir = os.path.join(base_dir, 'msg', 'attach') diff --git a/find_image_key_macos.py b/find_image_key_macos.py new file mode 100644 index 0000000..b1b1703 --- /dev/null +++ b/find_image_key_macos.py @@ -0,0 +1,642 @@ +"""macOS WeChat 4.x 图片 AES key 派生(无需读运行进程)。 + +通过 macOS 微信 4.x 在磁盘上的命名约定派生出 V2 .dat 图片解密所需的 +(xor_key, aes_key)。解决 issue #23:macOS 用户无法用 C 版扫描器从运行 +进程读取出有效的访问凭据(197K 候选全部失败)。 + +派生算法(共享核心) +-------------------- +- xor_key = uin & 0xFF +- aes_key = MD5(str(uin) + cleaned_wxid).hex()[:16] # ASCII 字符串 +- 用 V2 _t.dat 文件 [0xF:0x1F] 16 字节做模板验证:派生出的 aes_key 把 + 密文 AES-128-ECB 解出图像 magic(JPEG / PNG / GIF / WebP / wxgf)即视为命中 +- 为防短 magic 偶然命中,要求多个不同模板都通过验证才视为成功 + +uin 来源(两条路径,dispatcher 自动 fallback) +---------------------------------------------- +方案1(kvcomm 缓存文件名,主路径): + 读 ~/.../app_data/net/kvcomm/key__*.statistic 提 uin。 + 优点:~毫秒级;缺点:依赖缓存文件,多账号下可能歧义。 + +方案2(wxid 后缀候选搜索,fallback 路径): + 关键洞察:wxid 目录后 4 位 hex == md5(str(uin))[:4]。 + 流程:从 V2 .dat 末字节投票反推 xor_key (假设 JPG EOI = 0xD9) → + 枚举 (uin & 0xff == xor_key) 的 2^24 个候选 → md5 前缀匹配 + 得 ~256 个 uin 候选 → AES 模板验证唯一定位。 + 优点:不依赖 kvcomm,多账号无歧义;缺点:~7 秒(单核 2^24 MD5)。 + +命中后写回 config.json 的 image_aes_key / image_xor_key,monitor_web.py +启动时自动加载,图片消息显示内联预览。 + +致谢 +---- +- 方案1(kvcomm 派生)算法源自 @hicccc77 在 issue #23 的评论,参考实现 + 位于 https://github.com/hicccc77/WeFlow (CC BY-NC-SA 4.0)。 +- 方案2(wxid 后缀候选搜索)思路源自 @H3CoF6 在 issue #68 的评论, + 提供了 "wxid 后 4 位 == md5(uin)[:4]" 这一关键结构性洞察。 + +本模块是独立的 Python 实现,未复制任何上游 TypeScript / C 源码;函数 +边界与变量命名沿用算法的自然结构(regex / MD5 调用顺序 / magic 字节表 +等不可避免地相同)。 + +用法 +---- + python find_image_key_macos.py +""" +import hashlib +import json +import multiprocessing +import os +import platform +import queue as _queue +import re +import sys +import time +from collections import Counter + +from Crypto.Cipher import AES + +# V2 .dat 文件 magic(与 decode_image.py 中 V2_MAGIC_FULL 一致) +V2_MAGIC = bytes.fromhex("070856320807") + +# kvcomm 文件名格式:key__<其他段>.statistic +# code 必须紧跟在 "key_" 之后(不能是 "key_reportnow_..." 这种带前缀的) +_KVCOMM_FILENAME_RE = re.compile(r"^key_(\d+)_.+\.statistic$", re.IGNORECASE) + +# AES 解密结果允许的图像 magic +_IMAGE_MAGICS = ( + b"\xff\xd8\xff", # JPEG + b"\x89\x50\x4e\x47", # PNG + b"GIF", # GIF + b"RIFF", # WebP container(首块只能看前 16B,全检需 [8:12]==b"WEBP") + b"wxgf", # 微信 HEVC GIF / Live Photo +) + + +def normalize_wxid(account_id): + """归一化账号 ID。 + + - wxid_ 形式:保留 wxid_,丢弃后续下划线分段 + - _<4 alnum> 形式:丢弃 _<4 alnum> 后缀(macOS 路径目录名常见) + - 其他:原样返回 + """ + aid = (account_id or "").strip() + if not aid: + return "" + if aid.lower().startswith("wxid_"): + m = re.match(r"^(wxid_[^_]+)", aid, re.IGNORECASE) + return m.group(1) if m else aid + m = re.match(r"^(.+)_([a-zA-Z0-9]{4})$", aid) + return m.group(1) if m else aid + + +def derive_image_keys(code, wxid): + """从 (code, wxid) 派生 (xor_key, aes_key_ascii)。 + + aes_key_ascii 是 16 字符 hex 字符串;调用方按 ASCII 编码取前 16 字节作为 + AES-128 密钥。本函数不做 wxid 归一化(由调用方枚举原值与归一化值)。 + """ + xor_key = int(code) & 0xFF + aes_key = hashlib.md5(f"{code}{wxid}".encode("utf-8")).hexdigest()[:16] + return xor_key, aes_key + + +def derive_kvcomm_dir_candidates(db_dir): + """从 db_dir 推算所有可能的 kvcomm 缓存目录(按优先级排序)。 + + 微信 4.x 在不同版本 / 安装方式下 kvcomm 路径不固定,需要枚举多个候选。 + 返回的列表里至少有一项被 os.path.isdir 确认存在时才算可用。 + """ + parts = db_dir.rstrip(os.sep).split(os.sep) + candidates = [] + if "xwechat_files" in parts: + idx = parts.index("xwechat_files") + documents_root = os.sep.join(parts[:idx]) + # 1) 与 xwechat_files 兄弟目录的 app_data + candidates.append(os.path.join(documents_root, "app_data", "net", "kvcomm")) + # 2) 旧版可能放 xwechat 子目录 + candidates.append(os.path.join(documents_root, "xwechat", "net", "kvcomm")) + # 3) 容器内 Application Support 路径(部分版本) + if idx >= 1: + container_root = os.sep.join(parts[:idx - 1]) # Documents 之上 + candidates.append(os.path.join( + container_root, "Library", "Application Support", + "com.tencent.xinWeChat", "xwechat", "net", "kvcomm")) + candidates.append(os.path.join( + container_root, "Library", "Application Support", + "com.tencent.xinWeChat", "net", "kvcomm")) + # 4) 兜底:HOME 下默认沙盒路径 + home = os.path.expanduser("~") + candidates.append(os.path.join( + home, "Library", "Containers", "com.tencent.xinWeChat", "Data", + "Documents", "app_data", "net", "kvcomm")) + # 去重,保留顺序 + seen = set() + deduped = [] + for c in candidates: + if c not in seen: + seen.add(c) + deduped.append(c) + return deduped + + +def find_existing_kvcomm_dir(db_dir): + """从候选路径中返回第一个存在的 kvcomm 目录;都不存在返回 None。""" + for candidate in derive_kvcomm_dir_candidates(db_dir): + if os.path.isdir(candidate): + return candidate + return None + + +def collect_kvcomm_codes(kvcomm_dir): + """扫 kvcomm 目录,返回去重排序的 code 列表。""" + if not kvcomm_dir or not os.path.isdir(kvcomm_dir): + return [] + codes = set() + try: + names = os.listdir(kvcomm_dir) + except OSError: + return [] + for name in names: + m = _KVCOMM_FILENAME_RE.match(name) + if not m: + continue + try: + code = int(m.group(1)) + except ValueError: + continue + if 0 < code <= 0xFFFFFFFF: + codes.add(code) + return sorted(codes) + + +def collect_wxid_candidates(db_dir): + """从 db_dir 提取候选 wxid(含原值和归一化值)。""" + parts = db_dir.rstrip(os.sep).split(os.sep) + if "xwechat_files" not in parts: + return [] + idx = parts.index("xwechat_files") + if idx + 1 >= len(parts): + return [] + raw = parts[idx + 1] + candidates = [raw] + normalized = normalize_wxid(raw) + if normalized and normalized != raw: + candidates.append(normalized) + return candidates + + +def find_v2_template_ciphertexts(attach_dir, max_templates=3, max_files=64): + """在 attach_dir 下找 V2 .dat 文件的模板密文([0xF:0x1F] 16 字节)。 + + 优先 _t.dat(缩略图小、读得快),找不到再降级用任意 .dat。 + 返回最多 max_templates 个**不同**的密文,用于交叉验证防止短 magic 偶然命中。 + """ + if not attach_dir or not os.path.isdir(attach_dir): + return [] + + def _scan(suffix): + # 出口条件只看是否凑够 max_templates 个**不同**密文;不因为 + # examined 达到 max_files 提前退出 —— 否则若前 64 个文件都是同一 + # 张图的副本,结果只有 1 个 template,交叉验证就退化成单模板。 + out, seen = [], set() + examined = 0 + for root, _, files in os.walk(attach_dir): + for f in files: + if not f.endswith(suffix): + continue + examined += 1 + try: + with open(os.path.join(root, f), "rb") as fp: + data = fp.read(0x20) + except OSError: + continue + if len(data) >= 0x1F and data[:6] == V2_MAGIC: + ct = data[0xF:0x1F] + if ct not in seen: + seen.add(ct) + out.append(ct) + if len(out) >= max_templates: + return out + # 兜底:扫了 max_files 个文件还凑不齐 max_templates 个不同的, + # 提前停止以免在巨型 attach 目录里跑很久(只在 out 不空时才能停) + if examined >= max_files and out: + return out + return out + + return _scan("_t.dat") or _scan(".dat") + + +def verify_aes_key(aes_key_ascii, template_ct): + """AES-128-ECB 解 template_ct(16 字节),检查头部是否是图像 magic。""" + if not aes_key_ascii or not template_ct or len(template_ct) != 16: + return False + key_bytes = aes_key_ascii.encode("ascii", errors="ignore")[:16] + if len(key_bytes) < 16: + return False + try: + cipher = AES.new(key_bytes, AES.MODE_ECB) + decrypted = cipher.decrypt(template_ct) + except (ValueError, KeyError): + return False + return any(decrypted.startswith(m) for m in _IMAGE_MAGICS) + + +def verify_aes_key_against_all(aes_key_ascii, templates): + """在多个模板上交叉验证 aes_key。全部通过才算命中(防短 magic 偶然碰撞)。""" + if not templates: + return False + return all(verify_aes_key(aes_key_ascii, ct) for ct in templates) + + +# ---------- 方案2 (wxid 后缀候选搜索, fallback) ---------- # + +# md5 hex 后缀只可能是 [0-9a-f]; 严格匹配避免误吃非 hex 字符的 wxid 后缀 +# (microsoft 改方案 / 异常路径) 后悄悄返回空候选误导用户。 +_WXID_HEX_SUFFIX_RE = re.compile(r"^(.+)_([0-9a-fA-F]{4})$") + + +def extract_wxid_parts(db_dir): + """从 db_dir 提取 (wxid_full, wxid_norm, suffix)。 + + db_dir 形如 .../xwechat_files/_<4hex>/db_storage + 返回 ('your_wxid_a1b2', 'your_wxid', 'a1b2') 或 None(不匹配 _<4 hex> 后缀)。 + + suffix 是 4 位小写 hex(macOS 路径目录名固定格式 = md5(str(uin))[:4]), + 用作方案2 中候选搜索的 md5 前缀目标。 + """ + wxid_candidates = collect_wxid_candidates(db_dir) + if not wxid_candidates: + return None + wxid_full = wxid_candidates[0] # raw 总是第一个 + m = _WXID_HEX_SUFFIX_RE.match(wxid_full) + if not m: + return None + return wxid_full, m.group(1), m.group(2).lower() + + +def derive_xor_key_from_v2_dat(attach_dir, sample=10, min_samples=3): + """扫多个 V2 .dat 末字节投票反推 xor_key(假设 JPG EOI = 0xD9)。 + + macOS 缩略图 _t.dat 几乎都是 JPG,末字节 = 0xD9 ^ xor_key 反推稳定。 + 投票多数一致才信;分歧大说明假设破灭(不全是 JPG)。 + + Args: + attach_dir: 微信 attach 目录 + sample: 扫到 N 个 V2 .dat 即停止(性能上限) + min_samples: 至少 N 个样本才视为"投票可信"。低于此返回 None, + 避免 1-2 个样本时一旦撞到非 JPG 就 lock 错 xor_key。 + Returns: + (xor_key, votes, total) 或 None (样本不足 / 找不到 V2 .dat)。 + votes < total 时调用方应警告 (假设可能破灭)。 + """ + if not attach_dir or not os.path.isdir(attach_dir): + return None + last_bytes = [] + for root, _, files in os.walk(attach_dir): + for f in files: + if not f.endswith(".dat"): + continue + path = os.path.join(root, f) + try: + if os.path.getsize(path) < 0x20: + continue + with open(path, "rb") as fp: + head = fp.read(6) + if head != V2_MAGIC: + continue + fp.seek(-1, 2) + last = fp.read(1)[0] + last_bytes.append(last ^ 0xD9) + if len(last_bytes) >= sample: + break + except OSError: + continue + if len(last_bytes) >= sample: + break + if len(last_bytes) < min_samples: + return None + top, votes = Counter(last_bytes).most_common(1)[0] + return top, votes, len(last_bytes) + + +def bruteforce_uin_candidates(xor_key, wxid_suffix): + """枚举 0~2^32 中 (uin & 0xff == xor_key) 且 md5(str(uin))[:4] == suffix 的 uin。 + + 单核 ~7-8 秒(2^24 = 16M MD5)。期望命中数 ~256(2^24 / 16^4)。 + + 注意 uin 上限假设为 2^32(4 字节无符号整数)。函数命名沿用密码学 + 候选搜索的 brute-force 术语;中文 prose 用 "枚举 / 候选搜索" 表述。 + + 本函数是单进程 + hex 比较版本, 主要用作算法金标准 (测试) 与 + parallel 路径不可用时的 fallback。生产 dispatcher 走 parallel + 版本 (见 `_bruteforce_with_aes_parallel`)。 + """ + target = wxid_suffix.lower() + out = [] + for uin in range(xor_key, 2 ** 32, 256): + if hashlib.md5(str(uin).encode()).hexdigest()[:4] == target: + out.append(uin) + return out + + +def _aes_template_match(aes_bytes, ciphertext): + """worker 进程内: AES-128-ECB 解 ciphertext 并检查图像 magic。 + + 放模块顶层是为了 multiprocessing pickle (worker 函数必须可 import). + 比 verify_aes_key 更紧凑 (省去 try-except 默认通过短路) — 在百万次 + 调用循环里这点开销有意义。 + """ + try: + decrypted = AES.new(aes_bytes, AES.MODE_ECB).decrypt(ciphertext) + except (ValueError, KeyError): + return False + return any(decrypted.startswith(m) for m in _IMAGE_MAGICS) + + +def _bruteforce_worker_chunk(start, end, xor_key, suffix_bytes, wxid_bytes, + templates, result_queue): + """worker: 扫候选区间, 命中 (md5 前缀 + 全模板 AES) 推入 queue 即返回。 + + 内联做 md5 + AES 验证 (不分两 pass) 让早停在 worker 内有效。 + suffix 用 binary 比 (digest()[:2] vs hexdigest()[:4]), 节省 hex 转换。 + """ + for i in range(start, end): + uin = (i << 8) | xor_key + uin_bytes = str(uin).encode("ascii") + if hashlib.md5(uin_bytes).digest()[:2] == suffix_bytes: + aes_hex = hashlib.md5(uin_bytes + wxid_bytes).hexdigest()[:16] + aes_bytes = aes_hex.encode("ascii") + if all(_aes_template_match(aes_bytes, ct) for ct in templates): + result_queue.put((uin, aes_hex)) + return + + +def _bruteforce_with_aes_parallel(xor_key, suffix_hex, wxid_norm, templates, + workers=None, timeout=60): + """方案2 多进程实现 — 加速思路借鉴自 @H3CoF6 PR #69. + + 与单进程版本的差异: + - cpu_count 个 worker 并行扫 0~2^32 候选 (~5-8x 加速) + - 二进制 md5 digest()[:2] 替代 hexdigest()[:4] (省 hex 转换) + - 内联多模板 AES 验证 (无两 pass; PR #69 是单模板, 本实现保留多模板 + 交叉验证防短 magic 偶然命中) + - 任一 worker 命中即推 queue, 主进程 terminate 其他 (早停) + + Returns: + (uin, aes_key_hex) 或 None (timeout / 全 worker 跑完未命中) + """ + suffix_bytes = bytes.fromhex(suffix_hex) + wxid_bytes = wxid_norm.encode("ascii") + if workers is None: + workers = max(1, multiprocessing.cpu_count()) + total = 1 << 24 + chunk = total // workers + + queue = multiprocessing.Queue() + procs = [] + for i in range(workers): + start_i = i * chunk + end_i = (i + 1) * chunk if i != workers - 1 else total + p = multiprocessing.Process( + target=_bruteforce_worker_chunk, + args=(start_i, end_i, xor_key, suffix_bytes, wxid_bytes, + templates, queue), + daemon=True, + ) + p.start() + procs.append(p) + + found = None + deadline = time.time() + timeout + try: + while any(p.is_alive() for p in procs) and time.time() < deadline: + try: + found = queue.get(timeout=0.1) + break + except _queue.Empty: + continue + # 所有 worker 死亡后 queue 仍可能有最后入队的数据 + if not found: + try: + found = queue.get_nowait() + except _queue.Empty: + pass + finally: + for p in procs: + if p.is_alive(): + p.terminate() + for p in procs: + p.join(timeout=1) + return found + + +# ---------- Dispatcher + 两条路径 ---------- # + +def _find_via_kvcomm(db_dir, templates): + """方案1:从 kvcomm 缓存文件名提 uin 候选。 + + 要求:~/.../app_data/net/kvcomm/key__*.statistic 存在。 + 返回 (xor_key, aes_key) 或 None(kvcomm 缺失 / 无 code / wxid 提不出 / + 所有组合都验证失败)。 + """ + kvcomm_dir = find_existing_kvcomm_dir(db_dir) + if not kvcomm_dir: + print("[!] 方案1: 找不到 kvcomm 缓存目录,已尝试以下候选:", flush=True) + for c in derive_kvcomm_dir_candidates(db_dir): + print(f" {c}", flush=True) + return None + print(f"[+] 方案1: 使用 kvcomm 目录 {kvcomm_dir}", flush=True) + + codes = collect_kvcomm_codes(kvcomm_dir) + if not codes: + print("[!] 方案1: kvcomm 目录无 key_*.statistic 文件", flush=True) + return None + print(f"[+] 方案1: 找到 {len(codes)} 个 uin 候选", flush=True) + + wxid_candidates = collect_wxid_candidates(db_dir) + if not wxid_candidates: + print("[!] 方案1: 无法从 db_dir 提取 wxid", flush=True) + return None + print(f"[+] 方案1: wxid 候选 {wxid_candidates}", flush=True) + + # 穷举顺序:wxid 外、uin 内。多账号系统下当前账号的所有 uin 优先尝试。 + for wxid in wxid_candidates: + for code in codes: + xor_key, aes_key = derive_image_keys(code, wxid) + if verify_aes_key_against_all(aes_key, templates): + print() + print("[OK] 方案1 验证成功(所有模板均通过):", flush=True) + print(f" uin = {code}", flush=True) + print(f" wxid = {wxid}", flush=True) + print(f" xor_key = 0x{xor_key:02x}", flush=True) + print(f" aes_key = {aes_key}", flush=True) + return xor_key, aes_key + + print("[!] 方案1: 所有 (wxid × uin) 组合都未通过交叉验证", flush=True) + return None + + +def _find_via_bruteforce(db_dir, attach_dir, templates): + """方案2 (fallback):从 wxid 后缀候选搜索 uin(不依赖 kvcomm)。 + + 流程:wxid 后缀 + V2 .dat 末字节投票反推 xor_key → 枚举 2^24 个 uin + 候选 → 用 templates 跑 AES 验证唯一定位。 + """ + parts = extract_wxid_parts(db_dir) + if not parts: + print("[!] 方案2: wxid 路径不含 _<4 hex> 后缀,无法应用方案2", flush=True) + return None + wxid_full, wxid_norm, suffix = parts + print(f"[+] 方案2: wxid_full={wxid_full}, suffix={suffix}", flush=True) + + xres = derive_xor_key_from_v2_dat(attach_dir) + if not xres: + print("[!] 方案2: V2 .dat 样本不足 (需 >= 3 个), 无法投票反推 xor_key", + flush=True) + print(" 请先在微信中再看 1-2 张图片,让微信生成更多 V2 .dat 文件", + flush=True) + return None + xor_key, votes, total = xres + if votes == total: + print(f"[+] 方案2: xor_key=0x{xor_key:02x} ({votes}/{total} 一致, 假设 JPG)", + flush=True) + else: + print(f"[!] 方案2: xor_key 投票分歧 {votes}/{total}, 取多数 0x{xor_key:02x} " + f"(可能 attach 不全是 JPG)", flush=True) + + workers = max(1, multiprocessing.cpu_count()) + print(f"[*] 方案2: 多进程枚举 (workers={workers}, 预计 ~1-2 秒)...", + flush=True) + + # 同时试 wxid_full 和 wxid_norm(normalize_wxid 可能去掉后缀) + wxid_tries = [wxid_norm] + if wxid_full != wxid_norm: + wxid_tries.append(wxid_full) + + t0 = time.time() + for wxid_try in wxid_tries: + result = _bruteforce_with_aes_parallel( + xor_key, suffix, wxid_try, templates, workers=workers + ) + if result: + uin, aes_key = result + elapsed = time.time() - t0 + print() + print(f"[OK] 方案2 (fallback) 验证成功 (耗时 {elapsed:.1f}s):", + flush=True) + print(f" uin = {uin}", flush=True) + print(f" wxid = {wxid_try}", flush=True) + print(f" xor_key = 0x{xor_key:02x}", flush=True) + print(f" aes_key = {aes_key}", flush=True) + return xor_key, aes_key + + elapsed = time.time() - t0 + print(f"[!] 方案2: 所有 uin 候选都未通过 AES 验证 (耗时 {elapsed:.1f}s)", + flush=True) + return None + + +def find_image_key_macos(db_dir): + """在 macOS 上派生并交叉验证 V2 图片密钥。 + + Dispatcher:先尝试方案1 (kvcomm),失败 fallback 到方案2 (候选搜索)。 + 两条路径都需要 V2 .dat 模板做 AES 验证 — 模板缺失就直接失败。 + + Returns: + (xor_key, aes_key_ascii) on success;失败返回 None 并打印诊断信息。 + """ + base_dir = os.path.dirname(db_dir) # 去掉 db_storage + attach_dir = os.path.join(base_dir, "msg", "attach") + templates = find_v2_template_ciphertexts(attach_dir) + if not templates: + print(f"[!] 在 {attach_dir} 下找不到 V2 模板文件", flush=True) + print(" 请先在微信中查看 1-2 张图片,让微信生成 V2 .dat 文件", + flush=True) + return None + print(f"[+] 找到 {len(templates)} 个不同模板用于交叉验证", flush=True) + + # 方案1 (主路径): kvcomm 缓存 + result = _find_via_kvcomm(db_dir, templates) + if result is not None: + return result + + # 方案2 (fallback): wxid 后缀候选搜索 + print() + print("[*] 方案1 失败, 尝试方案2 (wxid 后缀候选搜索, fallback)", flush=True) + return _find_via_bruteforce(db_dir, attach_dir, templates) + + +def _save_config_atomic(config_path, config): + """原子写 config.json:tmp + os.replace 防止中断留下半截文件。 + + 若 json.dump 或 os.replace 抛错,向上抛出(让 main 给出 stacktrace + 而不是默默写坏 config);同时清理可能残留的 .tmp 文件。 + """ + tmp_path = config_path + ".tmp" + try: + with open(tmp_path, "w", encoding="utf-8") as f: + json.dump(config, f, indent=2, ensure_ascii=False) + os.replace(tmp_path, config_path) + finally: + # 失败路径上 .tmp 可能残留;成功路径上 os.replace 已经把 tmp 移走了 + if os.path.exists(tmp_path): + try: + os.unlink(tmp_path) + except OSError: + pass + + +def main(config_path=None): + """CLI 入口。`config_path` 默认是脚本同目录下的 config.json, + 暴露此参数主要为方便单元测试注入隔离的临时配置。""" + if platform.system().lower() != "darwin": + print("此脚本只在 macOS 上工作。其他平台请用 find_image_key.py(内存扫描)。", + file=sys.stderr, flush=True) + sys.exit(1) + + if config_path is None: + config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), + "config.json") + try: + with open(config_path, encoding="utf-8") as f: + config = json.load(f) + except (OSError, json.JSONDecodeError) as e: + print(f"[!] 读取 {config_path} 失败: {e}", file=sys.stderr, flush=True) + sys.exit(1) + + db_dir = config.get("db_dir", "") + if not db_dir: + print("[!] config.json 中未配置 db_dir", file=sys.stderr, flush=True) + sys.exit(1) + db_dir = os.path.expanduser(os.path.expandvars(db_dir)) + print(f"[*] db_dir = {db_dir}", flush=True) + + # 短路:如果已有 image_aes_key 且仍能在所有模板上验证通过,直接退出 + # (沿用 find_image_key.py 的 UX 约定,避免无谓重写 config.json) + existing_aes = config.get("image_aes_key") + if existing_aes: + base_dir = os.path.dirname(db_dir) + attach_dir = os.path.join(base_dir, "msg", "attach") + templates = find_v2_template_ciphertexts(attach_dir) + if templates and verify_aes_key_against_all(existing_aes, templates): + print(f"[+] 已有 image_aes_key={existing_aes} 在 " + f"{len(templates)} 个模板上仍然有效,无需重新派生", flush=True) + return + + result = find_image_key_macos(db_dir) + if result is None: + sys.exit(1) + + xor_key, aes_key = result + config["image_aes_key"] = aes_key + config["image_xor_key"] = xor_key + _save_config_atomic(config_path, config) + print() + print(f"[+] 已写入 {config_path}", flush=True) + print(" 下次启动 monitor_web.py 时会自动加载新密钥,图片消息显示内联预览", + flush=True) + + +if __name__ == "__main__": + main() diff --git a/find_image_key_monitor.py b/find_image_key_monitor.py index fb68cee..20a7400 100644 --- a/find_image_key_monitor.py +++ b/find_image_key_monitor.py @@ -235,7 +235,7 @@ def main(): except (FileNotFoundError, json.JSONDecodeError): config_raw = {} - db_dir = config['db_dir'] + db_dir = os.path.expanduser(os.path.expandvars(config['db_dir'])) base_dir = os.path.dirname(db_dir) attach_dir = os.path.join(base_dir, 'msg', 'attach') diff --git a/main.py b/main.py index 885f975..9862af6 100644 --- a/main.py +++ b/main.py @@ -1,14 +1,21 @@ """ WeChat Decrypt 一键启动 -python main.py # 提取密钥 + 启动 Web UI -python main.py decrypt # 提取密钥 + 解密全部数据库 +python main.py # 提取密钥 + 启动 Web UI +python main.py decrypt # 提取密钥 + 解密全部数据库 +python main.py export # 提取密钥 + 解密 + 批量导出聊天记录 +python main.py all # 从零到完成:密钥 → 解密 → 导出 +python main.py status # 显示当前数据状态 """ -import json -import os -import sys import functools +import glob +import json +import os +import platform +import subprocess +import sys + print = functools.partial(print, flush=True) from key_utils import strip_key_metadata @@ -16,6 +23,8 @@ from key_utils import strip_key_metadata def check_wechat_running(): """检查微信是否在运行,返回 True/False""" + if platform.system().lower() == "darwin": + return subprocess.run(["pgrep", "-x", "WeChat"], capture_output=True).returncode == 0 from find_all_keys import get_pids try: get_pids() @@ -24,6 +33,97 @@ def check_wechat_running(): return False +def _run_decode_images(cfg, argv): + """`decode-images` 子命令:批量把 .dat 图片解密成明文图片树。 + + 与 decrypt 不同,decode-images **不需要** 微信进程在运行,也不需要 DB 密钥 + (只读已存在的 .dat 文件;V2 文件用 config.json 里的 image_aes_key)。 + """ + import argparse + from decode_image import decode_all_dats + + parser = argparse.ArgumentParser( + prog="main.py decode-images", + description=( + "批量解密微信本地 .dat 图片到明文图片树。" + "区别于 decode_image.py 单文件 CLI,本子命令扫描 attach_dir 下" + "全部 .dat,镜像目录结构产出明文(jpg / png / gif / webp / hevc)。" + ), + ) + default_base = cfg.get("wechat_base_dir") or os.path.dirname(cfg["db_dir"]) + default_attach = os.path.join(default_base, "msg", "attach") + default_out = cfg.get("decoded_image_dir", "decoded_images") + parser.add_argument( + "--attach-dir", default=None, + help=f"微信 msg/attach 根目录,覆盖默认推断(默认: {default_attach})", + ) + parser.add_argument( + "--decoded-dir", default=None, + help=f"明文图片输出根目录,覆盖 config.json 的 decoded_image_dir(默认: {default_out})", + ) + parser.add_argument( + "--aes-key", default=None, + help="V2 AES key(16 字节 ASCII 字符串),覆盖 config.json 的 image_aes_key", + ) + parser.add_argument( + "--xor-key", default=None, + help="V2 XOR key(可十进制或 0x 十六进制),覆盖 config.json 的 image_xor_key(默认: 0x88)", + ) + parser.add_argument( + "--force", action="store_true", + help="忽略已存在目标重新解密(默认按 basename 跳过)", + ) + args = parser.parse_args(argv) + + attach_dir = args.attach_dir or default_attach + out_dir = args.decoded_dir or default_out + aes_key = args.aes_key if args.aes_key is not None else cfg.get("image_aes_key") + xor_key_raw = args.xor_key if args.xor_key is not None else cfg.get("image_xor_key", 0x88) + if isinstance(xor_key_raw, str): + xor_key = int(xor_key_raw, 0) + else: + xor_key = xor_key_raw + + if not os.path.isdir(attach_dir): + print(f"[ERROR] attach 目录不存在: {attach_dir}", file=sys.stderr) + sys.exit(1) + + if aes_key is None: + print( + "[NOTE] 未配置 image_aes_key,V2 加密图片将被跳过(计入 skipped_no_key);" + "V1 / 老 XOR 图片不受影响。提取 V2 key 见 README 的图片解密章节。", + file=sys.stderr, + ) + + print(f" attach_dir = {attach_dir}") + print(f" out_dir = {out_dir}") + print(f" aes_key = {'已配置' if aes_key else '未配置'}") + print(f" xor_key = 0x{xor_key:02x}") + print(f" force = {args.force}") + print() + + stats = decode_all_dats( + attach_dir=attach_dir, + out_dir=out_dir, + aes_key=aes_key, + xor_key=xor_key, + force=args.force, + ) + + print() + print("=" * 60) + print(f"扫描 {stats['total']} 个 .dat 文件") + print(f" 解码: {stats['decoded']} 跳过(已存在): {stats['skipped']} " + f"无 key 跳过: {stats['skipped_no_key']} 失败: {stats['failed']}") + if stats["formats"]: + fmt_summary = ", ".join(f"{ext}={n}" for ext, n in sorted(stats["formats"].items())) + print(f" 按格式: {fmt_summary}") + print(f"输出在: {out_dir}") + + if stats["failed"] > 0: + sys.exit(2) + + def ensure_keys(keys_file, db_dir): """确保密钥文件存在且匹配当前 db_dir,否则重新提取""" if os.path.exists(keys_file): @@ -32,7 +132,6 @@ def ensure_keys(keys_file, db_dir): keys = json.load(f) except (json.JSONDecodeError, ValueError): keys = {} - # 检查密钥是否匹配当前 db_dir(防止切换账号后误复用旧密钥) saved_dir = keys.pop("_db_dir", None) if saved_dir and os.path.normcase(os.path.normpath(saved_dir)) != os.path.normcase(os.path.normpath(db_dir)): print(f"[!] 密钥文件对应的目录已变更,需要重新提取") @@ -54,7 +153,6 @@ def ensure_keys(keys_file, db_dir): sys.exit(1) print() - # 提取后再次检查 if not os.path.exists(keys_file): print("[!] 密钥提取失败") sys.exit(1) @@ -70,16 +168,126 @@ def ensure_keys(keys_file, db_dir): sys.exit(1) +def show_status(): + """显示当前数据状态""" + cfg = {} + config_file = "config.json" + if os.path.exists(config_file): + with open(config_file, encoding="utf-8") as f: + cfg = json.load(f) + print(f"[config] db_dir = {cfg.get('db_dir', '?')}") + else: + print("[config] 未找到 config.json") + + keys_files = sorted(glob.glob("all_keys*.json")) + print(f"[keys] {len(keys_files)} 个密钥文件") + for kf in keys_files: + sz = os.path.getsize(kf) / 1024 + print(f" {kf} ({sz:.0f} KB)") + + decrypted_dir = cfg.get("decrypted_dir", "decrypted") + if os.path.exists(decrypted_dir): + dbs = glob.glob(os.path.join(decrypted_dir, "**/*.db"), recursive=True) + total_mb = sum(os.path.getsize(f) for f in dbs) / 1024 / 1024 + print(f"[decrypt] {len(dbs)} 个数据库 ({total_mb:.0f} MB)") + # 检查是否有消息内容(约略估计是否已导出) + for db in dbs: + if "message" in os.path.basename(db): + sz = os.path.getsize(db) / 1024 / 1024 + print(f" 消息库: {len([d for d in dbs if 'message' in d])} 个 ({sz:.0f} MB)") + break + else: + print("[decrypt] 未解密 (运行: python main.py decrypt)") + + exported_dir = "exported_chats" + if os.path.exists(exported_dir): + jsons = [f for f in glob.glob(os.path.join(exported_dir, "*.json")) + if not f.endswith("_transcribed.json")] + tx_jsons = glob.glob(os.path.join(exported_dir, "*_transcribed.json")) + total_sz = sum(os.path.getsize(f) for f in jsons) / 1024 / 1024 + print(f"[export] {len(jsons)} 个 JSON ({total_sz:.0f} MB)") + else: + print("[export] 未导出 (运行: python main.py export)") + + if os.path.exists(exported_dir): + total_voice = 0 + total_tx = 0 + for jp in glob.glob(os.path.join(exported_dir, "*_transcribed.json")): + try: + with open(jp, encoding="utf-8") as f: + data = json.load(f) + except Exception: + continue + if isinstance(data, dict) and "chats" in data: + for chat in data["chats"]: + for m in chat.get("messages", []): + if m.get("type") == "voice": + total_voice += 1 + if m.get("transcription"): + total_tx += 1 + elif isinstance(data, dict): + for m in data.get("messages", []): + if m.get("type") == "voice": + total_voice += 1 + if m.get("transcription"): + total_tx += 1 + if total_voice > 0: + pct = total_tx * 100 // max(total_voice, 1) + print(f"[transcribe] {total_tx}/{total_voice} ({pct}%) 条语音已转录") + + # 建议的下一步 + print() + steps = [] + if not os.path.exists(decrypted_dir): + steps.append("python main.py decrypt — 解密数据库") + elif not os.path.exists(exported_dir): + steps.append("main.py export — 导出聊天记录") + if steps: + print("建议的下一步:") + for s in steps: + print(f" {s}") + else: + print("所有步骤已完成。") + + +def print_usage(): + print("用法:") + print(" python main.py 启动实时消息监听 (Web UI)") + print(" python main.py decrypt 解密全部数据库到 decrypted/") + print(" python main.py decode-images 批量解密 .dat 图片到 decoded_image_dir/") + print(" python main.py decode-images --help 查看 decode-images 全部选项") + print(" python main.py export 解密 + 批量导出聊天记录") + print(" python main.py all 从零到完成:密钥 → 解密 → 导出") + print(" python main.py status 显示当前状态和磁盘用量") + + def main(): print("=" * 60) print(" WeChat Decrypt") print("=" * 60) print() - # 1. 加载配置(自动检测 db_dir) + cmd = sys.argv[1] if len(sys.argv) > 1 else "web" + + # help / status 不需要密钥和微信进程 + if cmd in ("help", "-h", "--help"): + print_usage() + return + if cmd in ("status", "-s"): + show_status() + return + + # 以下命令需要配置 + 微信进程 from config import load_config cfg = load_config() + # 早路由:decode-images 不需要微信进程在运行,也不需要 DB 密钥 + if len(sys.argv) > 1 and sys.argv[1] == "decode-images": + print("[*] 批量解密图片...") + print() + _run_decode_images(cfg, sys.argv[2:]) + return + # 2. 检查微信进程 if not check_wechat_running(): print(f"[!] 未检测到微信进程 ({cfg.get('wechat_process', 'WeChat')})") @@ -87,28 +295,52 @@ def main(): sys.exit(1) print("[+] 微信进程运行中") - # 3. 提取密钥 ensure_keys(cfg["keys_file"], cfg["db_dir"]) - # 4. 根据子命令执行 - cmd = sys.argv[1] if len(sys.argv) > 1 else "web" - if cmd == "decrypt": print("[*] 开始解密全部数据库...") print() from decrypt_db import main as decrypt_all decrypt_all() + + elif cmd in ("export", "all"): + print("[*] 开始解密全部数据库...") + print() + from decrypt_db import main as decrypt_all + decrypt_all() + print() + print("[*] 开始批量导出聊天记录...") + print() + from export_all_chats import main as export_all + try: + export_all() + except SystemExit: + pass + + if cmd == "all" and os.path.exists("exported_chats"): + print() + print("[*] 检查语音转录配置...") + from config import load_config + cfg2 = load_config() + from mcp_server import _resolve_active_backend + backend = _resolve_active_backend() + if backend and backend != "local": + print(f" 检测到 backend = {backend}") + print(" 如需转录语音,运行: python export_all_chats.py --with-transcriptions") + else: + print(" 未配置语音转录 backend (config.json 中设置)") + print(" 配置后运行: python export_all_chats.py --with-transcriptions") + elif cmd == "web": print("[*] 启动 Web UI...") print() from monitor_web import main as start_web start_web() + else: print(f"[!] 未知命令: {cmd}") print() - print("用法:") - print(" python main.py 启动实时消息监听 (Web UI)") - print(" python main.py decrypt 解密全部数据库到 decrypted/") + print_usage() sys.exit(1) diff --git a/mcp_server.py b/mcp_server.py index 455cf8f..bc360ba 100644 --- a/mcp_server.py +++ b/mcp_server.py @@ -5,10 +5,13 @@ Based on FastMCP (stdio transport), reuses existing decryption. Runs on Windows Python (needs access to D:\ WeChat databases). """ -import os, sys, json, time, sqlite3, tempfile, struct, hashlib, atexit, re -import hmac as hmac_mod -from contextlib import closing -from datetime import datetime +import io +import os, sys, json, time, sqlite3, tempfile, struct, hashlib, atexit, re, threading, subprocess +import glob +import wave +import hmac as hmac_mod +from contextlib import closing +from datetime import datetime, timedelta import xml.etree.ElementTree as ET from Crypto.Cipher import AES from mcp.server.fastmcp import FastMCP @@ -220,11 +223,21 @@ atexit.register(_cache.cleanup) _contact_names = None # {username: display_name} _contact_full = None # [{username, nick_name, remark}] -_self_username = None -_XML_UNSAFE_RE = re.compile(r'> 3 + wire_type = tag & 0x07 + if wire_type == 0: # varint + while pos < n and data[pos] & 0x80: + pos += 1 + pos += 1 + elif wire_type == 2: # length-delimited + length = 0; shift = 0 + while pos < n: + b = data[pos]; pos += 1 + length |= (b & 0x7f) << shift + if not (b & 0x80): + break + shift += 7 + if field_num == 30: + try: + return data[pos:pos + length].decode('utf-8') + except Exception: + return None + pos += length + elif wire_type == 1: # 64-bit + pos += 8 + elif wire_type == 5: # 32-bit + pos += 4 + else: + break + return None + + +def _load_contact_tags(): + """加载并缓存联系人标签数据""" + global _contact_tags + + db_path = _get_contact_db_path() + if not db_path: + return {} + + if _contact_tags is not None: + return _contact_tags + + try: + conn = sqlite3.connect(db_path) + except Exception: + return {} + + try: + # 1. 加载标签定义 + try: + label_rows = conn.execute( + "SELECT label_id_, label_name_, sort_order_ FROM contact_label ORDER BY sort_order_" + ).fetchall() + except sqlite3.OperationalError: + return {} + if not label_rows: + return {} + + labels = {} + for lid, lname, sort_order in label_rows: + labels[lid] = {'name': lname, 'sort_order': sort_order, 'members': []} + + # 2. 扫描联系人的标签关联 + names = get_contact_names() + rows = conn.execute( + "SELECT username, extra_buffer FROM contact WHERE extra_buffer IS NOT NULL" + ).fetchall() + + for username, buf in rows: + label_str = _extract_pb_field_30(buf) + if not label_str: + continue + display = names.get(username, username) + for lid_s in label_str.split(','): + try: + lid = int(lid_s.strip()) + except (ValueError, AttributeError): + continue + if lid in labels: + labels[lid]['members'].append({'username': username, 'display_name': display}) + + _contact_tags = labels + return _contact_tags + except Exception: + return {} + finally: + conn.close() + + # ============ 辅助函数 ============ def format_msg_type(t): @@ -336,7 +470,11 @@ def _decompress_content(content, ct): def _parse_message_content(content, local_type, is_group): - """解析消息内容,返回 (sender_id, text)""" + """解析消息内容,返回 (sender_id, text)。 + + 群消息 content 形如 'wxid_xxx:\n';某些 type=19 合并转发也会 + 写成 'wxid_xxx: _XML_PARSE_MAX_LEN or _XML_UNSAFE_RE.search(content): +# 合并转发消息(含 recorditem 内嵌 XML)在 dataitem 数量多时显著超过默认 20K 上限, +# 实测真实 outer XML 可达 ~500KB。caller 可通过 max_len 参数为 type=19 类大消息放宽限制。 +_RECORD_XML_PARSE_MAX_LEN = 500_000 + + +def _safe_basename(name): + """对 user-derived filename(从消息 XML 来,不可信)做严格 sanitize。 + + Reject 而不是 normalize:哪怕 os.path.basename 把 '../foo' 剥成 'foo' 是 + safe 的,意图依然可疑,应该显式失败让用户看到。 + """ + if not name: + return '' + if '\x00' in name: + return '' + if os.path.isabs(name): + return '' + # 任何 path separator 或 .. component 直接拒(不做 normalize) + parts = name.replace('\\', '/').split('/') + if any(p in ('', '.', '..') for p in parts) and len(parts) > 1: + return '' + if len(parts) > 1: + return '' + if name in ('.', '..'): + return '' + return name + + +def _path_under_root(path, root): + """resolve realpath 后确认仍在 root 下(防 symlink 跳出)。""" + try: + real_path = os.path.realpath(path) + real_root = os.path.realpath(root) + except OSError: + return False + return real_path == real_root or real_path.startswith(real_root + os.sep) + + +# 大附件 md5 校验时的安全上限:超过此 size 直接拒绝校验(避免 MCP 进程 +# 在 100MB+ 视频/附件上一次性 read() 整文件爆内存或长时间阻塞)。 +_MD5_VERIFY_MAX_SIZE = 500 * 1024 * 1024 # 500 MB +_MD5_CHUNK_SIZE = 64 * 1024 # 64 KB + + +def _md5_file_chunked(path, max_size=_MD5_VERIFY_MAX_SIZE): + """流式分块计算文件 md5,避免大文件一次读完爆内存。 + + 超过 max_size 直接拒绝(DoS 防御 + 大附件 md5 校验现实意义不大)。 + 返回 (md5_hex, error);成功时 error 为 None。 + """ + try: + size = os.path.getsize(path) + except OSError as e: + return None, f"无法读取文件 size: {e}" + if size > max_size: + return None, f"文件 size {size:,} 超过 md5 校验上限 {max_size:,}(防 DoS)" + h = hashlib.md5() + try: + with open(path, 'rb') as f: + while True: + chunk = f.read(_MD5_CHUNK_SIZE) + if not chunk: + break + h.update(chunk) + except OSError as e: + return None, f"读取文件失败: {e}" + return h.hexdigest().lower(), None + + +def _parse_xml_root(content, max_len=_XML_PARSE_MAX_LEN): + if not content or len(content) > max_len or _XML_UNSAFE_RE.search(content): return None try: @@ -459,12 +675,50 @@ def _parse_int(value, fallback=0): return fallback +def _parse_app_message_outer(content): + """Parse outer appmsg XML,对 type=19 合并卡片自动放宽到 _RECORD_XML_PARSE_MAX_LEN。 + + 所有解析 outer appmsg 的 caller(get_chat_history 渲染 / decode_file_message / + decode_record_item)共用此 helper,避免同一条大消息在不同 caller 上行为不一致。 + Substring 短路保证非 type=19 的大 appmsg 不付出 500K parse 代价。""" + root = _parse_xml_root(content) + if root is None and content and len(content) <= _RECORD_XML_PARSE_MAX_LEN: + if '19' in content: + root = _parse_xml_root(content, max_len=_RECORD_XML_PARSE_MAX_LEN) + return root + + +def _format_namecard_text(content): + """Parse type=42 (名片) XML into a compact human-readable line. + + Source XML carries dozens of fields (antispamticket, biznamecardinfo, + brand URLs, image MD5s) but the useful signal is just three attrs: + ``nickname`` (display name), ``username`` (wxid; ``gh_*`` for 公众号), + and ``certinfo`` (the user-authored bio). Everything else is either + auth tokens that should not be piped to downstream systems, or + rendering metadata that bloats the chat log without helping a human + or an LLM understand the conversation. + """ + root = _parse_xml_root(content) + if root is None: + return None + nickname = (root.get("nickname") or "").strip() + username = (root.get("username") or "").strip() + certinfo = _collapse_text(root.get("certinfo") or "") + if not nickname and not username: + return None + head = nickname or username + if username.startswith("gh_"): + head = f"{head} (公众号 {username})" + return f"[名片] {head}: {certinfo}" if certinfo else f"[名片] {head}" + + def _format_app_message_text(content, local_type, is_group, chat_username, chat_display_name, names): if not content or ' 160: - ref_content = ref_content[:160] + "..." + return _format_refer_message_text( + appmsg, is_group, chat_username, chat_display_name, names + ) - quote_text = title or "[引用消息]" - if ref_content: - ref_label = _resolve_quote_sender_label( - ref_user, ref_display_name, is_group, chat_username, chat_display_name, names - ) - prefix = f"回复 {ref_label}: " if ref_label else "回复: " - quote_text += f"\n ↳ {prefix}{ref_content}" - return quote_text + if app_type == 19: + return _format_record_message_text(appmsg, title) + + if app_type == 2000: + return _format_transfer_message_text(appmsg, title) if app_type == 6: return f"[文件] {title}" if title else "[文件]" @@ -508,6 +752,311 @@ def _format_app_message_text(content, local_type, is_group, chat_username, chat_ return "[链接/文件]" +_RECORD_MAX_ITEMS = 50 +_RECORD_MAX_LINE_LEN = 200 + +# 合并转发 dataitem 的 datatype → wechat 缓存子目录映射。仅这 4 类有真本地 +# binary 文件;其他 datatype(链接/名片/小程序/视频号 等)只有 metadata。 +_RECORD_BINARY_SUBDIR = {'8': 'F', '2': 'Img', '5': 'V', '4': 'A'} + +# datatype → 中文标签,散在多处使用:渲染合并卡片 / decode_record_item 的 +# 错误提示 / 单元测试。统一在模块顶部维护避免漂移。 +_RECORD_DATATYPE_LABEL = { + '1': '文本', '2': '图片', '3': '名片', '4': '语音', + '5': '视频', '6': '链接', '7': '位置', '8': '文件', + '17': '聊天记录', '19': '小程序', '22': '视频号', + '23': '视频号直播', '29': '音乐', '36': '小程序/H5', + '37': '表情包', +} + + +def _format_record_dataitem(item): + """格式化合并记录中的单个 dataitem,返回展示文本。""" + datatype = (item.get('datatype') or '').strip() + + if datatype == '1': + return _collapse_text(item.findtext('datadesc') or '') or '[文本]' + if datatype in ('2', '3', '4', '5', '7', '23', '37'): + return f"[{_RECORD_DATATYPE_LABEL[datatype]}]" + if datatype in ('6', '36'): + link_title = _collapse_text(item.findtext('datatitle') or '') + label = _RECORD_DATATYPE_LABEL[datatype] + return f"[{label}] {link_title}" if link_title else f"[{label}]" + if datatype == '8': + file_title = _collapse_text(item.findtext('datatitle') or '') + return f"[文件] {file_title}" if file_title else '[文件]' + if datatype == '17': + nested_title = _collapse_text(item.findtext('datatitle') or '') + return f"[聊天记录] {nested_title}" if nested_title else '[聊天记录]' + if datatype == '19': + # 小程序:appbranditem/sourcedisplayname 是直接子代,不需要 .// 递归 + app_name = _collapse_text(item.findtext('appbranditem/sourcedisplayname') or '') + item_title = _collapse_text(item.findtext('datatitle') or '') + label = item_title or app_name or '小程序' + return f"[小程序] {label}" + if datatype == '22': + feed_desc = _collapse_text(item.findtext('finderFeed/desc') or '') + return f"[视频号] {feed_desc[:80]}" if feed_desc else '[视频号]' + if datatype == '29': + song = _collapse_text(item.findtext('datatitle') or '') + artist = _collapse_text(item.findtext('datadesc') or '') + if song and artist: + return f"[音乐] {song} - {artist}" + return f"[音乐] {song}" if song else '[音乐]' + + desc = _collapse_text(item.findtext('datadesc') or '') + title_text = _collapse_text(item.findtext('datatitle') or '') + fallback = desc or title_text + return fallback if fallback else f"[未知类型 {datatype}]" + + +def _format_record_message_text(appmsg, title): + """解析合并转发的聊天记录卡片(appmsg type=19, recorditem)。""" + fallback_title = title or '聊天记录' + record_node = appmsg.find('recorditem') + if record_node is None or not record_node.text: + return f"[聊天记录] {fallback_title}(待加载)" + + inner = _parse_xml_root(record_node.text, max_len=_RECORD_XML_PARSE_MAX_LEN) + if inner is None: + return f"[聊天记录] {fallback_title}" + + record_title = _collapse_text(inner.findtext('title') or '') or fallback_title + is_chatroom = (inner.findtext('isChatRoom') or '').strip() == '1' + datalist = inner.find('datalist') + items = list(datalist.findall('dataitem')) if datalist is not None else [] + if not items: + suffix = "(群聊转发,待加载)" if is_chatroom else "(待加载)" + return f"[聊天记录] {record_title}{suffix}" + + header = f"[聊天记录] {record_title}" + if is_chatroom: + header += "(群聊转发)" + header += f",共 {len(items)} 条" + + lines = [header + ":"] + for idx, item in enumerate(items[:_RECORD_MAX_ITEMS]): + sender = _collapse_text(item.findtext('sourcename') or '') + when = _collapse_text(item.findtext('sourcetime') or '') + content = _format_record_dataitem(item) + + if len(content) > _RECORD_MAX_LINE_LEN: + content = content[:_RECORD_MAX_LINE_LEN] + '…' + + # 0-based index 让用户能用 decode_record_item(chat, local_id, item_index) 引用 + prefix_parts = [f"[{idx}]"] + [p for p in (when, sender) if p] + prefix = ' '.join(prefix_parts) + lines.append(f" {prefix}: {content}") + + if len(items) > _RECORD_MAX_ITEMS: + lines.append(f" …(还有 {len(items) - _RECORD_MAX_ITEMS} 条未显示)") + + return "\n".join(lines) + + +# 微信转账 (appmsg type=2000, ) paysubtype 含义。 +# 微信官方无公开文档,此表来自社区抓包归纳。1/3/4 在所有已知版本一致; +# 5/7/8 在不同版本存在变体("过期已退还"在某些抓包里也归为 4),所以遇到 +# 未识别值时降级显示原始数字,方便用户自行核对。 +_TRANSFER_PAYSUBTYPE_LABEL = { + '1': '发起转账', # 发送方记录:等待对方收钱 + '3': '已收款', # 双向:发送方看到"对方已收",接收方看到"已收钱" + '4': '已退还', # 主动退还或被退还 + '5': '过期已退还', # 24h 未收,自动退还(发送方记录) + '7': '待领取', # 已发起未接收 + '8': '已领取', # 部分版本:转账被领取(接收方记录) +} + + +# 微信引用回复(appmsg type=57, )内层 的标签映射。 +# refermsg/ 用的是顶层 base_type 数字(跟 format_msg_type 重合), +# 但语义不同:format_msg_type 给"消息类型 chip",这里给"被引用消息的一行摘要", +# 不展开 cdn url / aeskey / md5 等二进制元数据(直接截断 XML 字符串当摘要是 +# 现状的 bug,会把"图片/语音/视频/动画表情/嵌套卡片"渲染成乱码——见 issue #44 #45)。 +_REFER_INNER_TYPE_LABEL = { + '1': '文本', # 特殊:直接展开 content + '3': '图片', + '34': '语音', + '42': '名片', + '43': '视频', + '47': '动画表情', + '48': '位置', + '49': '链接/卡片', # 特殊:嵌套 appmsg,进一步解 inner type + '50': '通话', +} + +# refer_type=49 时 content 是嵌套 ...,inner appmsg/ → 标签。 +# 跟合并转发 _RECORD_DATATYPE_LABEL 的数字含义不同(datatype 是 recorditem 的私有 +# schema),独立维护。 +_INNER_APPMSG_TYPE_LABEL = { + '5': '链接', '6': '文件', '8': '动画表情卡', + '19': '聊天记录', '33': '小程序', '36': '小程序', + '51': '视频号', '57': '引用消息', + '2000': '转账', '2001': '红包', +} + + +def _extract_refer_info(appmsg): + """从 appmsg type=57 解出 refermsg 各字段,返回 dict 或 None。 + + refermsg/ 是 escape 后的字符串,内层 type 决定其 schema: + type=1 (纯文本) / 3 (img cdn) / 34 (voicemsg) / 47 (emoji) + / 49 (嵌套 appmsg) / ... + + refer_content 保留原始字符串(不 collapse),让 _summarize_refer_content + 按 type 进一步处理(type=49 还要再解一层 XML)。其他字段过 _collapse_text + 清掉换行/前后空白。 + """ + refer = appmsg.find('refermsg') + if refer is None: + return None + + return { + 'reply_text': _collapse_text(appmsg.findtext('title') or ''), + 'refer_type': _collapse_text(refer.findtext('type') or ''), + 'refer_svrid': _collapse_text(refer.findtext('svrid') or ''), + 'refer_fromusr': _collapse_text(refer.findtext('fromusr') or ''), + 'refer_chatusr': _collapse_text(refer.findtext('chatusr') or ''), + 'refer_displayname': _collapse_text(refer.findtext('displayname') or ''), + 'refer_content': refer.findtext('content') or '', + 'refer_createtime': _collapse_text(refer.findtext('createtime') or ''), + } + + +def _summarize_refer_content(refer_type, content, max_len=160): + """把被引用消息的 content 摘要成一行可读文本。 + + 分支规则: + type=1 (文本): 取原文,截断到 max_len + type=3/34/43/47/...: 给标签兜底,不展开 cdn url / aeskey / md5 + type=49 (嵌套 appmsg): 解一层 inner appmsg/type + title,给"[链接] xxx" + 未识别 type: 给 [type=N] 兜底,方便用户自查 + + max_len 只对 type=1 文本生效;标签型摘要本身就短。 + """ + refer_type = (refer_type or '').strip() + + if not content: + label = _REFER_INNER_TYPE_LABEL.get(refer_type) + if label: + return f'[{label}]' + return f'[type={refer_type}]' if refer_type else '[引用消息]' + + if refer_type == '1': + text = _collapse_text(content) + return text[:max_len] + '…' if len(text) > max_len else text + + if refer_type == '49': + # 嵌套 appmsg:content 是来源不可信的微信侧 payload,走 _parse_xml_root + # 经 _XML_UNSAFE_RE 过滤 DOCTYPE/ENTITY 防 XXE 注入。 + inner_root = _parse_xml_root(content) + if inner_root is None: + return '[卡片]' + inner_appmsg = inner_root.find('.//appmsg') + if inner_appmsg is None: + return '[卡片]' + inner_type = _collapse_text(inner_appmsg.findtext('type') or '') + inner_title = _collapse_text(inner_appmsg.findtext('title') or '') + label = _INNER_APPMSG_TYPE_LABEL.get( + inner_type, f'卡片 type={inner_type}' if inner_type else '卡片' + ) + return f'[{label}] {inner_title}' if inner_title else f'[{label}]' + + label = _REFER_INNER_TYPE_LABEL.get(refer_type) + if label: + return f'[{label}]' + return f'[type={refer_type}]' + + +def _format_refer_message_text(appmsg, is_group, chat_username, chat_display_name, names): + """渲染微信引用回复(appmsg type=57)的两行展示文本。 + + 格式: + <用户的回复正文> + ↳ 回复 <对方>: <被引用消息摘要> + + fallback: + 1) refermsg 缺失 → 退回到外层 title 兜底 + 2) refer_content 空 → summary 给"[refer_type 标签]"或"[引用消息]" + 3) sender 解析不出来 → "回复:" 不带名字 + """ + info = _extract_refer_info(appmsg) + if info is None: + title = _collapse_text(appmsg.findtext('title') or '') + return title or '[引用消息]' + + summary = _summarize_refer_content(info['refer_type'], info['refer_content']) + sender_label = _resolve_quote_sender_label( + info['refer_fromusr'], info['refer_displayname'], + is_group, chat_username, chat_display_name, names + ) + + quote_text = info['reply_text'] or '[引用消息]' + prefix = f'回复 {sender_label}: ' if sender_label else '回复: ' + quote_text += f'\n ↳ {prefix}{summary}' + return quote_text + + +def _extract_transfer_info(appmsg): + """从 appmsg type=2000 解出 wcpayinfo 各字段,返回 dict 或 None。 + + 字段大小写在不同微信版本间漂移(见过 feedesc/feeDesc, pay_memo/paymemo), + 用 lower-case 兜底。所有值用 _collapse_text 清掉换行/前后空白。 + """ + info = appmsg.find('wcpayinfo') + if info is None: + return None + + def _pick(*tags): + for t in tags: + v = _collapse_text(info.findtext(t) or '') + if v: + return v + return '' + + paysubtype = _pick('paysubtype') + return { + 'paysubtype': paysubtype, + 'paysubtype_label': _TRANSFER_PAYSUBTYPE_LABEL.get( + paysubtype, f'未知(paysubtype={paysubtype})' if paysubtype else '' + ), + # feedesc 通常是 "¥0.01" 风格的展示串;feedescxml 是富文本变体 + 'fee_desc': _pick('feedesc', 'feeDesc'), + 'pay_memo': _pick('pay_memo', 'paymemo'), + # 三种交易号:transcationid 是微信支付侧(注意拼写是 transc 不是 trans), + # transferid 是微信内部转账 id,paymsgid 偶见于旧版本 + 'transcation_id': _pick('transcationid', 'transcationId'), + 'transfer_id': _pick('transferid', 'transferId'), + 'pay_msg_id': _pick('paymsgid', 'payMsgId'), + 'begin_transfer_time': _pick('begintransfertime', 'beginTransferTime'), + 'invalid_time': _pick('invalidtime', 'invalidTime'), + 'effective_date': _pick('effectivedate', 'effectiveDate'), + 'payer_username': _pick('payer_username', 'payerUsername'), + 'receiver_username': _pick('receiver_username', 'receiverUsername'), + } + + +def _format_transfer_message_text(appmsg, title): + """渲染微信转账(appmsg type=2000)一行展示文本,给 history / monitor_web 共用。 + + fallback 顺序: + 1) wcpayinfo 缺失 → 只显示 title 兜底,避免吞数据 + 2) paysubtype 未知 → 显示原始数字让用户自查 + 3) 没有 fee_desc → 至少给个方向标签 + """ + info = _extract_transfer_info(appmsg) + if not info: + return f"[转账] {title}" if title else "[转账]" + + label = info['paysubtype_label'] or '转账' + parts = [f"[转账·{label}]"] if label != '转账' else ["[转账]"] + if info['fee_desc']: + parts.append(info['fee_desc']) + if info['pay_memo']: + parts.append(f"备注: {info['pay_memo']}") + return ' '.join(parts) + + def _format_voip_message_text(content): if not content or ' limit_max: - raise ValueError(f"limit 不能大于 {limit_max}") - if offset < 0: - raise ValueError("offset 不能小于 0") +def _find_msg_tables_for_user(username): + """返回用户在所有 message_N.db 中对应的消息表,按最新消息时间倒序排列。""" + table_hash = hashlib.md5(username.encode()).hexdigest() + table_name = f"Msg_{table_hash}" + if not _is_safe_msg_table_name(table_name): + return [] + + matches = [] + for rel_key in MSG_DB_KEYS: + path = _cache.get(rel_key) + if not path: + continue + conn = sqlite3.connect(path) + try: + exists = conn.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", + (table_name,) + ).fetchone() + if not exists: + continue + max_create_time = conn.execute( + f"SELECT MAX(create_time) FROM [{table_name}]" + ).fetchone()[0] or 0 + matches.append({ + 'db_path': path, + 'table_name': table_name, + 'max_create_time': max_create_time, + }) + except Exception: + pass + finally: + conn.close() + + matches.sort(key=lambda item: item['max_create_time'], reverse=True) + return matches + + +def _validate_pagination(limit, offset=0, limit_max=_QUERY_LIMIT_MAX): + if limit <= 0: + raise ValueError("limit 必须大于 0") + if limit_max is not None and limit > limit_max: + raise ValueError(f"limit 不能大于 {limit_max}") + if offset < 0: + raise ValueError("offset 不能小于 0") def _parse_time_value(value, field_name, is_end=False): @@ -677,7 +1262,53 @@ def _parse_time_range(start_time='', end_time=''): return start_ts, end_ts -def _build_message_filters(start_ts=None, end_ts=None, keyword=''): +def _pagination_hint(count, limit, offset): + """当返回结果数 == limit 时,提示调用方可能还有更多。 + + 用于工具返回字符串末尾,帮助 LLM 决定是否需要继续翻页。 + 返回结果数 < limit 表示已读到当前查询条件下的全部结果,不再提示。 + """ + if limit and count >= limit: + return f"\n\n(可能还有更多结果,可设 offset={offset + limit} 继续查询)" + return "" + + +_MSG_TYPE_MAP = { + 'text': [1], + 'image': [3], + 'voice': [34], + 'namecard': [42], + 'video': [43], + 'emoji': [47], + 'location': [48], + 'app': [49], + 'voip': [50], + 'system': [10000], +} + + +def _resolve_msg_types(msg_types): + """把 ['text', 'image'] 风格的输入翻成 local_type 整数列表。 + + 返回 (type_filter_list, error_msg); 任一项无效返回 (None, error)。 + None / 空列表表示不过滤。 + """ + if not msg_types: + return None, None + type_filter = [] + for t in msg_types: + key = t.strip().lower() + if key == 'file': + key = 'app' # 'file' 是常见叫法; WeChat 把文件归到 type=49 (app message) + if key not in _MSG_TYPE_MAP: + return None, ( + f"未知消息类型 \"{t}\"。可选: " + ", ".join(sorted(_MSG_TYPE_MAP)) + ) + type_filter.extend(_MSG_TYPE_MAP[key]) + return type_filter, None + + +def _build_message_filters(start_ts=None, end_ts=None, keyword='', type_filter=None): clauses = [] params = [] if start_ts is not None: @@ -689,62 +1320,67 @@ def _build_message_filters(start_ts=None, end_ts=None, keyword=''): if keyword: clauses.append('message_content LIKE ?') params.append(f'%{keyword}%') + if type_filter: + placeholders = ','.join('?' * len(type_filter)) + clauses.append(f'local_type IN ({placeholders})') + params.extend(type_filter) return clauses, params -def _query_messages(conn, table_name, start_ts=None, end_ts=None, keyword='', limit=20, offset=0): - if not _is_safe_msg_table_name(table_name): - raise ValueError(f'非法消息表名: {table_name}') - - clauses, params = _build_message_filters(start_ts, end_ts, keyword) - where_sql = f"WHERE {' AND '.join(clauses)}" if clauses else '' - sql = f""" - SELECT local_id, local_type, create_time, real_sender_id, message_content, - WCDB_CT_message_content - FROM [{table_name}] - {where_sql} - ORDER BY create_time DESC - """ - if limit is None: - return conn.execute(sql, params).fetchall() - sql += "\n LIMIT ? OFFSET ?" - return conn.execute(sql, (*params, limit, offset)).fetchall() +def _query_messages(conn, table_name, start_ts=None, end_ts=None, keyword='', limit=20, offset=0, oldest_first=False, type_filter=None): + if not _is_safe_msg_table_name(table_name): + raise ValueError(f'非法消息表名: {table_name}') + + clauses, params = _build_message_filters(start_ts, end_ts, keyword, type_filter) + where_sql = f"WHERE {' AND '.join(clauses)}" if clauses else '' + order = 'ASC' if oldest_first else 'DESC' + sql = f""" + SELECT local_id, local_type, create_time, real_sender_id, message_content, + WCDB_CT_message_content + FROM [{table_name}] + {where_sql} + ORDER BY create_time {order} + """ + if limit is None: + return conn.execute(sql, params).fetchall() + sql += "\n LIMIT ? OFFSET ?" + return conn.execute(sql, (*params, limit, offset)).fetchall() -def _resolve_chat_context(chat_name): - username = resolve_username(chat_name) - if not username: - return None - - names = get_contact_names() - display_name = names.get(username, username) - message_tables = _find_msg_tables_for_user(username) - if not message_tables: - return { - 'query': chat_name, - 'username': username, - 'display_name': display_name, - 'db_path': None, - 'table_name': None, - 'message_tables': [], - 'is_group': '@chatroom' in username, - } - - primary = message_tables[0] - return { - 'query': chat_name, - 'username': username, - 'display_name': display_name, - 'db_path': primary['db_path'], - 'table_name': primary['table_name'], - 'message_tables': message_tables, - 'is_group': '@chatroom' in username, - } +def _resolve_chat_context(chat_name): + username = resolve_username(chat_name) + if not username: + return None + + names = get_contact_names() + display_name = names.get(username, username) + message_tables = _find_msg_tables_for_user(username) + if not message_tables: + return { + 'query': chat_name, + 'username': username, + 'display_name': display_name, + 'db_path': None, + 'table_name': None, + 'message_tables': [], + 'is_group': '@chatroom' in username, + } + + primary = message_tables[0] + return { + 'query': chat_name, + 'username': username, + 'display_name': display_name, + 'db_path': primary['db_path'], + 'table_name': primary['table_name'], + 'message_tables': message_tables, + 'is_group': '@chatroom' in username, + } -def _resolve_chat_contexts(chat_names): - if not chat_names: - raise ValueError('chat_names 不能为空') +def _resolve_chat_contexts(chat_names): + if not chat_names: + raise ValueError('chat_names 不能为空') resolved = [] unresolved = [] @@ -760,57 +1396,58 @@ def _resolve_chat_contexts(chat_names): if not ctx: unresolved.append(name) continue - if not ctx['message_tables']: - missing_tables.append(ctx['display_name']) - continue - if ctx['username'] in seen: - continue + if not ctx['message_tables']: + missing_tables.append(ctx['display_name']) + continue + if ctx['username'] in seen: + continue seen.add(ctx['username']) resolved.append(ctx) - - return resolved, unresolved, missing_tables - - -def _normalize_chat_names(chat_name): - if chat_name is None: - return [] - if isinstance(chat_name, str): - value = chat_name.strip() - return [value] if value else [] - if isinstance(chat_name, (list, tuple, set)): - normalized = [] - for item in chat_name: - if item is None: - continue - value = str(item).strip() - if value: - normalized.append(value) - return normalized - value = str(chat_name).strip() - return [value] if value else [] + + return resolved, unresolved, missing_tables -def _format_history_lines(rows, username, display_name, is_group, names, id_to_username): - lines = [] - ctx = { - 'username': username, - 'display_name': display_name, - 'is_group': is_group, - } - for row in reversed(rows): - _, line = _build_history_line(row, ctx, names, id_to_username) - lines.append(line) - return lines +def _normalize_chat_names(chat_name): + if chat_name is None: + return [] + if isinstance(chat_name, str): + value = chat_name.strip() + return [value] if value else [] + if isinstance(chat_name, (list, tuple, set)): + normalized = [] + for item in chat_name: + if item is None: + continue + value = str(item).strip() + if value: + normalized.append(value) + return normalized + value = str(chat_name).strip() + return [value] if value else [] -def _build_search_entry(row, ctx, names, id_to_username): +def _format_history_lines(rows, username, display_name, is_group, names, id_to_username): + lines = [] + ctx = { + 'username': username, + 'display_name': display_name, + 'is_group': is_group, + } + for row in reversed(rows): + _, line = _build_history_line(row, ctx, names, id_to_username) + lines.append(line) + return lines + + +def _build_search_entry(row, ctx, names, id_to_username): local_id, local_type, create_time, real_sender_id, content, ct = row content = _decompress_content(content, ct) if content is None: return None sender, text = _format_message_text( - local_id, local_type, content, ctx['is_group'], ctx['username'], ctx['display_name'], names + local_id, local_type, content, ctx['is_group'], ctx['username'], ctx['display_name'], names, + create_time=create_time, ) if text and len(text) > 300: text = text[:300] + '...' @@ -828,346 +1465,349 @@ def _build_search_entry(row, ctx, names, id_to_username): entry = f"[{time_str}] [{ctx['display_name']}]" if sender_label: entry += f" {sender_label}:" - entry += f" {text}" - return create_time, entry - - -def _build_history_line(row, ctx, names, id_to_username): - local_id, local_type, create_time, real_sender_id, content, ct = row - time_str = datetime.fromtimestamp(create_time).strftime('%Y-%m-%d %H:%M') - content = _decompress_content(content, ct) - if content is None: - content = '(无法解压)' - - sender, text = _format_message_text( - local_id, local_type, content, ctx['is_group'], ctx['username'], ctx['display_name'], names - ) - - sender_label = _resolve_sender_label( - real_sender_id, sender, ctx['is_group'], ctx['username'], ctx['display_name'], names, id_to_username - ) - if sender_label: - return create_time, f'[{time_str}] {sender_label}: {text}' - return create_time, f'[{time_str}] {text}' - - -def _get_chat_message_tables(ctx): - if ctx.get('message_tables'): - return ctx['message_tables'] - if ctx.get('db_path') and ctx.get('table_name'): - return [{'db_path': ctx['db_path'], 'table_name': ctx['table_name']}] - return [] - - -def _iter_table_contexts(ctx): - for table in _get_chat_message_tables(ctx): - yield { - 'query': ctx['query'], - 'username': ctx['username'], - 'display_name': ctx['display_name'], - 'db_path': table['db_path'], - 'table_name': table['table_name'], - 'is_group': ctx['is_group'], - } - - -def _candidate_page_size(limit, offset): - return limit + offset - - -def _message_query_batch_size(candidate_limit): - return candidate_limit - - -def _history_query_batch_size(candidate_limit): - return min(candidate_limit, _HISTORY_QUERY_BATCH_SIZE) - - -def _page_ranked_entries(entries, limit, offset): - ordered = sorted(entries, key=lambda item: item[0], reverse=True) - paged = ordered[offset:offset + limit] - paged.sort(key=lambda item: item[0]) - return paged - - -def _collect_chat_history_lines(ctx, names, start_ts=None, end_ts=None, limit=20, offset=0): - collected = [] - failures = [] - candidate_limit = _candidate_page_size(limit, offset) - batch_size = _history_query_batch_size(candidate_limit) - - for table_ctx in _iter_table_contexts(ctx): - try: - with closing(sqlite3.connect(table_ctx['db_path'])) as conn: - id_to_username = _load_name2id_maps(conn) - fetch_offset = 0 - collected_before_table = len(collected) - # 当前页上的消息一定落在各分表最近的 offset+limit 条记录内。 - while len(collected) - collected_before_table < candidate_limit: - rows = _query_messages( - conn, - table_ctx['table_name'], - start_ts=start_ts, - end_ts=end_ts, - limit=batch_size, - offset=fetch_offset, - ) - if not rows: - break - fetch_offset += len(rows) - - for row in rows: - try: - collected.append(_build_history_line(row, table_ctx, names, id_to_username)) - except Exception as e: - failures.append( - f"{table_ctx['display_name']} local_id={row[0]} create_time={row[2]}: {e}" - ) - if len(collected) - collected_before_table >= candidate_limit: - break - - if len(rows) < batch_size: - break - except Exception as e: - failures.append(f"{table_ctx['db_path']}: {e}") - - paged = _page_ranked_entries(collected, limit, offset) - return [line for _, line in paged], failures - - -def _collect_chat_search_entries(ctx, names, keyword, start_ts=None, end_ts=None, candidate_limit=20): - collected = [] - failures = [] - contexts_by_db = {} - for table_ctx in _iter_table_contexts(ctx): - contexts_by_db.setdefault(table_ctx['db_path'], []).append(table_ctx) - - for db_path, db_contexts in contexts_by_db.items(): - try: - with closing(sqlite3.connect(db_path)) as conn: - db_entries, db_failures = _collect_search_entries( - conn, - db_contexts, - names, - keyword, - start_ts=start_ts, - end_ts=end_ts, - candidate_limit=candidate_limit, - ) - collected.extend(db_entries) - failures.extend(db_failures) - except Exception as e: - failures.extend(f"{table_ctx['display_name']}: {e}" for table_ctx in db_contexts) - - return collected, failures - - -def _load_search_contexts_from_db(conn, db_path, names): - tables = conn.execute( - "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg_%'" - ).fetchall() - - table_to_username = {} - try: - for (user_name,) in conn.execute("SELECT user_name FROM Name2Id").fetchall(): - if not user_name: - continue - table_hash = hashlib.md5(user_name.encode()).hexdigest() - table_to_username[f"Msg_{table_hash}"] = user_name - except sqlite3.Error: - pass - - contexts = [] - for (table_name,) in tables: - username = table_to_username.get(table_name, '') - display_name = names.get(username, username) if username else table_name - contexts.append({ - 'query': display_name, - 'username': username, - 'display_name': display_name, - 'db_path': db_path, - 'table_name': table_name, - 'is_group': '@chatroom' in username, - }) - return contexts - - -def _collect_search_entries(conn, contexts, names, keyword, start_ts=None, end_ts=None, candidate_limit=20): - collected = [] - failures = [] - id_to_username = _load_name2id_maps(conn) - batch_size = _message_query_batch_size(candidate_limit) - - for ctx in contexts: - try: - fetch_offset = 0 - collected_before_table = len(collected) - # 全局分页只需要每个分表最新的 offset+limit 条有效命中,无需把整表命中读进内存。 - while len(collected) - collected_before_table < candidate_limit: - rows = _query_messages( - conn, - ctx['table_name'], - start_ts=start_ts, - end_ts=end_ts, - keyword=keyword, - limit=batch_size, - offset=fetch_offset, - ) - if not rows: - break - fetch_offset += len(rows) - - for row in rows: - formatted = _build_search_entry(row, ctx, names, id_to_username) - if formatted: - collected.append(formatted) - if len(collected) - collected_before_table >= candidate_limit: - break - - if len(rows) < batch_size: - break - except Exception as e: - failures.append(f"{ctx['display_name']}: {e}") - - return collected, failures - - -def _page_search_entries(entries, limit, offset): - return _page_ranked_entries(entries, limit, offset) - - -def _search_single_chat(ctx, keyword, start_ts, end_ts, start_time, end_time, limit, offset): - names = get_contact_names() - candidate_limit = _candidate_page_size(limit, offset) - - entries, failures = _collect_chat_search_entries( - ctx, - names, - keyword, - start_ts=start_ts, - end_ts=end_ts, - candidate_limit=candidate_limit, - ) - - paged = _page_search_entries(entries, limit, offset) - - if not paged: - if failures: - return "查询失败: " + ";".join(failures) - return f"未在 {ctx['display_name']} 中找到包含 \"{keyword}\" 的消息" - - header = f"在 {ctx['display_name']} 中搜索 \"{keyword}\" 找到 {len(paged)} 条结果(offset={offset}, limit={limit})" - if start_time or end_time: - header += f"\n时间范围: {start_time or '最早'} ~ {end_time or '最新'}" - if failures: - header += "\n查询失败: " + ";".join(failures) - return header + ":\n\n" + "\n\n".join(item[1] for item in paged) - - -def _search_multiple_chats(chat_names, keyword, start_ts, end_ts, start_time, end_time, limit, offset): - try: - resolved_contexts, unresolved, missing_tables = _resolve_chat_contexts(chat_names) - except ValueError as e: - return f"错误: {e}" - - if not resolved_contexts: - details = [] - if unresolved: - details.append("未找到联系人: " + "、".join(unresolved)) - if missing_tables: - details.append("无消息表: " + "、".join(missing_tables)) - suffix = f"\n{chr(10).join(details)}" if details else "" - return f"错误: 没有可查询的聊天对象{suffix}" - - names = get_contact_names() - candidate_limit = _candidate_page_size(limit, offset) - collected = [] - failures = [] - for ctx in resolved_contexts: - chat_entries, chat_failures = _collect_chat_search_entries( - ctx, - names, - keyword, - start_ts=start_ts, - end_ts=end_ts, - candidate_limit=candidate_limit, - ) - collected.extend(chat_entries) - failures.extend(chat_failures) - - paged = _page_search_entries(collected, limit, offset) - - notes = [] - if unresolved: - notes.append("未找到联系人: " + "、".join(unresolved)) - if missing_tables: - notes.append("无消息表: " + "、".join(missing_tables)) - if failures: - notes.append("查询失败: " + ";".join(failures)) - - if not paged: - header = f"在 {len(resolved_contexts)} 个聊天对象中未找到包含 \"{keyword}\" 的消息" - if start_time or end_time: - header += f"\n时间范围: {start_time or '最早'} ~ {end_time or '最新'}" - if notes: - header += "\n" + "\n".join(notes) - return header - - header = ( - f"在 {len(resolved_contexts)} 个聊天对象中搜索 \"{keyword}\" 找到 {len(paged)} 条结果" - f"(offset={offset}, limit={limit})" - ) - if start_time or end_time: - header += f"\n时间范围: {start_time or '最早'} ~ {end_time or '最新'}" - if notes: - header += "\n" + "\n".join(notes) - return header + ":\n\n" + "\n\n".join(item[1] for item in paged) - - -def _search_all_messages(keyword, start_ts, end_ts, start_time, end_time, limit, offset): - names = get_contact_names() - collected = [] - failures = [] - candidate_limit = _candidate_page_size(limit, offset) - - for rel_key in MSG_DB_KEYS: - path = _cache.get(rel_key) - if not path: - continue - - try: - with closing(sqlite3.connect(path)) as conn: - contexts = _load_search_contexts_from_db(conn, path, names) - db_entries, db_failures = _collect_search_entries( - conn, - contexts, - names, - keyword, - start_ts=start_ts, - end_ts=end_ts, - candidate_limit=candidate_limit, - ) - collected.extend(db_entries) - failures.extend(db_failures) - except Exception as e: - failures.append(f"{rel_key}: {e}") - - paged = _page_search_entries(collected, limit, offset) - - if not paged: - header = f"未找到包含 \"{keyword}\" 的消息" - if start_time or end_time: - header += f"\n时间范围: {start_time or '最早'} ~ {end_time or '最新'}" - if failures: - header += "\n查询失败: " + ";".join(failures) - return header - - header = f"搜索 \"{keyword}\" 找到 {len(paged)} 条结果(offset={offset}, limit={limit})" - if start_time or end_time: - header += f"\n时间范围: {start_time or '最早'} ~ {end_time or '最新'}" - if failures: - header += "\n查询失败: " + ";".join(failures) - return header + ":\n\n" + "\n\n".join(item[1] for item in paged) + entry += f" {text}" + return create_time, entry + + +def _build_history_line(row, ctx, names, id_to_username): + local_id, local_type, create_time, real_sender_id, content, ct = row + time_str = datetime.fromtimestamp(create_time).strftime('%Y-%m-%d %H:%M') + content = _decompress_content(content, ct) + if content is None: + content = '(无法解压)' + + sender, text = _format_message_text( + local_id, local_type, content, ctx['is_group'], ctx['username'], ctx['display_name'], names, + create_time=create_time, + ) + + sender_label = _resolve_sender_label( + real_sender_id, sender, ctx['is_group'], ctx['username'], ctx['display_name'], names, id_to_username + ) + if sender_label: + return create_time, f'[{time_str}] {sender_label}: {text}' + return create_time, f'[{time_str}] {text}' + + +def _get_chat_message_tables(ctx): + if ctx.get('message_tables'): + return ctx['message_tables'] + if ctx.get('db_path') and ctx.get('table_name'): + return [{'db_path': ctx['db_path'], 'table_name': ctx['table_name']}] + return [] + + +def _iter_table_contexts(ctx): + for table in _get_chat_message_tables(ctx): + yield { + 'query': ctx['query'], + 'username': ctx['username'], + 'display_name': ctx['display_name'], + 'db_path': table['db_path'], + 'table_name': table['table_name'], + 'is_group': ctx['is_group'], + } + + +def _candidate_page_size(limit, offset): + return limit + offset + + +def _message_query_batch_size(candidate_limit): + return candidate_limit + + +def _history_query_batch_size(candidate_limit): + return min(candidate_limit, _HISTORY_QUERY_BATCH_SIZE) + + +def _page_ranked_entries(entries, limit, offset, oldest_first=False): + ordered = sorted(entries, key=lambda item: item[0], reverse=not oldest_first) + paged = ordered[offset:offset + limit] + paged.sort(key=lambda item: item[0]) + return paged + + +def _collect_chat_history_lines(ctx, names, start_ts=None, end_ts=None, limit=20, offset=0, oldest_first=False, type_filter=None): + collected = [] + failures = [] + candidate_limit = _candidate_page_size(limit, offset) + batch_size = _history_query_batch_size(candidate_limit) + + for table_ctx in _iter_table_contexts(ctx): + try: + with closing(sqlite3.connect(table_ctx['db_path'])) as conn: + id_to_username = _load_name2id_maps(conn) + fetch_offset = 0 + collected_before_table = len(collected) + # 当前页上的消息一定落在各分表最近的 offset+limit 条记录内。 + while len(collected) - collected_before_table < candidate_limit: + rows = _query_messages( + conn, + table_ctx['table_name'], + start_ts=start_ts, + end_ts=end_ts, + limit=batch_size, + offset=fetch_offset, + oldest_first=oldest_first, + type_filter=type_filter, + ) + if not rows: + break + fetch_offset += len(rows) + + for row in rows: + try: + collected.append(_build_history_line(row, table_ctx, names, id_to_username)) + except Exception as e: + failures.append( + f"{table_ctx['display_name']} local_id={row[0]} create_time={row[2]}: {e}" + ) + if len(collected) - collected_before_table >= candidate_limit: + break + + if len(rows) < batch_size: + break + except Exception as e: + failures.append(f"{table_ctx['db_path']}: {e}") + + paged = _page_ranked_entries(collected, limit, offset, oldest_first=oldest_first) + return [line for _, line in paged], failures + + +def _collect_chat_search_entries(ctx, names, keyword, start_ts=None, end_ts=None, candidate_limit=20): + collected = [] + failures = [] + contexts_by_db = {} + for table_ctx in _iter_table_contexts(ctx): + contexts_by_db.setdefault(table_ctx['db_path'], []).append(table_ctx) + + for db_path, db_contexts in contexts_by_db.items(): + try: + with closing(sqlite3.connect(db_path)) as conn: + db_entries, db_failures = _collect_search_entries( + conn, + db_contexts, + names, + keyword, + start_ts=start_ts, + end_ts=end_ts, + candidate_limit=candidate_limit, + ) + collected.extend(db_entries) + failures.extend(db_failures) + except Exception as e: + failures.extend(f"{table_ctx['display_name']}: {e}" for table_ctx in db_contexts) + + return collected, failures + + +def _load_search_contexts_from_db(conn, db_path, names): + tables = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg_%'" + ).fetchall() + + table_to_username = {} + try: + for (user_name,) in conn.execute("SELECT user_name FROM Name2Id").fetchall(): + if not user_name: + continue + table_hash = hashlib.md5(user_name.encode()).hexdigest() + table_to_username[f"Msg_{table_hash}"] = user_name + except sqlite3.Error: + pass + + contexts = [] + for (table_name,) in tables: + username = table_to_username.get(table_name, '') + display_name = names.get(username, username) if username else table_name + contexts.append({ + 'query': display_name, + 'username': username, + 'display_name': display_name, + 'db_path': db_path, + 'table_name': table_name, + 'is_group': '@chatroom' in username, + }) + return contexts + + +def _collect_search_entries(conn, contexts, names, keyword, start_ts=None, end_ts=None, candidate_limit=20): + collected = [] + failures = [] + id_to_username = _load_name2id_maps(conn) + batch_size = _message_query_batch_size(candidate_limit) + + for ctx in contexts: + try: + fetch_offset = 0 + collected_before_table = len(collected) + # 全局分页只需要每个分表最新的 offset+limit 条有效命中,无需把整表命中读进内存。 + while len(collected) - collected_before_table < candidate_limit: + rows = _query_messages( + conn, + ctx['table_name'], + start_ts=start_ts, + end_ts=end_ts, + keyword=keyword, + limit=batch_size, + offset=fetch_offset, + ) + if not rows: + break + fetch_offset += len(rows) + + for row in rows: + formatted = _build_search_entry(row, ctx, names, id_to_username) + if formatted: + collected.append(formatted) + if len(collected) - collected_before_table >= candidate_limit: + break + + if len(rows) < batch_size: + break + except Exception as e: + failures.append(f"{ctx['display_name']}: {e}") + + return collected, failures + + +def _page_search_entries(entries, limit, offset): + return _page_ranked_entries(entries, limit, offset) + + +def _search_single_chat(ctx, keyword, start_ts, end_ts, start_time, end_time, limit, offset): + names = get_contact_names() + candidate_limit = _candidate_page_size(limit, offset) + + entries, failures = _collect_chat_search_entries( + ctx, + names, + keyword, + start_ts=start_ts, + end_ts=end_ts, + candidate_limit=candidate_limit, + ) + + paged = _page_search_entries(entries, limit, offset) + + if not paged: + if failures: + return "查询失败: " + ";".join(failures) + return f"未在 {ctx['display_name']} 中找到包含 \"{keyword}\" 的消息" + + header = f"在 {ctx['display_name']} 中搜索 \"{keyword}\" 找到 {len(paged)} 条结果(offset={offset}, limit={limit})" + if start_time or end_time: + header += f"\n时间范围: {start_time or '最早'} ~ {end_time or '最新'}" + if failures: + header += "\n查询失败: " + ";".join(failures) + return header + ":\n\n" + "\n\n".join(item[1] for item in paged) + _pagination_hint(len(paged), limit, offset) + + +def _search_multiple_chats(chat_names, keyword, start_ts, end_ts, start_time, end_time, limit, offset): + try: + resolved_contexts, unresolved, missing_tables = _resolve_chat_contexts(chat_names) + except ValueError as e: + return f"错误: {e}" + + if not resolved_contexts: + details = [] + if unresolved: + details.append("未找到联系人: " + "、".join(unresolved)) + if missing_tables: + details.append("无消息表: " + "、".join(missing_tables)) + suffix = f"\n{chr(10).join(details)}" if details else "" + return f"错误: 没有可查询的聊天对象{suffix}" + + names = get_contact_names() + candidate_limit = _candidate_page_size(limit, offset) + collected = [] + failures = [] + for ctx in resolved_contexts: + chat_entries, chat_failures = _collect_chat_search_entries( + ctx, + names, + keyword, + start_ts=start_ts, + end_ts=end_ts, + candidate_limit=candidate_limit, + ) + collected.extend(chat_entries) + failures.extend(chat_failures) + + paged = _page_search_entries(collected, limit, offset) + + notes = [] + if unresolved: + notes.append("未找到联系人: " + "、".join(unresolved)) + if missing_tables: + notes.append("无消息表: " + "、".join(missing_tables)) + if failures: + notes.append("查询失败: " + ";".join(failures)) + + if not paged: + header = f"在 {len(resolved_contexts)} 个聊天对象中未找到包含 \"{keyword}\" 的消息" + if start_time or end_time: + header += f"\n时间范围: {start_time or '最早'} ~ {end_time or '最新'}" + if notes: + header += "\n" + "\n".join(notes) + return header + + header = ( + f"在 {len(resolved_contexts)} 个聊天对象中搜索 \"{keyword}\" 找到 {len(paged)} 条结果" + f"(offset={offset}, limit={limit})" + ) + if start_time or end_time: + header += f"\n时间范围: {start_time or '最早'} ~ {end_time or '最新'}" + if notes: + header += "\n" + "\n".join(notes) + return header + ":\n\n" + "\n\n".join(item[1] for item in paged) + _pagination_hint(len(paged), limit, offset) + + +def _search_all_messages(keyword, start_ts, end_ts, start_time, end_time, limit, offset): + names = get_contact_names() + collected = [] + failures = [] + candidate_limit = _candidate_page_size(limit, offset) + + for rel_key in MSG_DB_KEYS: + path = _cache.get(rel_key) + if not path: + continue + + try: + with closing(sqlite3.connect(path)) as conn: + contexts = _load_search_contexts_from_db(conn, path, names) + db_entries, db_failures = _collect_search_entries( + conn, + contexts, + names, + keyword, + start_ts=start_ts, + end_ts=end_ts, + candidate_limit=candidate_limit, + ) + collected.extend(db_entries) + failures.extend(db_failures) + except Exception as e: + failures.append(f"{rel_key}: {e}") + + paged = _page_search_entries(collected, limit, offset) + + if not paged: + header = f"未找到包含 \"{keyword}\" 的消息" + if start_time or end_time: + header += f"\n时间范围: {start_time or '最早'} ~ {end_time or '最新'}" + if failures: + header += "\n查询失败: " + ";".join(failures) + return header + + header = f"搜索 \"{keyword}\" 找到 {len(paged)} 条结果(offset={offset}, limit={limit})" + if start_time or end_time: + header += f"\n时间范围: {start_time or '最早'} ~ {end_time or '最新'}" + if failures: + header += "\n查询失败: " + ";".join(failures) + return header + ":\n\n" + "\n\n".join(item[1] for item in paged) + _pagination_hint(len(paged), limit, offset) # ============ MCP Server ============ @@ -1179,7 +1819,7 @@ _last_check_state = {} # {username: last_timestamp} @mcp.tool() -def get_recent_sessions(limit: int = 20) -> str: +def get_recent_sessions(limit: int = 20) -> str: """获取微信最近会话列表,包含最新消息摘要、未读数、时间等。 用于了解最近有哪些人/群在聊天。 @@ -1191,15 +1831,15 @@ def get_recent_sessions(limit: int = 20) -> str: return "错误: 无法解密 session.db" names = get_contact_names() - with closing(sqlite3.connect(path)) as conn: - rows = conn.execute(""" - SELECT username, unread_count, summary, last_timestamp, - last_msg_type, last_msg_sender, last_sender_display_name - FROM SessionTable - WHERE last_timestamp > 0 - ORDER BY last_timestamp DESC - LIMIT ? - """, (limit,)).fetchall() + with closing(sqlite3.connect(path)) as conn: + rows = conn.execute(""" + SELECT username, unread_count, summary, last_timestamp, + last_msg_type, last_msg_sender, last_sender_display_name + FROM SessionTable + WHERE last_timestamp > 0 + ORDER BY last_timestamp DESC + LIMIT ? + """, (limit,)).fetchall() results = [] for r in rows: @@ -1237,21 +1877,28 @@ def get_recent_sessions(limit: int = 20) -> str: @mcp.tool() -def get_chat_history(chat_name: str, limit: int = 50, offset: int = 0, start_time: str = "", end_time: str = "") -> str: - """获取指定聊天的消息记录。 - - Args: - chat_name: 聊天对象的名字、备注名或wxid,自动模糊匹配 - limit: 返回的消息数量,默认50;支持较大的值,建议配合 offset 分页使用 - offset: 分页偏移量,默认0 - start_time: 起始时间,支持 YYYY-MM-DD / YYYY-MM-DD HH:MM / YYYY-MM-DD HH:MM:SS - end_time: 结束时间,支持 YYYY-MM-DD / YYYY-MM-DD HH:MM / YYYY-MM-DD HH:MM:SS - """ - try: - _validate_pagination(limit, offset, limit_max=None) - start_ts, end_ts = _parse_time_range(start_time, end_time) - except ValueError as e: - return f"错误: {e}" +def get_chat_history(chat_name: str, limit: int = 50, offset: int = 0, start_time: str = "", end_time: str = "", oldest_first: bool = False, msg_types: list[str] | None = None) -> str: + """获取指定聊天的消息记录。 + + Args: + chat_name: 聊天对象的名字、备注名或wxid,自动模糊匹配 + limit: 返回的消息数量,默认50;支持较大的值,建议配合 offset 分页使用 + offset: 分页偏移量,默认0 + start_time: 起始时间,支持 YYYY-MM-DD / YYYY-MM-DD HH:MM / YYYY-MM-DD HH:MM:SS + end_time: 结束时间,支持 YYYY-MM-DD / YYYY-MM-DD HH:MM / YYYY-MM-DD HH:MM:SS + oldest_first: 为 True 时返回最早的消息(默认 False 返回最新消息) + msg_types: 按消息类型过滤,可选值: text, image, voice, video, file(=app), + emoji, location, namecard, voip, system。传 None 或不传表示不过滤 + """ + try: + _validate_pagination(limit, offset, limit_max=None) + start_ts, end_ts = _parse_time_range(start_time, end_time) + except ValueError as e: + return f"错误: {e}" + + type_filter, type_err = _resolve_msg_types(msg_types) + if type_err: + return f"错误: {type_err}" ctx = _resolve_chat_context(chat_name) if not ctx: @@ -1259,102 +1906,106 @@ def get_chat_history(chat_name: str, limit: int = 50, offset: int = 0, start_tim if not ctx['db_path']: return f"找不到 {ctx['display_name']} 的消息记录(可能在未解密的DB中或无消息)" - names = get_contact_names() - lines, failures = _collect_chat_history_lines( - ctx, - names, - start_ts=start_ts, - end_ts=end_ts, - limit=limit, - offset=offset, - ) - - if not lines: - if failures: - return "查询失败: " + ";".join(failures) - return f"{ctx['display_name']} 无消息记录" - - header = f"{ctx['display_name']} 的消息记录(返回 {len(lines)} 条,offset={offset}, limit={limit})" - if ctx['is_group']: - header += " [群聊]" - if start_time or end_time: - header += f"\n时间范围: {start_time or '最早'} ~ {end_time or '最新'}" - if failures: - header += "\n查询失败: " + ";".join(failures) - return header + ":\n\n" + "\n".join(lines) + names = get_contact_names() + lines, failures = _collect_chat_history_lines( + ctx, + names, + start_ts=start_ts, + end_ts=end_ts, + limit=limit, + offset=offset, + oldest_first=oldest_first, + type_filter=type_filter, + ) + + if not lines: + if failures: + return "查询失败: " + ";".join(failures) + return f"{ctx['display_name']} 无消息记录" + + header = f"{ctx['display_name']} 的消息记录(返回 {len(lines)} 条,offset={offset}, limit={limit})" + if ctx['is_group']: + header += " [群聊]" + if start_time or end_time: + header += f"\n时间范围: {start_time or '最早'} ~ {end_time or '最新'}" + if msg_types: + header += f"\n类型过滤: {', '.join(msg_types)}" + if failures: + header += "\n查询失败: " + ";".join(failures) + return header + ":\n\n" + "\n".join(lines) + _pagination_hint(len(lines), limit, offset) -@mcp.tool() -def search_messages( - keyword: str, - chat_name: str | list[str] | None = None, - start_time: str = "", - end_time: str = "", - limit: int = 20, - offset: int = 0, -) -> str: - """搜索消息内容,支持全库、单个聊天对象、多个聊天对象,以及时间范围和分页。 - - Args: - keyword: 搜索关键词 - chat_name: 聊天对象名称,可为空、单个字符串或字符串列表 - start_time: 起始时间,可为空 - end_time: 结束时间,可为空 - limit: 返回的结果数量,默认20,最大500 - offset: 分页偏移量,默认0 - """ - if not keyword or len(keyword) < 1: - return "请提供搜索关键词" - - chat_names = _normalize_chat_names(chat_name) - - try: - _validate_pagination(limit, offset) - start_ts, end_ts = _parse_time_range(start_time, end_time) - except ValueError as e: - return f"错误: {e}" - - if len(chat_names) == 1: - ctx = _resolve_chat_context(chat_names[0]) - if not ctx: - return f"找不到聊天对象: {chat_names[0]}\n提示: 可以用 get_contacts(query='{chat_names[0]}') 搜索联系人" - if not ctx['db_path']: - return f"找不到 {ctx['display_name']} 的消息记录(可能在未解密的DB中或无消息)" - return _search_single_chat( - ctx, - keyword, - start_ts, - end_ts, - start_time, - end_time, - limit, - offset, - ) - - if len(chat_names) > 1: - return _search_multiple_chats( - chat_names, - keyword, - start_ts, - end_ts, - start_time, - end_time, - limit, - offset, - ) - - return _search_all_messages( - keyword, - start_ts, - end_ts, - start_time, - end_time, - limit, - offset, - ) +@mcp.tool() +def search_messages( + keyword: str, + chat_name: str | list[str] | None = None, + start_time: str = "", + end_time: str = "", + limit: int = 20, + offset: int = 0, +) -> str: + """搜索消息内容,支持全库、单个聊天对象、多个聊天对象,以及时间范围和分页。 -@mcp.tool() -def get_contacts(query: str = "", limit: int = 50) -> str: + Args: + keyword: 搜索关键词 + chat_name: 聊天对象名称,可为空、单个字符串或字符串列表 + start_time: 起始时间,可为空 + end_time: 结束时间,可为空 + limit: 返回的结果数量,默认20,最大500 + offset: 分页偏移量,默认0 + """ + if not keyword or len(keyword) < 1: + return "请提供搜索关键词" + + chat_names = _normalize_chat_names(chat_name) + + try: + _validate_pagination(limit, offset) + start_ts, end_ts = _parse_time_range(start_time, end_time) + except ValueError as e: + return f"错误: {e}" + + if len(chat_names) == 1: + ctx = _resolve_chat_context(chat_names[0]) + if not ctx: + return f"找不到聊天对象: {chat_names[0]}\n提示: 可以用 get_contacts(query='{chat_names[0]}') 搜索联系人" + if not ctx['db_path']: + return f"找不到 {ctx['display_name']} 的消息记录(可能在未解密的DB中或无消息)" + return _search_single_chat( + ctx, + keyword, + start_ts, + end_ts, + start_time, + end_time, + limit, + offset, + ) + + if len(chat_names) > 1: + return _search_multiple_chats( + chat_names, + keyword, + start_ts, + end_ts, + start_time, + end_time, + limit, + offset, + ) + + return _search_all_messages( + keyword, + start_ts, + end_ts, + start_time, + end_time, + limit, + offset, + ) + +@mcp.tool() +def get_contacts(query: str = "", limit: int = 50) -> str: """搜索或列出微信联系人。 Args: @@ -1376,6 +2027,7 @@ def get_contacts(query: str = "", limit: int = 50) -> str: else: filtered = contacts + total = len(filtered) filtered = filtered[:limit] if not filtered: @@ -1393,11 +2045,72 @@ def get_contacts(query: str = "", limit: int = 50) -> str: header = f"找到 {len(filtered)} 个联系人" if query: header += f"(搜索: {query})" - return header + ":\n\n" + "\n".join(lines) + result = header + ":\n\n" + "\n".join(lines) + if total > limit: + result += f"\n\n(共 {total} 个匹配,当前仅显示前 {limit} 个,可增大 limit 查看更多)" + return result @mcp.tool() -def get_new_messages() -> str: +def get_contact_tags() -> str: + """列出所有微信联系人标签及成员数量。""" + tags = _load_contact_tags() + if not tags: + return "未找到标签数据(contact_label 表可能不存在)" + + sorted_tags = sorted(tags.values(), key=lambda t: t['sort_order']) + total_assoc = sum(len(t['members']) for t in sorted_tags) + + lines = [f"共 {len(sorted_tags)} 个标签,{total_assoc} 个关联:\n"] + for t in sorted_tags: + lines.append(f" [{t['name']}] {len(t['members'])}人") + return "\n".join(lines) + + +@mcp.tool() +def get_tag_members(tag_name: str) -> str: + """获取指定标签下的所有联系人。支持模糊匹配标签名。 + + Args: + tag_name: 标签名称,支持精确和模糊匹配 + """ + tags = _load_contact_tags() + if not tags: + return "未找到标签数据(contact_label 表可能不存在)" + + q = tag_name.strip().lower() + + # 精确匹配 + exact = [t for t in tags.values() if t['name'].lower() == q] + if exact: + matched = exact[0] + else: + # 模糊匹配 (contains) + fuzzy = [t for t in tags.values() if q in t['name'].lower()] + if not fuzzy: + all_names = [t['name'] for t in sorted(tags.values(), key=lambda t: t['sort_order'])] + return f"未找到匹配 \"{tag_name}\" 的标签。\n\n现有标签: {', '.join(all_names)}" + if len(fuzzy) == 1: + matched = fuzzy[0] + else: + names = [t['name'] for t in fuzzy] + return f"找到 {len(fuzzy)} 个匹配的标签,请指定:\n" + "\n".join(f" [{n}]" for n in names) + + members = matched['members'] + if not members: + return f"标签 [{matched['name']}] 没有成员" + + lines = [f"标签 [{matched['name']}] 共 {len(members)} 人:\n"] + for m in members: + line = m['username'] + if m['display_name'] != m['username']: + line += f" {m['display_name']}" + lines.append(f" {line}") + return "\n".join(lines) + + +@mcp.tool() +def get_new_messages() -> str: """获取自上次调用以来的新消息。首次调用返回最近的会话状态。""" global _last_check_state @@ -1406,14 +2119,14 @@ def get_new_messages() -> str: return "错误: 无法解密 session.db" names = get_contact_names() - with closing(sqlite3.connect(path)) as conn: - rows = conn.execute(""" - SELECT username, unread_count, summary, last_timestamp, - last_msg_type, last_msg_sender, last_sender_display_name - FROM SessionTable - WHERE last_timestamp > 0 - ORDER BY last_timestamp DESC - """).fetchall() + with closing(sqlite3.connect(path)) as conn: + rows = conn.execute(""" + SELECT username, unread_count, summary, last_timestamp, + last_msg_type, last_msg_sender, last_sender_display_name + FROM SessionTable + WHERE last_timestamp > 0 + ORDER BY last_timestamp DESC + """).fetchall() curr_state = {} for r in rows: @@ -1489,7 +2202,12 @@ def get_new_messages() -> str: # ============ 图片解密 ============ -_image_resolver = ImageResolver(WECHAT_BASE_DIR, DECODED_IMAGE_DIR, _cache) +_image_aes_key = _cfg.get("image_aes_key") # V2 格式 AES key (从微信内存提取) +_image_xor_key = _cfg.get("image_xor_key", 0x88) +_image_resolver = ImageResolver( + WECHAT_BASE_DIR, DECODED_IMAGE_DIR, _cache, + aes_key=_image_aes_key, xor_key=_image_xor_key, +) @mcp.tool() @@ -1524,7 +2242,853 @@ def decode_image(chat_name: str, local_id: int) -> str: @mcp.tool() -def get_chat_images(chat_name: str, limit: int = 20) -> str: +def decode_file_message(chat_name: str, local_id: int, create_time: int = 0) -> str: + """获取微信聊天中外层文件消息(PDF/docx/xlsx 等)的本地副本路径。 + + 微信会把对方发来的文件下载到 ~/Library/.../msg/file/{YYYY-MM}/原文件名.{ext} + (macOS)。本工具从消息记录解析出文件名/大小,在本地缓存中精确定位, + 然后返回原始路径,可直接交给 Read/PDF 工具读取。 + + 使用流程:先用 get_chat_history 找到 [文件] xxx.pdf (local_id=N, ts=T), + 把 N 和 T 一起传给本工具。create_time(ts) 用于跨分片场景下唯一定位。 + + Args: + chat_name: 聊天对象的名字、备注名或wxid + local_id: 文件消息的 local_id(从 get_chat_history 获取) + create_time: 消息的 unix 时间戳,从 get_chat_history 输出 ts=N 部分获取。 + 用于在 local_id 跨分片冲突时唯一定位;传 0 时若多个分片含同 local_id 会报歧义错误 + """ + try: + local_id = int(local_id) + create_time = int(create_time) + except (TypeError, ValueError): + return "错误: local_id 和 create_time 必须是整数" + + username = resolve_username(chat_name) + if not username: + return f"找不到聊天对象: {chat_name}" + + # 同一 chat 的消息可能分散在多个 message_N.db 分片中。扫所有分片收集 row, + # 多于一条就报歧义错误(避免 silent decoding wrong message)。 + shards = _find_msg_tables_for_user(username) + if not shards: + return f"找不到 {chat_name} 的消息表" + + # 扫所有分片收集 row。如果调用者传了 create_time,用 (local_id, create_time) + # 精确匹配;否则只按 local_id 收集,多匹配时报歧义并提示加 create_time。 + matches = [] + for shard in shards: + if not _is_safe_msg_table_name(shard['table_name']): + continue + with closing(sqlite3.connect(shard['db_path'])) as conn: + if create_time: + candidate_row = conn.execute( + f"SELECT local_type, create_time, message_content, WCDB_CT_message_content " + f"FROM [{shard['table_name']}] WHERE local_id=? AND create_time=?", + (local_id, create_time) + ).fetchone() + else: + candidate_row = conn.execute( + f"SELECT local_type, create_time, message_content, WCDB_CT_message_content " + f"FROM [{shard['table_name']}] WHERE local_id=?", + (local_id,) + ).fetchone() + if candidate_row: + matches.append((shard['db_path'], candidate_row)) + if not matches: + if create_time: + return f"找不到 (local_id={local_id}, create_time={create_time}) 的消息(已扫描 {len(shards)} 个分片)" + return f"找不到 local_id={local_id} 的消息(已扫描 {len(shards)} 个分片)" + if len(matches) > 1: + details = [] + for db_p, r in matches: + ct = r[1] + ts_str = datetime.fromtimestamp(ct).isoformat() if ct else '?' + details.append(f"{os.path.basename(db_p)} create_time={ct} ({ts_str})") + return ( + f"local_id={local_id} 在 {len(matches)} 个分片中都存在,无法唯一定位:\n " + + '\n '.join(details) + + f"\n请加 create_time 参数:decode_file_message(chat_name, local_id={local_id}, create_time=N)" + ) + + _, row = matches[0] + local_type, create_time, content, ct_compress = row + base_type, _ = _split_msg_type(local_type) + if base_type != 49: + return ( + f"不是文件消息(local_type={local_type},base_type={base_type})," + f"文件消息应为 base_type=49 且 appmsg type=6" + ) + + xml_text = _decompress_content(content, ct_compress) + if not xml_text: + return "消息 content 为空或无法解码" + + # 复用项目内现有 helper 剥离群聊 sender 前缀,避免自己写启发式 + is_group = username.endswith('@chatroom') + _, xml_text = _parse_message_content(xml_text, local_type, is_group) + + root = _parse_app_message_outer(xml_text) + if root is None: + return "无法解析消息 XML" + + appmsg = root.find('.//appmsg') + if appmsg is None: + return "消息中没有 appmsg 段(可能不是文件类型)" + + # 必须是 appmsg type=6 (文件),否则可能是链接/小程序/合并转发等带 title 的卡片, + # 按 title/size 全盘搜会误命中无关本地文件并伪装成"找到了"。 + app_type_in_msg = _parse_int(_collapse_text(appmsg.findtext('type') or ''), 0) + if app_type_in_msg != 6: + return ( + f"不是文件消息(appmsg type={app_type_in_msg})。" + f"文件消息要求 appmsg type=6;type=19 请用 decode_record_item," + f"type=5/33/36/44 等是链接/小程序,没有可下载的本地文件" + ) + + raw_title = _collapse_text(appmsg.findtext('title') or '') + fileext = _collapse_text(appmsg.findtext('.//fileext') or '') + totallen = _parse_int(_collapse_text(appmsg.findtext('.//totallen') or ''), 0) + # md5 字段在 type=6 外层(不是 appattach 子节点)—— 用于强校验候选文件归属 + expected_md5 = _collapse_text(appmsg.findtext('md5') or '').lower() + + # 没有 appattach 节点 = 不是真正的文件消息(type=6 必带 appattach) + if appmsg.find('appattach') is None: + return "消息没有 appattach 节点(可能 schema 异常或不是真文件消息)" + + if not raw_title: + return "消息中没有文件名 (title)" + + # title 来自不可信的 message XML,对方可能发恶意消息(含绝对路径或 ../)。 + # 必须 sanitize 成 safe basename 才能拼路径 + glob,否则有 path-traversal 风险。 + title = _safe_basename(raw_title) + if not title: + return f"消息中的文件名 {raw_title!r} 不安全(含绝对路径/路径分隔符/..),拒绝处理" + + # 性能优化:先按消息时间精确定位 msg/file/{YYYY-MM}/,命中即返回; + # 否则才退回 walk 全盘 os.walk(msg/attach 含数十万小文件,全盘扫描可达数秒) + candidates = [] + msg_file_dir = os.path.join(WECHAT_BASE_DIR, 'msg/file') + if create_time and os.path.isdir(msg_file_dir): + # 同名文件可能落到收到消息的当月、上一月或下一月(罕见跨月边界) + ts_dt = datetime.fromtimestamp(create_time) + candidate_months = { + ts_dt.strftime('%Y-%m'), + (ts_dt - timedelta(days=31)).strftime('%Y-%m'), + (ts_dt + timedelta(days=31)).strftime('%Y-%m'), + } + escaped_stem = glob.escape(os.path.splitext(title)[0]) + ext = os.path.splitext(title)[1] + for ym in candidate_months: + month_dir = os.path.join(msg_file_dir, ym) + if not os.path.isdir(month_dir): + continue + # 精确匹配 + 同名 (1)(2) 后缀变体 + for pattern in ( + glob.escape(title), + f"{escaped_stem}*{glob.escape(ext)}" if ext else f"{escaped_stem}*", + ): + for hit in glob.glob(os.path.join(month_dir, pattern)): + # 有 totallen 时立刻 size 验证:避免月扫命中"同名但 size 不对"的副本 + # 阻塞 walk 兜底,最终返回错误文件 + if totallen: + try: + if os.path.getsize(hit) != totallen: + continue + except OSError: + continue + if hit not in candidates: + candidates.append(hit) + + # 退路:未命中或没 create_time 时只 walk msg/file(slow path 兜底)。 + # 文件名匹配严格化:只接受精确匹配或 wechat 自动加副本的 "(N)" 后缀变体, + # 不做 stem 子串匹配——避免 "某某论文.pdf" 被当成 "论文.pdf"。 + if not candidates: + d = os.path.join(WECHAT_BASE_DIR, 'msg/file') + stem, ext = os.path.splitext(title) + copy_pattern = re.compile( + r'^' + re.escape(stem) + r' ?\(\d+\)' + re.escape(ext) + r'$' + ) + if os.path.isdir(d): + for root_dir, _, files in os.walk(d): + for f in files: + if f.startswith('.'): + continue + full = os.path.join(root_dir, f) + is_exact = (f == title) + is_copy_variant = bool(copy_pattern.match(f)) + if not (is_exact or is_copy_variant): + continue + if totallen: + try: + if os.path.getsize(full) != totallen: + continue + except OSError: + continue + candidates.append(full) + + if not candidates: + return ( + f"在本地缓存中找不到 {title}\n" + f" 期望路径模式: {WECHAT_BASE_DIR}/msg/file/YYYY-MM/{title}\n" + f" 可能原因:从未在 PC/Mac 微信打开过 / 已被清理" + ) + + # 严格 size 过滤(如果 totallen 已知,不匹配的全淘汰) + if totallen: + candidates = [c for c in candidates if os.path.getsize(c) == totallen] + if not candidates: + return ( + f"在本地缓存中找不到 {title} (期望 size={totallen:,})\n" + f" 说明:找到了同名文件但 size 都不匹配——可能从未真正下载完整 / 已被清理" + ) + + # 路径绑定策略:有 md5 → cryptographic verify;没 md5 → heuristic + + # warning。本工具是用户主动通过 MCP 调用,path 只在本地对话显示,所以 + # 没 md5 时不强制 fail-closed。 + cache_root = os.path.join(WECHAT_BASE_DIR, 'msg') + md5_verified = False + if expected_md5 and len(expected_md5) == 32: + # 用 md5 过滤候选——同 md5 = 真同一文件副本。 + md5_match = [] + md5_errors = [] + for c in candidates: + if not _path_under_root(c, cache_root): + md5_errors.append(f"{c}: 不在 {cache_root} 下,跳过") + continue + actual_md5, err = _md5_file_chunked(c) + if err: + md5_errors.append(f"{c}: {err}") + continue + if actual_md5 == expected_md5: + md5_match.append(c) + break # 多候选共享同 md5 = 同一文件副本,第一个命中即停 + if not md5_match: + info = ( + f"⚠️ 候选文件 md5 都不匹配,拒绝返回错文件:\n" + f" 期望 md5: {expected_md5}\n" + f" 说明:找到 {len(candidates)} 个同名同 size 的本地文件但 md5 都不对。" + f"目标文件可能未在 wechat 客户端打开过,或已被清理。" + ) + if md5_errors: + info += "\n 校验异常:\n " + "\n ".join(md5_errors) + return info + candidates = md5_match + md5_verified = True + + # 没 md5 时多 candidates 仍 fail-closed(避免 silent mtime pick) + if len(candidates) > 1 and not md5_verified: + details = [] + for c in candidates: + try: + mt = datetime.fromtimestamp(os.path.getmtime(c)).isoformat() + except OSError: + mt = '?' + details.append(f"{c} (mtime={mt})") + return ( + f"在本地缓存找到 {len(candidates)} 个匹配的副本,无法唯一定位" + f"(同名同 size 多份,且消息 XML 没含 md5 用于强校验):\n " + + '\n '.join(details) + + f"\n请人工 inspect mtime / 上下文区分" + ) + + chosen = candidates[0] + if not _path_under_root(chosen, cache_root): + return f"匹配到的路径 {chosen!r} 不在 {cache_root} 下,拒绝返回(可能是 symlink 攻击)" + + binding_note = ( + "✅ md5 校验通过,路径与消息唯一绑定" + if md5_verified else + f"⚠️ 消息 XML 没含 md5,路径基于 (filename+size) 启发式匹配——" + f"如果同 chat 缓存里另有同名同 size 的不相关文件,可能返回错副本,请人工验证。" + ) + return ( + f"找到本地文件:\n" + f" 路径: {chosen}\n" + f" 大小: {os.path.getsize(chosen):,} bytes\n" + f" 扩展名: {fileext or os.path.splitext(title)[1].lstrip('.') or '?'}\n" + f" 期望大小: {totallen:,} bytes\n" + f" {binding_note}" + ) + + +@mcp.tool() +def decode_record_item(chat_name: str, local_id: int, item_index: int, create_time: int = 0) -> str: + """获取合并转发聊天记录中某个内嵌文件/图片的本地副本路径。 + + 使用流程: + 1. 先用 get_chat_history 找到 [聊天记录] xxx (local_id=N, ts=T) 卡片,记下 N 和 T, + 以及展开行里 [item_index] 前缀(0-based) + 2. 用本工具拿本地路径,create_time 传 history 里的 ts 部分 + 3. 如果未下载,工具会精确告诉你去 wechat 客户端点击合并卡片里的第几项触发下载 + + 注意:合并转发里的内嵌文件只有在用户**点击查看**后 wechat 才会下载到本地。 + 没点过的 dataitem 用本工具会得到"未下载"提示。 + + Args: + chat_name: 聊天对象的名字、备注名或wxid + local_id: 合并转发消息(带"[聊天记录]"标记)的 local_id + item_index: dataitem 在 datalist 里的 0-based 索引(history 输出里的 [N] 前缀) + create_time: 消息的 unix 时间戳;用于跨分片唯一定位,传 0 时多匹配会报歧义 + """ + try: + local_id = int(local_id) + item_index = int(item_index) + create_time = int(create_time) + except (TypeError, ValueError): + return "错误: local_id / item_index / create_time 必须是整数" + + username = resolve_username(chat_name) + if not username: + return f"找不到聊天对象: {chat_name}" + + # 多分片扫描 + ambiguity 检测(避免 silent decoding wrong message,参考 decode_file_message) + shards = _find_msg_tables_for_user(username) + if not shards: + return f"找不到 {chat_name} 的消息表" + + matches = [] + for shard in shards: + if not _is_safe_msg_table_name(shard['table_name']): + continue + with closing(sqlite3.connect(shard['db_path'])) as conn: + if create_time: + candidate_row = conn.execute( + f"SELECT local_type, create_time, message_content, WCDB_CT_message_content " + f"FROM [{shard['table_name']}] WHERE local_id=? AND create_time=?", + (local_id, create_time) + ).fetchone() + else: + candidate_row = conn.execute( + f"SELECT local_type, create_time, message_content, WCDB_CT_message_content " + f"FROM [{shard['table_name']}] WHERE local_id=?", + (local_id,) + ).fetchone() + if candidate_row: + matches.append((shard['table_name'], candidate_row)) + if not matches: + if create_time: + return f"找不到 (local_id={local_id}, create_time={create_time}) 的消息(已扫描 {len(shards)} 个分片)" + return f"找不到 local_id={local_id} 的消息(已扫描 {len(shards)} 个分片)" + if len(matches) > 1: + details = [] + for tn, r in matches: + ts_str = datetime.fromtimestamp(r[1]).isoformat() if r[1] else '?' + details.append(f"table={tn[:12]}... create_time={r[1]} ({ts_str})") + return ( + f"local_id={local_id} 在 {len(matches)} 个分片中都存在,无法唯一定位:\n " + + '\n '.join(details) + + f"\n请加 create_time 参数:decode_record_item(chat_name, local_id={local_id}, item_index={item_index}, create_time=N)" + ) + + table_name, row = matches[0] + local_type, _create_time, content, ct_compress = row + base_type, _ = _split_msg_type(local_type) + if base_type != 49: + return ( + f"不是合并转发消息(local_type={local_type}, base_type={base_type})," + f"合并转发应为 base_type=49 + appmsg type=19" + ) + + xml_text = _decompress_content(content, ct_compress) + if not xml_text: + return "消息 content 为空或无法解码" + + # 复用项目内现有 helper 剥离群聊 sender 前缀,避免自己写启发式 + is_group = username.endswith('@chatroom') + _, xml_text = _parse_message_content(xml_text, local_type, is_group) + + root = _parse_app_message_outer(xml_text) + if root is None: + return "无法解析消息 XML(可能不是合并转发消息)" + appmsg = root.find('.//appmsg') + if appmsg is None: + return "消息中没有 appmsg 段" + + app_type = _parse_int(_collapse_text(appmsg.findtext('type') or ''), 0) + if app_type != 19: + return ( + f"不是合并转发消息(appmsg type={app_type})," + f"合并转发应为 type=19。请用 decode_file_message 处理外层独立文件" + ) + + record_node = appmsg.find('recorditem') + if record_node is None or not record_node.text: + return "消息中没有 recorditem(datalist 还未加载,请在 wechat 中点开此卡片让客户端拉取)" + + inner = _parse_xml_root(record_node.text, max_len=_RECORD_XML_PARSE_MAX_LEN) + if inner is None: + return "无法解析 recorditem 内嵌 XML" + + datalist = inner.find('datalist') + items = list(datalist.findall('dataitem')) if datalist is not None else [] + if not items: + return "datalist 为空(合并记录还未加载内容)" + if item_index < 0 or item_index >= len(items): + return f"item_index={item_index} 超出范围(共 {len(items)} 条 dataitem,0-based)" + + item = items[item_index] + datatype = (item.get('datatype') or '').strip() + raw_datatitle = _collapse_text(item.findtext('datatitle') or '') + # datatitle 来自不可信 XML,sanitize 防 path traversal + datatitle = _safe_basename(raw_datatitle) if raw_datatitle else '' + if raw_datatitle and not datatitle: + return f"该 dataitem 的 datatitle {raw_datatitle!r} 不安全(含绝对路径/分隔符/..),拒绝处理" + datasize = _parse_int(_collapse_text(item.findtext('datasize') or ''), 0) + datafmt = _collapse_text(item.findtext('datafmt') or '') + sourcename = _collapse_text(item.findtext('sourcename') or '') + # fullmd5 是文件内容唯一标识,用于把候选绑定到这条 record,避免误命中 + # 同 chat 内别条 record 的同名同 size 文件。 + expected_md5 = _collapse_text(item.findtext('fullmd5') or '').lower() + + type_label = _RECORD_DATATYPE_LABEL.get(datatype, f'datatype={datatype}') + + if datatype == '1': + text_content = _collapse_text(item.findtext('datadesc') or '') + return ( + f"该 dataitem 是文本,无需下载:\n" + f" 发送者: {sourcename}\n" + f" 内容: {text_content}" + ) + + # 仅以下 datatype 在 wechat 缓存里有真本地 binary(图片/语音/视频/文件); + # 其他类型如链接/位置/名片/小程序/视频号/嵌套聊天记录等只是 metadata, + # 没有可下载的本地副本。不在白名单里的 datatype 直接拒绝,避免 wildcard + # sub='*' 通配命中无关 record 的同名文件。 + subdir_map = _RECORD_BINARY_SUBDIR + if datatype not in subdir_map: + return ( + f"该 dataitem 类型 [{type_label}] 没有本地 binary 文件,无需下载\n" + f" 发送者: {sourcename}\n" + f" 标题: {datatitle or '(无)'}\n" + f" 说明:仅 datatype=2/4/5/8(图片/语音/视频/文件)有可下载内容;" + f"链接/位置/名片/小程序/视频号/嵌套聊天记录等是 metadata-only。" + f"\n如果你需要这条 dataitem 的 metadata 详情,看 get_chat_history 输出里" + f"已展开的 [{item_index}] 行内容即可。" + ) + + table_hash = table_name.replace('Msg_', '', 1) + attach_dir = os.path.join(WECHAT_BASE_DIR, 'msg/attach', table_hash) + + candidates = [] + if os.path.isdir(attach_dir): + import glob as glob_mod + sub = subdir_map.get(datatype, '*') + idx_str = str(item_index) + + # datatype=2 图片走 flat 文件命名 (Img/0_t / Img/0 / Img/0.{ext}), + # 不像文件类的 F/{idx}/{filename}。 + if datatype == '2': + flat_patterns = [ + f"{idx_str}_t", + idx_str, + f"{idx_str}.*", + f"{idx_str}_*", + ] + for fp in flat_patterns: + for hit in glob.glob(os.path.join(attach_dir, '*/Rec/*', sub, fp)): + if datasize: + try: + if os.path.getsize(hit) != datasize: + continue + except OSError: + continue + if hit not in candidates: + candidates.append(hit) + + # 文件 / 视频 / 语音类: F|V|A/{idx}/{filename} + if datatype != '2' and datatitle: + escaped_title = glob.escape(datatitle) + for hit in glob.glob(os.path.join(attach_dir, '*/Rec/*', sub, idx_str, escaped_title)): + if datasize: + try: + if os.path.getsize(hit) != datasize: + continue + except OSError: + continue + if hit not in candidates: + candidates.append(hit) + + # size only 兜底:仅在 datatitle 缺失且非 image(image 已上面处理)时启用 + if not candidates and not datatitle and datasize and datatype != '2': + for hit in glob.glob(os.path.join(attach_dir, '*/Rec/*', sub, idx_str, '*')): + try: + if os.path.getsize(hit) == datasize: + candidates.append(hit) + except OSError: + pass + + if not candidates: + return ( + f"在本地缓存中找不到此 dataitem(很可能未在 wechat 客户端点击查看过)\n" + f" 消息: {chat_name} 的 local_id={local_id}\n" + f" dataitem[{item_index}]: {sourcename}: [{type_label}] {datatitle or '(无标题)'}\n" + f" 期望大小: {datasize:,} bytes\n" + f" 期望路径模式: {attach_dir}/YYYY-MM/Rec/*/{subdir_map.get(datatype, '?')}/{item_index}/{datatitle}\n" + f" 解决方法: 在 wechat 客户端打开此合并记录卡片,点击第 {item_index + 1} 项让客户端下载,再试" + ) + + # 注意:早 ambiguity check(在 md5 filter 之前)已经被删除——它会让有 fullmd5 + # 但多 candidates 的合理 case silent 失败。md5 filter 后再做歧义判断(见下方)。 + # 威胁模型:本工具是用户主动通过 MCP 调用 + path 只在本地显示。 + # 跟 decode_file_message 一致路线:有 md5 强校验,没 md5 fallback 到 + # heuristic + warning(实用 over 严格)。 + cache_root = os.path.join(WECHAT_BASE_DIR, 'msg') + md5_verified = False + if expected_md5 and len(expected_md5) == 32: + md5_match = [] + md5_errors = [] + for c in candidates: + if not _path_under_root(c, cache_root): + md5_errors.append(f"{c}: 不在 {cache_root} 下,跳过") + continue + actual_md5, err = _md5_file_chunked(c) + if err: + md5_errors.append(f"{c}: {err}") + continue + if actual_md5 == expected_md5: + md5_match.append(c) + break # 多候选共享同 md5 = 同一文件副本,第一个命中即停 + if not md5_match: + info = ( + f"⚠️ 候选文件 md5 都不匹配,拒绝返回错文件:\n" + f" 期望 md5: {expected_md5}\n" + f" 说明:候选 {len(candidates)} 个,md5 都不对。" + f"目标 dataitem 可能未在 wechat 客户端点开过,请点击第 {item_index + 1} 项触发下载。" + ) + if md5_errors: + info += "\n 校验异常:\n " + "\n ".join(md5_errors) + return info + candidates = md5_match + md5_verified = True + + # 没 fullmd5 时多 candidates 仍 fail-closed + if len(candidates) > 1 and not md5_verified: + details = [] + for c in candidates: + try: + mt = datetime.fromtimestamp(os.path.getmtime(c)).isoformat() + except OSError: + mt = '?' + details.append(f"{c} (mtime={mt})") + return ( + f"找到 {len(candidates)} 个匹配的本地副本,无法唯一定位" + f"(同位置同名同 size 多份,且 dataitem XML 没含 fullmd5 用于强校验):\n " + + '\n '.join(details) + + f"\n请人工 inspect mtime / 上下文区分" + ) + + chosen = candidates[0] + if not _path_under_root(chosen, cache_root): + return f"匹配到的路径 {chosen!r} 不在 {cache_root} 下,拒绝返回(可能是 symlink 攻击)" + + binding_note = ( + "✅ md5 校验通过,路径与 dataitem 唯一绑定" + if md5_verified else + f"⚠️ 此 dataitem XML 没含 fullmd5,路径基于 (item_index+filename+size) 启发式匹配——" + f"如果同 chat 内多条合并卡片碰巧含同位置同名同 size 的文件,可能返回别条 record 的副本,请人工验证。" + ) + return ( + f"找到本地文件:\n" + f" 路径: {chosen}\n" + f" 大小: {os.path.getsize(chosen):,} bytes\n" + f" 期望大小: {datasize:,} bytes\n" + f" 发送者: {sourcename}\n" + f" 类型: [{type_label}] {datatitle or '(无标题)'}\n" + f" {binding_note}" + ) + + +@mcp.tool() +def decode_transfer(chat_name: str, local_id: int, create_time: int = 0) -> str: + """读取微信转账消息(appmsg type=2000)的结构化信息。 + + 返回方向(发起/收款/退还)、金额、备注、付款人/收款人 wxid、交易号、 + 发起/失效时间。仅 1v1 聊天有转账消息(微信不支持群转账)。 + + 使用流程:先用 get_chat_history 找到 [转账·xxx] 行 (local_id=N, ts=T), + 把 N 和 T 一起传进来。create_time(ts) 用于跨分片场景下唯一定位。 + + Args: + chat_name: 聊天对象的名字、备注名或wxid + local_id: 转账消息的 local_id(从 get_chat_history 获取) + create_time: 消息的 unix 时间戳,从 get_chat_history 输出 ts=N 部分获取。 + 用于在 local_id 跨分片冲突时唯一定位;传 0 时若多个分片含同 local_id 会报歧义错误 + """ + try: + local_id = int(local_id) + create_time = int(create_time) + except (TypeError, ValueError): + return "错误: local_id 和 create_time 必须是整数" + + username = resolve_username(chat_name) + if not username: + return f"找不到聊天对象: {chat_name}" + + # 多分片扫描 + ambiguity 检测,跟 decode_file_message 一致 + shards = _find_msg_tables_for_user(username) + if not shards: + return f"找不到 {chat_name} 的消息表" + + matches = [] + for shard in shards: + if not _is_safe_msg_table_name(shard['table_name']): + continue + with closing(sqlite3.connect(shard['db_path'])) as conn: + if create_time: + candidate_row = conn.execute( + f"SELECT local_type, create_time, message_content, WCDB_CT_message_content " + f"FROM [{shard['table_name']}] WHERE local_id=? AND create_time=?", + (local_id, create_time) + ).fetchone() + else: + candidate_row = conn.execute( + f"SELECT local_type, create_time, message_content, WCDB_CT_message_content " + f"FROM [{shard['table_name']}] WHERE local_id=?", + (local_id,) + ).fetchone() + if candidate_row: + matches.append((shard['db_path'], candidate_row)) + + if not matches: + if create_time: + return f"找不到 (local_id={local_id}, create_time={create_time}) 的消息(已扫描 {len(shards)} 个分片)" + return f"找不到 local_id={local_id} 的消息(已扫描 {len(shards)} 个分片)" + if len(matches) > 1: + details = [] + for db_p, r in matches: + ct = r[1] + ts_str = datetime.fromtimestamp(ct).isoformat() if ct else '?' + details.append(f"{os.path.basename(db_p)} create_time={ct} ({ts_str})") + return ( + f"local_id={local_id} 在 {len(matches)} 个分片中都存在,无法唯一定位:\n " + + '\n '.join(details) + + f"\n请加 create_time 参数:decode_transfer(chat_name, local_id={local_id}, create_time=N)" + ) + + _, row = matches[0] + local_type, msg_create_time, content, ct_compress = row + base_type, _ = _split_msg_type(local_type) + if base_type != 49: + return ( + f"不是转账消息(local_type={local_type}, base_type={base_type})," + f"转账消息应为 base_type=49 + appmsg type=2000" + ) + + xml_text = _decompress_content(content, ct_compress) + if not xml_text: + return "消息 content 为空或无法解码" + + is_group = username.endswith('@chatroom') + _, xml_text = _parse_message_content(xml_text, local_type, is_group) + + root = _parse_app_message_outer(xml_text) + if root is None: + return "无法解析消息 XML" + appmsg = root.find('.//appmsg') + if appmsg is None: + return "消息中没有 appmsg 段(不像转账)" + + app_type = _parse_int(_collapse_text(appmsg.findtext('type') or ''), 0) + if app_type != 2000: + return ( + f"不是转账消息(appmsg type={app_type})。" + f"转账要求 appmsg type=2000;type=6 是文件,type=19 是合并转发," + f"请用对应的 decode_file_message / decode_record_item 工具" + ) + + info = _extract_transfer_info(appmsg) + if info is None: + return "消息是 type=2000 但缺 节点(schema 异常)" + + def _fmt_ts(ts_str): + ts = _parse_int(ts_str, 0) + if not ts: + return '' + try: + return datetime.fromtimestamp(ts).isoformat() + except (ValueError, OSError, OverflowError): + return f'(无效 ts={ts_str})' + + direction = info['paysubtype_label'] or '(未知)' + raw_paysubtype = info['paysubtype'] or '?' + title = _collapse_text(appmsg.findtext('title') or '') or '微信转账' + des = _collapse_text(appmsg.findtext('des') or '') + + lines = [f"转账消息: {title}"] + if des: + lines.append(f" 描述: {des}") + lines.append(f" 方向: {direction} (paysubtype={raw_paysubtype})") + if info['fee_desc']: + lines.append(f" 金额: {info['fee_desc']}") + if info['pay_memo']: + lines.append(f" 备注: {info['pay_memo']}") + if info['payer_username']: + lines.append(f" 付款方 wxid: {info['payer_username']}") + if info['receiver_username']: + lines.append(f" 收款方 wxid: {info['receiver_username']}") + begin_ts = _fmt_ts(info['begin_transfer_time']) + if begin_ts: + lines.append(f" 发起时间: {begin_ts}") + invalid_ts = _fmt_ts(info['invalid_time']) + if invalid_ts: + lines.append(f" 失效时间: {invalid_ts}") + if info['transfer_id']: + lines.append(f" 转账 ID: {info['transfer_id']}") + if info['transcation_id']: + lines.append(f" 支付交易号: {info['transcation_id']}") + if info['pay_msg_id']: + lines.append(f" paymsgid: {info['pay_msg_id']}") + return "\n".join(lines) + + +@mcp.tool() +def decode_refer(chat_name: str, local_id: int, create_time: int = 0) -> str: + """读取微信引用回复消息(appmsg type=57)的结构化信息。 + + 返回回复正文、被引用消息的发送者/类型/摘要/svrid/createtime。被引用消息的 + type 决定摘要风格:1 文本展开原文,3/34/43/47/48/50 给 [图片]/[语音]/... + 标签,49 嵌套 appmsg 解一层 inner type 给 [链接] xxx。svrid 可用于回查 + 原消息(在 history / export_chat 输出里搜)。 + + 使用流程:先用 get_chat_history 找到 [引用消息] 行 (local_id=N, ts=T), + 把 N 和 T 一起传进来。create_time(ts) 用于跨分片场景下唯一定位。 + + Args: + chat_name: 聊天对象的名字、备注名或 wxid + local_id: 引用消息的 local_id(从 get_chat_history 获取) + create_time: 消息的 unix 时间戳,从 get_chat_history 输出 ts=N 部分获取。 + 用于在 local_id 跨分片冲突时唯一定位;传 0 时若多个分片含同 local_id 会报歧义错误 + """ + try: + local_id = int(local_id) + create_time = int(create_time) + except (TypeError, ValueError): + return "错误: local_id 和 create_time 必须是整数" + + username = resolve_username(chat_name) + if not username: + return f"找不到聊天对象: {chat_name}" + + shards = _find_msg_tables_for_user(username) + if not shards: + return f"找不到 {chat_name} 的消息表" + + matches = [] + for shard in shards: + if not _is_safe_msg_table_name(shard['table_name']): + continue + with closing(sqlite3.connect(shard['db_path'])) as conn: + if create_time: + candidate_row = conn.execute( + f"SELECT local_type, create_time, message_content, WCDB_CT_message_content " + f"FROM [{shard['table_name']}] WHERE local_id=? AND create_time=?", + (local_id, create_time) + ).fetchone() + else: + candidate_row = conn.execute( + f"SELECT local_type, create_time, message_content, WCDB_CT_message_content " + f"FROM [{shard['table_name']}] WHERE local_id=?", + (local_id,) + ).fetchone() + if candidate_row: + matches.append((shard['db_path'], candidate_row)) + + if not matches: + if create_time: + return f"找不到 (local_id={local_id}, create_time={create_time}) 的消息(已扫描 {len(shards)} 个分片)" + return f"找不到 local_id={local_id} 的消息(已扫描 {len(shards)} 个分片)" + if len(matches) > 1: + details = [] + for db_p, r in matches: + ct = r[1] + ts_str = datetime.fromtimestamp(ct).isoformat() if ct else '?' + details.append(f"{os.path.basename(db_p)} create_time={ct} ({ts_str})") + return ( + f"local_id={local_id} 在 {len(matches)} 个分片中都存在,无法唯一定位:\n " + + '\n '.join(details) + + f"\n请加 create_time 参数:decode_refer(chat_name, local_id={local_id}, create_time=N)" + ) + + _, row = matches[0] + local_type, msg_create_time, content, ct_compress = row + base_type, _ = _split_msg_type(local_type) + if base_type != 49: + return ( + f"不是引用消息(local_type={local_type}, base_type={base_type})," + f"引用消息应为 base_type=49 + appmsg type=57" + ) + + xml_text = _decompress_content(content, ct_compress) + if not xml_text: + return "消息 content 为空或无法解码" + + is_group = username.endswith('@chatroom') + _, xml_text = _parse_message_content(xml_text, local_type, is_group) + + root = _parse_app_message_outer(xml_text) + if root is None: + return "无法解析消息 XML" + appmsg = root.find('.//appmsg') + if appmsg is None: + return "消息中没有 appmsg 段(不像引用回复)" + + app_type = _parse_int(_collapse_text(appmsg.findtext('type') or ''), 0) + if app_type != 57: + return ( + f"不是引用消息(appmsg type={app_type})。" + f"引用回复要求 appmsg type=57;type=6 是文件、type=19 是合并转发、" + f"type=2000 是转账,请用对应的 decode_file_message / decode_record_item / " + f"decode_transfer 工具" + ) + + info = _extract_refer_info(appmsg) + if info is None: + return "消息是 type=57 但缺 节点(schema 异常)" + + refer_type_label = _REFER_INNER_TYPE_LABEL.get(info['refer_type'], '') + summary = _summarize_refer_content(info['refer_type'], info['refer_content']) + sender_label = _resolve_quote_sender_label( + info['refer_fromusr'], info['refer_displayname'], + is_group, username, chat_name, get_contact_names() + ) + + def _fmt_ts(ts_str): + ts = _parse_int(ts_str, 0) + if not ts: + return '' + try: + return datetime.fromtimestamp(ts).isoformat() + except (ValueError, OSError, OverflowError): + return f'(无效 ts={ts_str})' + + lines = [f"引用回复消息: {info['reply_text'] or '(无回复正文)'}"] + if sender_label: + lines.append(f" 被引用消息发送者: {sender_label}") + if info['refer_displayname']: + lines.append(f" 被引用消息显示名: {info['refer_displayname']}") + if info['refer_fromusr']: + lines.append(f" 被引用消息 from: {info['refer_fromusr']}") + if info['refer_chatusr']: + lines.append(f" 被引用消息 chatusr (群内发送者 wxid): {info['refer_chatusr']}") + raw_type = info['refer_type'] or '?' + type_display = ( + f"{refer_type_label} (refer_type={raw_type})" + if refer_type_label else f"refer_type={raw_type}" + ) + lines.append(f" 被引用消息类型: {type_display}") + lines.append(f" 被引用消息摘要: {summary}") + refer_ts = _fmt_ts(info['refer_createtime']) + if refer_ts: + lines.append(f" 被引用消息创建时间: {refer_ts}") + if info['refer_svrid']: + lines.append(f" 被引用消息 server_id: {info['refer_svrid']}") + + return "\n".join(lines) + + +@mcp.tool() +def get_chat_images(chat_name: str, limit: int = 20, offset: int = 0, start_time: str = "", end_time: str = "") -> str: """列出某个聊天中的图片消息。 返回图片的时间、local_id、MD5、文件大小等信息。 @@ -1533,7 +3097,16 @@ def get_chat_images(chat_name: str, limit: int = 20) -> str: Args: chat_name: 聊天对象的名字、备注名或wxid limit: 返回数量,默认20 + offset: 分页偏移量,默认0 + start_time: 起始时间,支持 YYYY-MM-DD / YYYY-MM-DD HH:MM / YYYY-MM-DD HH:MM:SS + end_time: 结束时间,支持 YYYY-MM-DD / YYYY-MM-DD HH:MM / YYYY-MM-DD HH:MM:SS """ + try: + _validate_pagination(limit, offset) + start_ts, end_ts = _parse_time_range(start_time, end_time) + except ValueError as e: + return f"错误: {e}" + username = resolve_username(chat_name) if not username: return f"找不到聊天对象: {chat_name}" @@ -1541,16 +3114,33 @@ def get_chat_images(chat_name: str, limit: int = 20) -> str: names = get_contact_names() display_name = names.get(username, username) - db_path, table_name = _find_msg_table_for_user(username) - if not db_path: + # 同 chat 的消息会分散在多个 message_N.db shard 里 (上限 ~100MB/shard 时滚动到下一个); + # 单 shard 查找会漏掉其他 shard 的图片。其他工具 (get_chat_history / search_messages / + # decode_image) 早已用复数版本 scan 全部 shard, 这里对齐一致。 + shards = _find_msg_tables_for_user(username) + if not shards: return f"找不到 {display_name} 的消息记录" - images = _image_resolver.list_chat_images(db_path, table_name, username, limit) - if not images: + # 每个 shard 取 limit+offset 张候选, 合并后按 create_time DESC 全局排序, 切片 + # [offset : offset+limit] 出本页。单 shard 至少凑得起本页, 避免某 shard 缺数据 + # 时本页变短。 + candidate_limit = limit + offset + all_images = [] + for shard in shards: + shard_images = _image_resolver.list_chat_images( + shard['db_path'], shard['table_name'], username, + limit=candidate_limit, start_ts=start_ts, end_ts=end_ts, + ) + all_images.extend(shard_images) + + if not all_images: return f"{display_name} 无图片消息" + all_images.sort(key=lambda img: img['create_time'], reverse=True) + paged = all_images[offset:offset + limit] + lines = [] - for img in images: + for img in paged: time_str = datetime.fromtimestamp(img['create_time']).strftime('%Y-%m-%d %H:%M') line = f"[{time_str}] local_id={img['local_id']}" if img.get('md5'): @@ -1562,7 +3152,600 @@ def get_chat_images(chat_name: str, limit: int = 20) -> str: line += " (无资源信息)" lines.append(line) - return f"{display_name} 的 {len(lines)} 张图片:\n\n" + "\n".join(lines) + header = f"{display_name} 的 {len(lines)} 张图片(offset={offset}, limit={limit})" + if start_time or end_time: + header += f"\n时间范围: {start_time or '最早'} ~ {end_time or '最新'}" + return header + ":\n\n" + "\n".join(lines) + _pagination_hint(len(lines), limit, offset) + + +# ============ 语音解密 ============ + +DECODED_VOICE_DIR = os.path.join(SCRIPT_DIR, "decoded_voices") + +# media DB 与 message DB 同样会分片(media_0.db、media_1.db…), +# 每个分片各有独立的 Name2Id / VoiceInfo 表。 +MEDIA_DB_KEYS = sorted([ + k for k in ALL_KEYS + if any(v.startswith("message/") for v in key_path_variants(k)) + and any(re.search(r"media_\d+\.db$", v) for v in key_path_variants(k)) +]) + + +def _iter_media_db_paths(): + for rel_key in MEDIA_DB_KEYS: + path = _cache.get(rel_key) + if path: + yield path + + +def _get_chat_name_id(conn, username): + row = conn.execute( + "SELECT rowid FROM Name2Id WHERE user_name = ?", (username,) + ).fetchone() + return row[0] if row else None + + +def _fetch_voice_row(username, local_id): + """遍历所有 media DB 分片,返回 (voice_data, create_time);找不到返回 None。""" + for media_db in _iter_media_db_paths(): + with closing(sqlite3.connect(media_db)) as conn: + chat_name_id = _get_chat_name_id(conn, username) + if chat_name_id is None: + continue + row = conn.execute( + "SELECT voice_data, create_time FROM VoiceInfo " + "WHERE chat_name_id = ? AND local_id = ?", + (chat_name_id, local_id), + ).fetchone() + if row: + return row + return None + + +def _silk_to_wav(voice_data, create_time, username, local_id): + """Decode SILK voice blob to WAV file, return output path.""" + # pypi 上有多个 SILK 相关包名(silk-python / pysilk / pilk), + # 这里用的是 synodriver/pysilk —— 安装包名 silk-python,import 名 pysilk + import pysilk + data = bytes(voice_data) + silk_data = data[1:] if data[0] == 0x02 else data + os.makedirs(DECODED_VOICE_DIR, exist_ok=True) + time_str = datetime.fromtimestamp(create_time).strftime('%Y%m%d_%H%M%S') + out_path = os.path.join(DECODED_VOICE_DIR, f"{username}_{time_str}_{local_id}.wav") + inp = io.BytesIO(silk_data) + out = io.BytesIO() + pysilk.decode(inp, out, 24000) + pcm = out.getvalue() + with wave.open(out_path, 'wb') as wf: + wf.setnchannels(1) + wf.setsampwidth(2) + wf.setframerate(24000) + wf.writeframes(pcm) + return out_path, len(pcm) + + +@mcp.tool() +def get_voice_messages(chat_name: str, limit: int = 20, offset: int = 0, start_time: str = "", end_time: str = "") -> str: + """列出某个聊天中的语音消息。 + + 返回语音的时间、local_id 和大小,可配合 decode_voice 工具解码。 + + Args: + chat_name: 聊天对象的名字、备注名或wxid + limit: 返回数量,默认20 + offset: 分页偏移量,默认0 + start_time: 起始时间,支持 YYYY-MM-DD / YYYY-MM-DD HH:MM / YYYY-MM-DD HH:MM:SS + end_time: 结束时间,支持 YYYY-MM-DD / YYYY-MM-DD HH:MM / YYYY-MM-DD HH:MM:SS + """ + try: + _validate_pagination(limit, offset) + start_ts, end_ts = _parse_time_range(start_time, end_time) + except ValueError as e: + return f"错误: {e}" + + username = resolve_username(chat_name) + if not username: + return f"找不到聊天对象: {chat_name}" + + names = get_contact_names() + display_name = names.get(username, username) + + if not MEDIA_DB_KEYS: + return "找不到 media DB" + + # 每分片各取 limit+offset 条候选, 合并后全局排序切片 [offset:offset+limit] 出本页。 + candidate_limit = limit + offset + clauses = ['chat_name_id = ?'] + if start_ts is not None: + clauses.append('create_time >= ?') + if end_ts is not None: + clauses.append('create_time <= ?') + where_sql = ' AND '.join(clauses) + + rows = [] + for media_db in _iter_media_db_paths(): + with closing(sqlite3.connect(media_db)) as conn: + chat_name_id = _get_chat_name_id(conn, username) + if chat_name_id is None: + continue + params = [chat_name_id] + if start_ts is not None: + params.append(start_ts) + if end_ts is not None: + params.append(end_ts) + params.append(candidate_limit) + rows.extend(conn.execute( + f"SELECT local_id, create_time, length(voice_data) FROM VoiceInfo " + f"WHERE {where_sql} ORDER BY create_time DESC LIMIT ?", + params, + ).fetchall()) + + if not rows: + return f"{display_name} 无语音消息" + + rows.sort(key=lambda r: r[1], reverse=True) + paged = rows[offset:offset + limit] + + lines = [] + for local_id, create_time, size in paged: + time_str = datetime.fromtimestamp(create_time).strftime('%Y-%m-%d %H:%M') + lines.append(f"[{time_str}] local_id={local_id} {size/1024:.0f}KB") + + header = f"{display_name} 的 {len(lines)} 条语音消息(offset={offset}, limit={limit})" + if start_time or end_time: + header += f"\n时间范围: {start_time or '最早'} ~ {end_time or '最新'}" + return header + ":\n\n" + "\n".join(lines) + _pagination_hint(len(lines), limit, offset) + + +@mcp.tool() +def decode_voice(chat_name: str, local_id: int) -> str: + """解码微信语音消息为 WAV 文件。 + + 先用 get_voice_messages 获取 local_id,再用此工具解码。 + 输出文件保存在 decoded_voices/ 目录。 + + 依赖: pip install silk-python (import 名为 pysilk) + + Args: + chat_name: 聊天对象的名字、备注名或wxid + local_id: 语音消息的 local_id(从 get_voice_messages 获取) + """ + try: + import pysilk # noqa: F401 + except ImportError: + return "缺少依赖: pip install silk-python" + + username = resolve_username(chat_name) + if not username: + return f"找不到聊天对象: {chat_name}" + + row = _fetch_voice_row(username, local_id) + if row is None: + return f"找不到 local_id={local_id} 的语音消息" + + voice_data, create_time = row + out_path, pcm_len = _silk_to_wav(voice_data, create_time, username, local_id) + duration_s = pcm_len / (24000 * 2) + return ( + f"解码成功!\n" + f" 文件: {out_path}\n" + f" 时长: {duration_s:.1f}秒\n" + f" 大小: {os.path.getsize(out_path):,} bytes" + ) + + +# ============ 语音转录缓存 ============ +# +# Whisper 转录耗时(CPU 下每条数秒到数十秒),且结果是确定性的 +# (同一段 voice_data → 同一段 text),非常适合缓存。 +# +# 缓存 key 用 json.dumps([username, local_id]):local_id 在单个 username 下 +# 稳定唯一,套一层 JSON 序列化保证 username 里若含分隔符也不会与其它条目碰撞。 +# +# 写入走 temp + os.replace 原子替换,避免进程中途被杀导致整份缓存损坏 +# (Whisper 的单次代价远高于 DBCache,破档不可接受)。 +# +# 条目里记录 model_size:Whisper 升级默认模型后,旧条目自动视为失效并重跑。 + +VOICE_TRANSCRIPTION_CACHE_FILE = os.path.join(SCRIPT_DIR, "voice_transcriptions.json") + +_voice_transcription_cache = None # 懒加载 dict;None 表示尚未加载 +_voice_transcription_cache_lock = threading.Lock() +_voice_transcription_save_warned = False # 写失败仅首次写 stderr,避免刷屏 + + +def _voice_transcription_cache_key(username, local_id): + """构造缓存 key。用 json.dumps 兜底 username 里可能出现的分隔符。""" + return json.dumps([username, int(local_id)], ensure_ascii=False) + + +def _load_voice_transcription_cache(): + """加载缓存到模块级 dict,返回该 dict。 + + 文件不存在 → 空 dict。JSON 损坏或 payload 非 dict → 空 dict + (与上游 DBCache 的容错风格一致:缓存坏了不要拖垮工具调用)。 + """ + global _voice_transcription_cache + with _voice_transcription_cache_lock: + if _voice_transcription_cache is not None: + return _voice_transcription_cache + if not os.path.exists(VOICE_TRANSCRIPTION_CACHE_FILE): + _voice_transcription_cache = {} + return _voice_transcription_cache + try: + with open(VOICE_TRANSCRIPTION_CACHE_FILE, encoding="utf-8") as f: + loaded = json.load(f) + _voice_transcription_cache = loaded if isinstance(loaded, dict) else {} + except (json.JSONDecodeError, OSError): + _voice_transcription_cache = {} + return _voice_transcription_cache + + +def _save_voice_transcription_cache(): + """持久化缓存到磁盘。 + + - 原子写:先写 .tmp 再 os.replace,避免 crash 中途留下半截文件。 + - 未加载过也允许保存:此时把 module 状态初始化为空 dict,避免上层 + 代码因调用顺序错误而静默丢数据。 + - OSError 不抛:避免转录成功但落盘失败时让工具调用也失败;但首次 + 失败会在 stderr 打一行警告,用户知道磁盘满 / 权限问题需要处理。 + """ + global _voice_transcription_cache, _voice_transcription_save_warned + with _voice_transcription_cache_lock: + if _voice_transcription_cache is None: + _voice_transcription_cache = {} + tmp_path = VOICE_TRANSCRIPTION_CACHE_FILE + ".tmp" + try: + with open(tmp_path, "w", encoding="utf-8") as f: + json.dump(_voice_transcription_cache, f, ensure_ascii=False) + os.replace(tmp_path, VOICE_TRANSCRIPTION_CACHE_FILE) + except OSError as exc: + if not _voice_transcription_save_warned: + print( + f"[voice_cache] 写入失败(后续不再提示): {exc}", + file=sys.stderr, + flush=True, + ) + _voice_transcription_save_warned = True + # 清理可能残留的 .tmp + try: + if os.path.exists(tmp_path): + os.unlink(tmp_path) + except OSError: + pass + + +# ============ 语音转录后端 ============ +# +# 默认 local: 完全保留原有行为,CPU 上跑本地 Whisper。 +# opt-in openai: 需要 transcription_backend="openai" 且 openai_api_key 都齐 +# 才会上云;任一缺失静默回退 local + stderr 一行警告(用户感知到误配置但不阻塞)。 +# 详见 README "语音转录隐私" 章节。 + +TRANSCRIPTION_BACKEND = _cfg.get("transcription_backend", "local") +LOCAL_WHISPER_MODEL = _cfg.get("local_whisper_model", "base") +OPENAI_API_KEY = _cfg.get("openai_api_key", "") + +OPENAI_WHISPER_MODEL = "whisper-1" # OpenAI 当前唯一型号 +OPENAI_AUDIO_LIMIT_BYTES = 25 * 1024 * 1024 # OpenAI 25MB 上限 + +_whisper_model = None +_openai_client = None +_openai_warning_emitted = False +_fallback_warning_emitted = False + +# whisper.cpp 后端(macOS Metal GPU 加速) +# 路径选项均为可选,默认自动检测 +WHISPER_CPP_BINARY = _cfg.get("whisper_cpp_binary", "") +WHISPER_CPP_MODEL = _cfg.get("whisper_cpp_model", "") +WHISPER_CPP_LANGUAGE = _cfg.get("whisper_cpp_language", "zh") +WHISPER_CPP_THREADS = _cfg.get("whisper_cpp_threads", 0) + +_WHISPER_CPP_BINARY_SEARCH_PATHS = [ + "/opt/homebrew/bin/whisper-cpp", + "/usr/local/bin/whisper-cpp", + os.path.expanduser("~/.local/bin/whisper-cpp"), +] + +_WHISPER_CPP_MODEL_SEARCH_PATHS = [ + os.path.expanduser("~/Library/Application Support/whisper-cpp"), + os.path.expanduser("~/Library/Application Support/Recordly/whisper"), + os.path.expanduser("~/whisper-models"), + os.path.expanduser("~/models"), + os.path.expanduser("~/Downloads"), + "/opt/homebrew/share/whisper-cpp/models", + "/usr/local/share/whisper-cpp/models", +] + +_whisper_cpp_binary_resolved = None # None=未检测, ""=未找到, str=路径 +_whisper_cpp_model_resolved = None # 同上 + + +def _resolve_whisper_cpp_binary(): + global _whisper_cpp_binary_resolved + if _whisper_cpp_binary_resolved is not None: + return _whisper_cpp_binary_resolved + if WHISPER_CPP_BINARY: + if os.path.isfile(WHISPER_CPP_BINARY) and os.access(WHISPER_CPP_BINARY, os.X_OK): + _whisper_cpp_binary_resolved = WHISPER_CPP_BINARY + return _whisper_cpp_binary_resolved + for p in _WHISPER_CPP_BINARY_SEARCH_PATHS: + if os.path.isfile(p) and os.access(p, os.X_OK): + _whisper_cpp_binary_resolved = p + return _whisper_cpp_binary_resolved + _whisper_cpp_binary_resolved = "" + return "" + + +def _resolve_whisper_cpp_model(): + global _whisper_cpp_model_resolved + if _whisper_cpp_model_resolved is not None: + return _whisper_cpp_model_resolved + if WHISPER_CPP_MODEL: + if os.path.isfile(WHISPER_CPP_MODEL): + _whisper_cpp_model_resolved = WHISPER_CPP_MODEL + return _whisper_cpp_model_resolved + for search_dir in _WHISPER_CPP_MODEL_SEARCH_PATHS: + if not os.path.isdir(search_dir): + continue + for f in sorted(os.listdir(search_dir)): + if f.startswith("ggml-") and f.endswith(".bin"): + _whisper_cpp_model_resolved = os.path.join(search_dir, f) + return _whisper_cpp_model_resolved + _whisper_cpp_model_resolved = "" + return "" + + +def _resolve_active_backend(): + """两因素 opt-in:openai 需要 flag + key 都齐才生效。 + whisper_cpp 需要 binary 可检测到,否则回退 local。""" + global _fallback_warning_emitted + if TRANSCRIPTION_BACKEND == "openai": + if not OPENAI_API_KEY: + if not _fallback_warning_emitted: + print( + "[whisper] transcription_backend=openai 但未配置 openai_api_key," + "回退到本地模型", + file=sys.stderr, flush=True, + ) + _fallback_warning_emitted = True + return "local" + return "openai" + if TRANSCRIPTION_BACKEND == "whisper_cpp": + if not _resolve_whisper_cpp_binary(): + if not _fallback_warning_emitted: + print( + "[whisper] transcription_backend=whisper_cpp 但未找到 " + "whisper-cpp 二进制文件,回退到本地模型。" + "安装: brew install whisper-cpp", + file=sys.stderr, flush=True, + ) + _fallback_warning_emitted = True + return "local" + return "whisper_cpp" + return "local" + + +def _cache_signature(): + """当前生效后端 + 模型,用作缓存命中判定 + 落盘字段。""" + backend = _resolve_active_backend() + if backend == "openai": + return {"backend": "openai", "model_size": OPENAI_WHISPER_MODEL} + if backend == "whisper_cpp": + model_path = _resolve_whisper_cpp_model() + model_name = os.path.basename(model_path) if model_path else "unknown" + return {"backend": "whisper_cpp", "model_size": model_name} + return {"backend": "local", "model_size": LOCAL_WHISPER_MODEL} + + +def _get_whisper_model(model_size=None): + global _whisper_model + if model_size is None: + model_size = LOCAL_WHISPER_MODEL + if _whisper_model is None: + import whisper + _whisper_model = whisper.load_model(model_size) + return _whisper_model + + +def _transcribe_local(wav_path): + model = _get_whisper_model() + result = model.transcribe(wav_path) + return { + "language": result.get("language", "unknown"), + "text": result.get("text", "").strip(), + } + + +def _transcribe_openai(wav_path): + """通过 OpenAI Whisper API 转录。失败抛 RuntimeError,调用方负责面向用户的提示。""" + global _openai_client, _openai_warning_emitted + + # 尺寸预检:放在 SDK 导入和实例化之前,确保超限文件绝不上传 + size = os.path.getsize(wav_path) + if size > OPENAI_AUDIO_LIMIT_BYTES: + raise RuntimeError( + f"音频 {size / 1024 / 1024:.1f}MB 超过 OpenAI 25MB 上限," + "提前拒绝以避免无谓上传" + ) + + try: + from openai import OpenAI + from openai import AuthenticationError, RateLimitError, APIError + except ImportError: + raise RuntimeError("缺少依赖: pip install openai") + + if not _openai_warning_emitted: + print( + "[whisper] 已启用 OpenAI Whisper API," + "语音将上传至 OpenAI 服务器进行转录", + file=sys.stderr, flush=True, + ) + _openai_warning_emitted = True + + if _openai_client is None: + _openai_client = OpenAI(api_key=OPENAI_API_KEY) + + try: + with open(wav_path, "rb") as f: + result = _openai_client.audio.transcriptions.create( + model=OPENAI_WHISPER_MODEL, + file=f, + response_format="verbose_json", + ) + except AuthenticationError: + raise RuntimeError("OpenAI 鉴权失败 (401):检查 openai_api_key") + except RateLimitError: + raise RuntimeError("OpenAI 限流 (429):稍后重试") + except APIError as e: + raise RuntimeError(f"OpenAI API 错误: {e}") + + return { + "language": getattr(result, "language", "unknown"), + "text": (getattr(result, "text", "") or "").strip(), + } + + +def _transcribe_whisper_cpp(wav_path): + """通过 whisper-cpp CLI(Metal GPU 加速)转录。失败抛 RuntimeError。""" + binary = _resolve_whisper_cpp_binary() + if not binary: + raise RuntimeError("whisper-cpp binary 未找到。安装: brew install whisper-cpp") + model = _resolve_whisper_cpp_model() + if not model: + raise RuntimeError( + "whisper.cpp 模型未找到。通过 config.json whisper_cpp_model 指定路径," + "或下载: https://huggingface.co/ggerganov/whisper.cpp" + ) + + threads = WHISPER_CPP_THREADS + if not threads: + try: + threads = min(os.cpu_count() or 4, 8) + except Exception: + threads = 4 + + try: + cmd = [ + binary, + "-m", model, + "-f", wav_path, + "-l", WHISPER_CPP_LANGUAGE, + "-t", str(threads), + "--no-fallback", + "-otxt", + ] + subprocess.run(cmd, capture_output=True, text=True, timeout=120) + + txt_path = f"{wav_path}.txt" + if os.path.isfile(txt_path): + with open(txt_path, encoding="utf-8") as f: + text = f.read().strip() + os.unlink(txt_path) + return {"language": WHISPER_CPP_LANGUAGE, "text": text or ""} + return {"language": WHISPER_CPP_LANGUAGE, "text": ""} + except subprocess.TimeoutExpired: + raise RuntimeError("whisper-cpp 超时 (120s)") + except Exception as e: + raise RuntimeError(f"whisper-cpp 转录失败: {e}") + + +def _transcribe(wav_path, backend): + if backend == "openai": + return _transcribe_openai(wav_path) + if backend == "whisper_cpp": + return _transcribe_whisper_cpp(wav_path) + return _transcribe_local(wav_path) + + +@mcp.tool() +def transcribe_voice(chat_name: str, local_id: int) -> str: + """将微信语音消息转录为文字(自动检测语言,保留原语言)。 + + 首次转录会先解码 SILK 语音为 WAV,再用 Whisper 转录;结果缓存到 + voice_transcriptions.json,重复调用直接返回缓存(跳过 SILK 解码 + 和 Whisper 推理)。后端切换或本地模型升级(如 base → small)后, + 旧条目自动视为失效并重新转录。首次运行本地模型会下载约 145MB 权重。 + + 后端由 config.json 中 transcription_backend 字段控制(local/openai/whisper_cpp)。 + 详见 README "语音转录隐私" 章节。 + + 依赖: + - 本地后端: pip install silk-python openai-whisper + (silk-python 的 import 名为 pysilk) + - OpenAI 后端: pip install silk-python openai + - whisper_cpp 后端: brew install whisper-cpp (macOS) + + Args: + chat_name: 聊天对象的名字、备注名或wxid + local_id: 语音消息的 local_id(从 get_voice_messages 获取) + """ + username = resolve_username(chat_name) + if not username: + return f"找不到聊天对象: {chat_name}" + + sig = _cache_signature() + cache_key = _voice_transcription_cache_key(username, local_id) + cache = _load_voice_transcription_cache() + entry = cache.get(cache_key) + # 命中要求 backend + model_size 都匹配。 + # 旧条目 (PR #58 schema) 缺 backend 字段,回填默认 "local" 保持向前兼容 + # —— 那时唯一存在的后端就是 local,语义上等价。 + if ( + isinstance(entry, dict) + and "text" in entry + and entry.get("backend", "local") == sig["backend"] + and entry.get("model_size") == sig["model_size"] + ): + # 命中缓存:跳过 DB 查询、SILK 解码、转录。 + # 条目里存了 create_time,即使源 DB 中消息已被清理仍能返回历史转录。 + lang = entry.get("language", "unknown") + cached_ts = entry.get("create_time") + if isinstance(cached_ts, int): + time_label = datetime.fromtimestamp(cached_ts).strftime('%Y-%m-%d %H:%M') + else: + time_label = "-" + return f"[{time_label}] ({lang})\n{entry['text']}" + + # 未命中:本地后端才需要 whisper 包,云后端在 _transcribe_openai 内单独检查 + if sig["backend"] == "local": + try: + import whisper # noqa: F401 + except ImportError: + return "缺少依赖: pip install openai-whisper" + # SILK 解码两条路径都需要 + try: + import pysilk # noqa: F401 + except ImportError: + return "缺少依赖: pip install silk-python" + + row = _fetch_voice_row(username, local_id) + if row is None: + return f"找不到 local_id={local_id} 的语音消息" + + voice_data, create_time = row + wav_path, _ = _silk_to_wav(voice_data, create_time, username, local_id) + + try: + result = _transcribe(wav_path, sig["backend"]) + except RuntimeError as e: + return str(e) + text = result["text"] + lang = result["language"] + + # 写缓存:即使 text 为空也缓存(Whisper 偶尔对静音/极短片段返回空), + # 配合 backend + model_size 字段,切换后端或升级模型后会自动重转。 + cache[cache_key] = { + "text": text, + "language": lang, + "create_time": int(create_time), + "backend": sig["backend"], + "model_size": sig["model_size"], + } + _save_voice_transcription_cache() + + time_label = datetime.fromtimestamp(create_time).strftime('%Y-%m-%d %H:%M') + return f"[{time_label}] ({lang})\n{text}" if __name__ == "__main__": diff --git a/monitor_web.py b/monitor_web.py index 41859e6..8132211 100644 --- a/monitor_web.py +++ b/monitor_web.py @@ -9,6 +9,7 @@ http://localhost:5678 import hashlib, struct, os, sys, json, time, sqlite3, io, threading, queue, traceback import hmac as hmac_mod from concurrent.futures import ThreadPoolExecutor +from contextlib import closing from datetime import datetime from http.server import HTTPServer, BaseHTTPRequestHandler from socketserver import ThreadingMixIn @@ -435,10 +436,16 @@ def decrypt_wal_full(wal_path, out_path, enc_key): return patched, ms -def load_contact_names(): +def load_contact_names(db_path=None): + """加载联系人名字字典。 + + Args: + db_path: 指定的 contact.db 路径。None 则使用 CONTACT_CACHE(静态快照,可能过期)。 + 实时场景应传入 db_cache.get("contact/contact.db") 返回的路径,确保数据最新。 + """ names = {} try: - conn = sqlite3.connect(CONTACT_CACHE) + conn = sqlite3.connect(db_path or CONTACT_CACHE) for r in conn.execute("SELECT username, nick_name, remark FROM contact").fetchall(): names[r[0]] = r[2] if r[2] else r[1] if r[1] else r[0] conn.close() @@ -447,6 +454,96 @@ def load_contact_names(): return names +def _extract_pb_field_30(data): + """从 extra_buffer (protobuf) 中提取 Field #30 的字符串值(联系人标签ID)""" + if not data: + return None + pos = 0 + n = len(data) + while pos < n: + tag = 0 + shift = 0 + while pos < n: + b = data[pos]; pos += 1 + tag |= (b & 0x7f) << shift + if not (b & 0x80): + break + shift += 7 + field_num = tag >> 3 + wire_type = tag & 0x07 + if wire_type == 0: + while pos < n and data[pos] & 0x80: + pos += 1 + pos += 1 + elif wire_type == 2: + length = 0; shift = 0 + while pos < n: + b = data[pos]; pos += 1 + length |= (b & 0x7f) << shift + if not (b & 0x80): + break + shift += 7 + if field_num == 30: + try: + return data[pos:pos + length].decode('utf-8') + except Exception: + return None + pos += length + elif wire_type == 1: + pos += 8 + elif wire_type == 5: + pos += 4 + else: + break + return None + + +def load_contact_tags(): + """加载联系人标签及其成员""" + try: + conn = sqlite3.connect(CONTACT_CACHE) + try: + label_rows = conn.execute( + "SELECT label_id_, label_name_, sort_order_ FROM contact_label ORDER BY sort_order_" + ).fetchall() + except Exception: + conn.close() + return [] + if not label_rows: + conn.close() + return [] + + labels = {} + for lid, lname, sort_order in label_rows: + labels[lid] = {'id': lid, 'name': lname, 'sort_order': sort_order, 'members': []} + + names = load_contact_names() + rows = conn.execute( + "SELECT username, extra_buffer FROM contact WHERE extra_buffer IS NOT NULL" + ).fetchall() + conn.close() + + for username, buf in rows: + label_str = _extract_pb_field_30(buf) + if not label_str: + continue + display = names.get(username, username) + for lid_s in label_str.split(','): + try: + lid = int(lid_s.strip()) + except (ValueError, AttributeError): + continue + if lid in labels: + labels[lid]['members'].append({'username': username, 'display_name': display}) + + result = sorted(labels.values(), key=lambda t: t['sort_order']) + for t in result: + t['member_count'] = len(t['members']) + return result + except Exception: + return [] + + def format_msg_type(t): return { 1: '文本', 3: '图片', 34: '语音', 42: '名片', @@ -531,6 +628,10 @@ def _convert_hevc_to_jpeg(hevc_path, jpeg_path): # ============ 监听器 ============ class SessionMonitor: + # 改名/备注变更场景的刷新最小间隔(秒)。低于此间隔的 mtime 变化不触发 + # 全量 reload,避免微信高频写 contact.db 时 CPU 抖动。30s 是经验值。 + CONTACT_REFRESH_COOLDOWN = 30 + def __init__(self, enc_key, session_db, contact_names, db_cache=None, username_db_map=None): self.enc_key = enc_key self.session_db = session_db @@ -543,6 +644,43 @@ class SessionMonitor: self.patched_pages = 0 # 已显示消息去重: {(username, timestamp, base_msg_type), ...} self._shown_keys = set() + # contact.db mtime + 上次刷新时间,用于检测改名/备注变更 + self._contact_db_mtime = 0 + self._last_contact_refresh = 0 + + def _maybe_refresh_contacts(self): + """检测 contact.db mtime 变化时全量 reload 联系人缓存。 + + 覆盖三种变更场景: + - 新增联系人(之前 commit e86e00d 只覆盖了这种) + - 修改备注名(issue #67) + - 修改群名 + + 受 CONTACT_REFRESH_COOLDOWN 节流,避免 contact.db 高频变更时反复 reload。 + """ + if not self.db_cache: + return + try: + contact_path = self.db_cache.get(os.path.join("contact", "contact.db")) + except Exception as e: + print(f" [contact] 实时解密 contact.db 失败: {e}", flush=True) + return + if not contact_path: + return + try: + curr_mtime = os.path.getmtime(contact_path) + except OSError: + return + now = time.time() + if curr_mtime <= self._contact_db_mtime: + return # mtime 没变,跳过 + if now - self._last_contact_refresh < self.CONTACT_REFRESH_COOLDOWN: + return # cooldown 中,等下次 + refreshed = load_contact_names(contact_path) + if refreshed: + self.contact_names.update(refreshed) + self._contact_db_mtime = curr_mtime + self._last_contact_refresh = now def resolve_image(self, username, timestamp): """解密图片: username+timestamp → 解密后的图片文件名,失败返回 None""" @@ -797,6 +935,38 @@ class SessionMonitor: except OSError: pass + def _lookup_latest_local_id(self, username, timestamp): + """从 message_N.db 查指定 username 在 timestamp 的最大 local_id。 + + SessionTable 触发推送时调用此方法拿到对应 local_id,加到 _shown_keys 后 + `_check_hidden_messages` 路径能用 (username, local_id) 精确去重,避免 issue #79 + 的"同秒同类型多条消息 10 丢 4"。 + + 时机风险:SessionTable 写入比 message DB 早几毫秒,可能查不到。查不到时返回 None, + 调用方应选择跳过加 key(让 hidden 路径稍后补救并自己加 key)。 + """ + if not self.db_cache or not self.username_db_map: + return None + db_keys = self.username_db_map.get(username, []) + if not db_keys: + return None + table_name = f"Msg_{hashlib.md5(username.encode()).hexdigest()}" + for db_key in db_keys: + dec_path = self.db_cache.get(db_key) + if not dec_path: + continue + try: + with closing(sqlite3.connect(f"file:{dec_path}?mode=ro&immutable=1", uri=True)) as conn: + row = conn.execute( + f"SELECT MAX(local_id) FROM [{table_name}] WHERE create_time = ?", + (timestamp,), + ).fetchone() + if row and row[0]: + return row[0] + except Exception: + continue + return None + def _check_hidden_messages(self, username, prev_ts, curr_ts, curr_msg_type, display, is_group, sender): """检查时间窗口内是否有被 session 摘要覆盖的消息(文字、图片、表情等) @@ -827,10 +997,10 @@ class SessionMonitor: try: conn = sqlite3.connect(f"file:{dec_path}?mode=ro", uri=True) rows = conn.execute(f""" - SELECT create_time, local_type, message_content, WCDB_CT_message_content + SELECT local_id, create_time, local_type, message_content, WCDB_CT_message_content FROM [{table_name}] WHERE create_time >= ? AND create_time <= ? - ORDER BY create_time ASC + ORDER BY create_time ASC, local_id ASC """, (prev_ts, curr_ts)).fetchall() conn.close() all_rows.extend(rows) @@ -839,7 +1009,8 @@ class SessionMonitor: cache_failed = True break # 检查是否找到了 curr_ts 的消息(说明缓存是最新的) - has_curr = any(r[0] == curr_ts for r in all_rows) + # 注: r[1] 是 create_time(新 schema:local_id, create_time, local_type, ...) + has_curr = any(r[1] == curr_ts for r in all_rows) if has_curr or cache_failed: break # 缓存可能还没更新到最新数据,短暂等待后重试 @@ -860,11 +1031,13 @@ class SessionMonitor: print(f" [hidden] 缓存查到 {len(all_rows)} 条", flush=True) # 过滤出隐藏消息 + # 去重 key 用 local_id(之前用 (username, ts, base) 太粗,同秒同类型多条会被 + # 误判为重复,导致 issue #79 的 "10 丢 4") hidden_msgs = [] - for ts, lt, mc, ct in all_rows: + for local_id, ts, lt, mc, ct in all_rows: base = lt % 4294967296 if lt > 4294967296 else lt - # 跳过已显示的消息(精确匹配 username+timestamp+type) - if (username, ts, base) in self._shown_keys: + # 跳过已显示的消息(按 local_id 精确去重) + if (username, local_id) in self._shown_keys: continue # 解压 zstd if isinstance(mc, bytes) and ct == 4: @@ -874,7 +1047,7 @@ class SessionMonitor: mc = mc.decode('utf-8', errors='replace') if isinstance(mc, bytes) else '' elif isinstance(mc, bytes): mc = mc.decode('utf-8', errors='replace') - hidden_msgs.append((ts, base, mc or '')) + hidden_msgs.append((local_id, ts, base, mc or '')) print(f" [hidden] 找到 {len(hidden_msgs)} 条隐藏消息", flush=True) @@ -882,8 +1055,8 @@ class SessionMonitor: return global messages_log - for ts, base, mc in hidden_msgs: - self._shown_keys.add((username, ts, base)) + for local_id, ts, base, mc in hidden_msgs: + self._shown_keys.add((username, local_id)) msg_data = { 'time': datetime.fromtimestamp(ts).strftime('%H:%M:%S'), 'timestamp': ts, @@ -1144,6 +1317,26 @@ class SessionMonitor: 'des': des[:200] if des else '', 'items': items, } + elif app_type == 2000: + # 微信转账 — 复用 mcp_server 已有的解析器,单一来源避免字段漂移 + # (snake/camel 大小写、未来新 paysubtype 兜底)。 + import mcp_server # 已被 chat_export_helpers 验证 import 安全 + info = mcp_server._extract_transfer_info(appmsg) or {} + pay_memo = info.get('pay_memo', '') + paysubtype = info.get('paysubtype', '') + # 已知 paysubtype 显示中文 label;未知用空串而非"未知(paysubtype=N)", + # 避免 UI 出现内部诊断字串。日志侧若需要可看 chat history。 + direction = (info.get('paysubtype_label', '') + if paysubtype in mcp_server._TRANSFER_PAYSUBTYPE_LABEL + else '') + return { + 'type': 'transfer', + 'title': title or '微信转账', + 'direction': direction, + 'paysubtype': paysubtype, + 'fee_desc': info.get('fee_desc', ''), + 'pay_memo': pay_memo[:200] if pay_memo else '', + } else: # 其他子类型: 用 title 显示 if title: @@ -1273,13 +1466,11 @@ class SessionMonitor: is_new = prev and (curr['timestamp'] > prev['timestamp'] or (curr['timestamp'] == prev['timestamp'] and curr['msg_type'] != prev.get('msg_type'))) if is_new: + # contact.db mtime 变化时刷新缓存:覆盖新增联系人、改名、改备注、群名 + # 修改等场景(issue #46, #67)。受 cooldown 节流。 + self._maybe_refresh_contacts() display = self.contact_names.get(username, username) is_group = '@chatroom' in username - # 新群/新联系人不在缓存中时,重新加载联系人 - if display == username and username not in self.contact_names: - refreshed = load_contact_names() - self.contact_names.update(refreshed) - display = self.contact_names.get(username, username) sender = '' if is_group: sender = self.contact_names.get(curr['sender'], curr['sender_name'] or curr['sender']) @@ -1309,7 +1500,13 @@ class SessionMonitor: } new_msgs.append(msg_data) - self._shown_keys.add((username, curr['timestamp'], curr['msg_type'])) + # _shown_keys 改用 (username, local_id) 精确去重(issue #79)。 + # SessionTable 不带 local_id,去 message_N.db 查 max(local_id) WHERE create_time=curr_ts。 + # 查不到时(message DB 写入滞后于 SessionTable)跳过加 key,让 _check_hidden_messages + # 1 秒后查到时自己 emit 并加 key。这种情况下偶发轻微重复,但比丢消息好。 + latest_local_id = self._lookup_latest_local_id(username, curr['timestamp']) + if latest_local_id is not None: + self._shown_keys.add((username, latest_local_id)) # 图片消息: 后台异步解密(不阻塞轮询) if curr['msg_type'] == 3: @@ -1360,9 +1557,12 @@ class SessionMonitor: self.prev_state = curr_state - # 清理过期的去重 key(保留最近 5 分钟) - cutoff = int(time.time()) - 300 - self._shown_keys = {k for k in self._shown_keys if k[1] > cutoff} + # 清理 _shown_keys(按数量上限):local_id 不是时间戳不能按时间 prune。 + # 超过 10000 时保留 local_id 最大的 5000 条(最新消息优先)。 + # 实际触发频率:~几小时一次,set lookup 仍是 O(1)。 + if len(self._shown_keys) > 10000: + by_local_id = sorted(self._shown_keys, key=lambda k: k[1], reverse=True) + self._shown_keys = set(by_local_id[:5000]) def monitor_thread(enc_key, session_db, contact_names, db_cache=None, username_db_map=None): mon = SessionMonitor(enc_key, session_db, contact_names, db_cache, username_db_map) @@ -1477,6 +1677,10 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;b .chatlog-item{font-size:12px;color:#999;line-height:1.5;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .chatlog-item b{color:#bbb;font-weight:500} .chatlog-more{font-size:11px;color:#555;margin-top:4px} +.msg-transfer{display:inline-block;background:rgba(255,170,60,.1);border:1px solid rgba(255,170,60,.25);border-radius:8px;padding:8px 14px;margin-top:4px;min-width:180px} +.msg-transfer-head{font-size:13px;color:#ffb84d;font-weight:500} +.msg-transfer-amount{font-size:18px;color:#ffd28a;font-weight:600;margin-top:4px} +.msg-transfer-memo{font-size:11px;color:#999;margin-top:4px} a.msg-link{text-decoration:none;color:inherit} #lightbox{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.92);z-index:1000;cursor:zoom-out;justify-content:center;align-items:center} #lightbox.show{display:flex} @@ -1592,6 +1796,12 @@ function renderRich(r){ } return `
${body}
`; } + if(r.type==='transfer') { + let dirLabel = r.direction || '微信转账'; + let amount = r.fee_desc ? '
'+esc(r.fee_desc)+'
' : ''; + let memo = r.pay_memo ? '
备注: '+esc(r.pay_memo)+'
' : ''; + return `
💸 ${esc(dirLabel)}
${amount}${memo}
`; + } if(r.type==='voice') return `
🎤 语音 ${r.duration}s
`; if(r.type==='video') return `
🎬 视频${r.duration?' '+r.duration+'s':''}
`; return null; @@ -1815,9 +2025,31 @@ class Handler(BaseHTTPRequestHandler): self.end_headers() self.wfile.write(HTML_PAGE.encode('utf-8')) - elif self.path == '/api/history': + elif self.path.startswith('/api/history'): + parsed = urllib.parse.urlparse(self.path) + params = urllib.parse.parse_qs(parsed.query) + filter_chat = params.get('chat', [''])[0].strip().lower() + since_ts = 0 + try: + since_ts = int(params.get('since', ['0'])[0]) + except (ValueError, TypeError): + pass + limit_val = 500 + try: + limit_val = min(int(params.get('limit', ['500'])[0]), 2000) + except (ValueError, TypeError): + pass + with messages_lock: data = sorted(messages_log, key=lambda m: m.get('timestamp', 0)) + + if since_ts: + data = [m for m in data if m.get('timestamp', 0) > since_ts] + if filter_chat: + data = [m for m in data if filter_chat in m.get('chat', '').lower() + or filter_chat in m.get('username', '').lower()] + data = data[-limit_val:] + self.send_response(200) self.send_header('Content-Type', 'application/json; charset=utf-8') self.end_headers() @@ -1849,6 +2081,20 @@ class Handler(BaseHTTPRequestHandler): self.end_headers() self.wfile.write(data) + elif self.path.startswith('/api/tags'): + parsed = urllib.parse.urlparse(self.path) + params = urllib.parse.parse_qs(parsed.query) + name_filter = params.get('name', [''])[0].strip().lower() + + tags = load_contact_tags() + if name_filter: + tags = [t for t in tags if name_filter in t['name'].lower()] + + self.send_response(200) + self.send_header('Content-Type', 'application/json; charset=utf-8') + self.end_headers() + self.wfile.write(json.dumps(tags, ensure_ascii=False).encode('utf-8')) + elif self.path == '/stream': self.send_response(200) self.send_header('Content-Type', 'text/event-stream') @@ -1955,7 +2201,8 @@ def main(): print("Ctrl+C 停止\n", flush=True) try: - os.system(f'cmd.exe /c start http://localhost:{PORT}') + import webbrowser + webbrowser.open(f'http://localhost:{PORT}') except Exception: pass diff --git a/requirements.txt b/requirements.txt index ed397cf..c26a34f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,4 @@ zstandard>=0.22,<1 mcp>=1.0,<2 pilk>=0.2 pyinstaller>=6.0 +# 可选:进度条 (pip install tqdm) diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..83a73c5 --- /dev/null +++ b/setup.py @@ -0,0 +1,245 @@ +#!/usr/bin/env python3 +""" +WeChat Decrypt — 交互式配置向导 + +检测微信数据目录、选择转录 backend、生成 config.json。 + +用法: + python3 setup.py # 交互式配置 + python3 setup.py --check # 仅检查环境,不修改文件 +""" + +import argparse +import glob +import json +import os +import platform +import shutil +import subprocess +import sys + + +def detect_wechat_dir(): + """自动检测微信数据目录""" + system = platform.system().lower() + + if system == "darwin": + containers = ( + os.path.expanduser( + "~/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files" + ) + ) + if os.path.exists(containers): + dirs = [ + os.path.join(containers, d, "db_storage") + for d in os.listdir(containers) + if os.path.isdir(os.path.join(containers, d)) + ] + dirs = [d for d in dirs if os.path.exists(d)] + # 按修改时间排序,最近活跃的排最前 + dirs.sort(key=lambda d: os.path.getmtime(d), reverse=True) + return dirs if dirs else None + + elif system == "linux": + home = os.path.expanduser("~") + candidates = [ + os.path.join(home, "Documents", "xwechat_files"), + os.path.join(home, ".xwechat", "files"), + ] + for c in candidates: + if os.path.exists(c): + dirs = [ + os.path.join(c, d, "db_storage") + for d in os.listdir(c) + if os.path.isdir(os.path.join(c, d)) + ] + dirs = [d for d in dirs if os.path.exists(d)] + if dirs: + return dirs + + elif system == "windows": + localappdata = os.environ.get("LOCALAPPDATA", "") + candidates = [ + os.path.join(localappdata, "xwechat_files"), + os.path.join(os.environ.get("USERPROFILE", ""), "Documents", "xwechat_files"), + ] + for c in candidates: + if os.path.exists(c): + dirs = [ + os.path.join(c, d, "db_storage") + for d in os.listdir(c) + if os.path.isdir(os.path.join(c, d)) + ] + dirs = [d for d in dirs if os.path.exists(d)] + if dirs: + return dirs + + return None + + +def detect_transcription_backends(): + """检测可用的转录 backend""" + backends = {"local": True} # local 总是可用(如果安装了依赖) + + # whisper-cpp + if shutil.which("whisper-cpp") or shutil.which("whisper-cli"): + backends["whisper_cpp"] = True + model_dirs = [ + os.path.expanduser("~/Library/Application Support/whisper-cpp"), + os.path.expanduser("~/whisper-models"), + "/opt/homebrew/share/whisper-cpp/models", + ] + for md in model_dirs: + if os.path.exists(md): + models = [f for f in os.listdir(md) if f.startswith("ggml-") and f.endswith(".bin")] + if models: + backends["whisper_cpp_model"] = models[0] + break + else: + backends["whisper_cpp"] = False + + # openai + try: + import openai # noqa + backends["openai"] = True + except ImportError: + backends["openai"] = False + + return backends + + +def check_environment(): + """检查环境,返回状态信息""" + print("=== 环境检查 ===") + py_ver = sys.version.split()[0] + print(f"[Python] {py_ver}") + + # venv + in_venv = sys.prefix != sys.base_prefix + print(f"[venv] {'是' if in_venv else '否'}") + if not in_venv: + print(" 建议使用: python3 -m venv .venv && source .venv/bin/activate") + + # whisper-cpp + whisper_bin = shutil.which("whisper-cpp") or shutil.which("whisper-cli") + print(f"[whisper-cpp] {'✓ ' + whisper_bin if whisper_bin else '✗ 未安装 (brew install whisper-cpp)'}") + + # config + if os.path.exists("config.json"): + with open("config.json") as f: + cfg = json.load(f) + db_dir = cfg.get("db_dir", "?") + backend = cfg.get("transcription_backend", "未设置") + print(f"[config.json] ✓ (db_dir = {db_dir}, backend = {backend})") + else: + print("[config.json] ✗ 未找到") + + # 微信目录 + dirs = detect_wechat_dir() + if dirs: + print(f"[微信目录] 找到 {len(dirs)} 个:") + for d in dirs: + age_days = (os.path.getmtime(__file__ if '__file__' in dir() else 0) - os.path.getmtime(d)) / 86400 if os.path.exists(d) else 0 + print(f" {d}") + else: + print("[微信目录] 未找到自动检测路径") + + return dirs + + +def interactive_setup(): + """交互式配置向导""" + print("\n=== 微信解密工具 — 配置向导 ===\n") + + # 加载或创建配置 + config = {} + if os.path.exists("config.json"): + with open("config.json") as f: + config = json.load(f) + print(f"现有 config.json 已加载 ({len(config)} 个字段)") + print() + + # 微信数据目录 + detected = detect_wechat_dir() + if detected: + if len(detected) == 1: + chosen = detected[0] + print(f"[1/3] 微信数据目录: 自动检测到") + print(f" {chosen}") + else: + print(f"[1/3] 检测到 {len(detected)} 个微信数据目录:") + for i, d in enumerate(detected, 1): + print(f" [{i}] {d}") + try: + sel = int(input("\n请选择 (1-{}): ".format(len(detected))) or "1") + chosen = detected[sel - 1] + except (ValueError, IndexError): + chosen = detected[0] + config["db_dir"] = chosen + else: + print("[1/3] 微信数据目录: 未能自动检测") + default_path = os.path.expanduser("~/Documents/xwechat_files/your_wxid/db_storage") + chosen = input(f" 请手动输入路径 [{default_path}]: ") or default_path + config["db_dir"] = chosen + + # 转录 backend + backends = detect_transcription_backends() + print(f"\n[2/3] 语音转录 backend:") + print(f" [1] local — 本地 CPU 转录(默认,隐私最佳,速度较慢)") + status_w = "✓" if backends.get("whisper_cpp") else "✗ (brew install whisper-cpp)" + print(f" [2] whisper_cpp — GPU 加速 ({status_w})") + status_o = "✓" if backends.get("openai") else "✗ (pip install openai)" + print(f" [3] openai — API 转录 ({status_o})") + + try: + sel = int(input("\n 请选择 (1-3) [1]: ") or "1") + if sel == 2: + config["transcription_backend"] = "whisper_cpp" + if "whisper_cpp_model" in backends: + config["whisper_cpp_model"] = backends["whisper_cpp_model"] + elif sel == 3: + config["transcription_backend"] = "openai" + key = input(" 输入 OpenAI API Key: ").strip() + if key: + config["openai_api_key"] = key + else: + config["transcription_backend"] = "local" + except (ValueError, IndexError): + config["transcription_backend"] = "local" + + # 确认 + print(f"\n[3/3] 即将写入 config.json:") + print(json.dumps(config, indent=4)) + ans = input("\n 确认?(Y/n): ").strip().lower() + if ans in ("", "y", "yes"): + with open("config.json", "w", encoding="utf-8") as f: + json.dump(config, f, ensure_ascii=False, indent=4) + print(" config.json 已写入") + else: + print(" 已取消,config.json 未修改") + + print("\n配置完成!下一步:") + print(" python main.py status — 查看状态") + print(" python main.py decrypt — 解密数据库") + print(" python main.py export — 解密 + 导出聊天记录") + + +def main(): + parser = argparse.ArgumentParser( + description="WeChat Decrypt — 配置向导", + ) + parser.add_argument( + "--check", + action="store_true", + help="仅检查环境,不修改文件", + ) + args = parser.parse_args() + + if args.check: + check_environment() + else: + interactive_setup() + + +if __name__ == "__main__": + main() diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000..dae056b --- /dev/null +++ b/setup.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +# setup.sh — 一键安装所有依赖 + 编译 + 初始配置 +# 幂等(可重复运行)。适用 macOS / Linux / Windows (Git Bash / WSL) +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR" + +echo "========================================================" +echo " WeChat Decrypt — 环境配置" +echo "========================================================" + +# ── 检测平台 ────────────────────────────────────────────────── +OS="$(uname -s | tr '[:upper:]' '[:lower:]')" +case "$OS" in + darwin) PLATFORM="macos" ;; + linux) PLATFORM="linux" ;; + mingw*|msys*|cygwin*) PLATFORM="windows" ;; + *) echo "未识别的平台: $OS"; exit 1 ;; +esac +echo "[平台] $PLATFORM" + +# ── Python / venv ──────────────────────────────────────────── +PYTHON="python3" +if command -v python3 &>/dev/null; then + PYTHON="python3" +elif command -v python &>/dev/null; then + PYTHON="python" +else + echo "[错误] 未找到 Python 3。请安装: https://python.org" + exit 1 +fi + +if [ ! -d .venv ]; then + echo "[venv] 创建虚拟环境..." + "$PYTHON" -m venv .venv +else + echo "[venv] 虚拟环境已存在" +fi + +# 激活 venv(跨平台兼容) +if [ "$PLATFORM" = "windows" ]; then + VENV_PY=".venv/Scripts/python.exe" +else + VENV_PY=".venv/bin/python3" +fi + +if [ ! -f "$VENV_PY" ]; then + echo "[错误] venv Python 未找到: $VENV_PY" + exit 1 +fi + +echo "[pip] 安装 Python 依赖..." +"$VENV_PY" -m pip install --upgrade pip -q +"$VENV_PY" -m pip install -r requirements.txt -q +echo "[pip] 完成" + +# ── macOS 特有 ──────────────────────────────────────────────── +if [ "$PLATFORM" = "macos" ]; then + echo "" + echo "[macOS] ---" + + # Xcode CLT + if ! xcode-select -p &>/dev/null; then + echo "[xcode] 安装 Command Line Tools..." + xcode-select --install || true + echo "[xcode] 安装完成后请重新运行 setup.sh" + exit 0 + else + echo "[xcode] ✓" + fi + + # whisper-cpp + if command -v brew &>/dev/null; then + if ! command -v whisper-cpp &>/dev/null; then + echo "[whisper-cpp] 通过 Homebrew 安装..." + brew install whisper-cpp + else + echo "[whisper-cpp] ✓ 已安装" + fi + else + echo "[brew] 未安装 Homebrew,跳过 whisper-cpp 自动安装" + echo " 手动安装: brew install whisper-cpp" + fi + + # 编译 C 扫描器 + if [ ! -f find_all_keys_macos ]; then + echo "[编译] find_all_keys_macos..." + cc -O2 -o find_all_keys_macos find_all_keys_macos.c -framework Foundation 2>/dev/null && \ + codesign -s - find_all_keys_macos 2>/dev/null && \ + echo "[编译] ✓" || echo "[编译] 跳过(c 源文件不存在?)" + else + echo "[编译] find_all_keys_macos 已存在(重新编译: make build)" + fi + + # 微信重签名提示 + echo "" + echo "[注意] 首次使用需要重签名微信:" + echo " killall WeChat" + echo " sudo codesign --force --deep --sign - /Applications/WeChat.app" +fi + +# ── Linux 特有 ──────────────────────────────────────────────── +if [ "$PLATFORM" = "linux" ]; then + echo "" + echo "[Linux] 需要 root 或 CAP_SYS_PTRACE 来扫描微信进程内存。" + echo " 运行密钥提取时使用: sudo python3 find_all_keys.py" +fi + +# ── config.json ─────────────────────────────────────────────── +if [ ! -f config.json ]; then + echo "" + echo "[config] 生成 config.json 模板..." + cat > config.json << 'CONFIG_EOF' +{ + "db_dir": "/path/to/your/wxid/db_storage", + "keys_file": "all_keys.json", + "decrypted_dir": "decrypted", + "wechat_process": "WeChat", + "__comment_db_dir": "各平台默认路径见 README.md" +} +CONFIG_EOF + echo "[config] 已生成,请编辑 config.json 中的 db_dir 路径" +else + echo "[config] 已存在(跳过)" +fi + +# ── 完成 ────────────────────────────────────────────────────── +echo "" +echo "========================================================" +echo " 配置完成!下一步:" +echo "" +echo " 1. 编辑 config.json 确认 db_dir 路径" +echo " 2. 提取密钥并解密:" +echo " macOS: sudo ./find_all_keys_macos && $VENV_PY decrypt_db.py" +echo " Linux: sudo $VENV_PY find_all_keys.py && $VENV_PY decrypt_db.py" +echo " Windows: python find_all_keys.py && python decrypt_db.py" +echo "" +echo " 3. 批量导出聊天记录:" +echo " $VENV_PY export_all_chats.py" +echo "" +echo " 或使用 Makefile: make decrypt / make all" +echo "========================================================" \ No newline at end of file diff --git a/tests/test_chat_export_helpers.py b/tests/test_chat_export_helpers.py new file mode 100644 index 0000000..e532879 --- /dev/null +++ b/tests/test_chat_export_helpers.py @@ -0,0 +1,104 @@ +"""Tests for `chat_export_helpers._extract_content` group prefix handling. + +Issue #88: 群聊里的引用回复 / appmsg 卡片在 export_chat / export_all_chats +渲染成 link_or_file 且 content 为空。根因是 `_extract_content` 把带 +`wxid_xxx:\\n` 群前缀的原始 content 直接喂给 `_format_app_message_text`, +XML 解析器在前缀文本上崩溃。 + +修复后: +- 检测到 chat_username 是 @chatroom,先用 `_parse_message_content` 剥前缀 +- 把 `is_group=True` 透传给 `_format_app_message_text` 让引用回复的发送者 + 标签解析走群路径 +- 用真实的 contact names dict 而不是 `{}` 让 1-on-1 也能解出昵称 +""" +import unittest +from unittest.mock import patch + +import chat_export_helpers +import mcp_server + + +def _refer_appmsg(refer_content="hello world"): + """合成一条引用回复 appmsg。""" + return ( + '' + 'quote reply' + '57' + '' + '1' + f'{refer_content}' + 'wxid_orig_sender' + 'Original Sender' + '' + '' + ) + + +class ExtractContentGroupPrefixTests(unittest.TestCase): + def setUp(self): + # Skip decompression + self._patch = patch.object( + mcp_server, '_decompress_content', + side_effect=lambda content, ct: content, + ) + self._patch.start() + self._names_patch = patch.object( + mcp_server, 'get_contact_names', + return_value={'wxid_orig_sender': 'Alice'}, + ) + self._names_patch.start() + + def tearDown(self): + self._patch.stop() + self._names_patch.stop() + + def test_group_appmsg_with_prefix_renders_correctly(self): + """Issue #88: 群引用回复带 'wxid_xxx:\\n' 前缀,需要正确剥离后再解析。""" + prefixed = 'wxid_group_member:\n' + _refer_appmsg('hello group') + rendered, extras = chat_export_helpers._extract_content( + local_id=100, local_type=49, content=prefixed, ct=0, + chat_username='12345@chatroom', chat_display_name='Test Group', + ) + self.assertIsNotNone(rendered, "群引用回复不应该解析失败返回 None") + self.assertIn('quote reply', rendered) + self.assertIn('hello group', rendered, "被引用内容应该出现在渲染结果里") + + def test_one_on_one_appmsg_unaffected(self): + """1-on-1 场景没有前缀,行为应该保持不变。""" + rendered, _ = chat_export_helpers._extract_content( + local_id=100, local_type=49, content=_refer_appmsg('hi'), ct=0, + chat_username='wxid_friend', chat_display_name='Friend', + ) + self.assertIsNotNone(rendered) + self.assertIn('hi', rendered) + + def test_group_text_prefix_stripped(self): + """群里的 base=1 text 消息,content 也带前缀,应该被剥掉。""" + text, _ = chat_export_helpers._extract_content( + local_id=100, local_type=1, content='wxid_xx:\nhello group', + ct=0, chat_username='12345@chatroom', chat_display_name='Group', + ) + self.assertEqual(text, 'hello group') + + def test_one_on_one_text_unaffected(self): + """1-on-1 text 没有前缀概念,原样返回。""" + text, _ = chat_export_helpers._extract_content( + local_id=100, local_type=1, content='hello friend', ct=0, + chat_username='wxid_friend', chat_display_name='Friend', + ) + self.assertEqual(text, 'hello friend') + + def test_group_quote_uses_real_names(self): + """群引用回复的发送者标签应该用真实 contact names 解析。""" + prefixed = 'wxid_group_member:\n' + _refer_appmsg() + rendered, _ = chat_export_helpers._extract_content( + local_id=100, local_type=49, content=prefixed, ct=0, + chat_username='12345@chatroom', chat_display_name='Test Group', + ) + # is_group=True 走 group 分支:用 ref_user (wxid_orig_sender) 查 names + # → 'Alice'。原先 names={} 会回退到 displayname。 + self.assertIn('Alice', rendered, "应该用 names dict 解析出 'Alice'") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_chat_images_query_align.py b/tests/test_chat_images_query_align.py new file mode 100644 index 0000000..c6cca2f --- /dev/null +++ b/tests/test_chat_images_query_align.py @@ -0,0 +1,97 @@ +"""测试 get_chat_images 新增的 offset / start_time / end_time 参数。""" +import os +import sys +from unittest.mock import patch + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import mcp_server + + +def _img(local_id, create_time, md5=None, size=None): + info = {'local_id': local_id, 'create_time': create_time, 'md5': md5} + if size is not None: + info['size'] = size + return info + + +def _run_with(shard_images_map, **kwargs): + """Helper: stub collaborators and call get_chat_images with new kwargs.""" + shards = [{'db_path': k, 'table_name': 'Msg_x'} for k in shard_images_map] + captured = {'calls': []} + + def fake_list(db_path, table_name, username, limit=20, start_ts=None, end_ts=None): + captured['calls'].append({ + 'db_path': db_path, 'limit': limit, 'start_ts': start_ts, 'end_ts': end_ts, + }) + return shard_images_map.get(db_path, []) + + with patch.object(mcp_server, 'resolve_username', return_value='wxid_demo'), \ + patch.object(mcp_server, 'get_contact_names', return_value={'wxid_demo': 'Demo'}), \ + patch.object(mcp_server, '_find_msg_tables_for_user', return_value=shards), \ + patch.object(mcp_server._image_resolver, 'list_chat_images', side_effect=fake_list): + return mcp_server.get_chat_images('Demo', **kwargs), captured + + +def test_invalid_offset_returns_error(): + out, _ = _run_with({}, offset=-1) + assert '错误' in out + + +def test_invalid_time_range_returns_error(): + """start_time 晚于 end_time 应报错。""" + out, _ = _run_with({}, start_time='2026-05-10', end_time='2026-05-01') + assert '错误' in out + + +def test_candidate_limit_includes_offset(): + """每 shard 拉 limit+offset 张候选, 保证全局分页能切到正确的页。""" + _, captured = _run_with({'/a': [_img(1, 1000)]}, limit=5, offset=10) + assert captured['calls'][0]['limit'] == 15 + + +def test_start_end_ts_forwarded_to_shard_query(): + """start_time / end_time 解析为 unix 秒后透传给 shard 查询。""" + _, captured = _run_with( + {'/a': []}, + start_time='2026-05-01', + end_time='2026-05-31', + ) + call = captured['calls'][0] + assert call['start_ts'] is not None + assert call['end_ts'] is not None + assert call['start_ts'] < call['end_ts'] + + +def test_offset_slices_paged_window(): + """offset=2, limit=2 取全局排序后第 3-4 张图片。""" + shard_a = [_img(1, 1100), _img(2, 1000)] + shard_b = [_img(3, 1300), _img(4, 1200)] + out, _ = _run_with({'/a': shard_a, '/b': shard_b}, limit=2, offset=2) + # 全局排序后顺序: 1300, 1200, 1100, 1000 → 第 3-4 是 1100, 1000 → local_id 1, 2 + assert 'local_id=1' in out + assert 'local_id=2' in out + assert 'local_id=3' not in out + assert 'local_id=4' not in out + + +def test_header_shows_time_range_when_given(): + shard_a = [_img(1, 1000, md5='abc')] + out, _ = _run_with({'/a': shard_a}, start_time='2026-05-01') + assert '时间范围' in out + assert '2026-05-01' in out + + +def test_header_shows_offset_limit(): + shard_a = [_img(1, 1000, md5='abc')] + out, _ = _run_with({'/a': shard_a}, limit=10, offset=20) + assert 'offset=20' in out + assert 'limit=10' in out + + +def test_default_behavior_unchanged(): + """不传新参数时行为与旧接口一致 — offset=0 切片就是 [:limit]。""" + shard_a = [_img(1, 1100, md5='a1'), _img(2, 1000, md5='a2')] + out, _ = _run_with({'/a': shard_a}) + assert 'local_id=1' in out + assert 'local_id=2' in out diff --git a/tests/test_decode_image_v2.py b/tests/test_decode_image_v2.py new file mode 100644 index 0000000..15e1132 --- /dev/null +++ b/tests/test_decode_image_v2.py @@ -0,0 +1,481 @@ +"""ImageResolver 在 V2 加密格式下的端到端解密测试。 + +覆盖: +- v2_decrypt_file 能正确还原 AES-ECB + XOR 混合加密的合成数据 +- decrypt_dat_file 按 magic 自动分发 V2 / V1 / 老 XOR 三条路径 +- ImageResolver 通过 __init__ 注入 aes_key/xor_key 后,能端到端解密 V2 .dat +- 没传 aes_key 时遇到 V2 文件返回结构化错误,而不是 crash 或返回错误数据 +- 默认参数下老 XOR 路径不受影响,保持向后兼容 +""" +import hashlib +import os +import sqlite3 +import struct +import tempfile +import unittest + +from Crypto.Cipher import AES +from Crypto.Util import Padding + +from decode_image import ( + V1_MAGIC_FULL, + V2_MAGIC_FULL, + ImageResolver, + decrypt_dat_file, + v2_decrypt_file, +) + + +# 测试用 16 字节 AES key (任意值,仅用于合成测试数据) +TEST_AES_KEY = b'1234567890abcdef' +TEST_XOR_KEY = 0x37 +# 最小可识别的 PNG payload (含 IHDR 和 IEND chunk),长度 88 字节 +TEST_PNG_PAYLOAD = ( + b'\x89PNG\r\n\x1a\n' + + b'\x00\x00\x00\rIHDR' + + b'\x00' * 64 + + b'IEND\xaeB`\x82' +) + + +def _build_v2_dat(plaintext, aes_size, xor_size, + aes_key=TEST_AES_KEY, xor_key=TEST_XOR_KEY, + magic=V2_MAGIC_FULL): + """构造合成的 V2 / V1 .dat 字节串。 + + 布局: [6B magic][4B aes_size LE][4B xor_size LE][1B pad][AES-ECB][raw][XOR] + aes_size / xor_size 是明文字段长度,AES 段做 PKCS7 padding 后向上对齐到 16 倍数。 + """ + if aes_size + xor_size > len(plaintext): + raise ValueError("aes_size + xor_size 超过 plaintext 长度") + aes_plain = plaintext[:aes_size] + raw_plain = plaintext[aes_size:len(plaintext) - xor_size] + xor_plain = plaintext[len(plaintext) - xor_size:] + + cipher = AES.new(aes_key[:16], AES.MODE_ECB) + aes_cipher = cipher.encrypt(Padding.pad(aes_plain, AES.block_size)) + xor_cipher = bytes(b ^ xor_key for b in xor_plain) + + header = magic + struct.pack(' chat_id 限定 + message_local_type=3 过滤图片。 + + packed_info 里嵌入 extract_md5_from_packed_info 期望的 protobuf marker + (\\x12\\x22\\x0a\\x20) 加 32 字节 ASCII hex MD5。 + + Args: + extra_rows: 额外 (chat_id, message_local_id, message_local_type, + message_create_time, file_md5) 元组列表, 用于构造同 local_id + 跨 chat / 同 chat 多版本的歧义场景。 + """ + marker = b'\x12\x22\x0a\x20' + def _packed(md5_hex): + return b'\x00' * 8 + marker + md5_hex.encode('ascii') + b'\x00' * 4 + + conn = sqlite3.connect(path) + try: + conn.execute(""" + CREATE TABLE MessageResourceInfo ( + message_id INTEGER PRIMARY KEY, + chat_id INTEGER, + sender_id INTEGER, + message_local_type INTEGER, + message_create_time INTEGER, + message_local_id INTEGER, + message_svr_id INTEGER, + message_origin_source INTEGER, + packed_info BLOB + ) + """) + conn.execute( + "CREATE TABLE ChatName2Id (user_name TEXT PRIMARY KEY, update_time INTEGER)" + ) + conn.execute( + "INSERT INTO ChatName2Id (rowid, user_name, update_time) VALUES (?, ?, ?)", + (chat_id, username, message_create_time), + ) + next_msg_id = 1 + conn.execute( + "INSERT INTO MessageResourceInfo " + "(message_id, chat_id, sender_id, message_local_type, message_create_time, " + " message_local_id, message_svr_id, message_origin_source, packed_info) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + (next_msg_id, chat_id, 0, message_local_type, message_create_time, + local_id, 0, 0, _packed(file_md5)), + ) + next_msg_id += 1 + for extra in extra_rows: + ex_chat_id, ex_local_id, ex_type, ex_ctime, ex_md5 = extra + conn.execute( + "INSERT INTO MessageResourceInfo " + "(message_id, chat_id, sender_id, message_local_type, message_create_time, " + " message_local_id, message_svr_id, message_origin_source, packed_info) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + (next_msg_id, ex_chat_id, 0, ex_type, ex_ctime, ex_local_id, 0, 0, _packed(ex_md5)), + ) + next_msg_id += 1 + conn.commit() + finally: + conn.close() + + +class TestV2DecryptSynthetic(unittest.TestCase): + """v2_decrypt_file / decrypt_dat_file 在合成数据上的正确性""" + + def test_v2_round_trip_recovers_payload(self): + # 合成 V2 .dat 解密后字节级等于原始 payload + with tempfile.TemporaryDirectory() as td: + dat_path = os.path.join(td, "test.dat") + with open(dat_path, 'wb') as f: + f.write(_build_v2_dat(TEST_PNG_PAYLOAD, aes_size=32, xor_size=16)) + + out_path, fmt = v2_decrypt_file( + dat_path, aes_key=TEST_AES_KEY, xor_key=TEST_XOR_KEY + ) + self.assertIsNotNone(out_path) + self.assertEqual(fmt, 'png') + with open(out_path, 'rb') as f: + self.assertEqual(f.read(), TEST_PNG_PAYLOAD) + + def test_decrypt_dat_file_routes_v2_by_magic(self): + # decrypt_dat_file 看到 V2 magic 应自动走 V2 路径 + with tempfile.TemporaryDirectory() as td: + dat_path = os.path.join(td, "test.dat") + with open(dat_path, 'wb') as f: + f.write(_build_v2_dat(TEST_PNG_PAYLOAD, aes_size=32, xor_size=16)) + + out_path, fmt = decrypt_dat_file( + dat_path, aes_key=TEST_AES_KEY, xor_key=TEST_XOR_KEY + ) + self.assertIsNotNone(out_path) + self.assertEqual(fmt, 'png') + + def test_decrypt_dat_file_v1_uses_fixed_key(self): + # V1 magic 走固定 key,即便外部不传 aes_key 也能解密 + v1_fixed_key = b'cfcd208495d565ef' # md5("0")[:16],由 v2_decrypt_file 内部使用 + with tempfile.TemporaryDirectory() as td: + dat_path = os.path.join(td, "test.dat") + with open(dat_path, 'wb') as f: + f.write(_build_v2_dat( + TEST_PNG_PAYLOAD, aes_size=32, xor_size=16, + aes_key=v1_fixed_key, magic=V1_MAGIC_FULL, + )) + + out_path, fmt = decrypt_dat_file( + dat_path, aes_key=None, xor_key=TEST_XOR_KEY + ) + self.assertIsNotNone(out_path) + self.assertEqual(fmt, 'png') + + def test_decrypt_dat_file_legacy_xor_route(self): + # 老 XOR 格式 (无 V1/V2 magic),decrypt_dat_file 应回退到 xor_decrypt_file 不需要 aes_key + xor_key = 0x37 + with tempfile.TemporaryDirectory() as td: + dat_path = os.path.join(td, "test.dat") + with open(dat_path, 'wb') as f: + f.write(bytes(b ^ xor_key for b in TEST_PNG_PAYLOAD)) + + out_path, fmt = decrypt_dat_file(dat_path, aes_key=None) + self.assertIsNotNone(out_path) + self.assertEqual(fmt, 'png') + + def test_v2_accepts_str_aes_key_from_config(self): + # 真实场景下 aes_key 来自 config.json,是 ASCII string 不是 bytes; + # v2_decrypt_file 内部应自行 encode,避免 TypeError + with tempfile.TemporaryDirectory() as td: + dat_path = os.path.join(td, "test.dat") + with open(dat_path, 'wb') as f: + f.write(_build_v2_dat(TEST_PNG_PAYLOAD, aes_size=32, xor_size=16)) + + out_path, fmt = decrypt_dat_file( + dat_path, aes_key=TEST_AES_KEY.decode('ascii'), xor_key=TEST_XOR_KEY, + ) + self.assertIsNotNone(out_path) + self.assertEqual(fmt, 'png') + + def test_v2_accepts_str_xor_key_from_config(self): + # 与 aes_key 的 str 处理对称: config.json 里把 xor_key 写成 "0x88" / "136" 也应能正常解密 + with tempfile.TemporaryDirectory() as td: + dat_path = os.path.join(td, "test.dat") + with open(dat_path, 'wb') as f: + f.write(_build_v2_dat(TEST_PNG_PAYLOAD, aes_size=32, xor_size=16)) + + out_path, fmt = decrypt_dat_file( + dat_path, aes_key=TEST_AES_KEY, xor_key=hex(TEST_XOR_KEY), + ) + self.assertIsNotNone(out_path) + self.assertEqual(fmt, 'png') + + def test_v2_wxgf_payload_returns_hevc_format(self): + # 微信 V2 动图 (wxgf 裸流 HEVC) 解密后 fmt='hevc',输出文件以 .hevc 结尾; + # 当前 ImageResolver 不再向 JPEG 转 (那是 monitor_web 的职责),保持原样输出。 + wxgf_payload = b'wxgf' + b'\x00' * 84 # 88 字节,与 PNG payload 同长度,避免改 aes/xor sizes + with tempfile.TemporaryDirectory() as td: + dat_path = os.path.join(td, "test.dat") + with open(dat_path, 'wb') as f: + f.write(_build_v2_dat(wxgf_payload, aes_size=32, xor_size=16)) + + out_path, fmt = decrypt_dat_file( + dat_path, aes_key=TEST_AES_KEY, xor_key=TEST_XOR_KEY, + ) + self.assertIsNotNone(out_path) + self.assertEqual(fmt, 'hevc') + self.assertTrue(out_path.endswith('.hevc')) + + def test_v2_rejects_wrong_aes_key(self): + # AES key 错时 detect_image_format 返回 'bin' (magic 不识别),v2_decrypt_file + # 应拒绝写出 .bin 垃圾文件并返回 (None, None),让 caller 知道解密失败。 + with tempfile.TemporaryDirectory() as td: + dat_path = os.path.join(td, "test.dat") + with open(dat_path, 'wb') as f: + f.write(_build_v2_dat(TEST_PNG_PAYLOAD, aes_size=32, xor_size=16)) + + wrong_aes_key = b'wrongkey00000000' + out_path, fmt = v2_decrypt_file( + dat_path, aes_key=wrong_aes_key, xor_key=TEST_XOR_KEY, + ) + self.assertIsNone(out_path) + self.assertIsNone(fmt) + + def test_v2_rejects_wrong_xor_key_jpg_trailer(self): + # JPG 必须以 FF D9 (EOI) 收尾。XOR key 错时尾部 16 字节乱码, + # FF D9 被破坏,触发尾部 magic 校验失败。 + jpg_payload = b'\xff\xd8\xff' + b'\x00' * 83 + b'\xff\xd9' # 88 bytes, FF D9 在末尾 + with tempfile.TemporaryDirectory() as td: + dat_path = os.path.join(td, "test.dat") + with open(dat_path, 'wb') as f: + f.write(_build_v2_dat(jpg_payload, aes_size=32, xor_size=16)) + + # 翻转所有 XOR 字节: TEST_XOR_KEY ^ 0xff 保证每字节都错位 + out_path, fmt = v2_decrypt_file( + dat_path, aes_key=TEST_AES_KEY, xor_key=TEST_XOR_KEY ^ 0xff, + ) + self.assertIsNone(out_path) + self.assertIsNone(fmt) + + def test_v2_rejects_wrong_xor_key_png_iend(self): + # PNG 末尾 12 字节必须含 IEND chunk。XOR key 错时 IEND 被破坏。 + with tempfile.TemporaryDirectory() as td: + dat_path = os.path.join(td, "test.dat") + with open(dat_path, 'wb') as f: + f.write(_build_v2_dat(TEST_PNG_PAYLOAD, aes_size=32, xor_size=16)) + + out_path, fmt = v2_decrypt_file( + dat_path, aes_key=TEST_AES_KEY, xor_key=TEST_XOR_KEY ^ 0xff, + ) + self.assertIsNone(out_path) + self.assertIsNone(fmt) + + def test_v2_skip_xor_validation_when_xor_size_zero(self): + # xor_size < 2 时没有 XOR 段(或样本不足以验证),不应触发尾部 magic 校验。 + # 构造 xor_size=0 的 PNG (整张图都在 AES + raw 段),xor_key 实际不参与解密。 + with tempfile.TemporaryDirectory() as td: + dat_path = os.path.join(td, "test.dat") + with open(dat_path, 'wb') as f: + f.write(_build_v2_dat(TEST_PNG_PAYLOAD, aes_size=32, xor_size=0)) + + # xor_key 传 0 也应成功 (XOR 段长度 0) + out_path, fmt = v2_decrypt_file( + dat_path, aes_key=TEST_AES_KEY, xor_key=0x00, + ) + self.assertIsNotNone(out_path) + self.assertEqual(fmt, 'png') + + def test_v2_wxgf_skips_trailer_validation(self): + # wxgf (HEVC 裸流) 没有强制 trailer signature,XOR key 错时也不应被尾部校验误杀 + # (wxgf 路径在 elif 链前面命中,直接 fmt='hevc',不进入 XOR 校验分支)。 + # 这里验证:即便 XOR key 错导致末尾字节乱码,只要 wxgf magic 在头部正确, + # 仍按 hevc 输出 — 因为我们只校验 jpg/png,其他格式跳过。 + wxgf_payload = b'wxgf' + b'\x00' * 84 + with tempfile.TemporaryDirectory() as td: + dat_path = os.path.join(td, "test.dat") + with open(dat_path, 'wb') as f: + f.write(_build_v2_dat(wxgf_payload, aes_size=32, xor_size=16)) + + out_path, fmt = v2_decrypt_file( + dat_path, aes_key=TEST_AES_KEY, xor_key=TEST_XOR_KEY ^ 0xff, + ) + self.assertIsNotNone(out_path) + self.assertEqual(fmt, 'hevc') + + +class TestImageResolverV2(unittest.TestCase): + """ImageResolver 端到端:从 local_id 到解密文件,验证 V2 keys 注入路径""" + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + tmp = self._tmp.name + + self.wechat_base = os.path.join(tmp, "wechat") + self.out_dir = os.path.join(tmp, "decoded") + os.makedirs(self.out_dir, exist_ok=True) + + self.username = "wxid_test123" + self.local_id = 42 + self.file_md5 = "0123456789abcdef0123456789abcdef" + + username_hash = hashlib.md5(self.username.encode()).hexdigest() + img_dir = os.path.join( + self.wechat_base, "msg", "attach", username_hash, "2025-08", "Img" + ) + os.makedirs(img_dir, exist_ok=True) + self.dat_path = os.path.join(img_dir, f"{self.file_md5}.dat") + with open(self.dat_path, 'wb') as f: + f.write(_build_v2_dat(TEST_PNG_PAYLOAD, aes_size=32, xor_size=16)) + + self.db_path = os.path.join(tmp, "message_resource.db") + _make_resource_db(self.db_path, self.local_id, self.file_md5) + self.cache = _FakeCache({"message/message_resource.db": self.db_path}) + + def test_decode_image_v2_with_keys(self): + resolver = ImageResolver( + self.wechat_base, self.out_dir, self.cache, + aes_key=TEST_AES_KEY, xor_key=TEST_XOR_KEY, + ) + result = resolver.decode_image(self.username, self.local_id) + self.assertTrue(result['success'], msg=result) + self.assertEqual(result['format'], 'png') + self.assertEqual(result['md5'], self.file_md5) + with open(result['path'], 'rb') as f: + self.assertEqual(f.read(), TEST_PNG_PAYLOAD) + + def test_decode_image_v2_missing_aes_key_returns_error(self): + # 没传 aes_key 时遇到 V2 文件应返回 success=False,而不是 crash 或写入错误文件 + resolver = ImageResolver( + self.wechat_base, self.out_dir, self.cache, aes_key=None, + ) + result = resolver.decode_image(self.username, self.local_id) + self.assertFalse(result['success']) + self.assertIn('AES key', result['error']) + self.assertEqual(result['md5'], self.file_md5) + + def test_decode_image_default_args_preserve_legacy_xor(self): + # 默认参数 (aes_key=None) + 老 XOR .dat 应保持向后兼容 + os.unlink(self.dat_path) + legacy_xor_key = 0x37 + with open(self.dat_path, 'wb') as f: + f.write(bytes(b ^ legacy_xor_key for b in TEST_PNG_PAYLOAD)) + + resolver = ImageResolver(self.wechat_base, self.out_dir, self.cache) + result = resolver.decode_image(self.username, self.local_id) + self.assertTrue(result['success'], msg=result) + self.assertEqual(result['format'], 'png') + + def test_decode_image_disambiguates_local_id_across_chats(self): + """同 local_id 跨 chat 重复时, 必须按 username -> chat_id 选对; 否则会拿到 + 别的 chat 的 MD5 (或视频 type=43 的 packed_info), 解出错图。 + + 生产 DB 上同一个 message_local_id 实测会出现在 5+ 个不同 chat 里, + 其中混有图片 (type=3) / 视频 (type=43) / 群聊 / 私聊, 必须 chat-scoped + + type 过滤才能定位。 + """ + os.unlink(self.db_path) + other_md5 = "f" * 32 + video_md5 = "a" * 32 + _make_resource_db( + self.db_path, self.local_id, self.file_md5, + username=self.username, chat_id=7, + message_create_time=1778487726, + extra_rows=[ + # 另一个 chat 同 local_id 同图片类型, MD5 不同 —— 选错就拿这个 + (5, self.local_id, 3, 1700000000, other_md5), + # 又一个 chat 同 local_id 但是视频 (type=43), 应被 type 过滤 + (132, self.local_id, 43, 1750000000, video_md5), + ], + ) + # 给冲突 chat 也注册 user_name, 否则 chat-scope 等价 + conn = sqlite3.connect(self.db_path) + conn.execute( + "INSERT INTO ChatName2Id (rowid, user_name, update_time) VALUES (5, ?, 0), (132, ?, 0)", + ("other_chat_wxid", "video_chat_wxid"), + ) + conn.commit() + conn.close() + + resolver = ImageResolver( + self.wechat_base, self.out_dir, self.cache, + aes_key=TEST_AES_KEY, xor_key=TEST_XOR_KEY, + ) + result = resolver.decode_image(self.username, self.local_id) + self.assertTrue(result['success'], msg=result) + # 必须拿目标 chat 的 MD5, 不是 other_chat 也不是视频 + self.assertEqual(result['md5'], self.file_md5) + + def test_decode_image_picks_latest_when_same_chat_local_id_reused(self): + """活跃 chat 里 local_id 会被复用 (实测同 chat 同 local_id 最多 7 条); + 默认应返回 message_create_time 最新的那张, 对应用户最近一次 reference。 + """ + os.unlink(self.db_path) + old_md5 = "c" * 32 + # self.file_md5 / self.local_id 在 _make_resource_db 默认插入为 "latest" 那条 + _make_resource_db( + self.db_path, self.local_id, self.file_md5, + username=self.username, chat_id=1, + message_create_time=1778487726, + extra_rows=[ + # 同 chat 同 local_id 但更早, 不应该被选中 + (1, self.local_id, 3, 1700000000, old_md5), + ], + ) + + resolver = ImageResolver( + self.wechat_base, self.out_dir, self.cache, + aes_key=TEST_AES_KEY, xor_key=TEST_XOR_KEY, + ) + result = resolver.decode_image(self.username, self.local_id) + self.assertTrue(result['success'], msg=result) + self.assertEqual(result['md5'], self.file_md5) + + def test_decode_image_unknown_chat_returns_error(self): + """username 在 ChatName2Id 里找不到时, 应返回结构化错误而不是 crash 或乱选 row。""" + resolver = ImageResolver( + self.wechat_base, self.out_dir, self.cache, + aes_key=TEST_AES_KEY, xor_key=TEST_XOR_KEY, + ) + result = resolver.decode_image("wxid_does_not_exist", self.local_id) + self.assertFalse(result['success']) + self.assertIn('wxid_does_not_exist', result['error']) + + def test_decode_image_v1_no_aes_key_uses_fixed_key(self): + # V1 magic 不会被 is_v2_format guard 拦截 (V1 magic 是 \x07\x08V1, V2 是 \x07\x08V2); + # 即便 ImageResolver(aes_key=None), V1 文件也应通过 decrypt_dat_file 内置固定 key 解密 + os.unlink(self.dat_path) + v1_fixed_key = b'cfcd208495d565ef' + with open(self.dat_path, 'wb') as f: + f.write(_build_v2_dat( + TEST_PNG_PAYLOAD, aes_size=32, xor_size=16, + aes_key=v1_fixed_key, magic=V1_MAGIC_FULL, + )) + + # xor_key 必须跟 _build_v2_dat 加密时用的一致,否则 XOR 段乱码, + # 触发新的尾部 magic 校验失败 (PNG IEND chunk 错位)。 + resolver = ImageResolver( + self.wechat_base, self.out_dir, self.cache, + aes_key=None, xor_key=TEST_XOR_KEY, + ) + result = resolver.decode_image(self.username, self.local_id) + self.assertTrue(result['success'], msg=result) + self.assertEqual(result['format'], 'png') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_decode_images_batch.py b/tests/test_decode_images_batch.py new file mode 100644 index 0000000..6bb4180 --- /dev/null +++ b/tests/test_decode_images_batch.py @@ -0,0 +1,295 @@ +"""decode_image.decode_all_dats() batch CLI 行为测试。 + +覆盖: +- 路径扫描:glob 命中 attach///Img/*.dat +- 路径解析:chat_hash / YYYY-MM 提取,_t / _h 后缀移除归并到原图 basename +- 幂等性:目标 basename 已存在(任何扩展名)时跳过;--force 强制重解 +- 原子写:写到 tmp 再 os.replace;失败/异常路径不留 .tmp +- V2 无 key:计入 skipped_no_key 而非 failed +- 错误隔离:单文件异常不阻塞批次;返回失败计数 + +decrypt_dat_file 用 mock 隔离(避免依赖真实加密图片);is_v2_format +单独覆盖真实 magic 检测路径。 +""" +import os +import struct +import tempfile +import unittest +from contextlib import redirect_stderr +import io +from unittest.mock import patch + +import decode_image + + +def _write(path, data): + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "wb") as f: + f.write(data) + + +def _v2_magic_bytes(): + # 仅用于让 is_v2_format() 返回 True + return decode_image.V2_MAGIC_FULL + struct.pack(" 形式只保留第一段下划线之内的内容 + self.assertEqual(fkm.normalize_wxid("wxid_abc123_extra_more"), "wxid_abc123") + + def test_wxid_no_extra_segments(self): + self.assertEqual(fkm.normalize_wxid("wxid_abc123"), "wxid_abc123") + + def test_account_with_4char_alnum_suffix_stripped(self): + # macOS 路径常见:your_wxid_a1b2 → your_wxid + self.assertEqual(fkm.normalize_wxid("your_wxid_a1b2"), "your_wxid") + + def test_account_without_recognizable_suffix_returned_asis(self): + self.assertEqual(fkm.normalize_wxid("simple"), "simple") + self.assertEqual(fkm.normalize_wxid("foo_bar_baz"), "foo_bar_baz") # baz 是 3 char + + def test_empty_or_none_returns_empty(self): + self.assertEqual(fkm.normalize_wxid(""), "") + self.assertEqual(fkm.normalize_wxid(None), "") + self.assertEqual(fkm.normalize_wxid(" "), "") + + +class DeriveImageKeysTests(unittest.TestCase): + def test_xor_is_low_byte_of_code(self): + xor, _ = fkm.derive_image_keys(0x12345678, "anything") + self.assertEqual(xor, 0x78) + + def test_xor_handles_small_codes(self): + self.assertEqual(fkm.derive_image_keys(0xFF, "x")[0], 0xFF) + self.assertEqual(fkm.derive_image_keys(0x00, "x")[0], 0x00) + + def test_aes_is_md5_hex_truncated_to_16(self): + # Golden value: 合成 fixture (uin=12345678) 派生; 算法正确性由公式 + # md5(str(uin)+wxid)[:16] 决定, 测试值无需对应任何真实账号。 + xor, aes = fkm.derive_image_keys(12345678, "your_wxid") + self.assertEqual(xor, 0x4E) # 12345678 & 0xFF + self.assertEqual(aes, "a0c093edddc98490") + + def test_aes_does_not_normalize_wxid_internally(self): + # 归一化由调用方负责;不同 wxid 字符串产出不同 key + _, aes_full = fkm.derive_image_keys(12345678, "your_wxid_a1b2") + _, aes_norm = fkm.derive_image_keys(12345678, "your_wxid") + self.assertNotEqual(aes_full, aes_norm) + + +class DeriveKvcommDirCandidatesTests(unittest.TestCase): + def test_canonical_macos_path_is_first_candidate(self): + db_dir = ( + "/Users/x/Library/Containers/com.tencent.xinWeChat/Data/Documents/" + "xwechat_files/wxid_abc/db_storage" + ) + candidates = fkm.derive_kvcomm_dir_candidates(db_dir) + self.assertGreater(len(candidates), 0) + expected_primary = ( + "/Users/x/Library/Containers/com.tencent.xinWeChat/Data/Documents/" + "app_data/net/kvcomm" + ) + self.assertEqual(candidates[0], expected_primary) + + def test_returns_multiple_candidates(self): + # 多候选是 Round 1 review 的关键修复点:跨版本路径覆盖 + db_dir = ( + "/Users/x/Library/Containers/com.tencent.xinWeChat/Data/Documents/" + "xwechat_files/wxid_abc/db_storage" + ) + candidates = fkm.derive_kvcomm_dir_candidates(db_dir) + self.assertGreaterEqual(len(candidates), 3, + "应返回多个候选路径以覆盖不同微信版本布局") + + def test_no_xwechat_files_still_returns_home_fallback(self): + # 即使无法从 db_dir 推算,也至少返回 HOME 默认路径作兜底 + candidates = fkm.derive_kvcomm_dir_candidates("/random/path") + self.assertGreaterEqual(len(candidates), 1) + self.assertTrue(any("Containers/com.tencent.xinWeChat" in c + for c in candidates)) + + def test_candidates_are_unique(self): + db_dir = "/x/y/Documents/xwechat_files/wxid_abc/db_storage" + candidates = fkm.derive_kvcomm_dir_candidates(db_dir) + self.assertEqual(len(candidates), len(set(candidates))) + + +class FindExistingKvcommDirTests(unittest.TestCase): + def test_returns_first_existing_candidate(self): + with tempfile.TemporaryDirectory() as tmp: + # 构造合法 db_dir 路径,在第一个候选位置创建实际目录 + base = os.path.join(tmp, "Documents", "xwechat_files", "wxid_x") + db_dir = os.path.join(base, "db_storage") + os.makedirs(db_dir) + kvcomm = os.path.join(tmp, "Documents", "app_data", "net", "kvcomm") + os.makedirs(kvcomm) + + self.assertEqual(fkm.find_existing_kvcomm_dir(db_dir), kvcomm) + + def test_returns_none_when_no_candidate_exists(self): + # 即使 HOME fallback 候选也不存在时,应返回 None。 + # 隔离测试不能依赖宿主机有/无微信安装;patch expanduser 指向 tmp。 + with tempfile.TemporaryDirectory() as fake_home: + with patch("os.path.expanduser", return_value=fake_home): + self.assertIsNone(fkm.find_existing_kvcomm_dir("/nonexistent/x/y/z")) + + +class CollectKvcommCodesTests(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + self.kvdir = self._tmp.name + + def _touch(self, name): + with open(os.path.join(self.kvdir, name), "w") as f: + f.write("") + + def test_extracts_code_from_filename(self): + # 长格式: 模拟真实 kvcomm 缓存文件命名 (合成 ID/时间戳, 测 regex 提 + # uin 的能力, 不绑定任何真实账号) + self._touch("key_12345678_1111111111_1_1700000000_22222_3600_input.statistic") + self._touch("key_99999999_yyy_zzz.statistic") + self.assertEqual(fkm.collect_kvcomm_codes(self.kvdir), [12345678, 99999999]) + + def test_ignores_files_with_non_numeric_first_segment(self): + self._touch("key_reportnow_12345678_xxx.statistic") + self._touch("key_abc_def.statistic") + self._touch("config.ini") + self._touch("monitordata_x") + self.assertEqual(fkm.collect_kvcomm_codes(self.kvdir), []) + + def test_dedupes_same_code_across_files(self): + self._touch("key_42_a.statistic") + self._touch("key_42_b.statistic") + self.assertEqual(fkm.collect_kvcomm_codes(self.kvdir), [42]) + + def test_missing_dir_returns_empty(self): + self.assertEqual(fkm.collect_kvcomm_codes("/nonexistent/xxx"), []) + + def test_none_dir_returns_empty(self): + self.assertEqual(fkm.collect_kvcomm_codes(None), []) + + +class CollectWxidCandidatesTests(unittest.TestCase): + def test_returns_raw_and_normalized_when_different(self): + db_dir = "/x/Documents/xwechat_files/your_wxid_a1b2/db_storage" + self.assertEqual(fkm.collect_wxid_candidates(db_dir), + ["your_wxid_a1b2", "your_wxid"]) + + def test_returns_one_when_normalize_is_identity(self): + db_dir = "/x/Documents/xwechat_files/wxid_abc/db_storage" + self.assertEqual(fkm.collect_wxid_candidates(db_dir), ["wxid_abc"]) + + def test_no_xwechat_files_returns_empty(self): + self.assertEqual(fkm.collect_wxid_candidates("/random/path"), []) + + def test_xwechat_files_at_end_returns_empty(self): + self.assertEqual(fkm.collect_wxid_candidates("/x/xwechat_files"), []) + + +class VerifyAesKeyTests(unittest.TestCase): + KEY = "a0c093edddc98490" + + def _encrypt(self, plaintext_16): + return AES.new(self.KEY.encode("ascii"), AES.MODE_ECB).encrypt(plaintext_16) + + def test_jpeg_magic_passes(self): + ct = self._encrypt(b"\xff\xd8\xff\xe0" + b"\x00" * 12) + self.assertTrue(fkm.verify_aes_key(self.KEY, ct)) + + def test_png_magic_passes(self): + ct = self._encrypt(b"\x89PNG\r\n\x1a\n" + b"\x00" * 8) + self.assertTrue(fkm.verify_aes_key(self.KEY, ct)) + + def test_gif_magic_passes(self): + ct = self._encrypt(b"GIF89a" + b"\x00" * 10) + self.assertTrue(fkm.verify_aes_key(self.KEY, ct)) + + def test_wxgf_magic_passes(self): + ct = self._encrypt(b"wxgf" + b"\x00" * 12) + self.assertTrue(fkm.verify_aes_key(self.KEY, ct)) + + def test_random_data_fails(self): + self.assertFalse(fkm.verify_aes_key(self.KEY, bytes(range(16)))) + + def test_wrong_length_template_fails(self): + self.assertFalse(fkm.verify_aes_key(self.KEY, b"short")) + self.assertFalse(fkm.verify_aes_key(self.KEY, b"")) + + def test_short_aes_key_fails(self): + self.assertFalse(fkm.verify_aes_key("short", b"\x00" * 16)) + + def test_empty_aes_key_fails(self): + self.assertFalse(fkm.verify_aes_key("", b"\x00" * 16)) + + +class VerifyAesKeyAgainstAllTests(unittest.TestCase): + """交叉验证:必须所有模板都通过才算命中(防短 magic 偶然碰撞)。""" + + KEY = "a0c093edddc98490" + + def _encrypt(self, plaintext_16): + return AES.new(self.KEY.encode("ascii"), AES.MODE_ECB).encrypt(plaintext_16) + + def test_all_templates_pass(self): + ct1 = self._encrypt(b"\xff\xd8\xff\xe0" + b"\x00" * 12) + ct2 = self._encrypt(b"\x89PNG\r\n\x1a\n" + b"\x00" * 8) + self.assertTrue(fkm.verify_aes_key_against_all(self.KEY, [ct1, ct2])) + + def test_one_template_fails_overall_fails(self): + ct1 = self._encrypt(b"\xff\xd8\xff\xe0" + b"\x00" * 12) # passes + ct2 = bytes(range(16)) # random, fails + self.assertFalse(fkm.verify_aes_key_against_all(self.KEY, [ct1, ct2])) + + def test_empty_template_list_returns_false(self): + # 没模板就不能验证;不视为通过(防"零样本=自动通过"陷阱) + self.assertFalse(fkm.verify_aes_key_against_all(self.KEY, [])) + + +class FindV2TemplateCiphertextsTests(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + self.dir = self._tmp.name + + def _build_v2_dat(self, name, ciphertext_16, subdir=""): + target_dir = os.path.join(self.dir, subdir) if subdir else self.dir + os.makedirs(target_dir, exist_ok=True) + path = os.path.join(target_dir, name) + with open(path, "wb") as f: + f.write(fkm.V2_MAGIC + b"\x00" * 9 + ciphertext_16 + b"\x00\x00") + return path + + def test_finds_one_template_in_v2_thumb(self): + ct = bytes(range(0xF, 0x1F)) + self._build_v2_dat("abc_t.dat", ct) + result = fkm.find_v2_template_ciphertexts(self.dir) + self.assertEqual(result, [ct]) + + def test_finds_multiple_distinct_templates(self): + cts = [bytes([i] * 16) for i in (0x11, 0x22, 0x33)] + for i, ct in enumerate(cts): + self._build_v2_dat(f"chat{i}_t.dat", ct, subdir=f"chat{i}") + result = fkm.find_v2_template_ciphertexts(self.dir, max_templates=3) + self.assertEqual(set(result), set(cts)) + + def test_dedupes_identical_templates(self): + ct = b"\x42" * 16 + self._build_v2_dat("a_t.dat", ct, subdir="a") + self._build_v2_dat("b_t.dat", ct, subdir="b") + result = fkm.find_v2_template_ciphertexts(self.dir) + self.assertEqual(result, [ct]) + + def test_falls_back_to_any_dat_if_no_thumb(self): + ct = b"\x33" * 16 + self._build_v2_dat("only_full.dat", ct) + self.assertEqual(fkm.find_v2_template_ciphertexts(self.dir), [ct]) + + def test_skips_non_v2_files(self): + path = os.path.join(self.dir, "abc_t.dat") + with open(path, "wb") as f: + f.write(b"\x00" * 100) + self.assertEqual(fkm.find_v2_template_ciphertexts(self.dir), []) + + def test_empty_dir_returns_empty(self): + self.assertEqual(fkm.find_v2_template_ciphertexts(self.dir), []) + + def test_missing_dir_returns_empty(self): + self.assertEqual(fkm.find_v2_template_ciphertexts("/nonexistent"), []) + + def test_walks_into_subdirs(self): + ct = b"\x44" * 16 + self._build_v2_dat("x_t.dat", ct, subdir="sub/deeper") + self.assertEqual(fkm.find_v2_template_ciphertexts(self.dir), [ct]) + + def test_respects_max_templates(self): + cts = [bytes([i] * 16) for i in range(10)] + for i, ct in enumerate(cts): + self._build_v2_dat(f"x{i}_t.dat", ct, subdir=f"d{i}") + result = fkm.find_v2_template_ciphertexts(self.dir, max_templates=2) + self.assertEqual(len(result), 2) + + +class FindImageKeyMacosIntegrationTests(unittest.TestCase): + """端到端集成:合成 kvcomm 文件 + 合成 V2 模板 → 期望派生出已知 key。""" + + def _build_test_env(self, tmpdir, code, wxid_raw, num_templates=2): + """构造测试环境,返回 (db_dir, expected_xor, expected_aes)。""" + wxid_norm = fkm.normalize_wxid(wxid_raw) + base = os.path.join(tmpdir, "Documents", "xwechat_files", wxid_raw) + db_dir = os.path.join(base, "db_storage") + os.makedirs(db_dir) + + kvcomm = os.path.join(tmpdir, "Documents", "app_data", "net", "kvcomm") + os.makedirs(kvcomm) + with open(os.path.join(kvcomm, f"key_{code}_x.statistic"), "w") as f: + f.write("") + + xor_expected, aes_expected = fkm.derive_image_keys(code, wxid_norm) + # 多个模板用不同的 plaintext 加密(仍是图像 magic 开头但内容不同) + plaintexts = [ + b"\xff\xd8\xff\xe0" + b"\x00" * 12, # JPEG + b"\x89PNG\r\n\x1a\n" + b"\x00" * 8, # PNG + b"GIF89a" + b"\x01\x02" + b"\x00" * 8, # GIF + ] + for i in range(num_templates): + pt = plaintexts[i % len(plaintexts)] + ct = AES.new(aes_expected.encode("ascii"), AES.MODE_ECB).encrypt(pt) + attach = os.path.join(base, "msg", "attach", f"chat{i}") + os.makedirs(attach) + with open(os.path.join(attach, f"img{i}_t.dat"), "wb") as f: + f.write(fkm.V2_MAGIC + b"\x00" * 9 + ct + b"\x00\x00") + return db_dir, xor_expected, aes_expected + + def test_full_flow_succeeds_with_normalized_wxid(self): + with tempfile.TemporaryDirectory() as tmp: + db_dir, xor_exp, aes_exp = self._build_test_env( + tmp, code=12345678, wxid_raw="your_wxid_a1b2", num_templates=3) + result = fkm.find_image_key_macos(db_dir) + self.assertIsNotNone(result, "派生应该成功") + self.assertEqual(result, (xor_exp, aes_exp)) + + def test_returns_none_when_no_kvcomm_codes(self): + with tempfile.TemporaryDirectory() as tmp: + base = os.path.join(tmp, "Documents", "xwechat_files", "wxid_x") + db_dir = os.path.join(base, "db_storage") + os.makedirs(db_dir) + self.assertIsNone(fkm.find_image_key_macos(db_dir)) + + def test_returns_none_when_no_v2_template(self): + with tempfile.TemporaryDirectory() as tmp: + base = os.path.join(tmp, "Documents", "xwechat_files", "wxid_x") + db_dir = os.path.join(base, "db_storage") + os.makedirs(db_dir) + kvcomm = os.path.join(tmp, "Documents", "app_data", "net", "kvcomm") + os.makedirs(kvcomm) + with open(os.path.join(kvcomm, "key_42_x.statistic"), "w") as f: + f.write("") + self.assertIsNone(fkm.find_image_key_macos(db_dir)) + + def test_returns_none_when_no_combination_verifies(self): + # 有 code 也有 V2 .dat,但密文是随机的,没有任何 key 能解出 + with tempfile.TemporaryDirectory() as tmp: + base = os.path.join(tmp, "Documents", "xwechat_files", "wxid_x") + db_dir = os.path.join(base, "db_storage") + os.makedirs(db_dir) + kvcomm = os.path.join(tmp, "Documents", "app_data", "net", "kvcomm") + os.makedirs(kvcomm) + with open(os.path.join(kvcomm, "key_42_x.statistic"), "w") as f: + f.write("") + attach = os.path.join(base, "msg", "attach", "x") + os.makedirs(attach) + with open(os.path.join(attach, "x_t.dat"), "wb") as f: + f.write(fkm.V2_MAGIC + b"\x00" * 9 + b"\xde\xad\xbe\xef" * 4 + b"\x00\x00") + self.assertIsNone(fkm.find_image_key_macos(db_dir)) + + def test_empty_db_dir_returns_none_without_crash(self): + # 防御:空字符串、不合理路径不应抛异常。 + # patch expanduser 让 HOME fallback 也指向不存在的路径,避免 + # 测试在装了真实微信的开发机上意外深入到 wxid 缺失分支。 + with tempfile.TemporaryDirectory() as fake_home: + with patch("os.path.expanduser", return_value=fake_home): + self.assertIsNone(fkm.find_image_key_macos("")) + + +class MainShortCircuitTests(unittest.TestCase): + """main() 短路:已有 image_aes_key 仍然有效时,不应重新派生 / 不应改写 config。""" + + def test_existing_valid_key_skips_derivation(self): + with tempfile.TemporaryDirectory() as tmp: + wxid = "wxid_abc" + base = os.path.join(tmp, "Documents", "xwechat_files", wxid) + db_dir = os.path.join(base, "db_storage") + os.makedirs(db_dir) + + # kvcomm 里放个 code,证明若真去派生也能算出 key + kvcomm = os.path.join(tmp, "Documents", "app_data", "net", "kvcomm") + os.makedirs(kvcomm) + code = 42 + with open(os.path.join(kvcomm, f"key_{code}_x.statistic"), "w") as f: + f.write("") + + # 用真实派生的 key 加密 V2 模板,使现有 key 在该模板上能验证通过 + xor_exp, aes_exp = fkm.derive_image_keys(code, wxid) + jpeg_pt = b"\xff\xd8\xff\xe0" + b"\x00" * 12 + ct = AES.new(aes_exp.encode("ascii"), AES.MODE_ECB).encrypt(jpeg_pt) + attach = os.path.join(base, "msg", "attach", "x") + os.makedirs(attach) + with open(os.path.join(attach, "test_t.dat"), "wb") as f: + f.write(fkm.V2_MAGIC + b"\x00" * 9 + ct + b"\x00\x00") + + # 写入"已有有效 key"的 config + cfg_path = os.path.join(tmp, "config.json") + cfg_initial = { + "db_dir": db_dir, + "image_aes_key": aes_exp, + "image_xor_key": xor_exp, + "extra_field": "must_be_preserved", # 证明 main 不会重写 + } + with open(cfg_path, "w", encoding="utf-8") as f: + json.dump(cfg_initial, f) + mtime_before = os.path.getmtime(cfg_path) + + # 关键:patch find_image_key_macos 让它若被误调用立刻可见 + with patch.object(fkm, "find_image_key_macos") as mock_derive: + fkm.main(config_path=cfg_path) + + mock_derive.assert_not_called() # 短路应直接 return,不进派生 + # config.json 不应被重写 + self.assertEqual(os.path.getmtime(cfg_path), mtime_before) + with open(cfg_path, encoding="utf-8") as f: + self.assertEqual(json.load(f), cfg_initial) + + def test_existing_invalid_key_falls_through_to_derivation(self): + with tempfile.TemporaryDirectory() as tmp: + wxid = "wxid_abc" + base = os.path.join(tmp, "Documents", "xwechat_files", wxid) + db_dir = os.path.join(base, "db_storage") + os.makedirs(db_dir) + + kvcomm = os.path.join(tmp, "Documents", "app_data", "net", "kvcomm") + os.makedirs(kvcomm) + code = 42 + with open(os.path.join(kvcomm, f"key_{code}_x.statistic"), "w") as f: + f.write("") + + xor_exp, aes_exp = fkm.derive_image_keys(code, wxid) + jpeg_pt = b"\xff\xd8\xff\xe0" + b"\x00" * 12 + ct = AES.new(aes_exp.encode("ascii"), AES.MODE_ECB).encrypt(jpeg_pt) + attach = os.path.join(base, "msg", "attach", "x") + os.makedirs(attach) + with open(os.path.join(attach, "test_t.dat"), "wb") as f: + f.write(fkm.V2_MAGIC + b"\x00" * 9 + ct + b"\x00\x00") + + cfg_path = os.path.join(tmp, "config.json") + cfg_initial = { + "db_dir": db_dir, + "image_aes_key": "deadbeefdeadbeef", # 故意写一个错的 + } + with open(cfg_path, "w", encoding="utf-8") as f: + json.dump(cfg_initial, f) + + fkm.main(config_path=cfg_path) + + # 短路应失败,进入派生路径,配置应被改写为正确的 key + with open(cfg_path, encoding="utf-8") as f: + cfg_after = json.load(f) + self.assertEqual(cfg_after["image_aes_key"], aes_exp) + self.assertEqual(cfg_after["image_xor_key"], xor_exp) + + +# ---------- 方案2 (wxid 后缀候选搜索, fallback) 单元测试 ---------- # + +class ExtractWxidPartsTests(unittest.TestCase): + """extract_wxid_parts: 从 db_dir 提 (full, norm, suffix)。""" + + def test_extracts_norm_and_suffix_from_alnum_suffix(self): + db_dir = "/foo/Documents/xwechat_files/your_wxid_25d5/db_storage" + self.assertEqual( + fkm.extract_wxid_parts(db_dir), + ("your_wxid_25d5", "your_wxid", "25d5"), + ) + + def test_wxid_format_with_4char_suffix(self): + db_dir = "/foo/Documents/xwechat_files/wxid_abc_e2f4/db_storage" + self.assertEqual( + fkm.extract_wxid_parts(db_dir), + ("wxid_abc_e2f4", "wxid_abc", "e2f4"), + ) + + def test_uppercase_suffix_lowercased(self): + db_dir = "/foo/Documents/xwechat_files/your_wxid_ABCD/db_storage" + result = fkm.extract_wxid_parts(db_dir) + self.assertIsNotNone(result) + self.assertEqual(result[2], "abcd") + + def test_no_4char_suffix_returns_none(self): + # 6字符尾缀不匹配 _<4字符>$, 算法假设破灭 + db_dir = "/foo/Documents/xwechat_files/wxid_simple/db_storage" + self.assertIsNone(fkm.extract_wxid_parts(db_dir)) + + def test_no_xwechat_files_returns_none(self): + self.assertIsNone(fkm.extract_wxid_parts("/random/path/db_storage")) + + +class DeriveXorKeyFromV2DatTests(unittest.TestCase): + """derive_xor_key_from_v2_dat: 末字节投票反推 xor_key。""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.dir = self.tmp.name + + def tearDown(self): + self.tmp.cleanup() + + def _write_v2_dat(self, name, last_byte, subdir=""): + d = os.path.join(self.dir, subdir) if subdir else self.dir + os.makedirs(d, exist_ok=True) + body = (fkm.V2_MAGIC + b"\x00" * 9 + b"\x11" * 16 + + b"\x00" * 4 + bytes([last_byte])) + with open(os.path.join(d, name), "wb") as f: + f.write(body) + + def test_unanimous_vote(self): + # 全部末字节 = 0xA6 → xor_key = 0xA6 ^ 0xD9 = 0x7F + for i in range(10): + self._write_v2_dat(f"x{i}_t.dat", 0xA6) + self.assertEqual(fkm.derive_xor_key_from_v2_dat(self.dir), + (0x7F, 10, 10)) + + def test_majority_vote_with_dissent(self): + # 8 个 0xA6, 2 个 0x55: 多数 0x7F 胜出 + for i in range(8): + self._write_v2_dat(f"good{i}_t.dat", 0xA6) + for i in range(2): + self._write_v2_dat(f"bad{i}_t.dat", 0x55) + result = fkm.derive_xor_key_from_v2_dat(self.dir) + self.assertEqual(result, (0x7F, 8, 10)) + + def test_no_v2_dat_returns_none(self): + self.assertIsNone(fkm.derive_xor_key_from_v2_dat(self.dir)) + + def test_below_min_samples_returns_none(self): + # 默认 min_samples=3, 仅有 2 个样本应被视为不可信 + for i in range(2): + self._write_v2_dat(f"x{i}_t.dat", 0xA6) + self.assertIsNone(fkm.derive_xor_key_from_v2_dat(self.dir)) + + def test_missing_dir_returns_none(self): + self.assertIsNone(fkm.derive_xor_key_from_v2_dat("/nonexistent")) + + def test_walks_into_subdirs(self): + for i in range(10): + self._write_v2_dat(f"x{i}_t.dat", 0xA6, subdir=f"deep/sub{i}") + result = fkm.derive_xor_key_from_v2_dat(self.dir) + self.assertIsNotNone(result) + self.assertEqual(result[0], 0x7F) + + def test_skips_non_v2_files(self): + # 不是 V2 magic 的 .dat 不计入投票 + with open(os.path.join(self.dir, "junk.dat"), "wb") as f: + f.write(b"NOT_V2" + b"\x00" * 30) + for i in range(10): + self._write_v2_dat(f"x{i}_t.dat", 0xA6) + result = fkm.derive_xor_key_from_v2_dat(self.dir) + self.assertEqual(result, (0x7F, 10, 10)) + + +class BruteforceUinCandidatesTests(unittest.TestCase): + """bruteforce_uin_candidates: 候选枚举 + md5 前缀匹配。 + + 注意:test_real_bruteforce_against_golden 单核 ~7-8 秒,全套测试耗时大头。 + """ + + def test_real_bruteforce_against_golden(self): + # 真跑全空间 2^24 候选, 同时验证: (a) 合成 uin 在结果里 + # (b) 候选数合理 (~256) (c) 候选都满足 xor_key 约束 + # md5("12345678")[:4] == "25d5", 12345678 & 0xff == 0x4E + out = fkm.bruteforce_uin_candidates(0x4E, "25d5") + self.assertIn(12345678, out, "合成 uin 应在候选里") + self.assertTrue(200 <= len(out) <= 350, + f"候选数 {len(out)} 偏离 ~256 (理论 2^24/2^16)") + for uin in out[:20]: + self.assertEqual(uin & 0xFF, 0x4E, + f"uin {uin} 不满足 xor_key 约束") + + + +class FindViaBruteforceTests(unittest.TestCase): + """方案2 端到端 (合成 fixture, 多进程 worker 实跑)。 + + 注: parallel 路径在合成 uin (低数值, 在 worker 0 chunk 早期命中) 上 + < 0.2s 完成, 不需要 mock 加速。worker spawn 开销是真实集成测试的合理代价。 + """ + + def _build_bruteforce_env(self, tmp, uin, wxid_norm, suffix): + wxid_full = f"{wxid_norm}_{suffix}" + base = os.path.join(tmp, "Documents", "xwechat_files", wxid_full) + db_dir = os.path.join(base, "db_storage") + attach_dir = os.path.join(base, "msg", "attach") + os.makedirs(db_dir) + os.makedirs(attach_dir) + xor_exp, aes_exp = fkm.derive_image_keys(uin, wxid_norm) + last_byte = 0xD9 ^ xor_exp + jpeg_pt = b"\xff\xd8\xff\xe0" + b"\x00" * 12 + ct = AES.new(aes_exp.encode("ascii"), AES.MODE_ECB).encrypt(jpeg_pt) + # 构造 10 个 V2 dat 让 derive_xor_key 投票稳定 + for i in range(10): + with open(os.path.join(attach_dir, f"img{i}_t.dat"), "wb") as f: + f.write(fkm.V2_MAGIC + b"\x00" * 9 + ct + + b"\x00" * 4 + bytes([last_byte])) + return db_dir, attach_dir, xor_exp, aes_exp + + def test_full_flow_finds_synthetic_uin(self): + with tempfile.TemporaryDirectory() as tmp: + db_dir, attach_dir, xor_exp, aes_exp = self._build_bruteforce_env( + tmp, uin=12345678, wxid_norm="your_wxid", suffix="25d5") + templates = fkm.find_v2_template_ciphertexts(attach_dir) + result = fkm._find_via_bruteforce(db_dir, attach_dir, templates) + self.assertEqual(result, (xor_exp, aes_exp)) + + def test_returns_none_when_no_wxid_suffix(self): + with tempfile.TemporaryDirectory() as tmp: + base = os.path.join(tmp, "Documents", "xwechat_files", "wxid_nosuffix") + db_dir = os.path.join(base, "db_storage") + attach_dir = os.path.join(base, "msg", "attach") + os.makedirs(db_dir) + os.makedirs(attach_dir) + self.assertIsNone(fkm._find_via_bruteforce(db_dir, attach_dir, [])) + + def test_returns_none_when_no_v2_dat(self): + with tempfile.TemporaryDirectory() as tmp: + base = os.path.join(tmp, "Documents", "xwechat_files", + "your_wxid_25d5") + db_dir = os.path.join(base, "db_storage") + attach_dir = os.path.join(base, "msg", "attach") + os.makedirs(db_dir) + os.makedirs(attach_dir) + self.assertIsNone(fkm._find_via_bruteforce(db_dir, attach_dir, [])) + + +class DispatcherFallbackTests(unittest.TestCase): + """find_image_key_macos dispatcher: 方案1 失败 → fallback 方案2。""" + + def test_kvcomm_missing_falls_back_to_bruteforce(self): + with tempfile.TemporaryDirectory() as tmp: + uin, wxid_norm, suffix = 12345678, "your_wxid", "25d5" + wxid_full = f"{wxid_norm}_{suffix}" + base = os.path.join(tmp, "Documents", "xwechat_files", wxid_full) + db_dir = os.path.join(base, "db_storage") + attach_dir = os.path.join(base, "msg", "attach") + os.makedirs(db_dir) + os.makedirs(attach_dir) + + # 不创建 kvcomm → 方案1 失败 + xor_exp, aes_exp = fkm.derive_image_keys(uin, wxid_norm) + last_byte = 0xD9 ^ xor_exp + jpeg_pt = b"\xff\xd8\xff\xe0" + b"\x00" * 12 + ct = AES.new(aes_exp.encode("ascii"), AES.MODE_ECB).encrypt(jpeg_pt) + for i in range(10): + with open(os.path.join(attach_dir, f"img{i}_t.dat"), "wb") as f: + f.write(fkm.V2_MAGIC + b"\x00" * 9 + ct + + b"\x00" * 4 + bytes([last_byte])) + + # patch HOME 让兜底 kvcomm 路径也找不到, 强制走方案2 + with patch("os.path.expanduser", return_value=tmp): + result = fkm.find_image_key_macos(db_dir) + self.assertEqual(result, (xor_exp, aes_exp)) + + +class BruteforceParallelTests(unittest.TestCase): + """方案2 多进程实现的两层覆盖: + - 算法核心 (_bruteforce_worker_chunk): 直接调用, 无 process spawn, 极快 + - 集成 (_bruteforce_with_aes_parallel): workers=1 验证 spawn + pickle 链路 + + 多进程 e2e 由 FindViaBruteforceTests / DispatcherFallbackTests 间接覆盖 + (cpu_count workers, 真实 fixture)。这里只测函数契约, 避免 spawn 开销 + 被反复支付。 + """ + + @classmethod + def setUpClass(cls): + # 合成 fixture, 跨多个测试复用 + cls.uin = 12345678 + cls.xor_key = cls.uin & 0xFF # 0x4E + cls.wxid_norm = "your_wxid" + cls.suffix_hex = hashlib.md5(str(cls.uin).encode()).hexdigest()[:4] + cls.suffix_bytes = bytes.fromhex(cls.suffix_hex) + cls.aes_hex = hashlib.md5( + f"{cls.uin}{cls.wxid_norm}".encode() + ).hexdigest()[:16] + cls.template = AES.new( + cls.aes_hex.encode("ascii"), AES.MODE_ECB + ).encrypt(b"\xff\xd8\xff\xe0" + b"\x00" * 12) + # i = (uin - xor_key) >> 8: worker 用 i 索引, 主进程倒推区间 + cls.target_i = (cls.uin - cls.xor_key) >> 8 + + # 注: multiprocessing.Queue.put() 通过 feeder thread 异步刷到 pipe, + # get_nowait() 读取会 race。所有 queue 读用 get(timeout=...): + # - 命中场景: timeout=2s 给 feeder 充足时间 (实际 ~ms 级) + # - 不命中场景: timeout=0.5s 既证空又不拖慢测试 + + def test_worker_finds_known_uin_in_chunk(self): + q = multiprocessing.Queue() + fkm._bruteforce_worker_chunk( + self.target_i - 50, self.target_i + 50, + self.xor_key, self.suffix_bytes, + self.wxid_norm.encode("ascii"), + [self.template], q, + ) + result = q.get(timeout=2) + self.assertEqual(result, (self.uin, self.aes_hex)) + + def test_worker_no_match_returns_silently(self): + # 区间不含 target_i (~48k), worker 扫完, queue 应保持空 + q = multiprocessing.Queue() + fkm._bruteforce_worker_chunk( + 0, 1000, + self.xor_key, self.suffix_bytes, + self.wxid_norm.encode("ascii"), + [self.template], q, + ) + with self.assertRaises(_queue_mod.Empty): + q.get(timeout=0.5) + + def test_worker_skips_when_aes_fails(self): + # md5 prefix 命中但 AES 模板错: 不入队 (防止 md5 单 gate 假阳) + q = multiprocessing.Queue() + wrong_template = b"\x00" * 16 # AES 解出来非图像 magic + fkm._bruteforce_worker_chunk( + self.target_i - 50, self.target_i + 50, + self.xor_key, self.suffix_bytes, + self.wxid_norm.encode("ascii"), + [wrong_template], q, + ) + with self.assertRaises(_queue_mod.Empty): + q.get(timeout=0.5) + + def test_parallel_workers_1_finds_synthetic_uin(self): + # 集成: workers=1 验证 process spawn + pickle + queue 跨进程通信 + result = fkm._bruteforce_with_aes_parallel( + self.xor_key, self.suffix_hex, self.wxid_norm, + [self.template], workers=1, timeout=30, + ) + self.assertEqual(result, (self.uin, self.aes_hex)) + + +class SaveConfigAtomicTests(unittest.TestCase): + """原子写测试:os.replace 保证 config.json 不会被半截覆盖。""" + + def test_roundtrip_writes_pretty_utf8(self): + with tempfile.TemporaryDirectory() as tmp: + cfg_path = os.path.join(tmp, "config.json") + cfg = {"db_dir": "/x", "image_aes_key": "中文测试key"} + fkm._save_config_atomic(cfg_path, cfg) + with open(cfg_path, encoding="utf-8") as f: + self.assertEqual(json.load(f), cfg) + # ensure_ascii=False:中文应直接落盘,不被转义 + with open(cfg_path, "rb") as f: + self.assertIn("中文测试key".encode("utf-8"), f.read()) + + def test_failed_replace_leaves_original_intact(self): + with tempfile.TemporaryDirectory() as tmp: + cfg_path = os.path.join(tmp, "config.json") + with open(cfg_path, "w", encoding="utf-8") as f: + json.dump({"original": True}, f) + with patch.object(os, "replace", + side_effect=OSError("disk full during rename")): + with self.assertRaises(OSError): + fkm._save_config_atomic(cfg_path, {"new": True}) + # 原文件应保持不变 + with open(cfg_path, encoding="utf-8") as f: + self.assertEqual(json.load(f), {"original": True}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_get_chat_images_multishard.py b/tests/test_get_chat_images_multishard.py new file mode 100644 index 0000000..75afa09 --- /dev/null +++ b/tests/test_get_chat_images_multishard.py @@ -0,0 +1,133 @@ +"""Tests for `get_chat_images` multi-shard scanning. + +WeChat rolls a chat's messages over to the next `message_N.db` shard once +the current one fills up, so any chat older than the current shard window +has its history split across multiple shards. The other query tools +(`get_chat_history`, `search_messages`, `decode_image`) already scan all +shards via `_find_msg_tables_for_user`; before this fix `get_chat_images` +used the single-shard `_find_msg_table_for_user`, so it silently dropped +every image that lived in a non-first shard. + +These tests pin the corrected behaviour: results come from all matching +shards, are sorted by `create_time` DESC across shards, and respect the +`limit` cap. +""" +import unittest +from unittest.mock import patch + +import mcp_server + + +class GetChatImagesMultiShardTests(unittest.TestCase): + def setUp(self): + # `resolve_username` / `get_contact_names` would hit real DBs; stub them. + self._patches = [ + patch.object(mcp_server, "resolve_username", + side_effect=lambda x: "wxid_demo"), + patch.object(mcp_server, "get_contact_names", + return_value={"wxid_demo": "Demo"}), + ] + for p in self._patches: + p.start() + self.addCleanup(p.stop) + + def _run(self, shards, shard_images_map, limit=20): + """Helper: stub the two collaborators and call the tool.""" + def fake_list(db_path, table_name, username, limit=20, start_ts=None, end_ts=None): + return shard_images_map.get(db_path, []) + + with patch.object(mcp_server, "_find_msg_tables_for_user", + return_value=shards), \ + patch.object(mcp_server._image_resolver, "list_chat_images", + side_effect=fake_list): + return mcp_server.get_chat_images("Demo", limit=limit) + + def test_collects_images_from_every_shard(self): + shards = [ + {"db_path": "/m/message_1.db", "table_name": "Msg_x", + "max_create_time": 1_800_000_000}, + {"db_path": "/m/message_2.db", "table_name": "Msg_x", + "max_create_time": 1_700_000_000}, + ] + shard_images = { + "/m/message_1.db": [ + {"local_id": 11, "create_time": 1_800_000_000, "md5": "a" * 32, "size": 1024}, + ], + "/m/message_2.db": [ + {"local_id": 22, "create_time": 1_700_000_000, "md5": "b" * 32, "size": 2048}, + ], + } + out = self._run(shards, shard_images) + # Both shards' images must appear; before the fix the message_2.db + # image was silently dropped. + self.assertIn("local_id=11", out) + self.assertIn("local_id=22", out) + self.assertIn("2 张图片", out) + + def test_global_sort_by_create_time_desc(self): + # Older shard happens to contain a NEWER image (e.g. when shards are + # ordered by max_create_time but individual rows interleave): the + # output must still be globally sorted, not per-shard concatenated. + shards = [ + {"db_path": "/m/message_1.db", "table_name": "Msg_x", + "max_create_time": 1_800_000_000}, + {"db_path": "/m/message_2.db", "table_name": "Msg_x", + "max_create_time": 1_700_000_000}, + ] + shard_images = { + "/m/message_1.db": [ + {"local_id": 11, "create_time": 1_750_000_000, "md5": "a" * 32}, + ], + "/m/message_2.db": [ + # Older shard, but this single image is newer than the one above. + {"local_id": 22, "create_time": 1_799_000_000, "md5": "b" * 32}, + ], + } + out = self._run(shards, shard_images) + pos_22 = out.find("local_id=22") + pos_11 = out.find("local_id=11") + self.assertGreaterEqual(pos_22, 0) + self.assertGreaterEqual(pos_11, 0) + self.assertLess(pos_22, pos_11) # newer first + + def test_limit_truncates_globally_across_shards(self): + shards = [ + {"db_path": "/m/message_1.db", "table_name": "Msg_x", + "max_create_time": 1_800_000_000}, + {"db_path": "/m/message_2.db", "table_name": "Msg_x", + "max_create_time": 1_700_000_000}, + ] + shard_images = { + "/m/message_1.db": [ + {"local_id": i, "create_time": 1_800_000_000 - i} + for i in range(0, 5) + ], + "/m/message_2.db": [ + {"local_id": 100 + i, "create_time": 1_700_000_000 - i} + for i in range(0, 5) + ], + } + out = self._run(shards, shard_images, limit=3) + # 3 newest overall = local_id=0, 1, 2 (all from shard 1, but the + # decision is global, not "first shard wins"). + self.assertIn("3 张图片", out) + self.assertIn("local_id=0", out) + self.assertIn("local_id=1", out) + self.assertIn("local_id=2", out) + self.assertNotIn("local_id=100", out) + + def test_no_shards_returns_not_found(self): + out = self._run(shards=[], shard_images_map={}) + self.assertIn("找不到", out) + + def test_all_shards_empty_returns_no_images(self): + shards = [ + {"db_path": "/m/message_1.db", "table_name": "Msg_x", + "max_create_time": 0}, + ] + out = self._run(shards, shard_images_map={"/m/message_1.db": []}) + self.assertIn("无图片消息", out) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_msg_types_filter.py b/tests/test_msg_types_filter.py new file mode 100644 index 0000000..788e14c --- /dev/null +++ b/tests/test_msg_types_filter.py @@ -0,0 +1,85 @@ +"""测试 _resolve_msg_types 和 _build_message_filters 的 type_filter 路径。""" +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import mcp_server + + +def test_resolve_none_returns_no_filter(): + assert mcp_server._resolve_msg_types(None) == (None, None) + + +def test_resolve_empty_returns_no_filter(): + assert mcp_server._resolve_msg_types([]) == (None, None) + + +def test_resolve_single_text_type(): + type_filter, err = mcp_server._resolve_msg_types(['text']) + assert err is None + assert type_filter == [1] + + +def test_resolve_multiple_types(): + type_filter, err = mcp_server._resolve_msg_types(['image', 'voice', 'video']) + assert err is None + assert sorted(type_filter) == [3, 34, 43] + + +def test_file_alias_maps_to_app(): + """'file' 是常见叫法, 实际是 type=49 (app message)。""" + type_filter, err = mcp_server._resolve_msg_types(['file']) + assert err is None + assert type_filter == [49] + + +def test_case_insensitive_and_strip(): + type_filter, err = mcp_server._resolve_msg_types([' Text ', 'IMAGE']) + assert err is None + assert sorted(type_filter) == [1, 3] + + +def test_unknown_type_returns_error(): + type_filter, err = mcp_server._resolve_msg_types(['unknown']) + assert type_filter is None + assert err is not None + assert 'unknown' in err + assert 'text' in err # 错误提示列出可选值 + + +def test_partial_unknown_aborts_whole(): + """混入一个未知类型时整体失败, 不偷偷过滤合法的。""" + type_filter, err = mcp_server._resolve_msg_types(['text', 'invalid_type']) + assert type_filter is None + assert 'invalid_type' in err + + +def test_build_filters_without_type_filter(): + """type_filter=None 时 SQL 不包含 local_type 子句。""" + clauses, params = mcp_server._build_message_filters() + assert not any('local_type' in c for c in clauses) + + +def test_build_filters_with_single_type(): + clauses, params = mcp_server._build_message_filters(type_filter=[1]) + assert any('local_type IN (?)' == c for c in clauses) + assert 1 in params + + +def test_build_filters_with_multiple_types(): + clauses, params = mcp_server._build_message_filters(type_filter=[1, 3, 34]) + type_clause = [c for c in clauses if 'local_type' in c][0] + assert type_clause == 'local_type IN (?,?,?)' + assert params == [1, 3, 34] + + +def test_build_filters_combines_with_time_and_keyword(): + clauses, params = mcp_server._build_message_filters( + start_ts=1000, end_ts=2000, keyword='hello', type_filter=[1] + ) + assert 'create_time >= ?' in clauses + assert 'create_time <= ?' in clauses + assert 'message_content LIKE ?' in clauses + assert any('local_type' in c for c in clauses) + assert params == [1000, 2000, '%hello%', 1] diff --git a/tests/test_namecard_format.py b/tests/test_namecard_format.py new file mode 100644 index 0000000..e61184d --- /dev/null +++ b/tests/test_namecard_format.py @@ -0,0 +1,73 @@ +"""Tests for `_format_namecard_text` (msg_type=42 鉴定). + +Before this helper, type=42 messages fell through the generic non-text branch +and emitted `[名片] `, dumping the full `` element including +antispamticket, biznamecardinfo and head-image URLs. Those tokens are PII that +should not be piped to downstream LLM / log systems. + +These tests pin the new behaviour: a compact `[名片] : ` line, +without any source-only XML fields. +""" +import unittest + +import mcp_server + + +# Realistic-shape sample with the noisy / sensitive attrs that used to leak. +_REAL_NAMECARD = ( + '' +) + + +class FormatNamecardTextTests(unittest.TestCase): + def test_compact_line_for_real_namecard(self): + out = mcp_server._format_namecard_text(_REAL_NAMECARD) + self.assertEqual(out, "[名片] 李雷: 搬砖工人 / 业余摄影") + + def test_no_pii_or_url_in_output(self): + out = mcp_server._format_namecard_text(_REAL_NAMECARD) + self.assertNotIn("antispamticket", out) + self.assertNotIn("v2_abc123def456", out) + self.assertNotIn("qlogo.cn", out) + self.assertNotIn("brandIconUrl", out) + self.assertNotIn("headimgurl", out) + + def test_official_account_marked(self): + xml = ( + '' + ) + out = mcp_server._format_namecard_text(xml) + self.assertEqual( + out, "[名片] Some Official Account (公众号 gh_some_official): 一个公众号" + ) + + def test_no_certinfo_falls_back_to_head_only(self): + xml = '' + out = mcp_server._format_namecard_text(xml) + self.assertEqual(out, "[名片] 韩梅梅") + + def test_only_username_when_nickname_missing(self): + xml = '' + out = mcp_server._format_namecard_text(xml) + self.assertEqual(out, "[名片] wxid_demo") + + def test_missing_both_identifiers_returns_none(self): + xml = '' + self.assertIsNone(mcp_server._format_namecard_text(xml)) + + def test_broken_xml_returns_none(self): + self.assertIsNone(mcp_server._format_namecard_text("")) + self.assertIsNone(mcp_server._format_namecard_text(" 25MB 在调用 OpenAI SDK 之前就被拒绝(保证不会无意上传) +2. 缓存正确性: backend 不匹配的旧条目不会被命中(避免切后端时返回错后端结果) + +其余路径要么琐碎(默认值读取)、要么坏掉时声音很大(SDK 错误、ImportError), +不再单独覆盖。 +""" +import os +import sys +import tempfile +import unittest +from unittest.mock import MagicMock, patch + +import mcp_server + + +class _CacheIsolationMixin: + """与 test_voice_transcription_cache.py 同款隔离:避免污染 module-level 缓存状态。""" + + def setUp(self): + self._saved_cache = mcp_server._voice_transcription_cache + self._saved_path = mcp_server.VOICE_TRANSCRIPTION_CACHE_FILE + self._saved_warned = mcp_server._voice_transcription_save_warned + + mcp_server._voice_transcription_cache = None + mcp_server._voice_transcription_save_warned = False + + self._tmp = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + mcp_server.VOICE_TRANSCRIPTION_CACHE_FILE = os.path.join( + self._tmp.name, "voice_transcriptions.json" + ) + + def tearDown(self): + mcp_server._voice_transcription_cache = self._saved_cache + mcp_server.VOICE_TRANSCRIPTION_CACHE_FILE = self._saved_path + mcp_server._voice_transcription_save_warned = self._saved_warned + + +class OpenAIBackendPrivacyTests(unittest.TestCase): + """隐私契约:超限文件必须在 OpenAI SDK 实例化之前就被拒绝。 + + 若有人把 size check 移到 OpenAI(api_key=...) 之后(即便仍在 upload 前), + 本测试会失败 —— 这层防御边界值得守住。 + """ + + def test_oversize_audio_rejected_before_sdk_call(self): + # 写一个 26MB 临时 WAV (用稀疏写法快速生成) + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: + f.seek(26 * 1024 * 1024) + f.write(b"\0") + big_path = f.name + self.addCleanup(os.unlink, big_path) + + # 注入一个假的 openai 模块,保证 import 成功;OpenAI 构造函数若被调用即测试失败 + fake_openai = MagicMock() + fake_openai.OpenAI = MagicMock( + side_effect=AssertionError("OpenAI() must not be instantiated for oversize files") + ) + fake_openai.AuthenticationError = type("AuthenticationError", (Exception,), {}) + fake_openai.RateLimitError = type("RateLimitError", (Exception,), {}) + fake_openai.APIError = type("APIError", (Exception,), {}) + + with patch.dict(sys.modules, {"openai": fake_openai}): + with self.assertRaises(RuntimeError) as ctx: + mcp_server._transcribe_openai(big_path) + + self.assertIn("25MB", str(ctx.exception)) + fake_openai.OpenAI.assert_not_called() + + +class CacheBackendMatchTests(_CacheIsolationMixin, unittest.TestCase): + """缓存正确性:backend 不匹配 → 视为 miss,避免切后端时返回错后端结果。""" + + def test_cache_hit_requires_backend_match(self): + # 种入一条 openai 后端的缓存条目 + key = mcp_server._voice_transcription_cache_key("wxid_test", 42) + cache = mcp_server._load_voice_transcription_cache() + cache[key] = { + "text": "openai-result", + "language": "zh", + "create_time": 1700000000, + "backend": "openai", + "model_size": "whisper-1", + } + mcp_server._save_voice_transcription_cache() + + # 当前后端是 local,应当 miss → 走转录流程而非返回 "openai-result" + with patch.object(mcp_server, "TRANSCRIPTION_BACKEND", "local"), \ + patch.object(mcp_server, "OPENAI_API_KEY", ""), \ + patch.object(mcp_server, "resolve_username", return_value="wxid_test"), \ + patch.object(mcp_server, "_fetch_voice_row", + return_value=(b"\x02fake-silk-blob", 1700000001)), \ + patch.object(mcp_server, "_silk_to_wav", + return_value=("/tmp/fake.wav", 24000 * 2)), \ + patch.object(mcp_server, "_transcribe_local", + return_value={"text": "local-result", "language": "zh"}), \ + patch.dict(sys.modules, {"whisper": MagicMock(), "pysilk": MagicMock()}): + result = mcp_server.transcribe_voice("test_contact", 42) + + # 没返回旧 openai 缓存,而是走了 local 转录流程 + self.assertNotIn("openai-result", result) + self.assertIn("local-result", result) + + # 落盘的新条目应记录当前后端 + mcp_server._voice_transcription_cache = None + reloaded = mcp_server._load_voice_transcription_cache() + self.assertEqual(reloaded[key]["backend"], "local") + self.assertEqual(reloaded[key]["text"], "local-result") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_pagination_hint.py b/tests/test_pagination_hint.py new file mode 100644 index 0000000..9c96411 --- /dev/null +++ b/tests/test_pagination_hint.py @@ -0,0 +1,36 @@ +"""测试分页提示语 _pagination_hint() 的边界行为。""" +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import mcp_server + + +def test_no_hint_when_count_less_than_limit(): + """count < limit 表示已读完当前条件下全部结果,不提示。""" + assert mcp_server._pagination_hint(count=10, limit=50, offset=0) == "" + + +def test_hint_when_count_equals_limit(): + """count == limit 时无法判断是否还有更多,提示下一页 offset。""" + hint = mcp_server._pagination_hint(count=50, limit=50, offset=0) + assert "可能还有更多" in hint + assert "offset=50" in hint + + +def test_hint_advances_offset_by_limit(): + """连续翻页时 offset 累加。""" + hint = mcp_server._pagination_hint(count=20, limit=20, offset=100) + assert "offset=120" in hint + + +def test_no_hint_when_limit_zero(): + """limit=0 是非法分页 (上游有 _validate_pagination 兜底);防御性返回空。""" + assert mcp_server._pagination_hint(count=0, limit=0, offset=0) == "" + + +def test_no_hint_when_count_exceeds_limit(): + """理论上 count > limit 不该发生 (调用方已 limit), 但若发生仍要提示。""" + hint = mcp_server._pagination_hint(count=51, limit=50, offset=0) + assert "可能还有更多" in hint diff --git a/tests/test_record_decoders.py b/tests/test_record_decoders.py new file mode 100644 index 0000000..0221b95 --- /dev/null +++ b/tests/test_record_decoders.py @@ -0,0 +1,470 @@ +"""Helper-level regression tests for the recorditem / decoder additions. + +Focused on locking in the bugs fixed across PR #65's many review rounds so +they don't regress. Covers helpers that are easy to call in isolation: + +- `_safe_basename` path-traversal sanitize (round-4 high #1) +- `_md5_file_chunked` streaming hash + size cap (round-6 medium #3) +- `_parse_message_content` group prefix stripping for both `:\n` and + `:19
` content (round-5 medium #3) +- `_format_record_message_text` end-to-end expansion of a >20KB outer + type-19 message (round-5 high #1, round-2 P2-1) +- `_format_record_dataitem` per-datatype rendering for the 14 known + types incl. text / file / image / 视频号 etc. + +The two MCP-tool wrappers (decode_file_message / decode_record_item) lean +heavily on module globals (WECHAT_BASE_DIR, _cache, MSG_DB_KEYS) and the +real wechat cache layout. They are exercised by real-data smoke runs in +the PR description rather than mocked here — mocking the entire wechat +cache tree would dwarf the actual logic under test. +""" + +import hashlib +import os +import tempfile +import unittest + +import mcp_server + + +# -------- _safe_basename ---------------------------------------------------- + + +class SafeBasenameTests(unittest.TestCase): + def test_normal_filename_passes(self): + self.assertEqual(mcp_server._safe_basename('normal.pdf'), 'normal.pdf') + self.assertEqual( + mcp_server._safe_basename('Lec 4- 零和.pdf'), 'Lec 4- 零和.pdf' + ) + self.assertEqual( + mcp_server._safe_basename('file (1).pdf'), 'file (1).pdf' + ) + + def test_absolute_path_rejected(self): + self.assertEqual(mcp_server._safe_basename('/etc/passwd'), '') + + def test_parent_dir_rejected(self): + # Strict reject — should not return the basename 'sensitive'. + self.assertEqual(mcp_server._safe_basename('../../sensitive'), '') + self.assertEqual(mcp_server._safe_basename('..'), '') + + def test_path_separator_rejected(self): + self.assertEqual(mcp_server._safe_basename('subdir/x.pdf'), '') + self.assertEqual(mcp_server._safe_basename('a\\b\\c.pdf'), '') + + def test_nul_rejected(self): + self.assertEqual(mcp_server._safe_basename('with\x00nul.pdf'), '') + + def test_empty_or_dot_rejected(self): + self.assertEqual(mcp_server._safe_basename(''), '') + self.assertEqual(mcp_server._safe_basename('.'), '') + + def test_inner_dots_pass(self): + # 'file...with..dots.pdf' has no separator → fine. + self.assertEqual( + mcp_server._safe_basename('file...with..dots.pdf'), + 'file...with..dots.pdf', + ) + + +# -------- _md5_file_chunked ------------------------------------------------- + + +class Md5FileChunkedTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.NamedTemporaryFile(delete=False) + self.tmp.write(b'x' * 1000) + self.tmp.close() + self.addCleanup(lambda: os.unlink(self.tmp.name)) + + def test_happy_path_matches_hashlib(self): + md5, err = mcp_server._md5_file_chunked(self.tmp.name) + self.assertIsNone(err) + self.assertEqual(md5, hashlib.md5(b'x' * 1000).hexdigest()) + + def test_size_cap_rejects_oversized_file(self): + md5, err = mcp_server._md5_file_chunked(self.tmp.name, max_size=500) + self.assertIsNone(md5) + self.assertIn('超过 md5 校验上限', err) + + def test_missing_file_returns_error(self): + md5, err = mcp_server._md5_file_chunked('/tmp/no/such/path/here_xxx') + self.assertIsNone(md5) + self.assertIsNotNone(err) + + +# -------- _parse_message_content -------------------------------------------- + + +class ParseMessageContentTests(unittest.TestCase): + def test_legacy_newline_prefix_in_group(self): + sender, text = mcp_server._parse_message_content( + 'wxid_abc:\nhi', 1, is_group=True + ) + self.assertEqual(sender, 'wxid_abc') + self.assertEqual(text, 'hi') + + def test_xml_decl_inline_prefix_in_group(self): + # round-7 high #1: 'sender:x', 1, is_group=True + ) + self.assertEqual(sender, 'wxid_abc') + self.assertTrue(text.startswith('x
', 1, is_group=True + ) + self.assertEqual(sender, 'wxid_abc') + self.assertEqual(text, 'x') + + def test_private_chat_does_not_strip(self): + sender, text = mcp_server._parse_message_content( + 'wxid_abc:x', 1, is_group=False + ) + self.assertEqual(sender, '') + self.assertEqual(text, 'wxid_abc:x') + + def test_bytes_content_returns_marker(self): + sender, text = mcp_server._parse_message_content(b'\x00\x01', 1, is_group=False) + self.assertEqual(sender, '') + self.assertEqual(text, '(二进制内容)') + + +# -------- _parse_app_message_outer ------------------------------------------ + + +class ParseAppMessageOuterTests(unittest.TestCase): + def test_small_xml_uses_default_path(self): + outer = '5x' + root = mcp_server._parse_app_message_outer(outer) + self.assertIsNotNone(root) + + def test_oversized_non_record_xml_short_circuits(self): + # round-5 medium #3: only 19 content should retry under + # the wider 500K cap. A 25KB non-type-19 message must NOT be parsed + # under the wider limit. + outer = '5' + 'X' * 25000 + '' + root = mcp_server._parse_app_message_outer(outer) + self.assertIsNone(root) + + def test_oversized_record_xml_retries(self): + # type=19 content > 20KB should succeed under the wider cap. + big_desc = 'A' * 25000 + outer = ( + '19x' + f'x' + f'' + f'{big_desc}' + f']]>' + ) + self.assertGreater(len(outer), 20000) + root = mcp_server._parse_app_message_outer(outer) + self.assertIsNotNone(root) + + +# -------- _format_record_dataitem ------------------------------------------ + + +class FormatRecordDataitemTests(unittest.TestCase): + def _item(self, xml): + import xml.etree.ElementTree as ET + return ET.fromstring(xml) + + def test_text(self): + item = self._item( + 'hello world' + ) + self.assertEqual(mcp_server._format_record_dataitem(item), 'hello world') + + def test_file_with_title(self): + item = self._item( + 'report.pdf' + ) + self.assertEqual( + mcp_server._format_record_dataitem(item), '[文件] report.pdf' + ) + + def test_image(self): + item = self._item('') + self.assertEqual(mcp_server._format_record_dataitem(item), '[图片]') + + def test_finder_feed(self): + # round-2 datatype 22 视频号 + item = self._item( + 'video desc' + ) + self.assertEqual( + mcp_server._format_record_dataitem(item), '[视频号] video desc' + ) + + def test_music(self): + item = self._item( + 'songartist' + ) + self.assertEqual( + mcp_server._format_record_dataitem(item), '[音乐] song - artist' + ) + + def test_unknown_datatype_falls_back_to_desc(self): + item = self._item( + 'fallback content' + ) + self.assertEqual( + mcp_server._format_record_dataitem(item), 'fallback content' + ) + + def test_unknown_datatype_with_no_desc_uses_label(self): + item = self._item('') + self.assertEqual( + mcp_server._format_record_dataitem(item), '[未知类型 999]' + ) + + +# -------- _format_record_message_text end-to-end --------------------------- + + +class FormatRecordMessageTextTests(unittest.TestCase): + def _outer_with_items(self, items_xml, title='Big card', is_chatroom=False): + chatroom = '1' if is_chatroom else '' + recordinfo = ( + f'{title}{chatroom}' + f'{items_xml}' + f'' + ) + return ( + 'x19' + f'' + '' + ) + + def test_large_outer_expands_via_app_message_path(self): + # round-2 P2-1 + round-5 high #1: 大 outer 端到端必须能展开 + items_xml = ''.join( + f'S{i}' + f'2025-01-01 00:00' + f'{"X" * 600}' + for i in range(40) + ) + outer = self._outer_with_items(items_xml) + self.assertGreater(len(outer), 20000) + out = mcp_server._format_app_message_text( + outer, + (19 << 32) | 49, + False, + 'wxid_dummy', + 'dummy', + {}, + ) + self.assertIsNotNone(out) + self.assertIn('[聊天记录]', out) + self.assertIn('共 40 条', out) + # 每行带 0-based index + self.assertIn('[0] ', out) + self.assertIn('[1] ', out) + + def test_empty_datalist_marks_loading(self): + # 空 datalist 应展示"(待加载)"而非"共 0 条" + outer = ( + 'x19' + 'x' + '0]]>' + '' + ) + out = mcp_server._format_app_message_text( + outer, (19 << 32) | 49, False, 'd', 'd', {} + ) + self.assertIn('待加载', out) + + def test_chatroom_marker_appended(self): + items_xml = ( + 'A' + 'hi' + ) + outer = self._outer_with_items(items_xml, title='G', is_chatroom=True) + out = mcp_server._format_app_message_text( + outer, (19 << 32) | 49, True, 'd', 'd', {} + ) + self.assertIn('群聊转发', out) + + def test_overflow_truncation_marker(self): + # > _RECORD_MAX_ITEMS dataitems should produce a + # "…(还有 N 条未显示)" line. + original_max = mcp_server._RECORD_MAX_ITEMS + try: + mcp_server._RECORD_MAX_ITEMS = 3 + items_xml = ''.join( + f'm{i}' + for i in range(7) + ) + outer = self._outer_with_items(items_xml) + out = mcp_server._format_app_message_text( + outer, (19 << 32) | 49, False, 'd', 'd', {} + ) + self.assertIn('还有 4 条未显示', out) + finally: + mcp_server._RECORD_MAX_ITEMS = original_max + + +# -------- WCPay transfer (appmsg type=2000) -------------------------------- +# +# All fixtures use synthetic placeholder values — no real wxid / fee / id / +# memo. paysubtype semantics are community consensus from open-source wechat +# tooling; treat any "未识别" branch as forward-compatible degradation. + + +class TransferPaysubTypeLabelTests(unittest.TestCase): + def test_known_subtypes_present(self): + labels = mcp_server._TRANSFER_PAYSUBTYPE_LABEL + self.assertEqual(labels['1'], '发起转账') + self.assertEqual(labels['3'], '已收款') + self.assertEqual(labels['4'], '已退还') + # 5/7/8 are version-dependent variants — locked to current text so a + # silent rename in mcp_server.py would surface here. + self.assertEqual(labels['5'], '过期已退还') + self.assertEqual(labels['7'], '待领取') + self.assertEqual(labels['8'], '已领取') + + +def _transfer_appmsg( + paysubtype='1', + fee_desc='¥100.00', + pay_memo='', + payer='wxid_payer_synth', + receiver='wxid_recv_synth', + transferid='1' + '0' * 27, + transcationid='1' + '0' * 27, + begin_ts='1746528000', + invalid_ts='1746614400', + title='微信转账', + des='请收钱', + feedesc_tag='feedesc', + paymemo_tag='pay_memo', +): + """Build a synthetic appmsg type=2000 root element. All values are + placeholder; tests never run against real wechat data.""" + import xml.etree.ElementTree as ET + fee_node = f'<{feedesc_tag}>{fee_desc}' if fee_desc else '' + memo_node = f'<{paymemo_tag}>{pay_memo}' if pay_memo else '' + xml_text = ( + f'{title}{des}' + f'2000' + f'' + f'{paysubtype}' + f'{fee_node}{memo_node}' + f'{transferid}' + f'{transcationid}' + f'{begin_ts}' + f'{invalid_ts}' + f'{payer}' + f'{receiver}' + f'' + ) + return ET.fromstring(xml_text), xml_text + + +class ExtractTransferInfoTests(unittest.TestCase): + def test_full_fields_round_trip(self): + root, _ = _transfer_appmsg(paysubtype='3', pay_memo='lunch split') + appmsg = root.find('.//appmsg') + info = mcp_server._extract_transfer_info(appmsg) + self.assertIsNotNone(info) + self.assertEqual(info['paysubtype'], '3') + self.assertEqual(info['paysubtype_label'], '已收款') + self.assertEqual(info['fee_desc'], '¥100.00') + self.assertEqual(info['pay_memo'], 'lunch split') + self.assertEqual(info['payer_username'], 'wxid_payer_synth') + self.assertEqual(info['receiver_username'], 'wxid_recv_synth') + self.assertEqual(info['begin_transfer_time'], '1746528000') + self.assertEqual(info['invalid_time'], '1746614400') + self.assertTrue(info['transfer_id'].startswith('1')) + self.assertTrue(info['transcation_id'].startswith('1')) + + def test_missing_wcpayinfo_returns_none(self): + import xml.etree.ElementTree as ET + root = ET.fromstring( + 'x2000' + ) + appmsg = root.find('.//appmsg') + self.assertIsNone(mcp_server._extract_transfer_info(appmsg)) + + def test_camelcase_feedesc_falls_back(self): + # 部分微信版本字段名为 feeDesc 而非 feedesc + root, _ = _transfer_appmsg(feedesc_tag='feeDesc') + appmsg = root.find('.//appmsg') + info = mcp_server._extract_transfer_info(appmsg) + self.assertEqual(info['fee_desc'], '¥100.00') + + def test_camelcase_paymemo_falls_back(self): + # paymemo (无下划线) 也是已知变体 + root, _ = _transfer_appmsg(pay_memo='note', paymemo_tag='paymemo') + appmsg = root.find('.//appmsg') + info = mcp_server._extract_transfer_info(appmsg) + self.assertEqual(info['pay_memo'], 'note') + + def test_unknown_paysubtype_label_degraded(self): + root, _ = _transfer_appmsg(paysubtype='99') + appmsg = root.find('.//appmsg') + info = mcp_server._extract_transfer_info(appmsg) + self.assertEqual(info['paysubtype'], '99') + self.assertIn('99', info['paysubtype_label']) + + def test_empty_paysubtype_label_empty(self): + root, _ = _transfer_appmsg(paysubtype='') + appmsg = root.find('.//appmsg') + info = mcp_server._extract_transfer_info(appmsg) + self.assertEqual(info['paysubtype_label'], '') + + +class FormatTransferMessageTextTests(unittest.TestCase): + def test_initiate_with_amount(self): + root, _ = _transfer_appmsg(paysubtype='1') + appmsg = root.find('.//appmsg') + out = mcp_server._format_transfer_message_text(appmsg, '微信转账') + self.assertIn('[转账·发起转账]', out) + self.assertIn('¥100.00', out) + + def test_received_with_memo(self): + root, _ = _transfer_appmsg(paysubtype='3', pay_memo='lunch') + appmsg = root.find('.//appmsg') + out = mcp_server._format_transfer_message_text(appmsg, '微信转账') + self.assertIn('[转账·已收款]', out) + self.assertIn('备注: lunch', out) + + def test_missing_wcpayinfo_falls_back_to_title(self): + import xml.etree.ElementTree as ET + root = ET.fromstring( + '微信转账2000' + ) + appmsg = root.find('.//appmsg') + out = mcp_server._format_transfer_message_text(appmsg, '微信转账') + self.assertEqual(out, '[转账] 微信转账') + + def test_missing_fee_desc_safe(self): + # 没有金额时也要给一行能看的输出,不能崩 + root, _ = _transfer_appmsg(paysubtype='4', fee_desc='') + appmsg = root.find('.//appmsg') + out = mcp_server._format_transfer_message_text(appmsg, '微信转账') + self.assertIn('[转账·已退还]', out) + + +class AppMessageDispatchTransferTests(unittest.TestCase): + """type=2000 must route through _format_transfer_message_text via + _format_app_message_text (so get_chat_history / export_chat both pick it up).""" + + def test_dispatch_calls_transfer_helper(self): + _, xml_text = _transfer_appmsg(paysubtype='3', pay_memo='dinner') + out = mcp_server._format_app_message_text( + xml_text, 49, False, 'wxid_dummy', 'dummy', {} + ) + self.assertIsNotNone(out) + self.assertIn('[转账·已收款]', out) + self.assertIn('¥100.00', out) + self.assertIn('备注: dinner', out) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_refer_message.py b/tests/test_refer_message.py new file mode 100644 index 0000000..fc763f5 --- /dev/null +++ b/tests/test_refer_message.py @@ -0,0 +1,219 @@ +"""微信引用回复消息(appmsg type=57)解析鉴定测试。 + +旧逻辑直接把 refermsg/content 按 [:160] 截断当摘要,对 type=3 (图片) / +34 (语音) / 47 (动画表情) / 49 (嵌套卡片) 这些"二进制"被引用消息会渲染 +成 cdnurl + aeskey + md5 一坨乱码 (issue #44 #45)。本组测试 pin 新行为: +按 refer_type 给 schema-aware 摘要,cdnurl / aeskey / md5 / cdnthumb / +voiceurl / externurl 全部不再泄漏到聊天历史。 + +合成 fixture:wxid_synth_a / wxid_synth_b / 12345@chatroom / Sender A/B / +svrid 1 + 0*18,无真实 PII。 +""" +import unittest +import xml.etree.ElementTree as ET + +import mcp_server + + +# ---------- 合成 fixture ---------- + +def _appmsg(refermsg_xml='', title='我的回复'): + """组装一个最小 type=57 appmsg 元素。""" + xml = ( + f'57{title}' + f'{refermsg_xml}' + ) + root = ET.fromstring(xml) + return root.find('.//appmsg') + + +def _refermsg(refer_type, content, fromusr='wxid_synth_a', + displayname='Sender A', svrid='1' + '0' * 18, + chatusr='', createtime='1700000000'): + return ( + '' + f'{refer_type}' + f'{svrid}' + f'{fromusr}' + f'{chatusr}' + f'{displayname}' + f'{createtime}' + f'{content}' + '' + ) + + +# ---------- 标签映射 ---------- + +class ReferInnerTypeLabelTests(unittest.TestCase): + def test_known_refer_inner_labels(self): + self.assertEqual(mcp_server._REFER_INNER_TYPE_LABEL['3'], '图片') + self.assertEqual(mcp_server._REFER_INNER_TYPE_LABEL['34'], '语音') + self.assertEqual(mcp_server._REFER_INNER_TYPE_LABEL['47'], '动画表情') + self.assertEqual(mcp_server._REFER_INNER_TYPE_LABEL['49'], '链接/卡片') + + def test_known_inner_appmsg_labels(self): + self.assertEqual(mcp_server._INNER_APPMSG_TYPE_LABEL['5'], '链接') + self.assertEqual(mcp_server._INNER_APPMSG_TYPE_LABEL['6'], '文件') + self.assertEqual(mcp_server._INNER_APPMSG_TYPE_LABEL['19'], '聊天记录') + + +# ---------- _extract_refer_info ---------- + +class ExtractReferInfoTests(unittest.TestCase): + def test_full_fields_round_trip(self): + appmsg = _appmsg(_refermsg('1', '原文本'), title='回复正文') + info = mcp_server._extract_refer_info(appmsg) + self.assertEqual(info['reply_text'], '回复正文') + self.assertEqual(info['refer_type'], '1') + self.assertEqual(info['refer_fromusr'], 'wxid_synth_a') + self.assertEqual(info['refer_displayname'], 'Sender A') + self.assertEqual(info['refer_svrid'], '1' + '0' * 18) + self.assertEqual(info['refer_content'], '原文本') + + def test_missing_refermsg_returns_none(self): + appmsg = _appmsg(refermsg_xml='', title='孤儿回复') + self.assertIsNone(mcp_server._extract_refer_info(appmsg)) + + +# ---------- _summarize_refer_content ---------- + +class SummarizeReferContentTests(unittest.TestCase): + def test_text_returns_original(self): + self.assertEqual(mcp_server._summarize_refer_content('1', '你好'), '你好') + + def test_text_truncates_to_max_len(self): + long = '中' * 200 + out = mcp_server._summarize_refer_content('1', long, max_len=160) + self.assertEqual(len(out), 161) # 160 + '…' + self.assertTrue(out.endswith('…')) + + def test_image_returns_label_not_xml(self): + v2_image_xml = ( + '' + ) + out = mcp_server._summarize_refer_content('3', v2_image_xml) + self.assertEqual(out, '[图片]') + # PII / 二进制元数据不能泄漏到摘要 + for leak in ('cdnurl', 'aeskey', 'md5', 'cdnthumb', 'leak_main'): + self.assertNotIn(leak, out) + + def test_voice_returns_label(self): + v_xml = '' + out = mcp_server._summarize_refer_content('34', v_xml) + self.assertEqual(out, '[语音]') + self.assertNotIn('voiceurl', out) + + def test_emoji_returns_label(self): + out = mcp_server._summarize_refer_content( + '47', '' + ) + self.assertEqual(out, '[动画表情]') + self.assertNotIn('externurl', out) + self.assertNotIn('leak', out) + + def test_nested_link_card_summary(self): + nested = '5分享标题'\ + 'http://example.com/leak' + out = mcp_server._summarize_refer_content('49', nested) + self.assertEqual(out, '[链接] 分享标题') + self.assertNotIn('http', out) + self.assertNotIn('url', out) + + def test_nested_record_card_summary(self): + nested = '19群聊天记录' + out = mcp_server._summarize_refer_content('49', nested) + self.assertEqual(out, '[聊天记录] 群聊天记录') + + def test_nested_invalid_xml_falls_back_to_card(self): + self.assertEqual( + mcp_server._summarize_refer_content('49', ']>' + '5&x;' + ) + out = mcp_server._summarize_refer_content('49', xxe) + self.assertEqual(out, '[卡片]') + + +# ---------- _format_refer_message_text ---------- + +class FormatReferMessageTextTests(unittest.TestCase): + def _names(self): + return {'wxid_synth_a': 'Sender A', 'wxid_synth_b': 'Sender B'} + + def test_text_refer_in_1v1(self): + appmsg = _appmsg(_refermsg('1', '你吃了吗'), title='吃了') + out = mcp_server._format_refer_message_text( + appmsg, is_group=False, chat_username='wxid_synth_a', + chat_display_name='Sender A', names=self._names(), + ) + self.assertEqual(out, '吃了\n ↳ 回复 Sender A: 你吃了吗') + + def test_image_refer_uses_label_not_xml_payload(self): + v2_image = ( + '<msg><img cdnurl="leak" aeskey="leak" md5="leak"/></msg>' + ) + appmsg = _appmsg(_refermsg('3', v2_image), title='这张?') + out = mcp_server._format_refer_message_text( + appmsg, is_group=False, chat_username='wxid_synth_a', + chat_display_name='Sender A', names=self._names(), + ) + self.assertIn('[图片]', out) + for leak in ('cdnurl', 'aeskey', 'md5'): + self.assertNotIn(leak, out) + + def test_missing_refermsg_falls_back_to_title(self): + appmsg = _appmsg(refermsg_xml='', title='孤儿回复') + out = mcp_server._format_refer_message_text( + appmsg, is_group=False, chat_username='wxid_synth_a', + chat_display_name='Sender A', names={}, + ) + self.assertEqual(out, '孤儿回复') + + def test_empty_reply_uses_placeholder(self): + appmsg = _appmsg(_refermsg('1', 'hi'), title='') + out = mcp_server._format_refer_message_text( + appmsg, is_group=False, chat_username='wxid_synth_a', + chat_display_name='Sender A', names=self._names(), + ) + self.assertTrue(out.startswith('[引用消息]')) + + +# ---------- 调度入口 ---------- + +class AppMessageDispatchReferTests(unittest.TestCase): + def test_type57_dispatches_to_helper(self): + # _format_app_message_text 的 type=57 分支必须走 _format_refer_message_text, + # 不再走旧的 inline [:160] 截断。 + v2_image = '<msg><img cdnurl="leak_main"/></msg>' + content = ( + f'57看这个' + f'{_refermsg("3", v2_image)}' + ) + out = mcp_server._format_app_message_text( + content, local_type=49, is_group=False, + chat_username='wxid_synth_a', chat_display_name='Sender A', names={}, + ) + self.assertIn('[图片]', out) + self.assertNotIn('leak_main', out) + self.assertNotIn('cdnurl', out) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_voice_format.py b/tests/test_voice_format.py new file mode 100644 index 0000000..0012a7c --- /dev/null +++ b/tests/test_voice_format.py @@ -0,0 +1,82 @@ +"""Tests for `_format_voice_text` (msg_type=34 鉴定). + +Voice messages previously rendered as a bare `[语音]` from the generic +non-text branch. LLMs reading chat history had no way to (a) judge whether a +clip was worth transcribing, or (b) call `decode_voice` without first round- +tripping through `get_voice_messages` to look up the `local_id`. + +These tests pin the new behaviour: `[语音 Ns]` (duration to 1 decimal) when +the embedded `` is parseable, with graceful +fallback to `[语音]` on missing / zero / malformed length. +""" +import unittest + +import mcp_server + + +def _voice_xml(length_ms): + return ( + f'' + ) + + +class FormatVoiceTextTests(unittest.TestCase): + def test_renders_duration_with_one_decimal(self): + self.assertEqual(mcp_server._format_voice_text(_voice_xml(3300)), "[语音 3.3s]") + + def test_subsecond_voice(self): + self.assertEqual(mcp_server._format_voice_text(_voice_xml(800)), "[语音 0.8s]") + + def test_long_clip(self): + self.assertEqual(mcp_server._format_voice_text(_voice_xml(62000)), "[语音 62.0s]") + + def test_missing_voicelength_falls_back(self): + xml = '' + self.assertEqual(mcp_server._format_voice_text(xml), "[语音]") + + def test_zero_voicelength_falls_back(self): + self.assertEqual(mcp_server._format_voice_text(_voice_xml(0)), "[语音]") + + def test_non_numeric_voicelength_falls_back(self): + xml = '' + self.assertEqual(mcp_server._format_voice_text(xml), "[语音]") + + def test_empty_content(self): + self.assertEqual(mcp_server._format_voice_text(""), "[语音]") + self.assertEqual(mcp_server._format_voice_text(None), "[语音]") + + def test_missing_voicemsg_tag(self): + self.assertEqual(mcp_server._format_voice_text(""), "[语音]") + + def test_malformed_xml(self): + self.assertEqual(mcp_server._format_voice_text("]>' + '' + ) + self.assertEqual(mcp_server._format_voice_text(xxe), "[语音]") + + def test_end_to_end_format_message_text_with_voicelength(self): + xml = _voice_xml(3300) + _, text = mcp_server._format_message_text( + local_id=72481, local_type=34, content=xml, is_group=False, + chat_username="wxid_synth_a", chat_display_name="A", names={}, + create_time=1700000000, + ) + self.assertEqual(text, "[语音 3.3s] (local_id=72481, ts=1700000000)") + + def test_end_to_end_without_voicelength(self): + _, text = mcp_server._format_message_text( + local_id=99, local_type=34, content="", is_group=False, + chat_username="wxid_synth_a", chat_display_name="A", names={}, + create_time=0, + ) + self.assertEqual(text, "[语音] (local_id=99)") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_voice_transcription_cache.py b/tests/test_voice_transcription_cache.py new file mode 100644 index 0000000..abd53ce --- /dev/null +++ b/tests/test_voice_transcription_cache.py @@ -0,0 +1,274 @@ +import json +import os +import tempfile +import threading +import unittest +from unittest.mock import patch + +import mcp_server + + +class _CacheIsolationMixin: + """所有测试共享:隔离 module-level 缓存状态 + 指向 tempdir 的 cache 文件。""" + + def setUp(self): + self._saved_cache = mcp_server._voice_transcription_cache + self._saved_path = mcp_server.VOICE_TRANSCRIPTION_CACHE_FILE + self._saved_warned = mcp_server._voice_transcription_save_warned + + mcp_server._voice_transcription_cache = None + mcp_server._voice_transcription_save_warned = False + + self._tmp = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + mcp_server.VOICE_TRANSCRIPTION_CACHE_FILE = os.path.join( + self._tmp.name, "voice_transcriptions.json" + ) + + def tearDown(self): + mcp_server._voice_transcription_cache = self._saved_cache + mcp_server.VOICE_TRANSCRIPTION_CACHE_FILE = self._saved_path + mcp_server._voice_transcription_save_warned = self._saved_warned + + +class VoiceTranscriptionCachePersistenceTests(_CacheIsolationMixin, unittest.TestCase): + """_load_voice_transcription_cache / _save_voice_transcription_cache 的持久化行为。""" + + def test_load_missing_file_returns_empty_dict(self): + self.assertEqual(mcp_server._load_voice_transcription_cache(), {}) + + def test_save_and_reload_roundtrip(self): + cache = mcp_server._load_voice_transcription_cache() + cache["wxid_foo:42"] = { + "text": "你好", + "language": "zh", + "create_time": 1700000000, + "model_size": "base", + } + mcp_server._save_voice_transcription_cache() + + # 强制下一次 load 从磁盘读 + mcp_server._voice_transcription_cache = None + reloaded = mcp_server._load_voice_transcription_cache() + self.assertEqual(reloaded["wxid_foo:42"]["text"], "你好") + self.assertEqual(reloaded["wxid_foo:42"]["language"], "zh") + + def test_corrupt_file_returns_empty_dict(self): + with open(mcp_server.VOICE_TRANSCRIPTION_CACHE_FILE, "w", encoding="utf-8") as f: + f.write("{{ not valid json") + self.assertEqual(mcp_server._load_voice_transcription_cache(), {}) + + def test_non_dict_payload_returns_empty_dict(self): + with open(mcp_server.VOICE_TRANSCRIPTION_CACHE_FILE, "w", encoding="utf-8") as f: + json.dump(["not", "a", "dict"], f) + self.assertEqual(mcp_server._load_voice_transcription_cache(), {}) + + def test_utf8_preserved_on_disk(self): + # ensure_ascii=False 必须生效,否则中文会被转义成 \uXXXX + cache = mcp_server._load_voice_transcription_cache() + cache["wxid_bar:1"] = {"text": "中文测试", "language": "zh"} + mcp_server._save_voice_transcription_cache() + + with open(mcp_server.VOICE_TRANSCRIPTION_CACHE_FILE, "rb") as f: + raw = f.read() + self.assertIn("中文测试".encode("utf-8"), raw) + + def test_save_without_prior_load_persists_empty_dict(self): + # 从未 load 过就直接 save:应落盘一个空 dict,而不是静默丢弃。 + mcp_server._voice_transcription_cache = None + mcp_server._save_voice_transcription_cache() + self.assertTrue(os.path.exists(mcp_server.VOICE_TRANSCRIPTION_CACHE_FILE)) + with open(mcp_server.VOICE_TRANSCRIPTION_CACHE_FILE, encoding="utf-8") as f: + self.assertEqual(json.load(f), {}) + + +class VoiceTranscriptionCacheAtomicityTests(_CacheIsolationMixin, unittest.TestCase): + """原子写 + crash-during-save 行为。""" + + def test_write_is_atomic_via_rename(self): + # 先写入一份已有缓存 + cache = mcp_server._load_voice_transcription_cache() + cache["wxid_x:1"] = {"text": "initial", "language": "zh", "model_size": "base"} + mcp_server._save_voice_transcription_cache() + + # 模拟:写 .tmp 正常但 os.replace 阶段失败 + original_replace = os.replace + + def flaky_replace(src, dst): + raise OSError("disk full during rename") + + cache["wxid_x:1"] = {"text": "MUTATED", "language": "zh", "model_size": "base"} + with patch.object(os, "replace", side_effect=flaky_replace): + mcp_server._save_voice_transcription_cache() # 不应抛 + + # 磁盘上应仍然是 initial,不是 MUTATED,也不是损坏的半截文件 + with open(mcp_server.VOICE_TRANSCRIPTION_CACHE_FILE, encoding="utf-8") as f: + disk = json.load(f) + self.assertEqual(disk["wxid_x:1"]["text"], "initial") + + # .tmp 应该被清理,避免污染目录 + tmp_path = mcp_server.VOICE_TRANSCRIPTION_CACHE_FILE + ".tmp" + # 注:patch 生效期间 os.replace 失败,finally 里会尝试 unlink + _ = original_replace # 防 lint 警告 + self.assertFalse(os.path.exists(tmp_path)) + + def test_early_save_error_preserves_existing_file(self): + # json.dump 在 .tmp 上抛异常时(模拟磁盘满 / 权限问题),主文件应保持原样; + # 注意此测试不是"写到一半中断"而是"写前就失败"的场景。 + cache = mcp_server._load_voice_transcription_cache() + cache["wxid_y:1"] = {"text": "survives", "language": "zh", "model_size": "base"} + mcp_server._save_voice_transcription_cache() + + cache["wxid_y:1"] = {"text": "DO NOT SEE", "language": "zh", "model_size": "base"} + + def boom(*args, **kwargs): + raise OSError("disk full") + + with patch.object(mcp_server.json, "dump", side_effect=boom): + mcp_server._save_voice_transcription_cache() # 静默降级,不抛 + + # 主文件没被破坏:仍然可 json.load 出原先内容 + mcp_server._voice_transcription_cache = None + reloaded = mcp_server._load_voice_transcription_cache() + self.assertEqual(reloaded["wxid_y:1"]["text"], "survives") + + +class VoiceTranscriptionCacheConcurrencyTests(_CacheIsolationMixin, unittest.TestCase): + """多线程下的 load/save 行为。""" + + def test_concurrent_load_returns_same_dict_instance(self): + # 16 个线程同时触发首次 load,应当只实际化一份 dict(lock 生效) + barrier = threading.Barrier(16) + results = [] + results_lock = threading.Lock() + + def worker(): + barrier.wait() + d = mcp_server._load_voice_transcription_cache() + with results_lock: + results.append(id(d)) + + threads = [threading.Thread(target=worker) for _ in range(16)] + for t in threads: + t.start() + for t in threads: + t.join() + + self.assertEqual(len(set(results)), 1, "并发 load 应返回同一个 dict 对象") + + def test_concurrent_save_does_not_corrupt(self): + # 多个线程同时 save,磁盘上最终文件必须是合法 JSON(原子写 + lock 保障) + cache = mcp_server._load_voice_transcription_cache() + for i in range(100): + cache[f"wxid_z:{i}"] = { + "text": f"msg-{i}", + "language": "zh", + "model_size": "base", + } + + def worker(): + mcp_server._save_voice_transcription_cache() + + threads = [threading.Thread(target=worker) for _ in range(8)] + for t in threads: + t.start() + for t in threads: + t.join() + + with open(mcp_server.VOICE_TRANSCRIPTION_CACHE_FILE, encoding="utf-8") as f: + disk = json.load(f) # 必须能解析 + self.assertEqual(len(disk), 100) + + +class TranscribeVoiceCacheHitTests(_CacheIsolationMixin, unittest.TestCase): + """transcribe_voice 的缓存命中 / 失效路径。""" + + def _seed(self, key, entry): + cache = mcp_server._load_voice_transcription_cache() + cache[key] = entry + mcp_server._save_voice_transcription_cache() + + def test_cache_hit_skips_fetch_and_transcribe(self): + key = mcp_server._voice_transcription_cache_key("wxid_test", 7) + self._seed(key, { + "text": "缓存命中文本", + "language": "zh", + "create_time": 1700000000, + "model_size": mcp_server.LOCAL_WHISPER_MODEL, + }) + + with patch.object(mcp_server, "resolve_username", return_value="wxid_test") as mock_resolve, \ + patch.object(mcp_server, "_fetch_voice_row") as mock_fetch, \ + patch.object(mcp_server, "_silk_to_wav") as mock_silk, \ + patch.object(mcp_server, "_get_whisper_model") as mock_model: + result = mcp_server.transcribe_voice("test_contact", 7) + + mock_resolve.assert_called_once_with("test_contact") + mock_fetch.assert_not_called() + mock_silk.assert_not_called() + mock_model.assert_not_called() + self.assertIn("缓存命中文本", result) + self.assertIn("(zh)", result) + + def test_cache_hit_uses_placeholder_when_create_time_missing(self): + # 旧条目若没有 create_time 字段,不应崩溃 + key = mcp_server._voice_transcription_cache_key("wxid_test", 8) + self._seed(key, { + "text": "历史条目", + "language": "zh", + "model_size": mcp_server.LOCAL_WHISPER_MODEL, + }) + + with patch.object(mcp_server, "resolve_username", return_value="wxid_test"), \ + patch.object(mcp_server, "_fetch_voice_row") as mock_fetch: + result = mcp_server.transcribe_voice("test_contact", 8) + + mock_fetch.assert_not_called() + self.assertIn("历史条目", result) + + def test_cache_hit_returns_empty_text_without_retranscribing(self): + # Whisper 返回空也要缓存;再次调用应直接返回空,不进入 miss 路径 + key = mcp_server._voice_transcription_cache_key("wxid_test", 9) + self._seed(key, { + "text": "", + "language": "zh", + "create_time": 1700000000, + "model_size": mcp_server.LOCAL_WHISPER_MODEL, + }) + + with patch.object(mcp_server, "resolve_username", return_value="wxid_test"), \ + patch.object(mcp_server, "_fetch_voice_row") as mock_fetch: + result = mcp_server.transcribe_voice("test_contact", 9) + + mock_fetch.assert_not_called() + self.assertIn("(zh)", result) + + def test_model_mismatch_is_treated_as_miss(self): + # 缓存条目的 model_size 和当前 LOCAL_WHISPER_MODEL 不一致时, + # 不应命中;进入 miss 路径(这里无 whisper 依赖,应落到"缺少依赖"分支)。 + key = mcp_server._voice_transcription_cache_key("wxid_test", 10) + self._seed(key, { + "text": "旧模型结果", + "language": "zh", + "create_time": 1700000000, + "model_size": "OUTDATED_MODEL", + }) + + with patch.object(mcp_server, "resolve_username", return_value="wxid_test"), \ + patch.dict("sys.modules", {"whisper": None}): + # whisper=None 时 `import whisper` 触发 ImportError + result = mcp_server.transcribe_voice("test_contact", 10) + + # 走了 miss 路径 → 返回缺依赖提示,而不是返回旧缓存文本 + self.assertNotIn("旧模型结果", result) + self.assertIn("缺少依赖", result) + + def test_cache_key_handles_colon_in_username(self): + # 若上游未来的 resolve_username 放出带 ':' 的 username,也不会和其他条目冲突 + key_a = mcp_server._voice_transcription_cache_key("wxid:foo", 1) + key_b = mcp_server._voice_transcription_cache_key("wxid", 1) + self.assertNotEqual(key_a, key_b) + + +if __name__ == "__main__": + unittest.main() diff --git a/transcribe_chat.py b/transcribe_chat.py new file mode 100644 index 0000000..ca55f61 --- /dev/null +++ b/transcribe_chat.py @@ -0,0 +1,111 @@ +""" +为聊天导出 JSON 中的语音消息补齐转录文本。 + +用法: + .venv/bin/python3 transcribe_chat.py [output.json] + +参数: + 由 export_chat.py 产出的 JSON。 + [output.json] 可选输出路径,默认 "_transcribed.json"。 + +完整流程示例: + .venv/bin/python3 export_chat.py /tmp/chat.json + .venv/bin/python3 transcribe_chat.py /tmp/chat.json /tmp/chat_transcribed.json + +行为说明: + - 后端由 config.json 中 transcription_backend 字段控制 (local/openai/whisper_cpp), + 与 MCP transcribe_voice 工具共享配置。详见 README "语音转录隐私" 章节。 + - 默认 local: 使用本地 Whisper (CPU,单线程),首次运行下载 ~145 MB 权重。 + - 切到 openai: 语音上传至 OpenAI 服务器转录 (~$0.006/分钟)。 + - 切到 whisper_cpp: 使用 whisper-cpp CLI (Metal GPU 加速,仅 macOS)。 + - 幂等: 已有 "transcription" 字段的消息会被跳过,因此崩溃/中断后可安全重跑。 + - 崩溃安全: 每处理完一条即整体重写输出 JSON,进程中断最多丢失当前一条。 + +需要 WeChat DB 仍然在线/已解密 —— 语音 blob 是从 DB 现场按 local_id 读取的, +不从 JSON 读。 +""" +import json +import os +import sys +from datetime import datetime + +import mcp_server + + +def _transcribe_local_id(username, local_id, backend): + row = mcp_server._fetch_voice_row(username, local_id) + if row is None: + return "[not found]" + + voice_data, create_time = row + try: + wav_path, _ = mcp_server._silk_to_wav(voice_data, create_time, username, local_id) + except Exception as e: + return f"[decode error: {e}]" + + try: + result = mcp_server._transcribe(wav_path, backend) + return result["text"] + except Exception as e: + return f"[transcribe error: {e}]" + + +def transcribe_export(input_path, output_path): + with open(input_path, encoding="utf-8") as f: + data = json.load(f) + + # 优先使用导出 JSON 中已记录的 username,避免重新模糊匹配导致同名联系人漂移。 + username = data.get("username") + chat_name = data.get("chat", "") + if not username: + username = mcp_server.resolve_username(chat_name) + if not username: + print(f"Could not resolve username for: {chat_name}") + sys.exit(1) + + messages = data["messages"] + # Compact format: type is absent for text; transcription is only present when filled. + pending = [m for m in messages if m.get("type") == "voice" and not m.get("transcription")] + total = len(pending) + + if total == 0: + print("No voice messages to transcribe.") + return + + backend = mcp_server._resolve_active_backend() + print(f"Found {total} voice messages to transcribe.") + print(f"Backend: {backend}") + if backend == "local": + print("Loading Whisper model (first run downloads ~145MB)...") + mcp_server._get_whisper_model() + print("Model ready.\n") + elif backend == "whisper_cpp": + print("Using whisper-cpp with Metal GPU acceleration\n") + else: + print("") + + for i, msg in enumerate(pending, 1): + local_id = msg["local_id"] + ts = msg["timestamp"] + ts_str = datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S") if isinstance(ts, (int, float)) else ts + print(f"[{i}/{total}] local_id={local_id} ({ts_str}) ... ", end="", flush=True) + result = _transcribe_local_id(username, local_id, backend) + msg["transcription"] = result + print(repr(result[:60]) if result else '""') + + # Save after each transcription so progress isn't lost on crash + with open(output_path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + print(f"\nDone. Written to {output_path}") + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("Usage: python3 transcribe_chat.py [output.json]") + sys.exit(1) + + inp = sys.argv[1] + base, ext = os.path.splitext(inp) + out = sys.argv[2] if len(sys.argv) > 2 else f"{base}_transcribed{ext}" + transcribe_export(inp, out)