Commit Graph

133 Commits

Author SHA1 Message Date
phoenixray2000
5e0eaa33fa feat(export): 增加微信聊天增量 JSON 导出 (#122)
新增 export_all_chats --delta-only 按时间窗口导出每个会话的增量 JSON.

- --delta-only 写入 deltas/<run_id>/manifest.json + 有消息的会话文件, 便于后续按批次消费
- 沿用现有 CSV plan 选择逻辑, 可通过 --from-plan-csv 控制范围
- 空时间窗口会话直接跳过, 不再生成 message_count=0 的 delta 文件
- 增量 JSON 保留会话级 metadata, 不改变现有 CSV 导出默认流程
- manifest.json 只记录实际有消息的会话, 无法解析的留在 errors 中便于定位

测试: tests/test_export_all_chats_delta.py 7 个 case (run_id 时间戳形状 / 文件名安全化 / msg_uid 哈希组成 / 写窗口不覆盖全量 JSON / 空窗口跳过 / CLI 参数校验 / manifest 记录文件).

跟 #114 (CSV plan) 互补 — CSV plan 选 "哪些会话" 而 delta 选 "哪个时间窗口".
2026-05-29 13:18:53 +08:00
Belugary
eba9a9d4cd feat: 解析微信位置消息 (base_type=48) + decode_location MCP 工具 (#124)
位置消息 (base_type=48) 老逻辑命中 _format_message_text 的 elif base_type != 1 兜底, 以 raw XML 形态进 get_chat_history. 一条 location XML 平均 12-15 个 attr (poiBusinessHour / poiPhone / adcode / buildingId / floorName / infourl / fromusername / maptype / scale / version 等), LLM context 里堆机器字段而看不到 "用户分享了哪里" 的核心语义.

修复 (双层模式, 跟 #83 namecard / #85 transfer / #100 refer 一致):
- _extract_location_info: 解析 <location> 节点为 dict, 全部 17 个 attr + lat/lng (x/y 数值化) + category_top
- _is_location_poiname_placeholder: 检测客户端在用户手扔图钉时的占位符 [位置] / [Location]
- _format_location_text: 单行渲染只挑 3 个信号 (poiCategoryTips 主类 → 前缀, poiname, label), fallback chain
- decode_location MCP 工具: 给 LLM 拿全部字段 (POI id / 电话 / 营业时间 / 价格 / 城市 / 区划码 / 经纬度), 兼容跨分片场景

字段语义基于 1411 条真实样本统计指导分类决策 (修了 #121 自撤的核心教训 — 没逐字段过语义就套 #83 模板). 关键差异:
- poiid (#121 错丢) → 80% 出现率, deeplink 种子, 结构化保留
- infourl (#121 没充分论证就丢) → 1411 样本 0% 非空, render 丢 / structured 防御性留
- poiCategoryTips (#121 未识别) → 渲染前缀
- poiPhone (#121 未识别) → 22% 非空商家电话, 结构化保留
- poiname=[位置] 占位符 (#121 未处理) → 检测并 fallback 到 label

测试: tests/test_location_message.py 18 case, 5 个全合成 fixture (POI 名/地址/城市/坐标/电话/poiid 全部占位符, 不绑定任何真实地理数据). 18/18 全过. baseline 309 → 327.

Closes #121
2026-05-26 16:54:43 +08:00
Belugary
5208f6f517 fix(export_sns): _load_comments 过滤已撤回的点赞/评论 (#120)
微信对撤回的点赞/评论不硬删, 只在 SnsMessage_tmp3 行上打 del_status 标记. 老 _load_comments 直接 SELECT 不带任何 WHERE, 结果导出的 likes/comments 里混着撤回行 — 等于 "对方撤回的赞还能在本地导出里看到", 违反用户预期.

修复: SQL 加 WHERE COALESCE(del_status, 0) = 0
- COALESCE 兜底: 老 schema NULL 视作 0 (保留)
- WHERE 而非 Python 端过滤: 大 db 少传 row
- 函数签名/返回结构不变
- 缺列时仍走现有 try/except 路径, 返回 {} 不崩

测试: LoadCommentsTests 3 case (撤回过滤 / NULL 保留 / 缺列兜底), 全合成 sqlite 无 PII. baseline 17 → 20 全过.

承接 #119 的 SNS 导出可靠性线: #119 修 "老 XML 让整行帖子丢失", 本 PR 修 "互动里混入已撤回 row".
2026-05-25 21:23:07 +08:00
wingIsCrazy
7ab90dcd20 fix(mcp): filter out chat room members from contact list
_load_contacts_from() loaded all rows from the contact table without
filtering, causing ~8000+ chat room member records (local_type=3) to
appear as contacts. This inflated the contact count and polluted search
results. Add WHERE local_type != 3 to exclude chat room members.
2026-05-23 18:14:50 +08:00
Belugary
87b0b419d6 fix(export_sns): _parse_timeline_xml 兼容 4 种 content 编码 + 老 XML 清洗 (#119)
朋友圈 XML 解析跨版本兼容 + 顺手堵 XXE 绕过.

1. SnsTimeLine.content 跨版本会以 4 种编码出现: bytes (zstd 压缩, magic 28 B5 2F FD) / plain XML / hex / base64. 老逻辑直接 ET.fromstring 喂 raw 入参, 后三种 ParseError → 整条 row 静默丢失, 不报错不警告.

2. 老朋友圈 (2013-2017) XML 还含 ElementTree 拒绝的字符: URL 里的裸 &, 文本字段手打的 < >, 控制字符 (\x00-\x08). 一样导致 ParseError → 静默丢 row.

3. 安全修复: XXE / 长度检查老逻辑跑在 raw 入参, zstd / hex / base64 编码的恶意 DOCTYPE 能绕过. 新逻辑跑在 decoded payload 上, 拦得住.

修复:
- _decode_sns_content_blob: 按 bytes / 已 XML / hex / base64 顺序检测, bytes 带 zstd magic 时先解压
- _sanitize_sns_pseudo_xml: 剥控制字符, CDATA 外裸 & 转义, text-only 节点内部裸 < > 转义
- _parse_timeline_xml: decode → 安全检查 (decoded payload) → sanitize → ET

测试: 17 case (DecodeContentBlobTests 10 + SanitizePseudoXmlTests 4 + SecurityAndLimitsTests 3), 全合成 XML 无 PII.
2026-05-23 18:13:27 +08:00
phoenixray2000
be361bb6c0 fix: 计划 CSV 默认留空 export 2026-05-19 02:22:02 +08:00
phoenixray2000
f3c32d4ca2 feat: 支持双模式 exe 入口 2026-05-19 02:21:57 +08:00
phoenixray2000
728dbb72df feat(export): 支持 CSV 计划导出和稳定导出索引 (#114)
两个核心增强:

1. **CSV 计划导出工作流** (`--write-plan-csv` / `--from-plan-csv`)
   - 支持 blacklist (export=0 跳过) / whitelist (export=1 导出) 两种模式
   - `--size-mode estimate|scan` 控制是否扫本地附件
   - UTF-8 BOM 编码, Excel/WPS 直接打开
   - 解决了之前"动不动全量导出"的痛点

2. **`_export_index.json` 稳定导出索引**
   - 用 username 追踪当前 JSON 文件
   - 联系人备注/群名变化时自动重命名旧文件 (而不是产生孤儿)
   - 同名联系人冲突时自动追加 `__<username>` 后缀
   - atomic write (tmp + os.replace), bootstrap from existing files

3. **JSON metadata 扩展**
   - 新增 `date_first_msg / date_last_msg / contact_remark / contact_nick_name / contact_tags / contact_memo`

测试: 717 行, 20 tests pass, 覆盖 CRUD 索引、黑白名单、命名冲突、incremental rename、UTF-8 BOM。
2026-05-19 02:20:30 +08:00
ylytdeng
4a29841ec1 docs(readme): TG 交流群 / 防失联链接放到 README 最开头
之前藏在文件最后 (line 611) 容易被忽略, 用户问"防失联怎么找到", 提到
应该往最显眼的位置放。

标题正下方加一行引用块, 一进 README 第一眼看到, 链接 + 用途清晰。
原结尾保留一行简短引用 (附 "顶部也有" 提示), 不删避免破坏老书签
位置感。
2026-05-17 19:44:39 +08:00
ylytdeng
9e38684bc6 revert: 恢复 tkinter app_gui.py, 改成 Web UI / 桌面 GUI 共存
## 用户反馈

上次 commit e826b1a "删除 tkinter, 完全切到 Web UI" 误解了用户意图。
用户原意是 "完全用 webUI, **用户可以选择**" → 指 Web UI 内部给用户
充分选择, 不是删 tkinter。

用户澄清: "我的意思是共存", 即两套 GUI 都保留, 用户按场景挑。

## 改动

### 恢复
- \`app_gui.py\` (939 行, 从 e826b1a^ restore, 含之前修的 --task bug fix)

### 保留 e826b1a 的合理部分 (不回滚)
- \`monitor_web.py\` 的 \`_start_monitor_if_ready\` 重构 (无 keys 仍能开
  Web UI) - 这个 standalone 改进, 跟 GUI 选哪个无关, 保留
- \`WeChatDecrypt.spec\` 入口 \`monitor_web.py\` (Web UI 体验更好, 默认
  打包它; 想打 tkinter exe 改 spec 那一行就行)
- \`build.bat\` 简化 (单一 source of truth = spec, 不重复 add-data 清单)

### README 改共存叙事
- Windows 快速开始拆 2 个 details:
  - "Web UI (推荐, 跨平台 + 实时监听)"
  - "桌面 GUI / EXE (tkinter, 适合不开浏览器的场景)"
- 文件清单 \`app_gui.py\` 那行回来, 说明跟 \`monitor_web.py\` 共存关系
- "GUI 工具箱" 章节重写: 先讲 Web UI 推荐路径 + 三 tab 能力, 再讲
  桌面 GUI 备用场景 (公司机器禁浏览器 / 全离线 / 喜欢传统桌面应用),
  最后讲打包 (默认 Web UI exe, 改 spec 可打 tkinter exe)

### EXE_USAGE.md 共存
- 结尾的 "为什么没 tkinter" 改成 "还有个 tkinter (备用)"
- 说明何时该用哪个 + 怎么打 tkinter 版 exe

## 实测

- app_gui.py syntax OK
- monitor_web.py syntax OK
- 测试 185/185 通过
2026-05-17 19:28:57 +08:00
ylytdeng
e826b1a565 refactor: 删除 tkinter app_gui.py, 完全切到 Web UI 作为唯一入口
## 决策背景

用户反馈 "windows GUI 太难看了, 还是完全用 webUI 吧, 用户可以选择"。

tkinter app_gui.py (PR #107 引入, 929 行) 的问题:
- 中文字体下渲染糊 ("WeChat Decrypt 工具箱" 标题模糊)
- 90 年代 Windows 控件风格, 不暗色不现代
- 只能跑 Windows, 不跨平台
- 没法远程访问
- 跟 Web UI 维护两套, 重复

Web UI (monitor_web.py) 已经完全对齐功能:
- 8 个工具按钮 (3 tab 分组: 个人微信 / 企微 / 工具)
- 终止按钮 + 实时日志推送
- 导出筛选模态框 (会话搜索/复选/格式选择, close #112)
- 跟实时消息监听共享 SSE 通道
- Lucide SVG icon 统一风格 (无 emoji)
- 暗色主题 + design tokens + 玻璃质感顶栏

## 改动

### 删除
- \`app_gui.py\` (929 行 tkinter GUI)

### monitor_web.py
拆 \`main()\` → \`_start_monitor_if_ready()\` + 精简的 \`main()\`:

之前: keys 不存在 → \`sys.exit(1)\` 直接挂

现在: keys 不存在 → 跳过监听线程启动, 仅起 Web UI 服务。
用户从工具箱点 "① 提取密钥 + 解密数据库" 跑完后重启进程, 监听自动激活。
这样 exe 用户第一次双击时不会报错挂掉, 而是看到 Web UI 工具箱可以
直接用。

新启动流程: \`_start_monitor_if_ready\` 检查 keys 文件 / session.db
密钥 / session.db 路径都 OK 才启 monitor_thread, 任一不满足都给友好
提示但不退出。

### WeChatDecrypt.spec
- 入口 \`app_gui.py\` → \`monitor_web.py\`
- datas 清单补全 (加 export_all_chats / chat_export_helpers /
  batch_decrypt_images / transcribe_chat 等之前漏的)
- hiddenimports 显式列 Crypto / zstandard / pilk (避免 PyInstaller
  漏 detect)

### build.bat
- 删 30 行重复的 --add-data 清单 (跟 .spec 漂移风险)
- 改成 \`pyinstaller --noconfirm WeChatDecrypt.spec\`
- 单一 source of truth 是 .spec
- 完成提示从 "GUI 启动" 改成 "双击 → 浏览器开 Web UI"

### EXE_USAGE.md
完全重写为 Web UI 视角:
- 快速开始: 双击 exe → 浏览器自动打开
- 工具箱 3 tab 各自能力详解
- 导出筛选模态框使用说明
- 任务终止说明
- 输出目录布局
- 远程访问说明
- 结尾解释为什么去掉了 tkinter

### README.md
- 三平台 quick-start 里 "Windows GUI / EXE" → "Windows Web UI / EXE"
- 文件清单: \`app_gui.py\` 那行 → \`monitor_web.py\` (新身份: Web UI
  总入口)
- 技术细节里 "GUI 工具箱" 章节重写: 强调 Web UI 优势 (筛选模态框 /
  终止按钮 / 跨平台 / 远程访问 / 跟监听共存) + 末尾 1 句历史说明
  解释 tkinter 已删除

## 实测

- python monitor_web.py: keys 在 → 正常启动监听 + Web UI
- python monitor_web.py: 假装 keys 不在 → 跳过监听, Web UI 仍能开,
  用户从工具箱点 "① 提取密钥" 后重启即激活
- 测试 185/185 通过

## 没改 (留 follow-up)

- 实际打包 .spec 验证 (需要 Win 跑 pyinstaller, 没在 CI 跑)
- monitor_web 启动后**自动**检测 keys 文件 mtime 变化重启监听, 不需要
  用户手动重启进程 (现在的设计是"重启进程才激活")
2026-05-17 19:22:47 +08:00
ylytdeng
273fe65a07 perf(monitor_web): 主循环 hot path 不再触发 message DB 全量解密 (修 8-125s spike)
## 根因

用户反馈实时消息延迟从亚秒级 → 8-125 秒。

后端 log:
  [perf] decrypt=576页/47.8ms, query=46.6ms
  ... 总耗时=10381.0ms / 44964.4ms / 125398.8ms ...

解密+查询只 95ms, 但总耗时 8-125 秒。源头:

PR #106 (commit 1aa12c8, issue #42) 引入了 _lookup_latest_message,
在 check_updates 主循环里每个新消息都调:

  dec_path = self.db_cache.get(db_key)
            ^^^^^^^^^^^^^^^^^^^^^^^^
            mtime 变化时同步 full_decrypt 整个 message_N.db (~10s)

微信写消息时 message_N.db mtime 跟着变 → get() 触发全量解密 →
主循环阻塞 10 秒。多个 session 同时更新就叠加成几十秒。

之前 db_cache.get 主要在 _check_hidden_messages (走 _hidden_executor
后台线程) 调用, 不阻塞主线程。PR #106 把它带到了主线程 hot path。

证据 (log 里清晰可见):
  [cache] message\message_0.db 全量解密 10551ms
  [18:23:31 延迟=14.0s] [...] 莫名感触: ...   ← 实测消息延迟 14 秒

## 修复

MonitorDBCache 加 peek(rel_key) 方法, **不触发**重新解密, 只读
当前已解密文件路径 (可能 stale 1 个 mtime 周期):

  def peek(self, rel_key):
      out_path = os.path.join(self.tmp_dir, out_name)
      return out_path if os.path.exists(out_path) else None

_lookup_latest_message 把 self.db_cache.get(db_key) 改成 peek(db_key)。

## Trade-off

stale cache 可能让 _lookup_latest_message 查不到刚写入的 local_id,
返回 (None, None)。check_updates 会:
- 跳过加 _shown_keys (issue #79 的去重保险)
- 跳过用 full_content 替换 summary (issue #42 的扩展正文)

但**两个 fallback 路径都正常**:
- 1 秒后 _check_hidden_messages (异步线程) 会用 db_cache.get 等待
  解密完, 拿到 local_id 并 emit hidden 消息 (issue #79 不破坏)
- 第一次推送仍用 SessionTable.summary 的 80 字短截断, 后续如果 user
  开了详情自然加载完整 (issue #42 退化为"原始行为", 不影响主流程)

权衡: 用"偶发 80 字摘要" 换 "无 8-125 秒延迟"。

## 实测预期

修复后主循环总耗时应回到 < 200ms (跟解密+查询 95ms 同量级)。
SessionTable 跑 hidden_executor 仍然能补抓密集消息 (issue #79 保留)。

## 副作用 (无)

- get() 行为不变, hidden 路径仍同步等解密 (它在后台线程, 不影响主线程)
- peek() 是新加方法, 不影响现有调用方
- 测试 185/185 通过
2026-05-17 19:03:44 +08:00
ylytdeng
dd9db0b25b perf(monitor_web): UI 性能优化 + toolsPanel 默认收起
## 用户反馈

"为啥我感觉网页的实时消息没那么快了"

## 数据

后端 log 分析 60 次扫描:
- 19 次总耗时 > 5 秒, 最长 125 秒
- 平均 7585ms (正常应该 < 200ms)
- 但 decrypt 只 47ms, query 46ms — **慢在别处**

## 这次修复 (前端 UI 部分)

### 1. toolsPanel 改回默认收起
之前为了 headless 截图改的默认展开, 让消息容器占用屏幕高度从满屏
变成 ~500px, 用户感受是"消息一直在滚, 看不清"。改回默认隐藏 (用户
点 🛠️ 工具 按钮才展开)。

### 2. 去掉 backdrop-filter blur(20px)
header 之前用 backdrop-filter:blur(20px) 实现"玻璃质感", 但每次 SSE
推消息触发 reflow 都要 GPU 重绘整个 header (尤其低端机)。改回纯 CSS
渐变背景, 视觉差异不大但性能好很多。

## 没解决的 (后端固有问题)

backend log 显示 spike 早在 18:34 第一行就有 (62 秒), 远早于 UI 改动。
监控主循环 monitor_thread 的 check_updates() 总耗时偶发飙到 8-125 秒,
但解密+查询只 95ms。**剩余时间在 SSE broadcast / 图片任务排队 /
emoji lookup / protobuf 解析等次要路径**。

这是独立性能问题, 跟 PR #107 / Web UI 改动无关。开 issue 跟进:

→ 见 follow-up issue (会另开)
2026-05-17 18:54:11 +08:00
ylytdeng
b93e85a1a0 feat(monitor_web): 导出筛选模态框 — 不再一点就跑全量 (closes #112)
## 痛点

之前 Web UI 工具箱里 "③ 导出聊天" / "⑦ 企业微信导出" 一点就跑全量。
用户 3142 个个人微信会话 + 14 个企微会话, 全量导出几个 GB JSON 几小时,
还没法只导某几个群。

## 设计

点击导出按钮 → 弹模态框选会话 + 格式 → 确认后才跑。

```
┌────────────────────────────────────────┐
│  导出个人微信聊天             [×]      │
├────────────────────────────────────────┤
│  🔍 按名字 / wxid 搜索...               │
│  ┌──────────────────────────────────┐ │
│  │ ☐ [群]   交易所消息    2026-05-14│ │
│  │ ☐ [单聊] 张三          2026-05-13│ │
│  │ ☐ [公众号] xxx日报     2026-05-10│ │
│  │ ...                              │ │
│  └──────────────────────────────────┘ │
│  [全选] [清空] [选最近30天活跃]  已选 N│
│                                        │
│  格式 (仅企微) ☑CSV ☐HTML ☐JSON         │
│                                        │
│         [取消]  [确认导出 →]            │
└────────────────────────────────────────┘
```

## 实现拆解

### Backend
1. **GET /api/sessions?source=wechat|wxwork** — 列会话
   - wechat: 从 decrypted/session/session.db SessionTable 读, 拼合
     contact.db 的 nick_name/remark, type 分群/单聊/公众号
   - wxwork: 从 wxwork_decrypted/session.db conversation_table 读,
     id 前缀分 R/S/E/Y → 群/单聊/外部/其他
   - 按 last_ts 降序

2. **TOOL_TASKS schema 加 build_steps + needs_modal**
   - 旧固定任务: `steps: [cmd, ...]`
   - 新动态任务: `build_steps: fn(users, formats) → [cmd, ...]`
   - export_all / wxwork_export 用 build_steps + needs_modal 标记

3. **_run_tool_task 接收 args**, 优先调 build_steps 生成 cmd

4. **POST /api/tool body 接收 args**: `{task, args: {users:[...], formats:[...]}}`

### CLI 脚本
- `export_all_chats.py` 加 `--users wxid1,wxid2` (alt: env WECHAT_EXPORT_USERS),
  在加载完 sessions 后做白名单过滤, 空集报错退出
- `export_wxwork_messages.py` 原本就支持 `--conversation` (multi-arg) +
  `--formats csv,html,json`, 不动它, monitor_web 拼 argv 即可

### Frontend
- HTML 加 `#exportModal` 模态框骨架 (overlay + dialog + search + list +
  format checkboxes + footer buttons)
- CSS .modal-* 一套 (用 design tokens, 跟整体暗色风格统一)
- JS:
  - NEEDS_MODAL = {export_all: 'export_wechat', wxwork_export: 'export_wxwork'}
  - runTool 拦截这两个 task → openExportModal
  - openExportModal fetch /api/sessions → renderSessions (复选框列表)
  - filterSessions 实时搜索过滤
  - selectAllSessions / selectRecentSessions(30) 批量选择
  - confirmExport 收集 selected usernames + formats → runToolWithArgs
- runTool 拆成 runTool (entry, 拦截/取消) + runToolWithArgs (实际跑)

## 实测验证

```
$ curl -s "http://localhost:5678/api/sessions?source=wxwork"
[{"username": "R:358645240322", "name": "交易所消息", "type": "群", "last_ts": 1778658597, ...},
 {"username": "E:10223", "name": "E:10223", "type": "外部", ...}, ...]

$ curl -s "http://localhost:5678/api/sessions?source=wechat"
[{"username": "46222992238@chatroom", "name": "月下健人", "type": "群", "last_ts": 1779014016, ...}, ...]
```

API 返回正确, 个人微信 3142 个 / 企微 14 个会话, 按 last_ts 降序。
测试 185/185 通过。

## 副带改进

- runToolWithArgs 拆出来后, fix 之前一个小 bug: error 路径里的
  `b.textContent = b.dataset.origText` 改成 innerHTML 路径 (一致性)

## 仍未跟进 (follow-up)

- export_all_chats 加 `--format csv,html` 多格式输出 (个人微信脚本
  目前只支持 JSON, 模态框格式选项对它隐藏了, 看 #109 推进)
- 个人微信导出 sessions 列表性能: 3142 个一次 fetch ~5MB JSON,
  搜索 + render 在低端机可能卡, 可加分页 / 虚拟列表
2026-05-17 18:35:44 +08:00
ylytdeng
a1e79f764d fix(monitor_web): 消息按 timestamp 排序插入,不再按到达顺序倒序
## 痛点

用户截图显示消息列表完全乱序:
  18:13:54 (top)
  18:21:59
  18:19:46
  18:21:51 (bottom)
无法按时间阅读。

## 根因

addMsg 行 2216:
  M.insertBefore(d, M.firstChild);

每条新 msg 永远插最前面, **完全按到达顺序倒序**:
- SSE 推送的实时消息 (timestamp 大) 跟 hidden 补抓的旧消息 (timestamp 小)
  按到达顺序穿插
- /api/history 初始化时按 ASC 顺序 forEach addMsg 也是这种倒序行为
- 结果显示顺序跟 timestamp 没有任何关系

## 修复

按 timestamp 找正确插入位置 (降序: 大 ts 在顶, 符合"最新在上"日志流惯例):

  d.dataset.ts = m.timestamp || 0;
  // 遍历现有 children, 找第一个 ts 比新消息小的位置插入
  for(let i=0; i<kids.length; i++){
    const existingTs = +(kids[i].dataset.ts || 0);
    if(ts > existingTs){
      M.insertBefore(d, kids[i]);
      inserted = true;
      break;
    }
  }
  if(!inserted) M.appendChild(d);  // 比所有都早, 放最底

O(n) 但常见情况 (新消息最大 ts) O(1) 命中。200 条上限 fast enough。

## 实测

修复前:
  18:13:54 / 18:21:59 / 18:19:46 / 18:21:51  ← 乱

修复后预期 (降序):
  18:21:59 / 18:21:51 / 18:19:46 / 18:13:54  ← 最新在顶

测试 185/185 通过 (改动只动 addMsg 排序逻辑)。
2026-05-17 18:24:45 +08:00
ylytdeng
a01d326f40 style(monitor_web): 工具区 emoji 全部换成 Lucide 风格 SVG icon
## 痛点

用户: "最讨厌的就是 emoji"

工具区原来用了 10 处 emoji 作 icon: 🛠️ ⚙️ 📱 🏢 🔧 ⚠️ 💡 📡 🛑 。
emoji 的问题:
- 跨平台渲染不一致 (Apple/Win/Linux 字体不同, 大小色调全飘)
- 暗色主题里花花绿绿的"装饰感"破坏现代极简风
- 字号控制不了, 跟周围文字基线不对齐

## 实现

### SVG symbol library 内嵌
- body 开头加 <svg style="position:absolute"> 内嵌 10 个 <symbol>
- 全部 Lucide 风格: viewBox 24x24, stroke="currentColor" stroke-width=2,
  线条 round cap/join
- 使用 <svg class="i"><use href="#i-xxx"/></svg> 引用
- currentColor 自动跟文字色

### 通用 .i CSS
.i      16x16 (默认, 标签/按钮)
.i-sm   13x13 (chip 内)
.i-lg   20x20
.i-xl   32x32 (空状态)
.spin   配 @keyframes spin 1s linear infinite, 给 loader 用

### 替换清单

| 位置 | 之前 | 现在 |
|---|---|---|
| header 工具按钮 | 🛠️ 工具 | #i-wrench + 工具 |
| header 设置按钮 | ⚙️ | #i-settings |
| tab 个人微信 | 📱 个人微信 | #i-chat + 个人微信 |
| tab 企业微信 | 🏢 企业微信 | #i-briefcase + 企业微信 |
| tab 工具 | 🔧 工具 | #i-sliders + 工具 |
| 前置警告 (微信/企微 tab) | ⚠️ 前置 | #i-alert + 前置 |
| 前置信息 (工具 tab) | 💡 跟微信... | .info 主题 + #i-info + 文字 |
| 空状态 | 📡 等待新消息 | #i-radio (i-xl, opacity .4) |
| 终止按钮 (运行中) | 🛑 终止 | #i-stop (实心方块) + 终止 |
| 运行中状态 |  运行中 | #i-loader + .spin 旋转 |

(消息流里的 msg type emoji 风险大, 本次不动, follow-up)

### 副带改动
- .tool-prereq 加 .info 变体 (绿蓝色, 区别 warn 黄)
- .tool-prereq::before ● 去掉 (跟 SVG icon 重复)
- 按钮 cancel 状态用 innerHTML 保留 SVG (之前 textContent 会丢)

## 实测

headless chrome 截图 (179KB), 全部 emoji 消失,
线性 icon 协调一致, 暗色主题没花花绿绿装饰感。

测试 185/185 通过。

剩余 emoji (本次不动):
- mcp_server/monitor_web msg type icon (560-561 line): 📞 ⚙️ ↩️ 📨 等,
  这是消息渲染逻辑, 单独 PR follow-up
- Notification API 的 icon 字段 (2176, 2178): 浏览器通知配图,
  emoji 实际不显示需要 url, 无影响
2026-05-17 18:22:13 +08:00
ylytdeng
cf0e67b50b feat(monitor_web): 加终止按钮 + voice_to_mp3 优雅降级
## 痛点

用户反馈:
1. 没终止按钮 - 任务一旦点了, 跑死(✗ 失败)或跑长(解密 30s+)都没法停
2. ⑧ 语音转 MP3 报 ModuleNotFoundError: 'pilk', 直接 traceback

## Patch 1: 终止任务

### Backend
- _tool_running 加 proc / cancelled 字段
- _run_tool_task 把 proc 暴露到 _tool_running, 每轮 stdout read 后
  检查 cancelled 标志, 命中就 break
- 新加 POST /api/tool/cancel 路由: proc.terminate() + 1.5s 后 kill,
  设 cancelled=True 让 tool runner 收尾
- tool_done 事件加 cancelled 字段

### Frontend
- 任务运行时, 触发按钮临时变成 "🛑 终止" + 红色脉冲动画
- 再点一下就调 /api/tool/cancel
- tool_done 收到后还原按钮文本和颜色
- "⊘ 已终止" 状态徽章替代 "✓ 完成"

### CSS
- .tool-task-btn.cancel: 实心红渐变 + box-shadow + pulseRed 1.5s 动画

## Patch 2: voice_to_mp3 优雅降级

之前直接 `import pilk` 失败时打 traceback, 用户看不懂。改成:

  try: import pilk
  except ImportError:
      print 友好提示 + pip install pilk 命令 + 退出

加 ffmpeg 在 PATH 检查 (pilk 解码后需要 ffmpeg 编 MP3), 给三平台
安装指引。

requirements.txt 加注释说明 pilk 还需要 ffmpeg 配合, pyinstaller
仅打包需要(开发不必装)。

## 测试

185/185 通过 (不影响现有功能)。

实测 web UI:
- 点 ⑧ 语音转 MP3 → 立刻看到友好提示而不是 traceback
- 点任何任务 → 按钮变红色"🛑 终止" → 再点 → 任务收尾 → 状态变"⊘ 已终止"

## 仍未跟进 (follow-up)

- 导出筛选: ③ 导出全部聊天 / ⑦ 企微导出 一点就跑全量很可怕。
  应该弹模态框选会话 + 格式. 单独开 issue #112 跟进, 需要:
  - 新加 GET /api/sessions 路由列会话
  - export_all_chats / export_wxwork_messages 加 --filter 参数
  - 前端模态框 UI
2026-05-17 18:14:28 +08:00
ylytdeng
fb260063ef style(monitor_web): Web UI polish - 4 类 design 改进
跟 design-critique skill 评审后, 实施 4 项可执行改进:

## 1. 🔴 修 header flex overflow bug

原: .stats { margin-left:auto } 在浏览器窄时占主轴空间, 把后面的
🛠️ 工具 + ⚙️ 按钮挤出视野 (用户截图反复出现"看不到按钮")。

改:
  .stats: 加 min-width:0 + overflow:hidden + white-space:nowrap
  .header: 加 flex-wrap:wrap + row-gap:8px 兜底换行
  .tools-btn / .settings-btn: 加 flex-shrink:0 不许被压缩

## 2. 加 design tokens (:root)

20+ 处硬编码颜色 / 间距 / 圆角散落, 改一处怕漏一处。统一:
  颜色: --bg / --bg-elev / --surface / --border / --accent / --warn ...
  间距: --s1..s6 (4/8/12/16/24/32)
  字号: --t1..t6 (11/12/13/15/18/24)
  圆角: --r1 (6) / --r2 (10) / --r3 (14) / --r-pill (999)
  阴影: --shadow-1 / --shadow-2 / --shadow-glow

工具区 CSS 全量切到 token, 老消息流 CSS 保持原样不动 (避免回归)。

## 3. 视觉强化 (tab + primary button)

Tab:
  - active 加 linear-gradient 渐变背景 (透明 → accent-bg)
  - ::after 蓝色下划线 + box-shadow glow 发光
  - hover 微微抬亮
  - 切换有 fadeIn 200ms 动画

Primary 按钮 (Step 1 主操作):
  - 改成实心渐变 #4fc3f7 → #29b6f6 (之前是空心边框, 跟普通按钮区别太小)
  - 加 box-shadow 立体感 + inset 高光
  - hover 上浮 2px + 阴影放大
  - 跟普通按钮 (透明 surface) 主次关系清晰

前置条件:
  - 之前: 黄色左边框 + 灰文字 (像 form error)
  - 现在: 紧凑 pill (背景 + 圆角 999), 头部加 ● 点
  - 显眼但不焦虑

## 4. 上台阶的小招

  body: radial-gradient(ellipse at top, #14142a, #0a0a0f) 顶部柔光
  body: 中文字体优先 PingFang SC / HarmonyOS / Source Han Sans
        (Segoe UI 渲染中文糊)
  body: antialiased + optimizeLegibility
  header: backdrop-filter blur(20px) + position:sticky 玻璃质感
  日志框: JetBrains Mono > SF Mono > Consolas, line-height 1.55

## 默认展开 toolsPanel

之前默认隐藏要先点 🛠️ 工具 按钮才能用, 用户进来一脸懵。改成默认展开:
toolsPanel 加 class="show"。toggleTools() 仍能切换 (隐藏后可还原)。

## 实测

headless chrome 截图确认:
- 🟢 三个按钮全可见
- 🟢 tab active 蓝色高亮 + 发光下划线
- 🟢 STEP 1 主按钮 primary 蓝色实心 突出
- 🟢 STEP 2 普通按钮 透明 layer
- 🟢 前置条件 pill 黄色 chip
- 🟢 背景 radial gradient 顶部柔光过渡

测试 185/185 通过。

## 跟评审建议的对照

| critique 建议 | 实施 |
|---|---|
| 修 header flex bug |  |
| 加 design tokens 系统化 |  (工具区全量, 老消息流保持原状) |
| tab active 渐变 + 发光下划线 |  |
| primary 按钮实心渐变 + 阴影 |  |
| body radial-gradient 背景 |  |
| 中文字体栈优先苹方 |  |
| header backdrop-filter |  |
| 前置条件 pill 化 |  |
2026-05-17 18:09:58 +08:00
ylytdeng
62b015337a refactor(monitor_web): Web UI 工具箱按产品分 tab,不再混杂个人/企微/工具
## 反馈

用户实测后说"产品没设计好,企微和个人微信应该分开"。原版把 8 个按钮平
铺成两行,虽有"个人微信:" / "朋友圈/企微:" label 但只是文字注释,视觉
分组弱,工作流顺序也不明确 (新用户不知道该先点哪个再点哪个)。

## 重设计 (3 个 tab + 工作流 Step 分组)

```
[📱 个人微信]  [🏢 企业微信]  [🔧 工具]   <- tab 切换

📱 个人微信 (active)
⚠️ 前置: 微信 PC 版正在运行且已登录
─────────────────────────────────────────
STEP 1 — 解密
  [① 提取密钥 + 解密数据库] (primary)
  [② 提取图片密钥]

STEP 2 — 导出/解码 (可独立运行,需先 Step 1)
  [③ 导出全部聊天 (JSON)]
  [④ 批量解密 .dat 图片]
  [⑤ 朋友圈解密 + 导出]

[实时日志区]
```

```
🏢 企业微信
⚠️ 前置: 企业微信 PC 版正在运行且已登录 (独立于个人微信)
─────────────────────────────────────────
STEP 1 — 解密
  [① 提取密钥 + 解密数据库] (primary)

STEP 2 — 导出
  [② 导出聊天 (CSV/HTML/JSON)]

[实时日志区]
```

```
🔧 工具
💡 跟微信/企微进程无关,只读已解密产物
─────────────────────────────────────────
语音 / 转码
  [语音转 MP3 (需 ffmpeg in PATH)]

[实时日志区]
```

## 改动细节

### CSS
- 加 `.tool-tabs` / `.tool-tab` / `.tool-pane` (tab 头 + 内容切换)
- 加 `.tool-prereq` (黄色边框前置条件提示)
- 加 `.tool-step` + `.tool-step-label` (大写小灰字步骤标签)
- 加 `.primary` 给 Step 1 主操作按钮更显眼的边框

### JS
- `switchToolTab(name)` 切换 tab,记录到 `window.__activeToolPane`
- `runTool` 把日志写到当前激活 pane 的 `toolLog_<pane>` 而不是单一全局
- SSE `tool_log` 监听同样按 `__activeToolPane` dispatch 到对应日志框
- DOMContentLoaded 时同时绑 tab click + 任务 button click

### Backend
- TOOL_TASKS 不动 (still 8 个),只是前端重新组织
- 一次只跑一个任务的约束 (_tool_lock) 不变

## 优势

- 工作流明确: 新用户进来直接看到"前置 + Step 1 → Step 2"
- 视觉分组: tab 切换比平铺一目了然
- 跟现有 SSE / settings panel 不冲突
- 浏览器 hard refresh 即可看到新版

Tests: 185/185 通过
2026-05-17 17:50:08 +08:00
ylytdeng
bf5d16d48c feat(monitor_web): 加 Web UI 工具箱 - 替代 tkinter app_gui.py 的 8 个按钮
把 PR #107 引入的 tkinter GUI (app_gui.py, 929 行) 整套功能搬到现有
monitor_web 的浏览器 UI 上。复用已有的 SSE 通道,后端跑子进程,实时把
stdout 推到浏览器。

## 实现

### Backend (~110 行)
- `TOOL_TASKS` dict 定义 8 个任务 (cmd 串列表)
- `_run_tool_task(job_id, task_name)` 后台线程顺序跑 steps,
  subprocess.Popen + readline → broadcast_sse 推 event=tool_log
- 完成后推 event=tool_done
- `Handler.do_POST` 加 /api/tool 路由,接 {"task":"..."} 触发
- _tool_lock 保证全局同时只跑 1 个任务,避免两个解密挤内存
- 子进程环境传 WECHAT_DECRYPT_NONINTERACTIVE=1, 走脚本里已有的非交互
  分支 (自动选最近账号等)
- subprocess 用 CREATE_NO_WINDOW 隐藏黑窗 (Win)

### Frontend (~130 行 HTML/CSS/JS)
- header 加 🛠️ 工具 按钮
- toggleTools() 切换 #toolsPanel 显隐 (默认隐藏)
- 8 个按钮分两行: 个人微信 (① 解密 / ② 图片密钥 / ③ 导出聊天 / ④ 批量
  解图片) + 朋友圈/企微 (⑤ 朋友圈 / ⑥ 企微解密 / ⑦ 企微导出 / ⑧ 语音
  转 MP3)
- 黑色日志区,Monaco/Consolas 字体,实时 append (auto-scroll 到底)
- 状态条  运行中 / ✓ 完成 / ✗ 失败
- es.addEventListener('tool_log' / 'tool_done') 跟现有 message 事件
  共存

### 跟现有监听共存
- 不动 messages 容器和消息流逻辑
- 跟 settings 面板共用一套 header 按钮风格
- SSE 通道复用 /stream, 不另起 channel

## 实测

WXWork 在跑的情况下点 ⑥ 企微解密, 浏览器实时显示:
- find_wxwork_keys cipher 结构体扫描进度
- enc_key=cc68fa7d...4c (16 字节)
- decrypt_wxwork_db 17/17 db 解密成功
- 完成后 wxwork_keys.json / wxwork_decrypted/*.db mtime 全部更新

测试 185/185 通过 (不影响现有消息监听逻辑)。

## 对比 app_gui.py

| | tkinter (app_gui.py) | Web UI (monitor_web) |
|---|---|---|
| 外观 | 90 年代 Windows | 暗色现代渐变 |
| 中文字体 | 渲染糊 | 浏览器原生清晰 |
| 跨平台 | 名义支持实际只 Win | macOS/Linux/Win 浏览器都行 |
| 远程访问 |  | bind 0.0.0.0 即可 |
| 维护 | 单独 929 行 stdlib tkinter | 跟监听共享 SSE/HTML |
| 包大小 (打包后) | 多带 tkinter dll | 走 monitor_web 已有的 stdlib |

## 后续

仍开着 (留 follow-up):
- 导出 ③ / ⑦ 当前是"全量"模式; tkinter 里弹了"选会话"对话框,
  Web 版要做 /api/sessions + 模态框 (估 0.5 天)
- 任务完成后给"打开输出目录"按钮 (15 分钟)
- 删 app_gui.py + 改 build.bat 把 monitor_web 当 exe 入口 (1 小时)
- 数据浏览 Tab (浏览器看解密后的 db / 朋友圈 timeline) - P3
2026-05-17 17:36:12 +08:00
ylytdeng
e9ff0d3287 fix(app_gui): _run_subprocess 漏 __file__ 导致开发模式 GUI 所有按钮失效
## 问题

GUI 点任何子任务按钮 (⑤ 企微解密 / ③ 导出 / 等), 日志报:

  unknown option --task
  usage: ... python.exe [option] ... [-c cmd | -m mod | file | -] [arg]...

返回码 2, 任务失败。

## 根因

`_run_subprocess` 构造的命令是:

  cmd = [sys.executable, "--task", task]

打包成 exe (sys.frozen=True) 时 sys.executable 就是 WeChatDecrypt.exe
本身, `--task` 是它的 argparse 子命令, 命令变成
`WeChatDecrypt.exe --task find_wxwork_keys` —— OK。

开发模式 (`python app_gui.py`) 时 sys.executable 是 python.exe,
命令变成 `python.exe --task find_wxwork_keys`, 但 `--task` 不是 python
解释器的选项, 直接报 unknown option。

## 修复

判断是否打包:

  if getattr(sys, "frozen", False):
      cmd = [sys.executable, "--task", task]
  else:
      cmd = [sys.executable, os.path.abspath(__file__), "--task", task]

加注释说明两种模式下命令行长什么样。

## 影响

- 开发模式 GUI 之前完全废, 任何按钮点了都报错。修复后恢复正常。
- 打包成 exe 路径未受影响 (该分支保持原行为)。

## 实测

本地 (Windows) 跑 `python app_gui.py`, 点 ⑤ 企业微信解密 现在能正常调
find_wxwork_keys 并解出 17 个 db。

发现路径: code review (4 个 reviewer 之一) 提到 GUI subprocess 调用方式
但没指出具体 bug, 用户实测点按钮时通过日志 `unknown option --task` 暴露。
2026-05-17 17:15:24 +08:00
ylytdeng
7954d49240 docs(readme): 重组项目说明覆盖 PR #107 / #95 / #93 / #94 新内容
## 顶部介绍

老版本只说"实时消息监听、MCP Server、批量导出和语音转录",漏了:
- 企业微信解密 (PR #107)
- 朋友圈解密 (PR #107)
- Windows GUI + EXE 打包 (PR #107)
- 增量 / 日期范围 / dry-run (PR #95)
- 交互式配置向导 / cleanup (PR #93 / #94)

改成 7 行能力矩阵 (个人微信 / 企微 / 监听 / 导出 / 图片 / 语音 / 朋友圈 /
GUI) 一眼看清覆盖范围。

## 快速开始

Windows 部分从 1 条 (CLI) 扩成 3 条:
- Windows — 最小路径 (CLI) — 老用户
- Windows — GUI / EXE (推荐非技术用户) — PR #107 新增
- Windows — 企业微信 (实验) — PR #107 新增

## 文件说明

从 28 行无序大表 (`find_all_keys.py` 还重复了 2 次) 重组成 8 个折叠分组:
① 入口 / 一键脚本 (5 个)
② 密钥提取 (9 个, 含 wxwork)
③ 数据库 / 图片 / 语音解密 (6 个, 含 sns + wxwork)
④ 导出 (6 个)
⑤ 实时监听 / 服务 (4 个, 加上 decode_transfer CLI)
⑥ 语音 / 音频 (2 个)
⑦ 打包 / 配置 / 文档 (7 个, 含 EXE_USAGE / WeChatDecrypt.spec)
⑧ 测试 / 文档 (tests/ + docs/ + latency_test)

修正:
- 老版漏了: decrypt_sns.py / export_sns.py / wxwork_crypto.py /
  batch_decrypt_images.py / setup.py / cleanup.py / app_gui.py /
  decode_transfer.py / EXE_USAGE.md / WeChatDecrypt.spec /
  key_scan_common.py / key_utils.py / find_image_key_monitor.py /
  latency_test.py
- 老版重复了 2 次的: find_all_keys.py
- 老版顺序混乱 (GUI/企微/扫描/导出混在一起)
2026-05-17 17:08:03 +08:00
ylytdeng
e5e2269947 fix: PR #107 后续清理 (security/正确性/一致性)
针对 4 个 review agent 在 PR #107 (5649 行巨型 PR) 找到的关键问题做最小
侵入修复。已合并代码本身能跑,这次是收紧 security + 消重 + 文档一致性。

## 安全修复

### wxwork_keys.json 落盘权限 (find_wxwork_keys.py)
含明文 16-byte raw key 的 keys 文件,之前 default umask 落盘。改成:
  1. 写 tmp 文件
  2. chmod 0o600 (Unix 严格 owner-only; Windows 上 chmod 控制只读位,
     至少避免世界可读最差情况)
  3. atomic rename
旧产物自然过期,新生成的都受保护。

### SNS XXE 防护 (export_sns.py)
朋友圈 XML 来源是不可信输入(他人发的 content),原 `ET.fromstring()`
完全没过滤,可被恶意 entity expansion / 外部实体引用攻击。加跟
`mcp_server._XML_UNSAFE_RE` 同模式的过滤(拒 `<!DOCTYPE>` / `<!ENTITY>`)
+ 200KB 大小上限。`_parse_timeline_xml` 检查后才进 ET.fromstring。

## 正确性 / 消重

### AES 对齐公式统一 (decode_image.py + decrypt_sns.py + export_sns.py)
原本三处各写一份:
  - decode_image.py:   aes_size -= ~(~aes_size % 16)   ← bitwise trick
  - decrypt_sns.py:    同上
  - export_sns.py:     aes_size + (16 - aes_size%16) if … else aes_size+16
两个公式数学等价(对 0/1/15/16/17/100/1000/12345 全部验证一致),但
bitwise trick 难读且漂移风险高。抽 `aligned_aes_block_size()` 到
decode_image.py 作 canonical 实现, 另两处 import 复用。

### 32-bit pointer 假设明确化 (find_wxwork_keys.py)
reviewer 担心 `_read_u32` 在 64-bit 进程上错位,实测 WXWork.exe 5.0.x
是 **32-bit 进程** (`Program Files (x86)\WXWork\` + PE Machine = x86),
所以 4 字节读指针是对的。加注释明确这个假设,腾讯如果升级到 64-bit
要重做整套逆向, 当前实测全部 17 db 解密通过印证。

## 一致性

### main.py show_status() 走 _config_file_path() (main.py)
原硬编码 `config_file = "config.json"` 绕开 PR #107 新引入的
`_config_file_path()`,打包成 exe 后 cwd 不一定是 exe 目录,会读到错
位置。改成 `from config import _config_file_path`。

### EXE_USAGE.md 输出目录写错 (EXE_USAGE.md)
EXE_USAGE 说导出到 `export/`,代码实际 `output_base_dir = wechat_files/
<wxid>/`,联系人下还是 `messages.csv/html/json` 而不是
`message_0.db.csv`。修正成真实结构。

## 文档

README 加两段:
  - 安全提示: keys 文件 chmod 0600 + 不要 commit 到 git
  - 朋友圈 XML XXE 防护说明

## 测试

185/185 通过 (含已有 wxsqlite3 roundtrip + image v2 + msg types filter
+ pagination hint + chat export helpers 等)。
aligned_aes_block_size 单独验证跟旧公式等价(0/1/15/16/17/100/1000/12345)。

## 未跟进 (后续 follow-up issue)

- 3 处 V1/V2/XOR 解密代码完全重复(decode_image / decrypt_sns /
  export_messages 各自实现)——抽出来工作量大,本次先抽 helper 不动
  完整解密路径,后续单独 PR
- export_messages HTML base64 内联图片可能爆几 GB,应改成可选 flag
- SNS / wxwork export / batch_decrypt_images / voice_to_mp3 测试缺位
  (0 个 test)
2026-05-17 17:00:20 +08:00
Belugary
1aa12c86fa fix(monitor_web): use full message content, not truncated session summary (#42)
## Problem

In `--web` mode, messages longer than ~90 chars get truncated in the
SSE feed. `SessionMonitor.check_updates()` pushes `SessionTable.summary`
to clients, but `summary` is WeChat's own ~80-char preview kept for the
client-side chat list — not the full message body.

## Fix

`_lookup_latest_local_id` already hits `Msg_<md5(username)>` for the
row at `(username, create_time)` to obtain `local_id` for #79's
dedup. Extend the same query to also return `message_content` and
`WCDB_CT_message_content`, and use it to replace `summary` when the
DB body is longer:

    SELECT local_id, message_content, WCDB_CT_message_content
    FROM [Msg_<md5>]
    WHERE create_time = ?
    ORDER BY local_id DESC LIMIT 1

Same row, same query — zero additional IO vs. the prior `MAX(local_id)`.

Renamed to `_lookup_latest_message` to reflect the new
`(local_id, content)` return shape. zstd handling and `wxid_xxx:\n`
group-prefix stripping mirror the existing `SessionTable.summary`
logic in `check_updates`, so the SSE `content` field stays in the same
format clients already render.

Replacement is conservative — only swaps in `full_content` when
strictly longer than `summary`. This never shortens existing behavior
and degrades cleanly if the message-DB write hasn't landed yet (the
SessionTable-vs-message_N.db timing race that #79 already documented).

## Scope

- `monitor_web.py`: one helper extended + one call site adjusted. No
  schema change, no new dependency, no client/UI change.
- `_check_hidden_messages` cold path is untouched — its same-second
  multi-message coverage still runs as before.
2026-05-17 16:44:48 +08:00
ylytdeng
b98641347c Merge PR #107: GUI + 企业微信 + 朋友圈 + 单 exe 打包
支持:
- 企业微信 (WXWork) Windows 解密 (wxSQLite3 AES-128-CBC):
  - find_wxwork_keys.py: cipher 结构体扫描自动定位 16-byte raw key
  - wxwork_crypto.py: per-page MD5 派生 + AES-128-CBC 解密
  - decrypt_wxwork_db.py: 批量解密所有加密 db (17/17 实测通过)
  - export_wxwork_messages.py: 按个人/群导出 CSV/HTML/JSON
- 朋友圈 (SNS) 解密 & 导出:
  - decrypt_sns.py / export_sns.py
- GUI 工具箱 (app_gui.py, tkinter):
  - 整合解密/导出/音频转换/企业微信
- 单 exe 打包: WeChatDecrypt.spec + build.bat (PyInstaller)
- 音频工具: voice_to_mp3.py (SILK_V3 → MP3)
- 批量图片: batch_decrypt_images.py
- 共享 export 模块: export_messages.py

config 改动:
- 加 _app_base_dir() 支持打包后 exe 找 config (WECHAT_DECRYPT_APP_DIR)
- 加 wxwork_* / output_base_dir / wechat_files_dir / xwechat_attach_dir 等
- _choose_candidate 加 WECHAT_DECRYPT_NONINTERACTIVE / _GUI 非交互模式
- main.py 加 _call_with_argv helper 隔离子命令 argparse

文档:
- README 加 GUI / 企业微信 / 打包章节
- 新增 EXE_USAGE.md

测试: 185/185 通过 (新增 1 个 wxsqlite3 roundtrip test)

重磅意义: 推翻了 docs/wxwork-research.md 5/13 的"derive-use-zero
不可破"结论 — 企微 5.0.8.6009 实际用 wxSQLite3 AES-128-CBC, 16-byte
raw key 存在内存的 cipher 结构体里, 可被结构体扫描定位。本地实测
17/17 数据库全部解密成功, 读出真实明文消息验证时间戳一致 (含 5/13
登录通知)。

Closes #107
2026-05-17 16:42:29 +08:00
xincheng
26ecaac11e fix argparse 2026-05-17 07:12:26 +08:00
xincheng
5c3e6adfcd Add Windows GUI and WXWork export support 2026-05-17 07:07:07 +08:00
xincheng
16940a8771 update 2026-05-17 06:50:11 +08:00
xincheng
ecc1cfca64 增加企业微信的解密 2026-05-17 06:24:08 +08:00
Davy
de4cb092d9 feat(export): add incremental mode, date range filter, and dry-run
Three new flags for export_all_chats.py:

- -i / --incremental: reads existing JSON, appends only new messages
  (deduplicates by local_id, preserves transcription field on merge)
- --start / --end: filter messages by date range (YYYY-MM-DD or timestamp)
  passes start_ts/end_ts directly to mcp_server._query_messages
- --dry-run: preview mode (shows counts without writing files)

Voice transcription in incremental mode only processes newly appended
voice messages — existing transcribed entries are untouched.
2026-05-14 15:48:38 +08:00
Davy
e20682c3dd feat: cleanup.py + improved error messages 2026-05-14 15:46:06 +08:00
Davy
0ff354138d feat: tqdm progress bar + setup.py wizard 2026-05-14 15:45:00 +08:00
Davy
3801f69d71 feat(main): add export, all, status subcommands 2026-05-14 15:43:56 +08:00
Davy
96061bfd07 feat: add setup.sh for one-command dependency installation 2026-05-14 15:41:19 +08:00
Davy
e26a83afd9 docs: restructure README with quick-start, update USAGE.md 2026-05-14 15:40:04 +08:00
Belugary
403f014ac0 feat: 新增 decode-images 子命令(批量解密 .dat 图片到明文图片树)
## 问题

\`decode_image.py\` 目前只有 \`decrypt_dat_file()\` 单文件 API,以及 \`monitor_web\` 在收到新消息时\"按需解一张\"的路径。**没有\"一次性扫 attach 目录、产出明文图片树到固定路径\"的批量入口**。结果是任何想把微信图片做下游消费(数据分析、搜索索引、归档、第三方 viewer)的用户都得各自写一遍 walk + decrypt 的 wrapper,且各自约定输出布局,生态不收敛。

## 修复

- \`decode_image.py\` 新增 \`decode_all_dats(attach_dir, out_dir, aes_key, xor_key, force, on_file)\` 函数,扫描 \`<attach_dir>/<chat_hash>/<YYYY-MM>/Img/*.dat\` 并镜像产出 \`<out_dir>/<chat_hash>/<YYYY-MM>/<file_md5>.<ext>\`。
- \`main.py\` 新增 \`decode-images\` 子命令(早路由,跳过 \`check_wechat_running\` 和 \`ensure_keys\` —— 这条路径只读 \`.dat\` 文件,既不需要微信进程也不需要 DB 密钥)。

设计选择:

- **输出布局 1:1 镜像 attach**,只做最小 path massage(去 \`Img/\`、去 \`_t/_h\` 缩略图后缀、换扩展名),不发明新结构。下游能用 \`md5(username)\` 反推路径,无需读 mapping 文件。
- **幂等性 = 按 basename 存在性 skip**,不做 mtime 比较 —— \`.dat\` 是 content-hash 命名(\`file_md5 = 文件内容 md5\`),实际上 write-once。\`--force\` 强制重解。
- **原子写**:解密先写 \`<basename>.<ext>.tmp\`(同目录),\`os.replace\` 到正式路径。中断不留半个 jpg。残留 \`.tmp\` 不会被 skip 误判(glob 显式排除)。
- **错误隔离**:单文件失败计入 \`failed\` 继续下一个,stderr 打 \`[WARN]\` 指出相对路径。退出码 2 表示\"部分失败,产物部分可用\"。
- **V2 无 key**:计入 \`skipped_no_key\` 而非 \`failed\` —— 这是可恢复状态(跑 \`find_image_key_macos.py\` / \`find_image_key.py\` 后重跑即可),跟\"真失败\"区分对待。V1 / 老 XOR 不依赖 \`image_aes_key\`。
- **wxgf 容器**只产 \`.hevc\` 裸流,**不**做 mp4 转换:上游不引入 ffmpeg subprocess 依赖,转换是消费层职责。
- **CLI override**:\`--attach-dir\` / \`--decoded-dir\` / \`--aes-key\` / \`--xor-key\` / \`--force\` 都可覆盖 \`config.json\`,适合 CI / 多账号 / 容器化场景。

## 测试

新文件 \`tests/test_decode_images_batch.py\`,13 个新测试:

- \`PathParsingTests\` (4):glob 命中 / \`_t\` 后缀剥离 / \`_h\` 后缀剥离 / chat_hash + YYYY-MM 镜像
- \`IdempotentTests\` (3):已存在跳过 / \`--force\` 覆写 / 残留 \`.tmp\` 不误判
- \`AtomicWriteTests\` (3):成功路径无 \`.tmp\` / decrypt 返回 None 无 \`.tmp\` / decrypt 抛异常无 \`.tmp\`
- \`V2NoKeyTests\` (2):V2 + 无 key → skipped_no_key / V1 + 无 key 仍解码
- \`CallbackTests\` (1):\`on_file\` 回调每文件触发

基线 183 → 196 通过(+13 新增),0 回归。\`decrypt_dat_file\` 用 mock 隔离(避免依赖真实加密图片);\`is_v2_format\` 走真实 magic 检测路径。

## 范围

- \`decode_image.py\`:新增 \`decode_all_dats\` 函数,134 行,纯加,不改任何现有 API。
- \`main.py\`:新增 \`_run_decode_images\` helper + 早路由 + 用法 hint,104 行加 2 行删。无 backward-compat 影响。
- \`tests/test_decode_images_batch.py\`:新增,295 行。合成 fixture(假 V1/V2 magic + mock decrypt_dat_file),不依赖真实加密素材。
2026-05-14 15:39:13 +08:00
Belugary
a6cb3d0497 feat: 解析微信引用回复消息 (appmsg type=57) + 新增 decode_refer MCP 工具
> 高价值改动 rationale (override 路径)
>
> 引用回复 (appmsg type=57) 是聊天里第 3 高频的消息类型 (仅次于纯文本和
> 图片)。当前 _format_app_message_text 的 type=57 分支直接把 refermsg/
> content 按 [:160] 截断当摘要,对内层 type=3 (图片) / 34 (语音) /
> 43 (视频) / 47 (动画表情) / 49 (嵌套卡片) 这些"二进制"被引用消息,
> 会把 cdnurl / aeskey / md5 / cdnthumb / voiceurl / externurl 一坨乱码
> 渲染到 LLM 可见的 chat history,严重污染上下文。issue #44 #45 重复反馈
> 一个月无人接 —— 这是个明确的用户痛点,fork 实测覆盖 5 种内层 type 的真
> 实数据,渲染长度从原本几千字降到 21-58 字。改动较大但 review 风险低:
> 替换的就是 19 行 inline 截断逻辑,新加的 helpers / decode_refer 都是
> 纯加,不动现有 API。

\`_format_app_message_text\` 当前 type=57 分支用 19 行 inline 逻辑直接
\`refer.findtext('content')[:160]\` 当摘要。这对 type=1 (文本) 工作正常,
但对其他内层 type 是个隐藏的 bug:

- type=3 图片: 渲染 \`<msg><img cdnthumburl="…" aeskey="…" md5="…" cdnurl="…" />\` 截断
- type=34 语音: 渲染 \`<voicemsg voicelength="…" voiceurl="…" />\` 截断
- type=43 视频: 渲染 \`<videomsg cdnvideourl="…" cdnthumburl="…" />\` 截断
- type=47 动画表情: 渲染 \`<emoji md5="…" externurl="…" />\` 截断
- type=49 嵌套卡片: 渲染外层 escape 后的 XML 字符串截断

后果: cdnurl / aeskey / md5 / voiceurl / externurl 等二进制元数据泄漏到 LLM
可见的聊天历史,污染上下文且无信息量。引用回复是 type=57 是高频消息,影响面大。

按 refer_type 分发 schema-aware 摘要:

1. **新增三组 helpers (mcp_server.py +135 行,纯加)**:
   - \`_REFER_INNER_TYPE_LABEL\`: 内层 type → 中文标签 (1 文本 / 3 图片 / 34 语音 / ...)
   - \`_INNER_APPMSG_TYPE_LABEL\`: refer_type=49 时嵌套 appmsg/type → 标签 (5 链接 / 6 文件 / 19 聊天记录 / ...)
   - \`_extract_refer_info(appmsg)\`: 提取 refermsg 全字段返回 dict
   - \`_summarize_refer_content(refer_type, content)\`: 按 type 分支
     - type=1: 取原文,截断到 max_len
     - type=3/34/43/47/...: 给标签,**不**展开 cdnurl/aeskey/md5
     - type=49: 走 \`_parse_xml_root\` (经 \`_XML_UNSAFE_RE\` 过滤 DOCTYPE/ENTITY 防 XXE) 解一层 inner appmsg, 给 \`[链接] xxx\`
     - 未识别 type: 给 \`[type=N]\` 兜底
   - \`_format_refer_message_text(appmsg, ...)\`: 渲染两行格式
     \`<回复正文>\n  ↳ 回复 <对方>: <摘要>\`

2. **\`_format_app_message_text\` 的 type=57 分支简化**: 19 行 inline → 3 行 dispatch 到 helper。

3. **新增 MCP 工具 \`decode_refer(chat_name, local_id, create_time=0)\`**: 输出结构化多行文本 (回复正文 / 被引用发送者 / 类型 / 摘要 / svrid / createtime), 错误文案分别指引 \`decode_file_message\` (type=6) / \`decode_record_item\` (type=19) / \`decode_transfer\` (type=2000), 不让用户在 4 个工具间盲猜。

新文件 \`tests/test_refer_message.py\`, 20 个新测试:

- \`ReferInnerTypeLabelTests\` (2): 标签映射 spot-check
- \`ExtractReferInfoTests\` (2): 全字段提取 / refermsg 缺失返回 None
- \`SummarizeReferContentTests\` (11): 5 种 refer_type 标签 / type=1 文本截断 / type=49 嵌套链接卡 / type=49 聊天记录卡 / type=49 invalid XML 退化 / unknown type 兜底 / 空 content / XXE payload 拒绝
- \`FormatReferMessageTextTests\` (4): 1v1 文本引用渲染 / 图片引用不泄漏 PII (cdnurl/aeskey/md5) / refermsg 缺失退回 title / 空 reply 用占位符
- \`AppMessageDispatchReferTests\` (1): dispatcher 走新 helper 不走旧截断

合成 fixture (wxid_synth_a/b, 12345@chatroom, Sender A/B, svrid 1+0\*18), 无真实 PII。

基线 183 → 203 通过 (+20 新增), 0 回归。

- \`mcp_server.py\`: 替换 19 行 type=57 inline → 3 行 dispatch (净 -16 行); 新增 6 个 helpers + 1 个 MCP 工具 \`decode_refer\` (+275 行); 不改任何现有公开 API。
- \`tests/test_refer_message.py\`: 新增 (20 测试, 合成 fixture, 不依赖真实加密素材)。
- **本 PR 不包含 fork 里的 CLI 入口 (\`wxdec.cli.decode_refer\`) 和 \`export_chat\` / \`monitor_web\` 的对应改动** —— 那几处依赖 fork 私有的包结构 (\`wxdec/cli/\`), 不属于上游 scope。后续如有需要可单独提。

issue #44 #45 (引用回复渲染乱码)
2026-05-14 15:37:53 +08:00
Belugary
5bc275b81c feat(mcp): get_chat_images/get_voice_messages 加 offset/time_range
\`get_chat_images\` 和 \`get_voice_messages\` 仅有 \`limit\`, 接口与
\`get_chat_history\` / \`search_messages\` (\`offset\` + \`start_time\` +
\`end_time\`) 不对齐:

1. 查不了"某段时间内的图片/语音"
2. 不支持分页, 单次取 \`limit=1000\` 一次性拉
3. LLM 用同样模式调不同工具时签名不一致, 容易出错

两个工具各加 3 个可选参数:
- \`offset: int = 0\`
- \`start_time: str = ""\`
- \`end_time: str = ""\`

复用上游已有的 \`_validate_pagination\` + \`_parse_time_range\` helpers。

- 输入校验失败立即报错 (offset 负数 / 时间格式错 / start > end)
- 每 shard 拉 \`limit + offset\` 张候选, 合并后全局 \`create_time DESC\`
  排序, 切片 \`[offset : offset + limit]\` 出本页
- 单 shard 凑得起本页, 避免某 shard 缺数据时本页变短
- header 显示 offset/limit 和时间范围 (传了才显示)

- 加 \`start_ts=None\` / \`end_ts=None\` 参数
- SQL 动态拼 \`create_time >= ?\` / \`<= ?\` clause
- 不传时序参数完全等价旧行为 (向后兼容)

- 同样 3 个参数 + \`_validate_pagination\` + \`_parse_time_range\`
- VoiceInfo 表 SQL 动态拼 \`chat_name_id = ? AND create_time ?...\`
- 多 shard 各取 \`limit + offset\` 后合并切片

新增 \`tests/test_chat_images_query_align.py\` 8 个 case:
- offset 负数报错
- start > end 报错
- candidate_limit = limit + offset (shard 调用确认)
- 时间参数正确解析为 unix 秒并透传
- offset=2, limit=2 切到全局排序后第 3-4 张
- header 包含时间范围
- header 包含 offset/limit
- 默认调用(不传新参)行为与旧接口一致

修改 \`tests/test_get_chat_images_multishard.py\` 的 fake_list 签名:
- 旧: \`(db_path, table_name, username, lim)\` 位置参
- 新: \`(db_path, table_name, username, limit=20, start_ts=None, end_ts=None)\`
- 既支持旧调用模式 (kwargs), 也兼容新签名

全量 \`pytest tests/\` 208/208 通过。

3 个可选参数全部带默认值 → 既有调用方零修改。

shard candidate=\`limit+offset\` 的成本: 大 offset 时单 shard 请求量
增大。但 image/voice 表每 chat 单 shard 一般 < 10K 条, 实际 cost 可
忽略。如果将来要做"翻 100 页"级深翻, 可以加 keyset pagination, 现
在 offset 模式与 \`get_chat_history\` 一致即可。

与 #103 / #104 触碰同一文件, 合并顺序无所谓 — 后合的 rebase 即可。
2026-05-14 15:36:00 +08:00
Belugary
6606122c86 feat(mcp): get_chat_history 加 msg_types 按类型过滤
LLM 用 \`get_chat_history\` 查"和 X 的所有图片消息"时, 只能拉 50 条
混合消息再客户端过滤 —— 大部分 token 浪费在不需要的文本上。同样
"只看转账记录" / "只看语音" 的场景, 没有原生过滤手段。

\`get_chat_history\` 加一个可选 kwarg \`msg_types: list[str] | None = None\`:

- 接受 \`['text', 'image', 'voice', 'video', 'file', 'emoji', 'location',
  'namecard', 'voip', 'system']\` 子集
- \`'file'\` 是 alias → 'app' (WeChat 把文件归到 \`local_type=49\`,
  俗称 file)
- 输入大小写不敏感, 自动 strip
- 未知类型立即报错并列出可选值 (不偷偷过滤合法部分)
- None 或 \`[]\` 表示不过滤, 完全等价于旧行为 (向后兼容)

实现上拆 3 件:

1. \`_MSG_TYPE_MAP\` 常量 (字符串 → \`local_type\` 整数列表)
2. \`_resolve_msg_types()\` helper 做输入校验 + 翻译
3. \`_build_message_filters\` / \`_query_messages\` /
   \`_collect_chat_history_lines\` 链路加 \`type_filter=None\` 透传, SQL
   注入 \`local_type IN (?,?,...)\` clause

\`tests/test_msg_types_filter.py\` 12 个 case:

- None / 空 → 不过滤
- 单类型 / 多类型解析
- \`file\` alias → app
- 大小写 + strip 不敏感
- 未知类型报错且不放过合法的
- SQL 生成: 无过滤时 clauses 不含 \`local_type\`, 单类型生成 \`IN (?)\`,
  多类型生成 \`IN (?,?,?)\`
- 与 time / keyword 组合时 param 顺序正确

全量 \`pytest tests/\` 212/212 通过。

新参数默认 None, **既有调用方零修改**。

类型映射表 (\`_MSG_TYPE_MAP\`) 命名是有立场的判断 (比如 \`'app'\` 这一
桶实际混了文件 / 分享卡 / 小程序 / 转账 / 引用回复), 如果维护者
不同意具体 label 或想拆细, 改 dict 就行, 不影响接口。

与 #103 (\`_pagination_hint\`) 触碰同一文件 \`mcp_server.py\`, 后合的
rebase 即可, 无逻辑冲突。
2026-05-14 15:33:26 +08:00
Belugary
9450e46ca5 feat(mcp): 加 _pagination_hint 帮 LLM 决定是否续翻 (#103)
## 问题

LLM 调用 \`get_chat_history(limit=50)\` 拿到 50 条消息后, 无法判断
"是真只有 50 条" 还是 "还有 150 条没拿"。LLM 缺少续翻信号, 容易
基于不完整数据回答。

类似问题影响所有分页工具: \`search_messages\` / \`get_chat_images\` /
\`get_voice_messages\` / \`get_contacts\`。

## 修复

加 \`_pagination_hint(count, limit, offset)\` helper:
- \`count >= limit\` 时返回 \`(可能还有更多结果,可设 offset=N 继续查询)\`
- \`count < limit\` 时返回空 (表示已读完当前条件全部结果)
- \`limit == 0\` (理论非法, 上游有 \`_validate_pagination\` 兜底) 防御
  性返回空

应用到 5 个工具返回字符串末尾:
- \`get_chat_history\` (1 处)
- \`search_messages\` 三个内部分发 \`_search_single_chat\` /
  \`_search_multiple_chats\` / \`_search_all_messages\` (3 处)
- \`get_chat_images\` / \`get_voice_messages\` (各 1 处, 当前两者无 offset
  参数, 使用 \`offset=0\` 占位; 后续接口对齐 PR 会把 \`offset\` 加进来)
- \`get_contacts\` 单独用 \`total > limit\` 模式提示 "共 N 个匹配, 当前
  仅显示前 limit 个, 可增大 limit" — 因为 \`get_contacts\` 当前无
  pagination 语义, 仅有 limit, 文案语义不同

## 测试

\`tests/test_pagination_hint.py\` 5 个 case 覆盖:
- count < limit 不提示
- count == limit 提示且 offset 累加正确
- 连续翻页 offset 推进 (offset=100, limit=20 → 提示 offset=120)
- limit=0 防御
- count > limit 边界 (理论不该发生)

全量 205/205 通过。

## 范围

纯返回字符串末尾追加, 不改任何查询逻辑、不改函数签名、不改数据库
读路径。零破坏性, 调用方 100% 向后兼容。

提示文案如不合适可直接改, 不影响行为。
2026-05-14 15:30:16 +08:00
joshua-deng
70d44ef61f fix(export): strip group prefix before parsing appmsg in chat export (#101)
Issue #88: 群聊里的引用回复(appmsg type=57)/ 卡片 / 视频在 export_chat
和 export_all_chats 渲染成 type=link_or_file 且 content 为空。

根因:`_extract_content` 把数据库里带 `wxid_xxx:\n` 群前缀的原始 content
直接喂给 `_format_app_message_text`,XML 解析器在前缀文本上 ParseError,
返回 None。

修复:
- 用 `chat_username.endswith('@chatroom')` 判定群聊
- 在 dispatch 前调 `mcp_server._parse_message_content(..., is_group=True)`
  剥前缀;逻辑也对群里的 base=1 text 生效(之前同样带前缀)
- 把 `is_group=True` 透传给 `_format_app_message_text`,让引用回复走 group
  分支的发送者标签解析
- 用 `mcp_server.get_contact_names()` 代替之前硬编码的 `{}`,让 wxid 能
  正确解出昵称

测试:新增 5 个测试覆盖群引用回复带前缀 / 1-on-1 不受影响 / 群 text
前缀剥离 / 1-on-1 text 不变 / names dict 正确解析。126/126 通过。

Belugary 在 #100 修了 `_format_app_message_text` 内部的 type=57 schema
渲染(对 get_chat_history 生效),本 PR 是补 export 这条路径上的群前缀
bug。两者互补。

Co-authored-by: ylytdeng <ylytdeng@users.noreply.github.com>
2026-05-13 13:40:17 +08:00
Davy
8645fe4210 feat(export_all): add --with-transcriptions flag for voice transcription during export (#89) 2026-05-13 13:33:36 +08:00
Belugary
187d820bb0 feat(mcp): render voice messages with duration in chat history (#97)
## Problem

Voice messages in `_format_message_text` previously rendered as a bare
`[语音] (local_id=N, ts=T)` because msg_type=34 fell through to the generic
non-text branch with no schema-aware summarizer. LLMs reading chat history
had no way to judge whether a voice clip was worth calling `decode_voice`
on without first inspecting it.

## Fix

New helper `_format_voice_text(content)` parses the embedded
`<voicemsg voicelength="…">` and renders `[语音 N.Ns]` (duration to one
decimal, milliseconds → seconds). Type=34 dispatches through it, then
appends the existing `_id_suffix()` so the local_id annotation is
preserved end-to-end:

    [语音 3.3s] (local_id=72481, ts=1700000000)

Falls back to `[语音]` (still with `_id_suffix()`) when content is empty,
`<voicemsg>` is absent, XML parse fails, or `voicelength` is missing /
zero / non-numeric.

XML parsing routes through the existing `_parse_xml_root` so the
`_XML_UNSAFE_RE` DOCTYPE/ENTITY filter and 200KB size cap are reused —
no new XXE surface.

## Tests

12 new cases in `tests/test_voice_format.py`: happy path, subsecond,
multi-second, missing / zero / non-numeric voicelength, empty / None
content, missing `<voicemsg>` tag, malformed XML, XXE payload, and two
end-to-end cases through `_format_message_text` (with and without
voicelength) to pin the full rendered output including `_id_suffix()`.

Baseline 183 → 195 passing, 0 regressions.

## Scope

- `mcp_server.py`: adds `_format_voice_text` helper and one branch in
  `_format_message_text` (base_type == 34). No public surface change —
  this only affects formatting of messages that previously rendered as
  the bare `[语音]` fallback.
- `tests/test_voice_format.py`: new file, synthetic fixtures only (no
  real PII).
2026-05-13 13:31:18 +08:00
Belugary
8bb2d85d8c fix(contact): auto-invalidate in-memory caches when contact.db is re-decrypted (#98)
## Problem

`_contact_names`, `_contact_full`, `_contact_tags`, and `_self_username`
are populated lazily on first access and never invalidated for the
process lifetime. When `contact.db` is re-decrypted (new contact added,
remark or group name edited, etc.) the on-disk DB updates but the
running MCP server keeps serving stale data — newly-added contacts are
invisible to `resolve_username` and downstream tools until the server
is restarted.

## Fix

Track the mtime of the contact.db backing file. On every
`_get_contact_db_path()` call (which all contact accessors go through),
compare against `_contact_db_mtime`; if it changed, clear all four
caches and record the new mtime. Lookups that don't trigger a real
re-decryption pay only one `os.path.getmtime()` syscall.

The function is reorganized so `_get_contact_db_path()` is the single
source of truth for both "where is contact.db" and "do we need to
invalidate" — `get_contact_names` and `_load_contact_tags` consult it
unconditionally before the early-return on the populated cache.

Also reorders `_get_self_username` to call `get_contact_names()` first
(which now triggers the mtime check via the path lookup) before
returning a cached `_self_username` — otherwise the rename case would
still resolve to the stale name.

## Tests

Baseline 183 → 183 passing, 0 regressions.

The pattern (mtime-track + invalidate-on-change) mirrors the existing
behaviour of DBCache, which already re-decrypts contact.db when the
source mtime changes; this fix closes the symmetric gap on the
in-memory side.

## Scope

- `mcp_server.py` only.
- No public surface change. Affects the contact-cache layer's behaviour
  on re-decryption — previously: stale until restart; now: refreshed
  on next contact-related call.
2026-05-13 13:25:00 +08:00
ylytdeng
eb544b2bd6 refactor: monitor_web 复用 _extract_transfer_info 避免双份维护
PR #85 把转账消息解析放在了 mcp_server._extract_transfer_info(处理
snake/camel 字段名漂移 + 未知 paysubtype 兜底),但 monitor_web.py
内联重新实现了一遍 paysubtype 标签表 + camelCase fallback。

后果:将来 WeChat 新增 paysubtype 时需要两处改,容易漂。

修复:monitor_web 改为调 mcp_server._extract_transfer_info,跟
chat_export_helpers._extract_transfer_extras 走同一条路径。

UI 行为零变化:
- 已知 paysubtype 显示中文 label(同原行为)
- 未知 paysubtype 显示空串(避免"未知(paysubtype=N)"在 UI 出现)
- 字段抽取/截断逻辑不变

本地验证 OLD vs NEW 字节级一致。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:11:19 +08:00
Belugary
f03df51561 feat: parse WeChat transfer messages (appmsg type=2000) (#85)
Add structured parsing for transfer messages so they no longer fall
through to the generic `[链接/文件]` fallback in chat history exports.
Mirrors the dispatch + helper pattern PR #65 (merged-forward type=19)
established for `base_type=49` appmsg sub-types.

## What is added

**Helpers (mcp_server.py):**
- `_TRANSFER_PAYSUBTYPE_LABEL` — maps the 6 community-consensus paysubtypes
  (1 发起 / 3 已收款 / 4 已退还 / 5 过期已退还 / 7 待领取 / 8 已领取);
  unknown values degrade to `未知(paysubtype=N)` so a new variant in a
  future WeChat build is visible rather than silently dropped.
- `_extract_transfer_info(appmsg)` — pulls fields out of `<wcpayinfo>`,
  with snake/camelCase fallback (`feedesc`/`feeDesc`, `pay_memo`/`paymemo`)
  observed across WeChat versions.
- `_format_transfer_message_text(appmsg, title)` — one-line render
  for chat history: `[转账·已收款] ¥100.00 备注: lunch`.

**Dispatch (mcp_server.py):**
- `_format_app_message_text` gains an `app_type == 2000` branch that
  routes to `_format_transfer_message_text`. `get_chat_history`,
  `export_chat`, `export_all_chats` and `monitor_web` all inherit
  automatically.

**New MCP tool (mcp_server.py):**
- `decode_transfer(chat_name, local_id, create_time=0)` — full
  structured view: direction, amount, memo, payer/receiver wxid,
  transfer id, transcation id, begin/invalid timestamps. Uses the
  same multi-shard scan + ambiguity-by-create_time pattern as
  `decode_file_message` / `decode_record_item`.

**CLI wrapper:**
- `decode_transfer.py` at the repo root — argparse wrapper that prints
  the same text as the MCP tool, returning non-zero exit when the
  message can't be decoded (script-friendly).

**JSON export (chat_export_helpers.py + export_chat.py + export_all_chats.py):**
- `_extract_content` now returns `(rendered, extras)`. `extras` carries
  structured fields when a message type has more signal than the
  human-readable string (currently: transfers → `type:"transfer" +
  transfer:{direction, fee_desc, pay_memo, ...}`). The channel is
  forward-compatible — future additions (video号 metadata, expanded
  merged-forward, etc.) flow through the same shape without changing
  the caller signature. JSON consumers that only read `content` are
  unaffected; the change is additive.

**monitor_web (monitor_web.py):**
- Backend dispatch branch + orange-yellow `.msg-transfer` card CSS +
  `renderRich` JS handler.

## Tests

12 new cases in `tests/test_record_decoders.py`:

- `TransferPaysubTypeLabelTests` — locks the 6-value label table.
- `ExtractTransferInfoTests` (6 cases) — full field round-trip, missing
  `<wcpayinfo>` fallback, snake/camelCase variants, unknown paysubtype
  degradation, empty paysubtype handling.
- `FormatTransferMessageTextTests` (4 cases) — initiate / received-with-memo /
  missing-wcpayinfo / missing-fee-desc.
- `AppMessageDispatchTransferTests` — `_format_app_message_text` routes
  type=2000 correctly so `get_chat_history` / `export_chat` both pick
  it up.

All fixtures use synthetic placeholder values (`wxid_payer_synth`,
`¥100.00`, `1` + 27×`0`); no real PII or transaction IDs.

## Scope

7 files, +546 / -15 (additions only — no behavior change for existing
message types). All 180 tests pass locally (168 baseline + 12 new).
2026-05-12 21:03:08 +08:00
Belugary
8ea7e61a07 fix: clean up -shm/-wal residuals left by sqlite3 verification (#87)
The post-decrypt verification step (sqlite3.connect(out_path) + table list, around line 163) opens the freshly-written .db in default journal mode. Even though the connection is closed cleanly, SQLite leaves behind empty <db>-shm and <db>-wal companion files in OUT_DIR.

Downstream tools that later open the same .db will see those companion files and try to roll the (empty / stale) WAL forward, producing "database disk image is malformed" or silently masking the most recent pages. The decrypted DB itself is fine — the residuals are pure noise from the verification connection.

Fix: after the verification block (success or failure), unconditionally os.remove() out_path + "-shm" and out_path + "-wal" if present. Errors during cleanup are swallowed.

Tests: existing tests/ pass (168/168). The cleanup is additive and only runs after the existing verification path; no behavior change for callers that do not inspect OUT_DIR for companion files.

Scope: 10 lines in decrypt_db.py. No public API change, no schema change, no new dependency.
2026-05-12 21:02:57 +08:00
Belugary
84fd6c96bd fix(config): correct macOS db_dir template to sandbox container path (#86)
## Problem

On macOS, the default `db_dir` template in `config.py` (line 20) points to
`~/Documents/xwechat_files/your_wxid/db_storage`, but WeChat 4.x on macOS
stores data inside the app sandbox container at
`~/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files/<wxid>/db_storage`.

`_auto_detect_db_dir_macos()` (config.py:166) handles the common case, but
when auto-detect fails — fresh install with no scan results yet, permission
issues, atypical install location — the template fallback is what the user
sees in their generated `config.json`. Today that fallback is a Linux-style
path that does not exist on macOS, so the user has to manually correct it
before the first run can succeed.

## Fix

Update the darwin branch of `_DEFAULT_TEMPLATE_DIR` to the actual sandbox
container path. `your_wxid` remains a placeholder.

Linux and Windows templates are unchanged.

## Tests

Existing `tests/` pass (168 / 168). The change only affects a module-level
constant; no behavior change for users whose auto-detect already succeeds.

## Scope

3 lines in `config.py`. No public API change, no schema change, no
dependency change.
2026-05-12 21:02:49 +08:00
Belugary
b2affdcf88 fix(image): scope local_id lookup by chat_id + use real column name (#82)
* fix(image): scope local_id lookup by chat_id + use real column name

`ImageResolver.get_image_md5` made two wrong assumptions about the
production `MessageResourceInfo` schema, which made the decode_image
MCP tool always fail with "无法找到 local_id=X 的图片信息":

1. The column is `message_local_id`, not `local_id`. The current query
   throws `sqlite3.OperationalError: no such column: local_id`, but the
   exception is swallowed by `except Exception: pass`, masking the real
   failure as a silent miss.
2. `message_local_id` is not globally unique. In production it repeats
   across chats, and within an active chat the same local_id can recur
   up to 7 times (observed on a real DB). The production schema scopes
   by `chat_id`, resolved from `ChatName2Id.rowid WHERE user_name = ?`.

Fix: `get_image_md5` now takes `(username, local_id)`:
- Resolve `username -> chat_id` via `ChatName2Id`.
- Query `MessageResourceInfo` filtered by `chat_id + message_local_id
  + message_local_type == 3` (image type; high bits are session flags,
  so use `% 2^32`), ordered by `message_create_time DESC LIMIT 1`.

External callers (`mcp_server.decode_image_tool` /
`list_chat_images_tool`) already pass `username` through
`ImageResolver.decode_image()` / `list_chat_images()`, so the public
API is unchanged. Only the internal helper signature shifts.

The existing test fixture in `test_decode_image_v2` used the same wrong
schema as the buggy code (`CREATE TABLE MessageResourceInfo (local_id
INTEGER PRIMARY KEY, packed_info BLOB)`), so the tests passed against a
self-consistent fiction. The fixture is rebuilt to match real columns
plus `ChatName2Id`, and three regression tests are added:

- cross-chat collision (same local_id in 3 chats; must pick the right one
  and not the type=43 video row)
- same-chat reuse (same local_id, two timestamps; must pick the newer)
- unknown chat (username not in ChatName2Id; structured error, no crash)

All 154 tests pass locally (151 baseline + 3 new).

* fix(image): surface get_image_md5 errors and use read-only DB open

Two follow-ups on top of the chat_id scoping fix:

1. The bare `except Exception: pass` was the original failure mode: it
   silently swallowed `OperationalError: no such column: local_id` when
   the production schema diverged from the old `local_id` column name,
   masking the bug this PR fixes. Print the exception to stderr so
   future schema drift surfaces immediately instead of returning a
   misleading "image not found" error.

2. Open message_resource.db with `file:...?mode=ro` URI to match the
   rest of the project (monitor_web.py uses this idiom in 9 places).
   The DB is read-only for our purposes and a running WeChat may still
   hold it; using URI ro avoids any chance of lock contention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:57:44 +08:00
Belugary
cd329afa1b fix(mcp): scan all message DB shards in get_chat_images (#84)
WeChat rolls a chat's messages over to the next `message_N.db` shard
once the current shard fills up (~100 MB), so any chat older than the
current shard window has its history split across multiple shards. The
other message-query tools — `get_chat_history`, `search_messages`, and
`decode_image` — already iterate every matching shard via the plural
helper `_find_msg_tables_for_user`. Only `get_chat_images` still used
the singular `_find_msg_table_for_user`, which returns the first shard
that contains the user's table.

Effect: every image that lived in a non-first shard was silently
dropped from `get_chat_images`. On a long-lived chat with many images,
the tool would return only the most recent slice and pretend the rest
did not exist.

Fix: switch `get_chat_images` to `_find_msg_tables_for_user`, fetch
`limit` images per shard, merge, sort by `create_time` DESC, and slice
to `limit`. This mirrors how the other tools fan out across shards.

Tests in `tests/test_get_chat_images_multishard.py`:

- `test_collects_images_from_every_shard` — both shards' images appear
  in the output (the regression case)
- `test_global_sort_by_create_time_desc` — newer image from an older
  shard still wins, output is globally sorted (not per-shard concat)
- `test_limit_truncates_globally_across_shards` — limit=3 takes the 3
  newest overall, not "first shard wins"
- `test_no_shards_returns_not_found` — empty shard list path
- `test_all_shards_empty_returns_no_images` — every shard empty path

All 156 tests pass locally (151 baseline + 5 new). Public tool
signature is unchanged; only the internal scanning loop is widened.
2026-05-12 16:18:49 +08:00