Add Windows GUI and WXWork export support
This commit is contained in:
17
.gitignore
vendored
17
.gitignore
vendored
@@ -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/
|
||||
|
||||
46
Makefile
Normal file
46
Makefile
Normal file
@@ -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
|
||||
365
README.md
365
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 — 富媒体内容 & 组合消息修复
|
||||
<details open>
|
||||
<summary>macOS — 最小路径(展开查看)</summary>
|
||||
|
||||
- **表情包内联显示**: 自动从 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
|
||||
```
|
||||
|
||||
## 使用方法
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Windows — 最小路径</summary>
|
||||
|
||||
```bash
|
||||
# 1. 以管理员身份打开终端
|
||||
# 2. 安装依赖
|
||||
py -m pip install -r requirements.txt
|
||||
|
||||
# 3. 提取密钥 + 解密
|
||||
python main.py decrypt
|
||||
|
||||
# 4. 批量导出
|
||||
python export_all_chats.py
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Linux — 最小路径</summary>
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 📖 详细指南
|
||||
|
||||
### 环境要求
|
||||
|
||||
- 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/<pid>/mem`)
|
||||
- `db_dir` 默认类似 `~/Documents/xwechat_files/<wxid>/db_storage`
|
||||
**Linux**:
|
||||
- root 权限或 `CAP_SYS_PTRACE`
|
||||
- 微信正在运行
|
||||
|
||||
### 安装依赖
|
||||
|
||||
@@ -51,124 +95,180 @@ Linux:
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
Windows 如果遇到权限不足或全局环境不可写,可以改用:
|
||||
<details>
|
||||
<summary>⚠️ 安装失败? 点击展开</summary>
|
||||
|
||||
**问题:`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,也可能需要以管理员身份打开终端。
|
||||
</details>
|
||||
|
||||
### 快速开始
|
||||
### 配置
|
||||
|
||||
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/<wxid>/db_storage`。
|
||||
各平台默认路径:
|
||||
- macOS: `~/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files/<wxid>/db_storage`
|
||||
- Windows: 微信设置 → 文件管理中查看
|
||||
- Linux: `~/Documents/xwechat_files/<wxid>/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 <pid>
|
||||
```
|
||||
<details>
|
||||
<summary>点击展开</summary>
|
||||
|
||||
输出 `all_keys.json`,格式兼容 `decrypt_db.py`,可直接用于解密:
|
||||
#### 2025-03-03 — 富媒体内容 & 组合消息修复
|
||||
- 表情包内联显示
|
||||
- 富媒体内容解析(链接卡片、文件、视频号、小程序等)
|
||||
- 文字+图片组合消息不再丢失
|
||||
- 隐藏消息检测机制
|
||||
- Web UI 改进
|
||||
|
||||
```bash
|
||||
python3 decrypt_db.py
|
||||
```
|
||||
</details>
|
||||
|
||||
## 免责声明
|
||||
### 免责声明
|
||||
|
||||
本工具仅用于学习和研究目的,用于解密**自己的**微信数据。请遵守相关法律法规,不要用于未经授权的数据访问。
|
||||
|
||||
防失联 TG: https://t.me/wechat_decrypt
|
||||
|
||||
201
chat_export_helpers.py
Normal file
201
chat_export_helpers.py
Normal file
@@ -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 <varint len> <utf-8>
|
||||
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 "<sysmsg" not in content:
|
||||
return content
|
||||
root = mcp_server._parse_xml_root(content)
|
||||
if root is None:
|
||||
return content
|
||||
inner = root.findtext(".//content")
|
||||
return inner.strip() if inner else content
|
||||
|
||||
|
||||
def _format_video_message(content):
|
||||
root = mcp_server._parse_xml_root(content) if content else None
|
||||
if root is None:
|
||||
return "[视频]"
|
||||
video = root.find(".//videomsg")
|
||||
if video is None:
|
||||
return "[视频]"
|
||||
playlength = video.get("playlength")
|
||||
return f"[视频] {playlength}秒" if playlength else "[视频]"
|
||||
|
||||
|
||||
def _extract_transfer_extras(content):
|
||||
"""Detect appmsg type=2000 and return structured transfer fields, else None.
|
||||
|
||||
Reuses mcp_server._extract_transfer_info so the schema/version-quirks logic
|
||||
lives in one place. Empty values are dropped to keep the export compact.
|
||||
Numeric timestamps are returned as ints (consistent with the top-level
|
||||
`timestamp` field), not iso strings — downstream consumers can format.
|
||||
"""
|
||||
if not content or '<appmsg' not in content:
|
||||
return None
|
||||
root = mcp_server._parse_app_message_outer(content)
|
||||
if root is None:
|
||||
return None
|
||||
appmsg = root.find('.//appmsg')
|
||||
if appmsg is None:
|
||||
return None
|
||||
app_type = mcp_server._parse_int(
|
||||
mcp_server._collapse_text(appmsg.findtext('type') or ''), 0
|
||||
)
|
||||
if app_type != 2000:
|
||||
return None
|
||||
|
||||
info = mcp_server._extract_transfer_info(appmsg)
|
||||
if not info:
|
||||
return None
|
||||
|
||||
out = {}
|
||||
if info['paysubtype_label']:
|
||||
out['direction'] = info['paysubtype_label']
|
||||
for k in ('paysubtype', 'fee_desc', 'pay_memo',
|
||||
'payer_username', 'receiver_username',
|
||||
'transfer_id', 'transcation_id', 'pay_msg_id'):
|
||||
v = info.get(k)
|
||||
if v:
|
||||
out[k] = v
|
||||
for k in ('begin_transfer_time', 'invalid_time'):
|
||||
v = mcp_server._parse_int(info.get(k) or '', 0)
|
||||
if v:
|
||||
out[k] = v
|
||||
return out or None
|
||||
|
||||
|
||||
def _extract_content(local_id, local_type, content, ct, chat_username, chat_display_name):
|
||||
"""Return (rendered_text, extras_dict). Either may be None.
|
||||
|
||||
extras carries structured fields for non-text message types where caller
|
||||
wants more than the human-readable string (currently: transfer). Future
|
||||
additions (video号 metadata, merged-forward expansion, …) can flow through
|
||||
the same channel without changing the caller signature.
|
||||
"""
|
||||
content = mcp_server._decompress_content(content, ct)
|
||||
if content is None:
|
||||
return None, None
|
||||
|
||||
# 群消息的 content 形如 'wxid_xxx:\n<xml...>'。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
|
||||
240
cleanup.py
Normal file
240
cleanup.py
Normal file
@@ -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()
|
||||
60
config.py
60
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/<wxid>/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/<wxid>/db_storage")
|
||||
elif _SYSTEM == "darwin":
|
||||
print(" macOS 默认路径类似: ~/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files/<wxid>/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\<wxid>\db_storage
|
||||
|
||||
235
decode_image.py
235
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 的镜像目录树。
|
||||
|
||||
输入路径形态(微信本地约定):
|
||||
<attach_dir>/<chat_hash>/<YYYY-MM>/Img/<file_md5>[_t|_h].dat
|
||||
|
||||
其中 chat_hash = md5(username).hexdigest(),username 是 wxid 或
|
||||
<id>@chatroom;_t/_h 分别是缩略图 / 高清缩略图后缀。
|
||||
|
||||
输出路径形态(镜像 + 移除 _t/_h 缩略图后缀,平铺到原图 basename):
|
||||
<out_dir>/<chat_hash>/<YYYY-MM>/<file_md5>.<ext>
|
||||
|
||||
其中 <ext> 由 magic 自动检测(jpg / png / gif / webp / hevc 等)。
|
||||
wxgf 容器输出 .hevc;不在 upstream 做 mp4 转换(scope 留给下游)。
|
||||
|
||||
幂等性:目标存在(任何扩展名,基于 basename)时跳过,无需 mtime 比较 ——
|
||||
.dat 是 content-hash 命名,实际上 write-once。force=True 强制重解。
|
||||
|
||||
原子写:解密先写到 <basename>.<ext>.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\\<wxid>)
|
||||
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,
|
||||
|
||||
51
decode_transfer.py
Normal file
51
decode_transfer.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""
|
||||
读取微信转账消息(appmsg type=2000)的结构化字段。
|
||||
|
||||
用法:
|
||||
python3 decode_transfer.py <chat_name> <local_id> [<ts>]
|
||||
|
||||
参数:
|
||||
<chat_name> 联系人显示名、备注名或 wxid(仅 1v1 聊天有转账消息)。
|
||||
<local_id> 转账消息的 local_id(从 export_chat 输出 / monitor_web 等地方获取)。
|
||||
[<ts>] 可选 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())
|
||||
@@ -9,6 +9,7 @@ import hashlib, struct, os, sys, json
|
||||
import hmac as hmac_mod
|
||||
from Crypto.Cipher import AES
|
||||
|
||||
import argparse
|
||||
import functools
|
||||
print = functools.partial(print, flush=True)
|
||||
|
||||
@@ -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,15 +129,18 @@ 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}")
|
||||
if args.incremental:
|
||||
print(f"模式: 增量 (跳过未变更的数据库)")
|
||||
os.makedirs(OUT_DIR, exist_ok=True)
|
||||
|
||||
# 收集所有DB文件
|
||||
@@ -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
|
||||
print(f"SKIP: {rel} (无密钥,如已安装微信补丁可能需要重新运行密钥提取)")
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
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"])
|
||||
out_path = os.path.join(OUT_DIR, rel)
|
||||
|
||||
print(f"解密: {rel} ({sz/1024/1024:.1f}MB) ...", end=" ")
|
||||
|
||||
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}")
|
||||
|
||||
|
||||
259
docs/bugfix/mac-deploy-issues.md
Normal file
259
docs/bugfix/mac-deploy-issues.md
Normal file
@@ -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/<wxid>/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 安装 | ❌ 无法代码修复 | 环境限制,需使用虚拟环境 |
|
||||
97
docs/chat_export_format.md
Normal file
97
docs/chat_export_format.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# 聊天导出 JSON 数据格式
|
||||
|
||||
`export_chat.py` 与 `transcribe_chat.py` 生成的 JSON 文件采用紧凑格式:
|
||||
默认值与空值会被省略。本文档说明如何加载和解读这类文件。
|
||||
|
||||
## 生成文件
|
||||
|
||||
```bash
|
||||
.venv/bin/python3 export_chat.py <chat_name> [output.json]
|
||||
.venv/bin/python3 transcribe_chat.py <input.json> [output.json]
|
||||
```
|
||||
|
||||
`export_chat.py` 负责原始导出;`transcribe_chat.py` 使用 Whisper(CPU)
|
||||
为语音消息填充转录文本。`transcribe_chat.py` 可重复运行 —— 已转录的
|
||||
消息会被跳过。
|
||||
|
||||
## 顶层结构
|
||||
|
||||
```json
|
||||
{
|
||||
"chat": "<display name>",
|
||||
"username": "<wxid 或 @chatroom>",
|
||||
"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"`。
|
||||
418
export_all_chats.py
Normal file
418
export_all_chats.py
Normal file
@@ -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()
|
||||
134
export_chat.py
Normal file
134
export_chat.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""
|
||||
将单个聊天的全部消息导出为 JSON。
|
||||
|
||||
用法:
|
||||
.venv/bin/python3 export_chat.py <chat_name> [output.json]
|
||||
|
||||
参数:
|
||||
<chat_name> 联系人显示名、备注名、群名或 wxid。
|
||||
[output.json] 可选输出路径,默认 "<chat_name>_export.json"。
|
||||
|
||||
示例:
|
||||
.venv/bin/python3 export_chat.py <contact_name>
|
||||
.venv/bin/python3 export_chat.py <group_name> /tmp/out.json
|
||||
|
||||
输出 JSON 的紧凑结构:
|
||||
{
|
||||
"chat": "<display name>",
|
||||
"username": "<wxid 或 @chatroom>",
|
||||
"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": "<name>", "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 <chat_name> [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)
|
||||
189
find_all_keys.py
189
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:
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
642
find_image_key_macos.py
Normal file
642
find_image_key_macos.py
Normal file
@@ -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_<uin>_*.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_<code>_<其他段>.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_<seg> 形式:保留 wxid_<seg>,丢弃后续下划线分段
|
||||
- <base>_<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/<wxid>_<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_<uin>_*.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()
|
||||
@@ -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')
|
||||
|
||||
|
||||
262
main.py
262
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)
|
||||
|
||||
|
||||
|
||||
2337
mcp_server.py
2337
mcp_server.py
File diff suppressed because it is too large
Load Diff
291
monitor_web.py
291
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 `<div class="msg-chatlog"><div class="msg-link-title">📋 ${esc(r.title)}</div>${body}</div>`;
|
||||
}
|
||||
if(r.type==='transfer') {
|
||||
let dirLabel = r.direction || '微信转账';
|
||||
let amount = r.fee_desc ? '<div class="msg-transfer-amount">'+esc(r.fee_desc)+'</div>' : '';
|
||||
let memo = r.pay_memo ? '<div class="msg-transfer-memo">备注: '+esc(r.pay_memo)+'</div>' : '';
|
||||
return `<div class="msg-transfer"><div class="msg-transfer-head">💸 ${esc(dirLabel)}</div>${amount}${memo}</div>`;
|
||||
}
|
||||
if(r.type==='voice') return `<div class="msg-voice">🎤 语音 ${r.duration}s</div>`;
|
||||
if(r.type==='video') return `<div class="msg-video">🎬 视频${r.duration?' '+r.duration+'s':''}</div>`;
|
||||
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
|
||||
|
||||
|
||||
@@ -3,3 +3,4 @@ zstandard>=0.22,<1
|
||||
mcp>=1.0,<2
|
||||
pilk>=0.2
|
||||
pyinstaller>=6.0
|
||||
# 可选:进度条 (pip install tqdm)
|
||||
|
||||
245
setup.py
Normal file
245
setup.py
Normal file
@@ -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()
|
||||
143
setup.sh
Executable file
143
setup.sh
Executable file
@@ -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 "========================================================"
|
||||
104
tests/test_chat_export_helpers.py
Normal file
104
tests/test_chat_export_helpers.py
Normal file
@@ -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 (
|
||||
'<msg><appmsg appid="" sdkver="0">'
|
||||
'<title>quote reply</title>'
|
||||
'<type>57</type>'
|
||||
'<refermsg>'
|
||||
'<type>1</type>'
|
||||
f'<content>{refer_content}</content>'
|
||||
'<fromusr>wxid_orig_sender</fromusr>'
|
||||
'<displayname>Original Sender</displayname>'
|
||||
'</refermsg>'
|
||||
'</appmsg></msg>'
|
||||
)
|
||||
|
||||
|
||||
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()
|
||||
97
tests/test_chat_images_query_align.py
Normal file
97
tests/test_chat_images_query_align.py
Normal file
@@ -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
|
||||
481
tests/test_decode_image_v2.py
Normal file
481
tests/test_decode_image_v2.py
Normal file
@@ -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('<LL', aes_size, xor_size) + b'\x00'
|
||||
return header + aes_cipher + raw_plain + xor_cipher
|
||||
|
||||
|
||||
class _FakeCache:
|
||||
"""ImageResolver 测试用最小缓存桩,绕过真实 DB 解密。"""
|
||||
|
||||
def __init__(self, mapping):
|
||||
self._mapping = mapping
|
||||
|
||||
def get(self, rel_key):
|
||||
return self._mapping.get(rel_key)
|
||||
|
||||
|
||||
def _make_resource_db(path, local_id, file_md5, username="wxid_test123",
|
||||
chat_id=1, message_create_time=1700000000,
|
||||
message_local_type=3, extra_rows=()):
|
||||
"""构造最小 message_resource.db, 表 schema 对齐真实微信结构。
|
||||
|
||||
真实表里 message_local_id 不全局唯一 (跨 chat 重复, 活跃 chat 内也会复用),
|
||||
解析必须用 ChatName2Id.rowid -> 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()
|
||||
295
tests/test_decode_images_batch.py
Normal file
295
tests/test_decode_images_batch.py
Normal file
@@ -0,0 +1,295 @@
|
||||
"""decode_image.decode_all_dats() batch CLI 行为测试。
|
||||
|
||||
覆盖:
|
||||
- 路径扫描:glob 命中 attach/<chat_hash>/<YYYY-MM>/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("<LL", 0, 0) + b"\x00"
|
||||
|
||||
|
||||
def _v1_magic_bytes():
|
||||
return decode_image.V1_MAGIC_FULL + struct.pack("<LL", 0, 0) + b"\x00"
|
||||
|
||||
|
||||
class _MockedDecrypt:
|
||||
"""mock decrypt_dat_file:不真解密,只往 tmp 写一个 marker 字节串然后返回 (tmp, ext)。
|
||||
|
||||
通过实例化时配置返回的 ext / 是否抛异常 / 是否返回 (None, None),覆盖
|
||||
各种成功/失败路径。
|
||||
"""
|
||||
def __init__(self, ext="jpg", marker=b"DECODED", returns_none=False, raises=None):
|
||||
self.ext = ext
|
||||
self.marker = marker
|
||||
self.returns_none = returns_none
|
||||
self.raises = raises
|
||||
self.calls = []
|
||||
|
||||
def __call__(self, dat_path, out_path=None, aes_key=None, xor_key=0x88):
|
||||
self.calls.append((dat_path, out_path, aes_key, xor_key))
|
||||
if self.raises:
|
||||
raise self.raises
|
||||
if self.returns_none:
|
||||
return None, None
|
||||
# 写 tmp(decode_all_dats 期望我们写完才能 os.replace)
|
||||
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
||||
with open(out_path, "wb") as f:
|
||||
f.write(self.marker)
|
||||
return out_path, self.ext
|
||||
|
||||
|
||||
def _make_dat(attach_dir, chat_hash, ym, basename, content=None):
|
||||
"""在 attach_dir 下造一个 .dat 文件,返回完整路径。content 默认是非 V2 占位。"""
|
||||
if content is None:
|
||||
content = b"\x00\x00\x00\x00" # 非 V2 / 非 V1 magic
|
||||
p = os.path.join(attach_dir, chat_hash, ym, "Img", f"{basename}.dat")
|
||||
_write(p, content)
|
||||
return p
|
||||
|
||||
|
||||
class PathParsingTests(unittest.TestCase):
|
||||
"""路径扫描 / 解析 / _t _h 归并。"""
|
||||
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self._tmp.cleanup)
|
||||
self.attach = os.path.join(self._tmp.name, "attach")
|
||||
self.out = os.path.join(self._tmp.name, "out")
|
||||
|
||||
def test_finds_dat_files_under_chat_month_img(self):
|
||||
_make_dat(self.attach, "hash1", "2026-01", "abc123")
|
||||
_make_dat(self.attach, "hash2", "2026-02", "def456")
|
||||
mock = _MockedDecrypt()
|
||||
with patch.object(decode_image, "decrypt_dat_file", mock), \
|
||||
redirect_stderr(io.StringIO()):
|
||||
stats = decode_image.decode_all_dats(
|
||||
self.attach, self.out, aes_key="x" * 16, progress_every=None,
|
||||
)
|
||||
self.assertEqual(stats["total"], 2)
|
||||
self.assertEqual(stats["decoded"], 2)
|
||||
self.assertEqual(stats["failed"], 0)
|
||||
|
||||
def test_strips_t_suffix(self):
|
||||
_make_dat(self.attach, "hash1", "2026-01", "abc123_t")
|
||||
mock = _MockedDecrypt(ext="png")
|
||||
with patch.object(decode_image, "decrypt_dat_file", mock), \
|
||||
redirect_stderr(io.StringIO()):
|
||||
decode_image.decode_all_dats(
|
||||
self.attach, self.out, aes_key="x" * 16, progress_every=None,
|
||||
)
|
||||
# 期望产出: out/hash1/2026-01/abc123.png(_t 已被剥)
|
||||
produced = os.path.join(self.out, "hash1", "2026-01", "abc123.png")
|
||||
self.assertTrue(os.path.exists(produced), f"missing: {produced}")
|
||||
|
||||
def test_strips_h_suffix(self):
|
||||
_make_dat(self.attach, "hash1", "2026-01", "abc_h")
|
||||
mock = _MockedDecrypt(ext="jpg")
|
||||
with patch.object(decode_image, "decrypt_dat_file", mock), \
|
||||
redirect_stderr(io.StringIO()):
|
||||
decode_image.decode_all_dats(
|
||||
self.attach, self.out, aes_key="x" * 16, progress_every=None,
|
||||
)
|
||||
produced = os.path.join(self.out, "hash1", "2026-01", "abc.jpg")
|
||||
self.assertTrue(os.path.exists(produced))
|
||||
|
||||
def test_mirrors_chat_and_month(self):
|
||||
_make_dat(self.attach, "abcdef0123456789", "2026-04", "img1")
|
||||
mock = _MockedDecrypt(ext="jpg")
|
||||
with patch.object(decode_image, "decrypt_dat_file", mock), \
|
||||
redirect_stderr(io.StringIO()):
|
||||
decode_image.decode_all_dats(
|
||||
self.attach, self.out, aes_key="x" * 16, progress_every=None,
|
||||
)
|
||||
produced = os.path.join(self.out, "abcdef0123456789", "2026-04", "img1.jpg")
|
||||
self.assertTrue(os.path.exists(produced))
|
||||
|
||||
|
||||
class IdempotentTests(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self._tmp.cleanup)
|
||||
self.attach = os.path.join(self._tmp.name, "attach")
|
||||
self.out = os.path.join(self._tmp.name, "out")
|
||||
|
||||
def test_existing_target_basename_skipped(self):
|
||||
_make_dat(self.attach, "hash1", "2026-01", "img1")
|
||||
# 预先放一个目标(任何扩展名)
|
||||
existing = os.path.join(self.out, "hash1", "2026-01", "img1.png")
|
||||
_write(existing, b"OLD")
|
||||
mock = _MockedDecrypt()
|
||||
with patch.object(decode_image, "decrypt_dat_file", mock), \
|
||||
redirect_stderr(io.StringIO()):
|
||||
stats = decode_image.decode_all_dats(
|
||||
self.attach, self.out, aes_key="x" * 16, progress_every=None,
|
||||
)
|
||||
self.assertEqual(stats["skipped"], 1)
|
||||
self.assertEqual(stats["decoded"], 0)
|
||||
self.assertEqual(len(mock.calls), 0, "decrypt_dat_file 不该被调用")
|
||||
# 目标内容未被改写
|
||||
with open(existing, "rb") as f:
|
||||
self.assertEqual(f.read(), b"OLD")
|
||||
|
||||
def test_force_overrides_skip(self):
|
||||
_make_dat(self.attach, "hash1", "2026-01", "img1")
|
||||
existing = os.path.join(self.out, "hash1", "2026-01", "img1.png")
|
||||
_write(existing, b"OLD")
|
||||
mock = _MockedDecrypt(ext="jpg", marker=b"NEW")
|
||||
with patch.object(decode_image, "decrypt_dat_file", mock), \
|
||||
redirect_stderr(io.StringIO()):
|
||||
stats = decode_image.decode_all_dats(
|
||||
self.attach, self.out, aes_key="x" * 16,
|
||||
force=True, progress_every=None,
|
||||
)
|
||||
self.assertEqual(stats["decoded"], 1)
|
||||
self.assertEqual(stats["skipped"], 0)
|
||||
# 新文件以新 ext 落盘
|
||||
new_file = os.path.join(self.out, "hash1", "2026-01", "img1.jpg")
|
||||
self.assertTrue(os.path.exists(new_file))
|
||||
|
||||
def test_skip_ignores_tmp_files(self):
|
||||
"""残留的 .tmp 不应该被当成"已存在目标"误判跳过。"""
|
||||
_make_dat(self.attach, "hash1", "2026-01", "img1")
|
||||
# 模拟之前一次中断留下的 .tmp
|
||||
leftover_tmp = os.path.join(self.out, "hash1", "2026-01", "img1.unknown.tmp")
|
||||
_write(leftover_tmp, b"PARTIAL")
|
||||
mock = _MockedDecrypt(ext="jpg")
|
||||
with patch.object(decode_image, "decrypt_dat_file", mock), \
|
||||
redirect_stderr(io.StringIO()):
|
||||
stats = decode_image.decode_all_dats(
|
||||
self.attach, self.out, aes_key="x" * 16, progress_every=None,
|
||||
)
|
||||
self.assertEqual(stats["decoded"], 1, "残留 .tmp 不应该阻止重解")
|
||||
|
||||
|
||||
class AtomicWriteTests(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self._tmp.cleanup)
|
||||
self.attach = os.path.join(self._tmp.name, "attach")
|
||||
self.out = os.path.join(self._tmp.name, "out")
|
||||
_make_dat(self.attach, "hash1", "2026-01", "img1")
|
||||
|
||||
def test_success_path_no_tmp_leftover(self):
|
||||
mock = _MockedDecrypt(ext="jpg")
|
||||
with patch.object(decode_image, "decrypt_dat_file", mock), \
|
||||
redirect_stderr(io.StringIO()):
|
||||
decode_image.decode_all_dats(
|
||||
self.attach, self.out, aes_key="x" * 16, progress_every=None,
|
||||
)
|
||||
target_dir = os.path.join(self.out, "hash1", "2026-01")
|
||||
leftovers = [f for f in os.listdir(target_dir) if f.endswith(".tmp")]
|
||||
self.assertEqual(leftovers, [], "成功路径不应有 .tmp 残留")
|
||||
|
||||
def test_decrypt_returns_none_no_tmp_leftover(self):
|
||||
mock = _MockedDecrypt(returns_none=True)
|
||||
with patch.object(decode_image, "decrypt_dat_file", mock), \
|
||||
redirect_stderr(io.StringIO()):
|
||||
stats = decode_image.decode_all_dats(
|
||||
self.attach, self.out, aes_key="x" * 16, progress_every=None,
|
||||
)
|
||||
self.assertEqual(stats["failed"], 1)
|
||||
# decrypt 没写 tmp(returns_none=True 时也不写),所以目录可能不存在或为空
|
||||
target_dir = os.path.join(self.out, "hash1", "2026-01")
|
||||
if os.path.isdir(target_dir):
|
||||
leftovers = [f for f in os.listdir(target_dir) if f.endswith(".tmp")]
|
||||
self.assertEqual(leftovers, [])
|
||||
|
||||
def test_decrypt_raises_no_tmp_leftover(self):
|
||||
mock = _MockedDecrypt(raises=RuntimeError("synthetic decrypt failure"))
|
||||
with patch.object(decode_image, "decrypt_dat_file", mock), \
|
||||
redirect_stderr(io.StringIO()):
|
||||
stats = decode_image.decode_all_dats(
|
||||
self.attach, self.out, aes_key="x" * 16, progress_every=None,
|
||||
)
|
||||
self.assertEqual(stats["failed"], 1)
|
||||
target_dir = os.path.join(self.out, "hash1", "2026-01")
|
||||
if os.path.isdir(target_dir):
|
||||
leftovers = [f for f in os.listdir(target_dir) if f.endswith(".tmp")]
|
||||
self.assertEqual(leftovers, [], "异常路径必须清理 .tmp")
|
||||
|
||||
|
||||
class V2NoKeyTests(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self._tmp.cleanup)
|
||||
self.attach = os.path.join(self._tmp.name, "attach")
|
||||
self.out = os.path.join(self._tmp.name, "out")
|
||||
|
||||
def test_v2_dat_with_no_aes_key_skipped(self):
|
||||
_make_dat(self.attach, "hash1", "2026-01", "v2img", content=_v2_magic_bytes())
|
||||
mock = _MockedDecrypt()
|
||||
with patch.object(decode_image, "decrypt_dat_file", mock), \
|
||||
redirect_stderr(io.StringIO()):
|
||||
stats = decode_image.decode_all_dats(
|
||||
self.attach, self.out, aes_key=None, progress_every=None,
|
||||
)
|
||||
self.assertEqual(stats["skipped_no_key"], 1)
|
||||
self.assertEqual(stats["decoded"], 0)
|
||||
self.assertEqual(stats["failed"], 0)
|
||||
self.assertEqual(len(mock.calls), 0, "无 key 的 V2 文件不应该走 decrypt_dat_file")
|
||||
|
||||
def test_v1_dat_with_no_aes_key_still_decoded(self):
|
||||
"""V1 用固定 AES key,不需要 image_aes_key,仍应被处理。"""
|
||||
_make_dat(self.attach, "hash1", "2026-01", "v1img", content=_v1_magic_bytes())
|
||||
mock = _MockedDecrypt(ext="jpg")
|
||||
with patch.object(decode_image, "decrypt_dat_file", mock), \
|
||||
redirect_stderr(io.StringIO()):
|
||||
stats = decode_image.decode_all_dats(
|
||||
self.attach, self.out, aes_key=None, progress_every=None,
|
||||
)
|
||||
# is_v2_format 只识别 V2(纯 V2 magic),V1 不算 V2,所以会进入 decrypt 流程
|
||||
self.assertEqual(stats["decoded"], 1)
|
||||
self.assertEqual(stats["skipped_no_key"], 0)
|
||||
|
||||
|
||||
class CallbackTests(unittest.TestCase):
|
||||
|
||||
def test_on_file_callback_fires_per_file(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
attach = os.path.join(tmp, "attach")
|
||||
out = os.path.join(tmp, "out")
|
||||
_make_dat(attach, "h1", "2026-01", "a")
|
||||
_make_dat(attach, "h1", "2026-01", "b")
|
||||
_make_dat(attach, "h1", "2026-01", "c")
|
||||
events = []
|
||||
mock = _MockedDecrypt()
|
||||
with patch.object(decode_image, "decrypt_dat_file", mock), \
|
||||
redirect_stderr(io.StringIO()):
|
||||
decode_image.decode_all_dats(
|
||||
attach, out, aes_key="x" * 16, progress_every=None,
|
||||
on_file=lambda i, total, p, status, fmt: events.append(status),
|
||||
)
|
||||
self.assertEqual(len(events), 3)
|
||||
self.assertTrue(all(s == "decoded" for s in events))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
770
tests/test_find_image_key_macos.py
Normal file
770
tests/test_find_image_key_macos.py
Normal file
@@ -0,0 +1,770 @@
|
||||
"""单元测试:find_image_key_macos 派生算法 + 端到端 smoke。
|
||||
|
||||
不依赖真实微信数据;用 tempdir + 合成密文构造测试。
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import multiprocessing
|
||||
import os
|
||||
import queue as _queue_mod
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from Crypto.Cipher import AES
|
||||
|
||||
import find_image_key_macos as fkm
|
||||
|
||||
|
||||
class NormalizeWxidTests(unittest.TestCase):
|
||||
def test_wxid_with_extra_segments_keeps_only_first(self):
|
||||
# wxid_<seg> 形式只保留第一段下划线之内的内容
|
||||
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()
|
||||
133
tests/test_get_chat_images_multishard.py
Normal file
133
tests/test_get_chat_images_multishard.py
Normal file
@@ -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()
|
||||
85
tests/test_msg_types_filter.py
Normal file
85
tests/test_msg_types_filter.py
Normal file
@@ -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]
|
||||
73
tests/test_namecard_format.py
Normal file
73
tests/test_namecard_format.py
Normal file
@@ -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 `[名片] <raw XML>`, dumping the full `<msg .../>` 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 `[名片] <head>: <bio>` 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 = (
|
||||
'<msg username="wxid_friend_demo" nickname="李雷" '
|
||||
'antispamticket="v2_abc123def456_should_not_leak" '
|
||||
'fullpy="lilei" shortpy="LL" alias="" '
|
||||
'imagestatus="3" scene="17" province="北京" city="海淀" sign="" '
|
||||
'sex="1" certflag="0" certinfo="搬砖工人 / 业余摄影" '
|
||||
'brandIconUrl="https://wx.qlogo.cn/should_not_leak" '
|
||||
'bigheadimgurl="https://wx.qlogo.cn/should_not_leak_big" '
|
||||
'smallheadimgurl="https://wx.qlogo.cn/should_not_leak_small" />'
|
||||
)
|
||||
|
||||
|
||||
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 = (
|
||||
'<msg username="gh_some_official" nickname="Some Official Account" '
|
||||
'certinfo="一个公众号" />'
|
||||
)
|
||||
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 = '<msg username="wxid_demo" nickname="韩梅梅" />'
|
||||
out = mcp_server._format_namecard_text(xml)
|
||||
self.assertEqual(out, "[名片] 韩梅梅")
|
||||
|
||||
def test_only_username_when_nickname_missing(self):
|
||||
xml = '<msg username="wxid_demo" nickname="" />'
|
||||
out = mcp_server._format_namecard_text(xml)
|
||||
self.assertEqual(out, "[名片] wxid_demo")
|
||||
|
||||
def test_missing_both_identifiers_returns_none(self):
|
||||
xml = '<msg nickname="" username="" />'
|
||||
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("<msg "))
|
||||
self.assertIsNone(mcp_server._format_namecard_text("not xml at all"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
116
tests/test_openai_backend.py
Normal file
116
tests/test_openai_backend.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
issue #59: opt-in OpenAI Whisper API 后端的两条关键回归测试。
|
||||
|
||||
只测两件事:
|
||||
1. 隐私契约: 文件 > 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()
|
||||
36
tests/test_pagination_hint.py
Normal file
36
tests/test_pagination_hint.py
Normal file
@@ -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
|
||||
470
tests/test_record_decoders.py
Normal file
470
tests/test_record_decoders.py
Normal file
@@ -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
|
||||
`:<?xml`/`:<msg` shapes (round-7 high #1)
|
||||
- `_parse_app_message_outer` retry-with-wider-limit only fires for
|
||||
`<type>19</type>` 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:\n<msg>hi</msg>', 1, is_group=True
|
||||
)
|
||||
self.assertEqual(sender, 'wxid_abc')
|
||||
self.assertEqual(text, '<msg>hi</msg>')
|
||||
|
||||
def test_xml_decl_inline_prefix_in_group(self):
|
||||
# round-7 high #1: 'sender:<?xml...' without newline
|
||||
sender, text = mcp_server._parse_message_content(
|
||||
'wxid_abc:<?xml version="1.0"?><msg>x</msg>', 1, is_group=True
|
||||
)
|
||||
self.assertEqual(sender, 'wxid_abc')
|
||||
self.assertTrue(text.startswith('<?xml'))
|
||||
|
||||
def test_msg_inline_prefix_in_group(self):
|
||||
sender, text = mcp_server._parse_message_content(
|
||||
'wxid_abc:<msg>x</msg>', 1, is_group=True
|
||||
)
|
||||
self.assertEqual(sender, 'wxid_abc')
|
||||
self.assertEqual(text, '<msg>x</msg>')
|
||||
|
||||
def test_private_chat_does_not_strip(self):
|
||||
sender, text = mcp_server._parse_message_content(
|
||||
'wxid_abc:<msg>x</msg>', 1, is_group=False
|
||||
)
|
||||
self.assertEqual(sender, '')
|
||||
self.assertEqual(text, 'wxid_abc:<msg>x</msg>')
|
||||
|
||||
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 = '<msg><appmsg><type>5</type><title>x</title></appmsg></msg>'
|
||||
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 <type>19</type> content should retry under
|
||||
# the wider 500K cap. A 25KB non-type-19 message must NOT be parsed
|
||||
# under the wider limit.
|
||||
outer = '<msg><appmsg><type>5</type><title>' + 'X' * 25000 + '</title></appmsg></msg>'
|
||||
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 = (
|
||||
'<msg><appmsg><type>19</type><title>x</title>'
|
||||
f'<recorditem><![CDATA[<recordinfo><title>x</title>'
|
||||
f'<datalist count="1"><dataitem datatype="1">'
|
||||
f'<datadesc>{big_desc}</datadesc></dataitem></datalist>'
|
||||
f'</recordinfo>]]></recorditem></appmsg></msg>'
|
||||
)
|
||||
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(
|
||||
'<dataitem datatype="1"><datadesc>hello world</datadesc></dataitem>'
|
||||
)
|
||||
self.assertEqual(mcp_server._format_record_dataitem(item), 'hello world')
|
||||
|
||||
def test_file_with_title(self):
|
||||
item = self._item(
|
||||
'<dataitem datatype="8"><datatitle>report.pdf</datatitle></dataitem>'
|
||||
)
|
||||
self.assertEqual(
|
||||
mcp_server._format_record_dataitem(item), '[文件] report.pdf'
|
||||
)
|
||||
|
||||
def test_image(self):
|
||||
item = self._item('<dataitem datatype="2"></dataitem>')
|
||||
self.assertEqual(mcp_server._format_record_dataitem(item), '[图片]')
|
||||
|
||||
def test_finder_feed(self):
|
||||
# round-2 datatype 22 视频号
|
||||
item = self._item(
|
||||
'<dataitem datatype="22"><finderFeed><desc>video desc</desc></finderFeed></dataitem>'
|
||||
)
|
||||
self.assertEqual(
|
||||
mcp_server._format_record_dataitem(item), '[视频号] video desc'
|
||||
)
|
||||
|
||||
def test_music(self):
|
||||
item = self._item(
|
||||
'<dataitem datatype="29"><datatitle>song</datatitle><datadesc>artist</datadesc></dataitem>'
|
||||
)
|
||||
self.assertEqual(
|
||||
mcp_server._format_record_dataitem(item), '[音乐] song - artist'
|
||||
)
|
||||
|
||||
def test_unknown_datatype_falls_back_to_desc(self):
|
||||
item = self._item(
|
||||
'<dataitem datatype="99"><datadesc>fallback content</datadesc></dataitem>'
|
||||
)
|
||||
self.assertEqual(
|
||||
mcp_server._format_record_dataitem(item), 'fallback content'
|
||||
)
|
||||
|
||||
def test_unknown_datatype_with_no_desc_uses_label(self):
|
||||
item = self._item('<dataitem datatype="999"></dataitem>')
|
||||
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 = '<isChatRoom>1</isChatRoom>' if is_chatroom else ''
|
||||
recordinfo = (
|
||||
f'<recordinfo><title>{title}</title>{chatroom}'
|
||||
f'<datalist count="{items_xml.count("<dataitem")}">{items_xml}</datalist>'
|
||||
f'</recordinfo>'
|
||||
)
|
||||
return (
|
||||
'<?xml version="1.0"?><msg><appmsg><title>x</title><type>19</type>'
|
||||
f'<recorditem><![CDATA[{recordinfo}]]></recorditem>'
|
||||
'</appmsg></msg>'
|
||||
)
|
||||
|
||||
def test_large_outer_expands_via_app_message_path(self):
|
||||
# round-2 P2-1 + round-5 high #1: 大 outer 端到端必须能展开
|
||||
items_xml = ''.join(
|
||||
f'<dataitem datatype="1"><sourcename>S{i}</sourcename>'
|
||||
f'<sourcetime>2025-01-01 00:00</sourcetime>'
|
||||
f'<datadesc>{"X" * 600}</datadesc></dataitem>'
|
||||
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 = (
|
||||
'<?xml version="1.0"?><msg><appmsg><title>x</title><type>19</type>'
|
||||
'<recorditem><![CDATA[<recordinfo><title>x</title>'
|
||||
'<isChatRoom>0</isChatRoom></recordinfo>]]></recorditem>'
|
||||
'</appmsg></msg>'
|
||||
)
|
||||
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 = (
|
||||
'<dataitem datatype="1"><sourcename>A</sourcename>'
|
||||
'<datadesc>hi</datadesc></dataitem>'
|
||||
)
|
||||
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'<dataitem datatype="1"><datadesc>m{i}</datadesc></dataitem>'
|
||||
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}</{feedesc_tag}>' if fee_desc else ''
|
||||
memo_node = f'<{paymemo_tag}>{pay_memo}</{paymemo_tag}>' if pay_memo else ''
|
||||
xml_text = (
|
||||
f'<msg><appmsg><title>{title}</title><des>{des}</des>'
|
||||
f'<type>2000</type>'
|
||||
f'<wcpayinfo>'
|
||||
f'<paysubtype>{paysubtype}</paysubtype>'
|
||||
f'{fee_node}{memo_node}'
|
||||
f'<transferid>{transferid}</transferid>'
|
||||
f'<transcationid>{transcationid}</transcationid>'
|
||||
f'<begintransfertime>{begin_ts}</begintransfertime>'
|
||||
f'<invalidtime>{invalid_ts}</invalidtime>'
|
||||
f'<payer_username>{payer}</payer_username>'
|
||||
f'<receiver_username>{receiver}</receiver_username>'
|
||||
f'</wcpayinfo></appmsg></msg>'
|
||||
)
|
||||
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(
|
||||
'<msg><appmsg><title>x</title><type>2000</type></appmsg></msg>'
|
||||
)
|
||||
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(
|
||||
'<msg><appmsg><title>微信转账</title><type>2000</type></appmsg></msg>'
|
||||
)
|
||||
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()
|
||||
219
tests/test_refer_message.py
Normal file
219
tests/test_refer_message.py
Normal file
@@ -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'<msg><appmsg><type>57</type><title>{title}</title>'
|
||||
f'{refermsg_xml}</appmsg></msg>'
|
||||
)
|
||||
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 (
|
||||
'<refermsg>'
|
||||
f'<type>{refer_type}</type>'
|
||||
f'<svrid>{svrid}</svrid>'
|
||||
f'<fromusr>{fromusr}</fromusr>'
|
||||
f'<chatusr>{chatusr}</chatusr>'
|
||||
f'<displayname>{displayname}</displayname>'
|
||||
f'<createtime>{createtime}</createtime>'
|
||||
f'<content>{content}</content>'
|
||||
'</refermsg>'
|
||||
)
|
||||
|
||||
|
||||
# ---------- 标签映射 ----------
|
||||
|
||||
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 = (
|
||||
'<msg><img cdnthumburl="http://cdn.example/leak_thumb" '
|
||||
'aeskey="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" '
|
||||
'md5="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" '
|
||||
'cdnurl="http://cdn.example/leak_main" /></msg>'
|
||||
)
|
||||
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 = '<msg><voicemsg voicelength="3300" '\
|
||||
'voiceurl="http://cdn.example/leak.silk" /></msg>'
|
||||
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', '<msg><emoji md5="xx" externurl="leak.gif"/></msg>'
|
||||
)
|
||||
self.assertEqual(out, '[动画表情]')
|
||||
self.assertNotIn('externurl', out)
|
||||
self.assertNotIn('leak', out)
|
||||
|
||||
def test_nested_link_card_summary(self):
|
||||
nested = '<msg><appmsg><type>5</type><title>分享标题</title>'\
|
||||
'<url>http://example.com/leak</url></appmsg></msg>'
|
||||
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 = '<msg><appmsg><type>19</type><title>群聊天记录</title></appmsg></msg>'
|
||||
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', '<msg><appmsg'),
|
||||
'[卡片]',
|
||||
)
|
||||
|
||||
def test_unknown_refer_type_falls_back(self):
|
||||
out = mcp_server._summarize_refer_content('999', 'irrelevant')
|
||||
self.assertEqual(out, '[type=999]')
|
||||
|
||||
def test_empty_content_with_known_type(self):
|
||||
self.assertEqual(mcp_server._summarize_refer_content('3', ''), '[图片]')
|
||||
|
||||
def test_xxe_payload_rejected_in_nested(self):
|
||||
xxe = (
|
||||
'<!DOCTYPE foo [<!ENTITY x SYSTEM "file:///etc/passwd">]>'
|
||||
'<msg><appmsg><type>5</type><title>&x;</title></appmsg></msg>'
|
||||
)
|
||||
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'<msg><appmsg><type>57</type><title>看这个</title>'
|
||||
f'{_refermsg("3", v2_image)}</appmsg></msg>'
|
||||
)
|
||||
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()
|
||||
82
tests/test_voice_format.py
Normal file
82
tests/test_voice_format.py
Normal file
@@ -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 `<voicemsg voicelength="…">` is parseable, with graceful
|
||||
fallback to `[语音]` on missing / zero / malformed length.
|
||||
"""
|
||||
import unittest
|
||||
|
||||
import mcp_server
|
||||
|
||||
|
||||
def _voice_xml(length_ms):
|
||||
return (
|
||||
f'<msg><voicemsg endflag="1" length="2048" voicelength="{length_ms}" '
|
||||
'clientmsgid="abc" fromusername="wxid_synth_a" '
|
||||
'cancelflag="0" voiceformat="4" forwardflag="0" /></msg>'
|
||||
)
|
||||
|
||||
|
||||
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 = '<msg><voicemsg endflag="1" length="2048" /></msg>'
|
||||
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 = '<msg><voicemsg voicelength="abc" /></msg>'
|
||||
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("<msg></msg>"), "[语音]")
|
||||
|
||||
def test_malformed_xml(self):
|
||||
self.assertEqual(mcp_server._format_voice_text("<msg><voicemsg"), "[语音]")
|
||||
|
||||
def test_xxe_payload_rejected(self):
|
||||
xxe = (
|
||||
'<!DOCTYPE foo [<!ENTITY x SYSTEM "file:///etc/passwd">]>'
|
||||
'<msg><voicemsg voicelength="1000" /></msg>'
|
||||
)
|
||||
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="<msg></msg>", 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()
|
||||
274
tests/test_voice_transcription_cache.py
Normal file
274
tests/test_voice_transcription_cache.py
Normal file
@@ -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()
|
||||
111
transcribe_chat.py
Normal file
111
transcribe_chat.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""
|
||||
为聊天导出 JSON 中的语音消息补齐转录文本。
|
||||
|
||||
用法:
|
||||
.venv/bin/python3 transcribe_chat.py <input.json> [output.json]
|
||||
|
||||
参数:
|
||||
<input.json> 由 export_chat.py 产出的 JSON。
|
||||
[output.json] 可选输出路径,默认 "<input>_transcribed.json"。
|
||||
|
||||
完整流程示例:
|
||||
.venv/bin/python3 export_chat.py <chat_name> /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 <input.json> [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)
|
||||
Reference in New Issue
Block a user