Add Windows GUI and WXWork export support

This commit is contained in:
xincheng
2026-05-17 07:07:07 +08:00
38 changed files with 10005 additions and 887 deletions

17
.gitignore vendored
View File

@@ -310,6 +310,15 @@ paket-files/
# FAKE - F# Make # FAKE - F# Make
.fake/ .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 # CodeRush personal settings
.cr/personal .cr/personal
@@ -380,6 +389,14 @@ docs/.vitepress/dist
# Temporary build artifacts # Temporary build artifacts
x64/ x64/
# OS
.DS_Store
Thumbs.db
# Compiled binaries and output
find_all_keys_macos
decoded_voices/
voice_transcriptions.json
data/ data/
export/ export/
build/ build/

46
Makefile Normal file
View 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
View File

@@ -1,49 +1,93 @@
# WeChat 4.x Database Decryptor # WeChat 4.x Database Decryptor
微信 4.0 (Windows、MacOSLinux) 本地数据库解密工具。从运行中的微信进程内存提取加密密钥,解密所有 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和商店表情StoreCDN 下载后本地缓存 ```bash
- **富媒体内容解析**: 链接卡片type 49、文件、视频号、小程序、引用回复、位置分享等在 Web UI 中完整渲染 # 1. 安装依赖
- **文字+图片组合消息不再丢失**: 修复同时发送文字和图片时只显示最后一条的问题(前端去重 key 增加消息类型) python3 -m venv .venv
- **隐藏消息检测**: 新增 `_check_hidden_messages` 机制session.db 只保存最后一条消息摘要,现在会异步查 message DB 找回同一秒内的其他消息 source .venv/bin/activate
- **MonitorDBCache 线程安全**: 引入 per-key 锁,防止多线程并发解密同一数据库导致文件损坏 pip install -r requirements.txt
- **Web UI 改进**: 消息气泡样式优化、群聊发送者显示、图片缩略图点击放大 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 加密本地数据库: # 3. 解密 + 导出 + 转录
- **加密算法**: AES-256-CBC + HMAC-SHA512 python3 decrypt_db.py # 解密所有数据库
- **KDF**: PBKDF2-HMAC-SHA512, 256,000 iterations python3 export_all_chats.py -t # 导出全部聊天并转录语音
- **页面大小**: 4096 bytes, reserve = 80 (IV 16 + HMAC 64)
- **每个数据库有独立的 salt 和 enc_key**
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+ - Python 3.10+
- 微信 4.x - 微信 4.x 正在运行
- `pip install -r requirements.txt`
Windows **macOS**:
- Xcode Command Line Tools: `xcode-select --install`
- 需要对 `/Applications/WeChat.app` 做 ad-hoc 重签名(允许进程内存读取)
- 需要 root 权限运行扫描器
- Windows 10/11 **Windows**:
- 管理员权限(读取进程内存)
- 微信正在运行 - 微信正在运行
- 需要管理员权限(读取进程内存)
Linux **Linux**:
- root 权限或 `CAP_SYS_PTRACE`
- 64-bit Linux - 微信正在运行
- 需要 root 权限或 `CAP_SYS_PTRACE`(读取 `/proc/<pid>/mem`
- `db_dir` 默认类似 `~/Documents/xwechat_files/<wxid>/db_storage`
### 安装依赖 ### 安装依赖
@@ -51,124 +95,180 @@ Linux
pip install -r requirements.txt 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 ```bash
py -m pip install --user -r requirements.txt py -m pip install --user -r requirements.txt
``` ```
如果需要读取受保护的进程或把依赖安装到系统 Python也可能需要以管理员身份打开终端。 </details>
### 快速开始 ### 配置
Windows 程序会自动检测微信数据目录并生成 `config.json`。如果自动检测失败,手动创建
```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` 示例:
```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", "keys_file": "all_keys.json",
"decrypted_dir": "decrypted", "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 ServerClaude | `claude mcp add wechat -- python /path/to/mcp_server.py` |
### Web UI
`python main.py` 启动后打开 http://localhost:5678 查看实时消息流。 `python main.py` 启动后打开 http://localhost:5678 查看实时消息流。
- 30ms 轮询 WAL 文件变化 (mtime) - 30ms 轮询 WAL 文件变化
- 检测到变化后全量解密 + WAL patch (~70ms)
- SSE 实时推送到浏览器 - 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 ServerClaude AI 集成)
将微信数据查询能力接入 Claude Code让 AI 直接读取你的微信消息。
**注册:**
```bash ```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 GPU3-5x | 数据不出本机 | `brew install whisper-cpp` + 模型 | 自动检测 |
**配置方式config.json**
```json ```json
{ {
"mcpServers": { "transcription_backend": "whisper_cpp"
"wechat": {
"type": "stdio",
"command": "python",
"args": ["C:\\Users\\你的用户名\\wechat-decrypt\\mcp_server.py"]
}
}
} }
``` ```
注册后在 Claude Code 中即可使用以下工具 启用 whisper_cpp 前需安装
| 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 密钥需要从运行中的微信进程内存中提取:
```bash ```bash
# 1. 在微信中打开查看 2-3 张图片(点击看大图) brew install whisper-cpp
# 2. 立即运行密钥提取(持续监控版) # 模型自动检测常见路径,或手动下载
python find_image_key_monitor.py # curl -L -o ~/whisper-models/ggml-base.bin https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin
# 或单次扫描版:
python find_image_key.py
``` ```
密钥会自动保存到 `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_windows.py` | Windows 版内存扫描提 key |
| `find_all_keys_linux.py` | Linux 版内存扫描提 key | | `find_all_keys_linux.py` | Linux 版内存扫描提 key |
| `decrypt_db.py` | 全量解密所有数据库 | | `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 | | `find_wxwork_keys.py` | 企业微信 Windows 版内存扫描提 key |
| `decrypt_wxwork_db.py` | 企业微信 wxSQLite3 AES-128 数据库解密 | | `decrypt_wxwork_db.py` | 企业微信 wxSQLite3 AES-128 数据库解密 |
| `export_wxwork_messages.py` | 企业微信聊天记录导出(按个人/群筛选CSV / HTML / JSON | | `export_wxwork_messages.py` | 企业微信聊天记录导出(按个人/群筛选CSV / HTML / JSON |
| `mcp_server.py` | MCP Server让 Claude AI 查询微信数据 | | `mcp_server.py` | MCP Server让 Claude AI 查询微信数据 |
| `monitor_web.py` | 实时消息监听 (Web UI + SSE + 图片预览) | | `monitor_web.py` | 实时消息监听 (Web UI + SSE) |
| `monitor.py` | 实时消息监听 (命令行) | | `monitor.py` | 实时消息监听 (命令行) |
| `decode_image.py` | 图片 .dat 文件解密模块 (XOR / V1 / V2) | | `find_all_keys.py` | 平台分发入口Windows / Linux |
| `find_image_key.py` | 从微信进程内存提取图片 AES 密钥 |
| `find_image_key_monitor.py` | 持续监控版密钥提取(推荐) |
| `latency_test.py` | 延迟测量诊断工具 |
| `find_all_keys_macos.c` | macOS 版内存密钥扫描器 (C, Mach VM API) | | `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 打包 ### GUI 工具箱 & 单 exe 打包
@@ -230,7 +347,7 @@ build.bat
### WAL 处理 ### WAL 处理
微信使用 SQLite WAL 模式WAL 文件是**预分配固定大小** (4MB)。检测变化时: 微信使用 SQLite WAL 模式WAL 文件是**预分配固定大小** (4MB)。检测变化时:
- 不能用文件大小 (永远不变) - 不能用文件大小永远不变
- 使用 mtime 检测写入 - 使用 mtime 检测写入
- 解密 WAL frame 时需校验 salt 值,跳过旧周期遗留的 frame - 解密 WAL frame 时需校验 salt 值,跳过旧周期遗留的 frame
@@ -310,16 +427,20 @@ cc -O2 -o find_all_keys_macos find_all_keys_macos.c -framework Foundation
# 运行(自动查找微信进程、扫描内存、匹配 DB salt # 运行(自动查找微信进程、扫描内存、匹配 DB salt
sudo ./find_all_keys_macos sudo ./find_all_keys_macos
# 或指定 PID <details>
sudo ./find_all_keys_macos <pid> <summary>点击展开</summary>
```
输出 `all_keys.json`,格式兼容 `decrypt_db.py`,可直接用于解密: #### 2025-03-03 — 富媒体内容 & 组合消息修复
- 表情包内联显示
- 富媒体内容解析(链接卡片、文件、视频号、小程序等)
- 文字+图片组合消息不再丢失
- 隐藏消息检测机制
- Web UI 改进
```bash </details>
python3 decrypt_db.py
```
## 免责声明 ### 免责声明
本工具仅用于学习和研究目的,用于解密**自己的**微信数据。请遵守相关法律法规,不要用于未经授权的数据访问。 本工具仅用于学习和研究目的,用于解密**自己的**微信数据。请遵守相关法律法规,不要用于未经授权的数据访问。
防失联 TG: https://t.me/wechat_decrypt

201
chat_export_helpers.py Normal file
View 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
View 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()

View File

@@ -29,7 +29,9 @@ if _SYSTEM == "linux":
_DEFAULT_PROCESS = "wechat" _DEFAULT_PROCESS = "wechat"
elif _SYSTEM == "darwin": elif _SYSTEM == "darwin":
# macOS 使用独立的 C 扫描器 (find_all_keys_macos.c),此处仅提供 config 默认值 # 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" _DEFAULT_PROCESS = "WeChat"
else: else:
_DEFAULT_TEMPLATE_DIR = r"D:\xwechat_files\your_wxid\db_storage" _DEFAULT_TEMPLATE_DIR = r"D:\xwechat_files\your_wxid\db_storage"
@@ -46,6 +48,11 @@ _DEFAULT = {
"wxwork_decrypted_dir": "wxwork_decrypted", "wxwork_decrypted_dir": "wxwork_decrypted",
"wxwork_export_dir": "wxwork_export", "wxwork_export_dir": "wxwork_export",
"wxwork_process": "WXWork.exe", "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) 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(): def auto_detect_db_dir():
if _SYSTEM == "windows": if _SYSTEM == "windows":
return _auto_detect_db_dir_windows() return _auto_detect_db_dir_windows()
if _SYSTEM == "linux": if _SYSTEM == "linux":
return _auto_detect_db_dir_linux() return _auto_detect_db_dir_linux()
if _SYSTEM == "darwin":
return _auto_detect_db_dir_macos()
return None return None
@@ -214,6 +257,8 @@ def load_config():
print(f" 请手动编辑 {config_file} 中的 db_dir 字段") print(f" 请手动编辑 {config_file} 中的 db_dir 字段")
if _SYSTEM == "linux": if _SYSTEM == "linux":
print(" Linux 默认路径类似: ~/Documents/xwechat_files/<wxid>/db_storage") 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: else:
print(f" 路径可在 微信设置 → 文件管理 中找到") print(f" 路径可在 微信设置 → 文件管理 中找到")
sys.exit(1) sys.exit(1)
@@ -228,6 +273,19 @@ def load_config():
): ):
if key in cfg and cfg[key] and not os.path.isabs(cfg[key]): if key in cfg and cfg[key] and not os.path.isabs(cfg[key]):
cfg[key] = os.path.join(base, 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 的上级目录)
# db_dir 格式: D:\xwechat_files\<wxid>\db_storage # db_dir 格式: D:\xwechat_files\<wxid>\db_storage

View File

@@ -118,7 +118,7 @@ def v2_decrypt_file(dat_path, out_path=None, aes_key=None, xor_key=0x88):
dat_path: V2 .dat 文件路径 dat_path: V2 .dat 文件路径
out_path: 输出路径 (None 则自动命名) out_path: 输出路径 (None 则自动命名)
aes_key: 16 字节 AES key (bytes 或 str) aes_key: 16 字节 AES key (bytes 或 str)
xor_key: XOR key (int, 默认 0x88) xor_key: XOR key (int 或可被 int(_, 0) 解析的 str, 默认 0x88)
Returns: Returns:
(output_path, format) 或 (None, None) (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: if len(aes_key) < 16:
return None, None 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: with open(dat_path, 'rb') as f:
data = f.read() data = f.read()
@@ -185,6 +189,22 @@ def v2_decrypt_file(dat_path, out_path=None, aes_key=None, xor_key=0x88):
# wxgf (HEVC 裸流) 格式 # wxgf (HEVC 裸流) 格式
if decrypted[:4] == b'wxgf': if decrypted[:4] == b'wxgf':
fmt = 'hevc' 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: if out_path is None:
base = os.path.splitext(dat_path)[0] 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) 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): def extract_md5_from_packed_info(blob):
"""从 message_resource.db 的 packed_info (protobuf) 中提取文件 MD5 """从 message_resource.db 的 packed_info (protobuf) 中提取文件 MD5
@@ -299,34 +453,58 @@ def extract_md5_from_packed_info(blob):
class ImageResolver: class ImageResolver:
"""封装从 local_id 到图片文件的完整解析链""" """封装从 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: Args:
wechat_base_dir: 微信数据根目录 (如 D:\\xwechat_files\\<wxid>) wechat_base_dir: 微信数据根目录 (如 D:\\xwechat_files\\<wxid>)
decoded_image_dir: 解密图片输出目录 decoded_image_dir: 解密图片输出目录
cache: DBCache 实例,用于解密 message_resource.db 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.base_dir = wechat_base_dir
self.attach_dir = os.path.join(wechat_base_dir, "msg", "attach") self.attach_dir = os.path.join(wechat_base_dir, "msg", "attach")
self.out_dir = decoded_image_dir self.out_dir = decoded_image_dir
self.cache = cache self.cache = cache
self.aes_key = aes_key
self.xor_key = xor_key
def get_image_md5(self, local_id): def get_image_md5(self, username, local_id):
"""通过 local_id 查 message_resource.db 获取图片文件 MD5""" """通过 (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") path = self.cache.get("message/message_resource.db")
if not path: if not path:
return None return None
conn = sqlite3.connect(path) conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
try: 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( row = conn.execute(
"SELECT packed_info FROM MessageResourceInfo WHERE local_id = ?", "SELECT packed_info FROM MessageResourceInfo "
(local_id,) "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() ).fetchone()
if row and row[0]: if row and row[0]:
return extract_md5_from_packed_info(row[0]) return extract_md5_from_packed_info(row[0])
except Exception: except Exception as e:
pass print(f"[get_image_md5] {type(e).__name__}: {e}",
file=sys.stderr, flush=True)
finally: finally:
conn.close() conn.close()
@@ -357,10 +535,10 @@ class ImageResolver:
Returns: Returns:
dict with keys: success, path, format, md5, error dict with keys: success, path, format, md5, error
""" """
# 1. 获取 MD5 # 1. 获取 MD5 (chat-scoped: 同 local_id 跨 chat 重复)
file_md5 = self.get_image_md5(local_id) file_md5 = self.get_image_md5(username, local_id)
if not file_md5: 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 文件 # 2. 找 .dat 文件
dat_files = self.find_dat_files(username, file_md5) dat_files = self.find_dat_files(username, file_md5)
@@ -379,13 +557,17 @@ class ImageResolver:
selected = f selected = f
break break
# 3. 解密 # 3. 解密 (decrypt_dat_file 会按 magic 自动分发 V2 / V1 / 老 XOR)
out_name = f"{file_md5}" out_name = f"{file_md5}"
out_path_base = os.path.join(self.out_dir, out_name) 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: 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}" final_path = f"{out_path_base}.{fmt}"
@@ -402,17 +584,30 @@ class ImageResolver:
'size': os.path.getsize(final_path), '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) conn = sqlite3.connect(db_path)
try: try:
rows = conn.execute(f""" rows = conn.execute(f"""
SELECT local_id, create_time SELECT local_id, create_time
FROM [{table_name}] FROM [{table_name}]
WHERE local_type = 3 WHERE {where_sql}
ORDER BY create_time DESC ORDER BY create_time DESC
LIMIT ? LIMIT ?
""", (limit,)).fetchall() """, params).fetchall()
except Exception as e: except Exception as e:
conn.close() conn.close()
return [] return []
@@ -420,7 +615,7 @@ class ImageResolver:
results = [] results = []
for local_id, create_time in rows: 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 = { info = {
'local_id': local_id, 'local_id': local_id,
'create_time': create_time, 'create_time': create_time,

51
decode_transfer.py Normal file
View 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())

View File

@@ -9,6 +9,7 @@ import hashlib, struct, os, sys, json
import hmac as hmac_mod import hmac as hmac_mod
from Crypto.Cipher import AES from Crypto.Cipher import AES
import argparse
import functools import functools
print = functools.partial(print, flush=True) print = functools.partial(print, flush=True)
@@ -106,6 +107,21 @@ def decrypt_database(db_path, out_path, enc_key):
def main(): 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("=" * 60)
print(" WeChat 4.0 数据库解密器") print(" WeChat 4.0 数据库解密器")
print("=" * 60) print("=" * 60)
@@ -113,15 +129,18 @@ def main():
# 加载密钥 # 加载密钥
if not os.path.exists(KEYS_FILE): if not os.path.exists(KEYS_FILE):
print(f"[ERROR] 密钥文件不存在: {KEYS_FILE}") print(f"[ERROR] 密钥文件不存在: {KEYS_FILE}")
print("请先运行 find_all_keys.py") print("请先运行 python main.py decrypt 提取密钥并解密")
sys.exit(1) sys.exit(1)
with open(KEYS_FILE, encoding="utf-8") as f: with open(KEYS_FILE, encoding="utf-8") as f:
keys = json.load(f) keys = json.load(f)
keys = strip_key_metadata(keys) keys = strip_key_metadata(keys)
print(f"\n加载 {len(keys)} 个数据库密钥") print(f"\n加载 {len(keys)} 个数据库密钥")
print(f"输出目录: {OUT_DIR}") print(f"输出目录: {OUT_DIR}")
if args.incremental:
print(f"模式: 增量 (跳过未变更的数据库)")
os.makedirs(OUT_DIR, exist_ok=True) os.makedirs(OUT_DIR, exist_ok=True)
# 收集所有DB文件 # 收集所有DB文件
@@ -140,20 +159,42 @@ def main():
success = 0 success = 0
failed = 0 failed = 0
skipped = 0
skipped_unmodified = 0
total_bytes = 0 total_bytes = 0
for rel, path, sz in db_files: for rel, path, sz in db_files:
key_info = get_key_info(keys, rel) key_info = get_key_info(keys, rel)
if not key_info: if not key_info:
print(f"SKIP: {rel} (无密钥)") print(f"SKIP: {rel} (无密钥,如已安装微信补丁可能需要重新运行密钥提取)")
failed += 1 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 continue
enc_key = bytes.fromhex(key_info["enc_key"]) 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) ok = decrypt_database(path, out_path, enc_key)
if ok: if ok:
# SQLite验证 # SQLite验证
@@ -175,8 +216,24 @@ def main():
else: else:
failed += 1 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"\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"解密数据量: {total_bytes/1024/1024/1024:.1f}GB")
print(f"解密文件在: {OUT_DIR}") print(f"解密文件在: {OUT_DIR}")

View 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 UIdb_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 安装 | ❌ 无法代码修复 | 环境限制,需使用虚拟环境 |

View 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` 使用 WhisperCPU
为语音消息填充转录文本。`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
View 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
View 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)

View File

@@ -1,6 +1,179 @@
import functools import functools
import platform import platform
import sys 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) @functools.lru_cache(maxsize=1)
@@ -12,9 +185,16 @@ def _load_impl():
if system == "linux": if system == "linux":
import find_all_keys_linux as impl import find_all_keys_linux as impl
return 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( raise RuntimeError(
f"当前平台暂不支持通过 find_all_keys.py 提取密钥: {platform.system()}\n" f"当前平台暂不支持通过 find_all_keys.py 提取内存数据库密钥: {platform.system()}"
f"macOS 请使用 find_all_keys_macos.c (C 版扫描器)"
) )
@@ -23,10 +203,15 @@ def get_pids():
def main(): def main():
cfg = load_config()
find_image_key_offline(cfg)
return _load_impl().main() return _load_impl().main()
if __name__ == "__main__": if __name__ == "__main__":
multiprocessing.freeze_support()
try: try:
main() main()
except RuntimeError as exc: except RuntimeError as exc:

View File

@@ -343,7 +343,7 @@ def main():
except (FileNotFoundError, json.JSONDecodeError): except (FileNotFoundError, json.JSONDecodeError):
config_raw = {} 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) base_dir = os.path.dirname(db_dir)
attach_dir = os.path.join(base_dir, 'msg', 'attach') attach_dir = os.path.join(base_dir, 'msg', 'attach')

642
find_image_key_macos.py Normal file
View File

@@ -0,0 +1,642 @@
"""macOS WeChat 4.x 图片 AES key 派生(无需读运行进程)。
通过 macOS 微信 4.x 在磁盘上的命名约定派生出 V2 .dat 图片解密所需的
(xor_key, aes_key)。解决 issue #23macOS 用户无法用 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 解出图像 magicJPEG / PNG / GIF / WebP / wxgf即视为命中
- 为防短 magic 偶然命中,要求多个不同模板都通过验证才视为成功
uin 来源两条路径dispatcher 自动 fallback
----------------------------------------------
方案1kvcomm 缓存文件名,主路径):
读 ~/.../app_data/net/kvcomm/key_<uin>_*.statistic 提 uin。
优点:~毫秒级;缺点:依赖缓存文件,多账号下可能歧义。
方案2wxid 后缀候选搜索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_keymonitor_web.py
启动时自动加载,图片消息显示内联预览。
致谢
----
- 方案1kvcomm 派生)算法源自 @hicccc77 在 issue #23 的评论,参考实现
位于 https://github.com/hicccc77/WeFlow CC BY-NC-SA 4.0)。
- 方案2wxid 后缀候选搜索)思路源自 @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_ct16 字节),检查头部是否是图像 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 位小写 hexmacOS 路径目录名固定格式 = 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。期望命中数 ~2562^24 / 16^4
注意 uin 上限假设为 2^324 字节无符号整数)。函数命名沿用密码学
候选搜索的 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) 或 Nonekvcomm 缺失 / 无 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_normnormalize_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.jsontmp + 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()

View File

@@ -235,7 +235,7 @@ def main():
except (FileNotFoundError, json.JSONDecodeError): except (FileNotFoundError, json.JSONDecodeError):
config_raw = {} 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) base_dir = os.path.dirname(db_dir)
attach_dir = os.path.join(base_dir, 'msg', 'attach') attach_dir = os.path.join(base_dir, 'msg', 'attach')

262
main.py
View File

@@ -1,14 +1,21 @@
""" """
WeChat Decrypt 一键启动 WeChat Decrypt 一键启动
python main.py # 提取密钥 + 启动 Web UI python main.py # 提取密钥 + 启动 Web UI
python main.py decrypt # 提取密钥 + 解密全部数据库 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 functools
import glob
import json
import os
import platform
import subprocess
import sys
print = functools.partial(print, flush=True) print = functools.partial(print, flush=True)
from key_utils import strip_key_metadata from key_utils import strip_key_metadata
@@ -16,6 +23,8 @@ from key_utils import strip_key_metadata
def check_wechat_running(): def check_wechat_running():
"""检查微信是否在运行,返回 True/False""" """检查微信是否在运行,返回 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 from find_all_keys import get_pids
try: try:
get_pids() get_pids()
@@ -24,6 +33,97 @@ def check_wechat_running():
return False 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): def ensure_keys(keys_file, db_dir):
"""确保密钥文件存在且匹配当前 db_dir否则重新提取""" """确保密钥文件存在且匹配当前 db_dir否则重新提取"""
if os.path.exists(keys_file): if os.path.exists(keys_file):
@@ -32,7 +132,6 @@ def ensure_keys(keys_file, db_dir):
keys = json.load(f) keys = json.load(f)
except (json.JSONDecodeError, ValueError): except (json.JSONDecodeError, ValueError):
keys = {} keys = {}
# 检查密钥是否匹配当前 db_dir防止切换账号后误复用旧密钥
saved_dir = keys.pop("_db_dir", None) 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)): if saved_dir and os.path.normcase(os.path.normpath(saved_dir)) != os.path.normcase(os.path.normpath(db_dir)):
print(f"[!] 密钥文件对应的目录已变更,需要重新提取") print(f"[!] 密钥文件对应的目录已变更,需要重新提取")
@@ -54,7 +153,6 @@ def ensure_keys(keys_file, db_dir):
sys.exit(1) sys.exit(1)
print() print()
# 提取后再次检查
if not os.path.exists(keys_file): if not os.path.exists(keys_file):
print("[!] 密钥提取失败") print("[!] 密钥提取失败")
sys.exit(1) sys.exit(1)
@@ -70,16 +168,126 @@ def ensure_keys(keys_file, db_dir):
sys.exit(1) 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(): def main():
print("=" * 60) print("=" * 60)
print(" WeChat Decrypt") print(" WeChat Decrypt")
print("=" * 60) print("=" * 60)
print() 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 from config import load_config
cfg = 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. 检查微信进程 # 2. 检查微信进程
if not check_wechat_running(): if not check_wechat_running():
print(f"[!] 未检测到微信进程 ({cfg.get('wechat_process', 'WeChat')})") print(f"[!] 未检测到微信进程 ({cfg.get('wechat_process', 'WeChat')})")
@@ -87,28 +295,52 @@ def main():
sys.exit(1) sys.exit(1)
print("[+] 微信进程运行中") print("[+] 微信进程运行中")
# 3. 提取密钥
ensure_keys(cfg["keys_file"], cfg["db_dir"]) ensure_keys(cfg["keys_file"], cfg["db_dir"])
# 4. 根据子命令执行
cmd = sys.argv[1] if len(sys.argv) > 1 else "web"
if cmd == "decrypt": if cmd == "decrypt":
print("[*] 开始解密全部数据库...") print("[*] 开始解密全部数据库...")
print() print()
from decrypt_db import main as decrypt_all from decrypt_db import main as decrypt_all
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": elif cmd == "web":
print("[*] 启动 Web UI...") print("[*] 启动 Web UI...")
print() print()
from monitor_web import main as start_web from monitor_web import main as start_web
start_web() start_web()
else: else:
print(f"[!] 未知命令: {cmd}") print(f"[!] 未知命令: {cmd}")
print() print()
print("用法:") print_usage()
print(" python main.py 启动实时消息监听 (Web UI)")
print(" python main.py decrypt 解密全部数据库到 decrypted/")
sys.exit(1) sys.exit(1)

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,7 @@ http://localhost:5678
import hashlib, struct, os, sys, json, time, sqlite3, io, threading, queue, traceback import hashlib, struct, os, sys, json, time, sqlite3, io, threading, queue, traceback
import hmac as hmac_mod import hmac as hmac_mod
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from contextlib import closing
from datetime import datetime from datetime import datetime
from http.server import HTTPServer, BaseHTTPRequestHandler from http.server import HTTPServer, BaseHTTPRequestHandler
from socketserver import ThreadingMixIn from socketserver import ThreadingMixIn
@@ -435,10 +436,16 @@ def decrypt_wal_full(wal_path, out_path, enc_key):
return patched, ms 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 = {} names = {}
try: 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(): 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] names[r[0]] = r[2] if r[2] else r[1] if r[1] else r[0]
conn.close() conn.close()
@@ -447,6 +454,96 @@ def load_contact_names():
return 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): def format_msg_type(t):
return { return {
1: '文本', 3: '图片', 34: '语音', 42: '名片', 1: '文本', 3: '图片', 34: '语音', 42: '名片',
@@ -531,6 +628,10 @@ def _convert_hevc_to_jpeg(hevc_path, jpeg_path):
# ============ 监听器 ============ # ============ 监听器 ============
class SessionMonitor: 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): def __init__(self, enc_key, session_db, contact_names, db_cache=None, username_db_map=None):
self.enc_key = enc_key self.enc_key = enc_key
self.session_db = session_db self.session_db = session_db
@@ -543,6 +644,43 @@ class SessionMonitor:
self.patched_pages = 0 self.patched_pages = 0
# 已显示消息去重: {(username, timestamp, base_msg_type), ...} # 已显示消息去重: {(username, timestamp, base_msg_type), ...}
self._shown_keys = set() 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): def resolve_image(self, username, timestamp):
"""解密图片: username+timestamp → 解密后的图片文件名,失败返回 None""" """解密图片: username+timestamp → 解密后的图片文件名,失败返回 None"""
@@ -797,6 +935,38 @@ class SessionMonitor:
except OSError: except OSError:
pass 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): def _check_hidden_messages(self, username, prev_ts, curr_ts, curr_msg_type, display, is_group, sender):
"""检查时间窗口内是否有被 session 摘要覆盖的消息(文字、图片、表情等) """检查时间窗口内是否有被 session 摘要覆盖的消息(文字、图片、表情等)
@@ -827,10 +997,10 @@ class SessionMonitor:
try: try:
conn = sqlite3.connect(f"file:{dec_path}?mode=ro", uri=True) conn = sqlite3.connect(f"file:{dec_path}?mode=ro", uri=True)
rows = conn.execute(f""" 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}] FROM [{table_name}]
WHERE create_time >= ? AND create_time <= ? WHERE create_time >= ? AND create_time <= ?
ORDER BY create_time ASC ORDER BY create_time ASC, local_id ASC
""", (prev_ts, curr_ts)).fetchall() """, (prev_ts, curr_ts)).fetchall()
conn.close() conn.close()
all_rows.extend(rows) all_rows.extend(rows)
@@ -839,7 +1009,8 @@ class SessionMonitor:
cache_failed = True cache_failed = True
break break
# 检查是否找到了 curr_ts 的消息(说明缓存是最新的) # 检查是否找到了 curr_ts 的消息(说明缓存是最新的)
has_curr = any(r[0] == curr_ts for r in all_rows) # 注: r[1] 是 create_time新 schemalocal_id, create_time, local_type, ...
has_curr = any(r[1] == curr_ts for r in all_rows)
if has_curr or cache_failed: if has_curr or cache_failed:
break break
# 缓存可能还没更新到最新数据,短暂等待后重试 # 缓存可能还没更新到最新数据,短暂等待后重试
@@ -860,11 +1031,13 @@ class SessionMonitor:
print(f" [hidden] 缓存查到 {len(all_rows)}", flush=True) print(f" [hidden] 缓存查到 {len(all_rows)}", flush=True)
# 过滤出隐藏消息 # 过滤出隐藏消息
# 去重 key 用 local_id之前用 (username, ts, base) 太粗,同秒同类型多条会被
# 误判为重复,导致 issue #79 的 "10 丢 4"
hidden_msgs = [] 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 base = lt % 4294967296 if lt > 4294967296 else lt
# 跳过已显示的消息(精确匹配 username+timestamp+type # 跳过已显示的消息(按 local_id 精确去重
if (username, ts, base) in self._shown_keys: if (username, local_id) in self._shown_keys:
continue continue
# 解压 zstd # 解压 zstd
if isinstance(mc, bytes) and ct == 4: 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 '' mc = mc.decode('utf-8', errors='replace') if isinstance(mc, bytes) else ''
elif isinstance(mc, bytes): elif isinstance(mc, bytes):
mc = mc.decode('utf-8', errors='replace') 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) print(f" [hidden] 找到 {len(hidden_msgs)} 条隐藏消息", flush=True)
@@ -882,8 +1055,8 @@ class SessionMonitor:
return return
global messages_log global messages_log
for ts, base, mc in hidden_msgs: for local_id, ts, base, mc in hidden_msgs:
self._shown_keys.add((username, ts, base)) self._shown_keys.add((username, local_id))
msg_data = { msg_data = {
'time': datetime.fromtimestamp(ts).strftime('%H:%M:%S'), 'time': datetime.fromtimestamp(ts).strftime('%H:%M:%S'),
'timestamp': ts, 'timestamp': ts,
@@ -1144,6 +1317,26 @@ class SessionMonitor:
'des': des[:200] if des else '', 'des': des[:200] if des else '',
'items': items, '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: else:
# 其他子类型: 用 title 显示 # 其他子类型: 用 title 显示
if title: if title:
@@ -1273,13 +1466,11 @@ class SessionMonitor:
is_new = prev and (curr['timestamp'] > prev['timestamp'] or is_new = prev and (curr['timestamp'] > prev['timestamp'] or
(curr['timestamp'] == prev['timestamp'] and curr['msg_type'] != prev.get('msg_type'))) (curr['timestamp'] == prev['timestamp'] and curr['msg_type'] != prev.get('msg_type')))
if is_new: if is_new:
# contact.db mtime 变化时刷新缓存:覆盖新增联系人、改名、改备注、群名
# 修改等场景issue #46, #67。受 cooldown 节流。
self._maybe_refresh_contacts()
display = self.contact_names.get(username, username) display = self.contact_names.get(username, username)
is_group = '@chatroom' in 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 = '' sender = ''
if is_group: if is_group:
sender = self.contact_names.get(curr['sender'], curr['sender_name'] or curr['sender']) sender = self.contact_names.get(curr['sender'], curr['sender_name'] or curr['sender'])
@@ -1309,7 +1500,13 @@ class SessionMonitor:
} }
new_msgs.append(msg_data) 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: if curr['msg_type'] == 3:
@@ -1360,9 +1557,12 @@ class SessionMonitor:
self.prev_state = curr_state self.prev_state = curr_state
# 清理过期的去重 key保留最近 5 分钟) # 清理 _shown_keys按数量上限local_id 不是时间戳不能按时间 prune。
cutoff = int(time.time()) - 300 # 超过 10000 时保留 local_id 最大的 5000 条(最新消息优先)。
self._shown_keys = {k for k in self._shown_keys if k[1] > cutoff} # 实际触发频率:~几小时一次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): 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) 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{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-item b{color:#bbb;font-weight:500}
.chatlog-more{font-size:11px;color:#555;margin-top:4px} .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} 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{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} #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>`; 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==='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>`; if(r.type==='video') return `<div class="msg-video">🎬 视频${r.duration?' '+r.duration+'s':''}</div>`;
return null; return null;
@@ -1815,9 +2025,31 @@ class Handler(BaseHTTPRequestHandler):
self.end_headers() self.end_headers()
self.wfile.write(HTML_PAGE.encode('utf-8')) 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: with messages_lock:
data = sorted(messages_log, key=lambda m: m.get('timestamp', 0)) 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_response(200)
self.send_header('Content-Type', 'application/json; charset=utf-8') self.send_header('Content-Type', 'application/json; charset=utf-8')
self.end_headers() self.end_headers()
@@ -1849,6 +2081,20 @@ class Handler(BaseHTTPRequestHandler):
self.end_headers() self.end_headers()
self.wfile.write(data) 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': elif self.path == '/stream':
self.send_response(200) self.send_response(200)
self.send_header('Content-Type', 'text/event-stream') self.send_header('Content-Type', 'text/event-stream')
@@ -1955,7 +2201,8 @@ def main():
print("Ctrl+C 停止\n", flush=True) print("Ctrl+C 停止\n", flush=True)
try: try:
os.system(f'cmd.exe /c start http://localhost:{PORT}') import webbrowser
webbrowser.open(f'http://localhost:{PORT}')
except Exception: except Exception:
pass pass

View File

@@ -3,3 +3,4 @@ zstandard>=0.22,<1
mcp>=1.0,<2 mcp>=1.0,<2
pilk>=0.2 pilk>=0.2
pyinstaller>=6.0 pyinstaller>=6.0
# 可选:进度条 (pip install tqdm)

245
setup.py Normal file
View 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
View 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 "========================================================"

View 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()

View 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

View 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()

View 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()

View 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()

View 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()

View 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]

View 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()

View 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()

View 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

View 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
View 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 全部不再泄漏到聊天历史。
合成 fixturewxid_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 = (
'&lt;msg&gt;&lt;img cdnurl="leak" aeskey="leak" md5="leak"/&gt;&lt;/msg&gt;'
)
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 = '&lt;msg&gt;&lt;img cdnurl="leak_main"/&gt;&lt;/msg&gt;'
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()

View 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()

View 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应当只实际化一份 dictlock 生效)
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
View 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)