Commit Graph

108 Commits

Author SHA1 Message Date
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
Belugary
c162a9b92f fix(mcp): trim raw XML payload from namecard (type=42) chat output (#83)
When a chat history contains a name-card message (msg_type=42), the
dispatcher in `_format_message_text` had no case for `base_type == 42`,
so it fell through to the generic non-text branch:

    elif base_type != 1:
        type_label = format_msg_type(local_type)
        text = f"[{type_label}] {text}" if text else f"[{type_label}]"

`text` for type=42 is the full raw `<msg ...>` element, so chat history
exports emitted `[名片] <msg username="..." antispamticket="v2_..."
brandIconUrl="https://wx.qlogo.cn/..." bigheadimgurl="..." ... />`.

That payload has two problems:
1. It leaks anti-spam tokens (`antispamticket`) and head-image CDN URLs
   into chat logs that are routinely piped to LLMs and other downstream
   tools.
2. The raw XML drowns out the actual signal — a human or an LLM reading
   the chat just wants to know "X shared Y's contact".

This PR adds `_format_namecard_text(content)` that pulls only the three
useful attributes:

- `nickname` — display name
- `username` — wxid (annotated as "公众号" when prefixed `gh_`)
- `certinfo` — user-authored bio

and wires it into the dispatch chain via a new `elif base_type == 42:`
branch, sitting alongside the existing `49` (app message) handler. It
reuses `_parse_xml_root` and `_collapse_text` — no new helpers
introduced.

Tests: 7 cases in `tests/test_namecard_format.py` covering the realistic
shape (with antispamticket / brand URLs that must NOT appear in output),
official accounts (`gh_*`), missing certinfo, missing nickname, missing
both identifiers, and broken-XML fallthrough.

All 158 tests pass locally (151 baseline + 7 new).
2026-05-12 16:18:43 +08:00
Belugary
216f44a99f fix(image): reject corrupted V2 image when AES or XOR key is wrong (#81)
`v2_decrypt_file` previously wrote files to disk even when the keys were
wrong, producing garbage output with no way for the caller to detect the
failure:

1. Wrong AES key -> `detect_image_format` returns 'bin' (magic does not
   match any known format) -> a `.bin` file of random bytes was written.
2. Wrong XOR key with correct AES key -> file header looks like a valid
   jpg/png (the AES segment decrypts correctly) but the trailing XOR
   segment is scrambled -> callers get a half-valid image file that
   image viewers either render as truncated or fail to open.

Both cases now return `(None, None)`:

- `fmt == 'bin'` -> fail fast, no file written.
- `xor_size >= 2` -> validate trailer magic by format:
    * jpg must end with FF D9 (EOI marker)
    * png must contain IEND chunk in the last 12 bytes
  Other formats (gif/bmp/tif/webp/hevc/wxgf) lack a mandatory trailer
  signature, so they skip the check to avoid false rejection.

Also fixes a latent bug in `test_decode_image_v1_no_aes_key_uses_fixed_key`:
the test built the synthetic .dat with `TEST_XOR_KEY=0x37` but constructed
`ImageResolver` without `xor_key=`, defaulting to `0x88`. The XOR segment
was always scrambled — the test passed because the AES segment alone was
enough for `detect_image_format` to return 'png' from the header, and no
trailer validation existed to catch the corruption. The new trailer check
surfaces this, so the test now passes `xor_key=TEST_XOR_KEY` explicitly.

Tests: 5 new cases (wrong AES key / wrong XOR for jpg / wrong XOR for
png / xor_size=0 bypass / wxgf bypass). All 156 existing tests still pass.
2026-05-12 16:18:37 +08:00
Dru / Lu Rui
d86e0acad1 feat: macOS 自动检测微信数据目录 + 部署排错文档 (#80)
* feat: macOS 自动检测微信数据目录

新增 _auto_detect_db_dir_macos() 函数,搜索 ~/Library/Containers/ 下的
xwechat_files/*/db_storage 目录,按 mtime 排序优先最近活跃账号。

同时改进检测失败时的提示信息,macOS 给出正确的默认路径格式。

* docs: 记录 macOS 部署常见问题及修复方案

记录 task_for_pid failed:5、自动检测 db_dir 失败、
PEP 668 pip 安装拒绝三个问题的现象、原因和解决方法,
附完整 macOS 部署流程和修复状态汇总。

* docs: 增强 README macOS 部署说明和常见问题

- 安装依赖部分补充 PEP 668 虚拟环境解决方案
- 修正 macOS db_dir 路径为正确的 xwechat_files 格式
- 快速开始增加重签名前退出微信的步骤
- 新增 macOS 密钥扫描常见问题排错章节
  - task_for_pid failed:5 的原因和完整排查步骤
  - 自动检测数据目录失败的临时解决方案

---------

Co-authored-by: drulu <drulu@tencent.com>
2026-05-12 16:18:30 +08:00
ylytdeng
c45c107f45 fix: 密集消息遗漏(issue #79)— 去重 key 加 local_id
根因:_shown_keys 之前用 (username, timestamp, msg_type) 当 key,导致
同秒同类型多条消息(如"逐条转发"10 条文字)的去重 key 完全相同。
SessionTable 触发 emit 第一条后把 key 加进 _shown_keys,
_check_hidden_messages 查到剩余 N-1 条时全部命中"已显示",全部跳过。
juneleung 实测 "10 丢 4"。

本地用解密后的 message_message_0.db 验证:
  - 真实数据存在 4 条同秒消息(local_id 72480..72483)
  - 旧逻辑:1/4 收到
  - 新逻辑:4/4 收到

改动:

1. _shown_keys 改用 (username, local_id) 精确去重
2. 新增 _lookup_latest_local_id(username, timestamp) — SessionTable 触发
   推送时查 message_N.db 拿对应 local_id
3. _check_hidden_messages 的 SQL 加 local_id 字段,过滤循环用 local_id
4. _shown_keys 清理逻辑改为按数量上限(local_id 不能按时间 prune)

时机风险:SessionTable 写入比 message DB 早几毫秒,_lookup_latest_local_id
可能查不到 → 返回 None,跳过加 key。_check_hidden_messages 1 秒后查到
该消息时自己加 key,结果是偶发轻微重复(比丢消息好)。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:39:56 +08:00
Davy
fe5cc633ff feat: transcribe_voice 新增 whisper.cpp 后端(macOS Metal GPU 加速) (#78)
* Add transcribe_chat_whisper_cpp.py: macOS whisper.cpp transcription

whisper.cpp variant of transcribe_chat.py for Apple Silicon Macs.

Advantages over transcribe_chat.py:
- Uses whisper-cpp CLI with Metal/ANE GPU acceleration (3-5x faster)
- No PyTorch or openai/whisper Python dependency
- Same idempotent, crash-safe design as transcribe_chat.py
- Auto-detects model from common macOS locations:
  ~/Library/Application Support/whisper-cpp/,
  ~/Library/Application Support/Recordly/whisper/, etc.
- --model-size flag for automatic download if no model found
- Configurable --language (default: zh) and --threads

Usage: python3 transcribe_chat_whisper_cpp.py <input.json> [output.json]

* refactor: 将 whisper.cpp 转为后端选项集成到 mcp_server.py 中

根据 PR #78 review 反馈,将独立的 transcribe_chat_whisper_cpp.py 重构为
mcp_server.py 中的 whisper_cpp 后端,与 PR #66 OpenAl 后端模式对齐。

变更:
- mcp_server.py: 新增 _transcribe_whisper_cpp()、_resolve_whisper_cpp_binary()、
  _resolve_whisper_cpp_model(),更新 _resolve_active_backend()/_cache_signature()/
  _transcribe() 以分发至 whisper_cpp 后端
- transcribe_chat.py: 统一入口 mcp_server._transcribe 自动支持新后端,
  仅补充了 backend 打印信息
- 删除 transcribe_chat_whisper_cpp.py

config.json 启用方式:
  "transcription_backend": "whisper_cpp",
  "whisper_cpp_binary": "...",    # 可选,默认自动检测
  "whisper_cpp_model": "...",     # 可选,默认自动检测
  "whisper_cpp_language": "zh",   # 可选
  "whisper_cpp_threads": 4          # 可选,默认自动检测

* docs: 在语音转录隐私章节补充 whisper.cpp 后端说明

根据 PR #78 review 反馈,在 README.md ⚠️ 语音转录隐私章节
新增 whisper.cpp 后端(macOS Metal GPU 加速)的配置说明、隐私
属性和回退行为,与 OpenAI 后端并列。
2026-05-12 11:42:53 +08:00
Davy
67de4a1d0c feat: 批量导出所有聊天为 JSON + 提取共享 helper 模块 (#77)
* Add export_all_chats.py: batch export all WeChat chats to JSON

Mirrors export_chat.py functionality to export every chat in the
decrypted WeChat database at once. Output format is byte-for-byte
identical to export_chat.py.

Usage: python3 export_all_chats.py [output_dir]

- Reads all sessions from decrypted/session/session.db
- Uses the same content extraction pipeline as export_chat.py:
  _resolve_sender, _extract_content, _msg_type_str, sticker/video/system
- Outputs group_<display>.json or single_<display>.json with same schema
- Progress reporting every 100 exports with summary at end

* refactor: 将 7 个重复 helper 函数提取到 chat_export_helpers.py

根据 PR #77 review 反馈,将 export_chat.py 和 export_all_chats.py 中
逐字复制的消息格式化函数提取到共享模块 chat_export_helpers.py。

提取的函数:
  MSG_TYPE_MAP, _msg_type_str, _resolve_sender,
  _decode_sticker_desc, _format_sticker_message,
  _format_system_message, _format_video_message, _extract_content

两个导出脚本现在从 chat_export_helpers import 所需函数,
消除代码漂移风险。
2026-05-12 11:42:48 +08:00
ylytdeng
15cfdcd4cc fix: 改名/改备注/改群名时联系人缓存不刷新(issue #67)
之前 commit e86e00d 的修复只覆盖「新增联系人不在缓存」的场景:

    if username not in self.contact_names:
        refresh()

改名/改备注/改群名时 username 一直在缓存里,永远跳过刷新,
导致显示老名字。

改成检测 contact.db mtime 变化触发全量 reload,受 30 秒 cooldown
节流(避免微信高频写 contact.db 时 CPU 抖动)。三种变更场景统一覆盖:

- 新增联系人(原 #46 / e86e00d 场景)
- 修改备注名(issue #67)
- 修改群名

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 20:54:18 +08:00
Belugary
9764385617 fix: decrypt_db SKIP 与失败分开计数, 不把无密钥误报为失败 (#72)
decrypt_db 在遍历 .db 时, 遇到没匹配 key 的 db (e.g. migrate/
unspportmsg.db 这种迁移残留 / 微信内部不加密的库) print "SKIP: xxx
(无密钥)" 后会 failed += 1, summary 里就把这类合理跳过的 db 报成
"失败"。用户每次跑完都得 grep 一下确认那 N 个失败到底是真问题还是
SKIP 噪音。

参照 pytest (passed/failed/skipped) / rsync 的标准做法, SKIP 单独
计数, 不进 failed:

- 加 skipped 计数器
- SKIP 分支走 skipped += 1 (其余 HMAC / SQLite 校验失败仍记 failed)
- summary 多显示一栏 "K 跳过(无密钥)"

只动这 3 处; main() 末尾本来就没基于 failed 设 exit code, 不影响
退出码语义。

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 22:48:05 +08:00
Belugary
acd44376ba fix: find_image_key 三个 fallback 入口路径展开 + 方案2 hint (#71)
* fix: find_image_key 三个 fallback CLI 入口对齐 config.load_config 路径展开

PR #63 给 config.load_config() 加了 expanduser + expandvars, 但 find_image_key.py
/ find_image_key_macos.py / find_image_key_monitor.py 三个 CLI 入口的 main()
为了让单测注入隔离 config (find_image_key_macos.py docstring 明文写着), 走 raw
json.load(config_path), 绕开了 load_config 那层路径展开.

用户 config.json 里写 "db_dir": "~/Documents/..." 或 "$HOME/Documents/..."
时, 下游拼出的 attach_dir 仍带 ~ / $HOME 字面字符, glob *_t.dat 扫不到 →
find_image_key.py / monitor.py 报 "No V2 .dat files found", macOS 文件
dispatcher 还会误报"请先在微信中查看 1-2 张图片让微信生成 V2 .dat 文件",
但磁盘上 attach 已经塞满了 .dat.

三个文件各加 1 行 expanduser(expandvars(...)), 与 PR #63 / config.py:213
对齐, 不动 main() 既有的 raw json.load(为保留单测注入口子).

* fix(find_image_key_macos): 方案2 V2 _t.dat 样本不足时补一行下一步指引

dispatcher 层 (找不到 V2 模板分支) 已有"请先在微信中查看 1-2 张图片让微信
生成 V2 .dat 文件"指引, 但 _find_via_bruteforce 子路径在 V2 _t.dat 样本 < 3
时只 print "样本不足 (需 >= 3 个), 无法投票反推 xor_key", 用户不知该做什么.
加一行等价 hint, 与 dispatcher 层 UX 风格保持一致.
2026-05-05 22:47:51 +08:00
Belugary
ec921dd897 fix: monitor_web 用 webbrowser.open 替代 cmd.exe 实现跨平台开浏览器 (#70)
之前 main() 启动 HTTP server 后用 `os.system('cmd.exe /c start <url>')`
自动开浏览器, 这条命令在非 Windows 平台 cmd.exe 不存在, os.system 返回
非零退出码 (不抛异常, 外层 except 抓不到), 调用静默失败 → 自动开浏览器
功能在 Linux / macOS 完全失效; 同时 shell 会把 `cmd.exe: command not found`
写到终端 stderr 干扰用户.

改用 Python 标准库 webbrowser.open(), 跨平台自动选默认浏览器, 无新增依赖.
2026-05-05 22:47:39 +08:00
H3CoF6
e8de1249a4 feat: 离线计算图片密钥 (#69)
* feat: 离线计算图片密钥

* fix(find_all_keys): address review feedback on #69

Apply 5 fixes per @ylytdeng's review:

- find_xor_key: return None when last-byte ^ 0xD9 doesn't match the
  first-byte-derived xor_key (was returning xor_key in both branches,
  so the validation was a no-op)
- multiprocessing cleanup: split single-line terminate, add
  p.join(timeout=1) loop to avoid orphan workers
- replace 3 bare `except:` with `except Exception:` so KeyboardInterrupt
  can break the brute-force loop
- add actionable hint ("请先在微信中查看 2-3 张图片") when xor_key or
  ciphertext can't be derived from attach_dir
- drop try/except ImportError fallback on `from Crypto.Cipher import AES`
  (and the now-dead `if not AES` guards); pycryptodome is already a hard
  dependency elsewhere in the project

Original algorithm and multiprocessing implementation by @H3CoF6 in #69.
Review by @ylytdeng: https://github.com/ylytdeng/wechat-decrypt/pull/69

Co-authored-by: H3CoF6 <190114211+H3CoF6@users.noreply.github.com>

---------

Co-authored-by: Belugary <53219544+Belugary@users.noreply.github.com>
Co-authored-by: H3CoF6 <190114211+H3CoF6@users.noreply.github.com>
2026-05-05 22:46:48 +08:00
jiangbowen
4be1ac4713 feat: 解析合并转发的聊天记录消息(appmsg type=19)+ 文件本地路径查找工具 (#65)
* feat: 解析合并转发的聊天记录消息(appmsg type=19)+ 新增文件路径查找工具

mcp_server.py:
- _format_app_message_text 增加 app_type=19 分支,解析 <recorditem> 内嵌
  XML,把"[链接/文件] xxx的聊天记录"展开成多行 datalist 内容(含发送者/
  时间/数据类型)。覆盖 datatype 1/2/3/4/5/6/7/8/17/19/22/23/29/36/37
  共 14 种类型;超过 50 条自动截断;空 datalist fallback 到"(待加载)"
- 新增 decode_file_message 工具:从 type=49+sub=6 消息找本地副本路径
  (~/Library/.../msg/file/{YYYY-MM}/原文件名.{ext}),返回精确路径
  + size 二次确认,处理同名 (1)(2) 后缀
- 新增 decode_record_item 工具:从 type=49+sub=19 合并记录的第 N 个
  dataitem 找本地副本(msg/attach/{table_hash}/*/Rec/*/F/{idx}/{name}),
  未下载时给精确"在 wechat 点击哪一项"指引

回归测试:在数千条真实合并转发消息上 ~83% 完美展开 datalist 内容,
剩余的 content 缺失/消息被撤回情况下行为与改前一致(fallback 到原 [链接/文件])。

* fix: hoist subdir_map in decode_record_item to avoid UnboundLocalError

When the chat's attach directory does not exist (no merged-record
attachment ever downloaded for that chat), the if-block defining
`subdir_map` was skipped, so the not-found branch's reference to
`subdir_map.get(datatype, '?')` raised UnboundLocalError instead of
returning the intended guidance message.

Hoist the dict definition above the if-block so both branches can
safely reference it.

Caught by Codex review on PR #65.

* fix: address Codex P2 — large recorditem XML + glob escape

Two issues caught by Codex review on PR #65:

P2-1: _parse_xml_root rejects payloads >20KB, which silently dropped
~330 large merged-record cards (max observed 418KB with 99 dataitems)
back to "[链接/文件]" fallback. Add a dedicated _parse_record_xml with
a 500KB limit for embedded recorditem XML; routes both call sites in
_format_record_message_text and decode_record_item to it. Boosts
overall parse coverage from 83% to ~87% on real-world data.

P2-2: decode_record_item passed datatitle directly to glob, so file
names containing [ ] * ? would be treated as glob patterns rather
than literals — leading to wrong candidates or missed real files. Wrap
datatitle with glob.escape() before the exact-match query.

Full unit-test suite (35 tests) still passes.

* perf+style: speed up decode_file_message + minor consistency fixes

Self-review findings on top of the Codex P1+P2 fixes:

- perf: decode_file_message previously os.walk-ed `msg/file/` and
  `msg/attach/` from scratch on every call, scanning ~185k files /
  17GB on a real-world install (~6.3s per call). Now first reads
  `create_time` from the message and globs only the matching
  `msg/file/{YYYY-MM}/` (plus ±1 month for cross-month edge cases),
  with the original walk preserved as a fallback. Measured speed-up
  ~10x on first call, ~750x on warm cache.
- decode_record_item: extend `type_label` to cover datatype 23
  (视频号直播) and 36 (小程序/H5) so the not-found message matches
  the labels emitted by _format_record_dataitem instead of falling
  back to a raw `datatype=23` string.
- decode_record_item: replace unused `sub_type_packed` with `_` to
  silence the "name assigned but unused" smell.
- _parse_record_xml: comment now states the empirically observed
  ~418KB upper bound (was "~50KB"), making the 500KB ceiling
  obviously sufficient.

All 35 existing tests still pass.

* fix: address Codex round-3 P2 — multi-shard lookup + size-validate month scan

Two more issues caught by Codex review on PR #65 that I missed during
self-review:

P2-3 (multi-shard local_id lookup): both `decode_file_message` and
`decode_record_item` were resolving a single message-table via the
singular `_find_msg_table_for_user`, but a chat's messages can span
multiple message_N.db shards (search_messages and history-iteration
already use `_find_msg_tables_for_user`). When the requested local_id
lived in a different shard the tools incorrectly returned "找不到
local_id" or — if IDs collide across shards — picked the wrong row.
Now both tools iterate all shards and stop at the first hit; the
not-found message reports how many shards were scanned.

P2-4 (size validation in month-scan fast path): the perf-fix in the
prior commit collected `msg/file/{YYYY-MM}/` matches without verifying
size, so when a same-named-but-different-size copy existed in the
target month the candidate list was non-empty, the walk-fallback was
skipped, and the later `size_match` filter could end up empty —
returning a wrong-size file. Now the month-scan filters by `totallen`
upfront when known, so unmatched candidates don't poison the fallback.
Same one-shot size validation applied to `decode_record_item`'s
exact-name glob branch for symmetry.

These were both "I should have caught" issues — Codex did the
cross-tool consistency check (singular vs plural shard helper) that I
skipped, and stress-tested an edge case (month-scan finds same-name
wrong-size) that I didn't think through when writing the perf fix.

35/35 existing tests still pass. Real-data smoke: decode_file_message
0.96s end-to-end (multi-shard scan + size validation),
decode_record_item 0.03s.

* refactor: reuse _parse_message_content helper for group prefix stripping

Self-review found that decode_file_message and decode_record_item
hand-rolled their own heuristic for stripping group-chat sender
prefixes ("wxid_xxx:\n<xml...>") via a string-startswith check, while
the rest of the project already uses the canonical
`_parse_message_content(content, local_type, is_group)` helper for
exactly this purpose.

Wired both tools to that helper, deriving is_group from the username
suffix `@chatroom`. Existing edge cases (private chat content with
literal "<...>", group content with "wxid_xxx:\n", etc.) still pass.

35/35 tests still pass; 6/6 edge-case smokes still pass.

* fix: address Codex adversarial-review high+medium findings

Adversarial review caught four issues that the surface-level passes
missed. All four are now fixed end-to-end (validated against real
data, not just helper-level smoke):

[high] Large recorditem outer XML actually parsed:
  Previous P2 fix added _parse_record_xml(500KB) for the inner CDATA
  but the outer appmsg was still gated by _parse_xml_root(20KB), so
  any merged-record card whose outer XML exceeded 20KB silently fell
  back to "[链接/文件]" and never reached the inner expansion. Now
  _parse_xml_root accepts a max_len kwarg, _format_app_message_text
  retries with _RECORD_XML_PARSE_MAX_LEN when the default cap rejects
  the outer XML, and _format_record_message_text passes the wider cap
  for inner parses. Real-data check: a 34KB outer / 67-dataitem card
  now expands fully via the get_chat_history → _format_message_text
  → _format_app_message_text → _format_record_message_text chain.

[high] Multi-shard local_id ambiguity:
  decode_file_message and decode_record_item previously broke on the
  first shard match. Empirically confirmed local_id 171 in the test
  account exists in TWO shards as TWO different messages (one type=1
  text, one type=6 file at different create_times). Now both tools
  scan all shards, fail with an explicit ambiguity error when more
  than one row matches, and accept an optional create_time arg from
  the user to disambiguate uniquely.

[medium] History output now exposes (local_id, ts) for file and
record cards, and record dataitem rows are prefixed with their
0-based [item_index]. Without these, callers had no way to feed
decode_file_message / decode_record_item a stable identifier.

[medium] decode_file_message now requires appmsg type=6 and an
appattach node, refusing to search the local cache by title/size for
unrelated app messages (links, miniapps, record cards) that happen
to share a title with a real file.

35/35 existing tests still pass. Real-data smokes:
- 34KB outer XML / 67 dataitems expanded end-to-end
- multi-shard ambiguity correctly raised + resolved by ts kwarg
- history output now contains "(local_id=N, ts=T)" suffixes and
  "[N]" dataitem prefixes

* fix: address Codex adversarial round-2 high findings

Round-2 adversarial review caught two issues my self-review missed
again. Both are now fixed end-to-end:

[high] decode_record_item also rejects large outer XML (mcp_server.py:2124-2126)
  Round-1 high #1 was fixed by adding a wider-limit retry inside
  _format_app_message_text, but decode_record_item itself still
  parsed the outer appmsg with `_parse_xml_root(xml_text)` at the
  default 20KB cap. Same root cause: I patched one caller, missed
  the other — exactly the kind of cross-tool inconsistency that
  cost two rounds already.

  Extracted a shared `_parse_app_message_outer(content)` helper that
  encapsulates the "try default cap, fall back to wider limit when
  default rejects" pattern. Now used by all three call sites:
  _format_app_message_text, decode_file_message, decode_record_item.
  Real-data check: a 34KB outer (67 dataitems) parses through every
  caller path, not just history rendering.

[high] Record attachment lookup silently picks wrong cached file
  Previous lookup had three fallback tiers (filename+size → size only
  → cross-subdir size only) and on multiple matches sorted by mtime
  and took newest. Two failure modes:
  1. Different forwarded-record cards in the same chat may produce
     paths with identical (filename, item_index, datasize), and the
     mtime tiebreak lets the tool return another record's file while
     reporting "找到本地文件: ".
  2. Cross-subdir size-only fallback can match files belonging to
     unrelated dataitem types entirely.

  Now fail-closed:
  - Strict filename + size match only when datatitle is known.
  - Size-only fallback now ONLY when datatitle is missing
    (e.g. datatype=2 thumbnails) AND scoped to the same sub-dir +
    item_index — no more cross-Rec leakage.
  - Removed the cross-subdir terminal fallback entirely.
  - Multiple candidates after strict matching → ambiguity error
    listing all candidates with mtime, no silent pick.

35/35 existing tests still pass. Real-data smokes:
- 大 outer 34KB 卡片 _parse_app_message_outer 解析成功
- decode_record_item(local_id, ts) 正确命中 Lec 4 PDF
- 多分片冲突 + 不传 ts → 报歧义错误并提示加 create_time
- 未下载 dataitem → 精确指引"在 wechat 点第 N 项"

* fix: address Codex round-3 adversarial high+medium findings

Round-3 caught two more cross-tool inconsistency issues, both in the
same family I keep missing (修一处忘另一处):

[high] decode_file_message also needs to fail-closed on ambiguity
  Round-2 high #2 forced decode_record_item to fail-closed when
  multiple cached candidates remain after strict matching, but I
  forgot to apply the same change to decode_file_message — it still
  silently sorted by mtime and returned candidates[0]. Same root
  cause as round-1 high #1: Codex catches what I miss when the same
  pattern needs fixing in two places.

  Now decode_file_message: strict size filter when totallen is known,
  and ambiguity error (not mtime sort) when more than one candidate
  remains. Behavioral change: previously returned 逻辑审计论文(1).pdf
  on a real test case; now reports both candidates and asks user to
  disambiguate. UX regression but safety-correct.

[medium] decode_record_item rejects non-downloadable datatypes
  upfront. Previously, dataitems with unknown datatype fell through
  to a wildcard `sub='*'` glob over all attach subdirs (F/Img/V/A),
  which could match unrelated files for links/locations/cards/
  miniapps/nested-record dataitems that have only metadata, no
  binary payload. Now reject non-{2,4,5,8} datatypes with a clear
  "no local binary, look at history output instead" message before
  any filesystem lookup.

35/35 existing tests still pass.

* fix: address Codex adversarial round-4 high findings

Round-4 found three security/correctness issues. All addressed:

[high] Path traversal via untrusted XML titles
  title (decode_file_message) and datatitle (decode_record_item) come
  from message XML — attacker-controlled in the "malicious chat
  partner" threat model. glob.escape does NOT strip path separators
  or normalize absolute paths, so e.g. title="/etc/passwd" makes
  os.path.join(month_dir, "/etc/passwd") == "/etc/passwd" (POSIX
  rule: join drops left when right is absolute), and glob then walks
  outside msg/file. If size also matches, the tool returns an
  arbitrary system path as a "found wechat file".

  Added _safe_basename(name) helper with strict-reject semantics
  (per Codex: reject, don't normalize) — any name containing path
  separators, .. components, NUL, or absolute-path prefix is
  rejected outright. Both decoders sanitize their XML-derived names
  before any filesystem operation. Added _path_under_root realpath
  check after candidate selection as a second-line defense against
  symlink escapes.

[high] decode_file_message and decode_record_item can return cached
  files belonging to a DIFFERENT message even when len(candidates)==1
  Both tools rely on (filename + size + optional item_index)
  heuristic matching against the cache — they have no way to derive
  a record-bound or message-bound path from wechat metadata, so
  exactly one matching cached file from an unrelated message looks
  identical to a correct hit. This is a design limitation: wechat
  does not expose record_hash or attach-uuid in the message XML in
  any form derivable from outside the client.

  Acknowledged in tool output with an explicit ⚠️ "this path is
  heuristic, please verify mtime/context/content" warning attached
  to every "found local file" response. The match itself is still
  the same heuristic — closing this fully would require either
  removing the tools or reverse-engineering wechat's path hashing.
  Documented the limitation in the warning so callers can manually
  verify before trusting downstream Read/PDF results.

35/35 tests still pass; 12/12 path-sanitize edge cases pass.

* fix: address Codex round-5 adversarial findings + md5-strong binding

Codex round 5 caught two more high issues plus a perf/correctness
concern. All real and addressed:

[high] decode_file_message scanned msg/attach in fallback, picking
  up unrelated forwarded-record cached files. Outer files only ever
  live in msg/file/{YYYY-MM}/; restricted the slow-path walk to that
  subtree only. msg/attach holds merged-card and image attachments
  whose presence here is a different message's payload, not ours.

[high] **真正根治** record/file 路径绑定问题:用 md5 强校验
  Both decode_file_message (`<md5>` in appmsg) and decode_record_item
  (`<fullmd5>` in dataitem) now extract the WeChat-supplied md5 and
  hash candidate files locally to compare. If md5 doesn't match, the
  tool fails closed with an explicit md5-mismatch error rather than
  returning a path. The candidate that *does* match is uniquely
  bound to the selected message — md5 collisions of distinct files
  are cryptographically negligible. This fixes the heuristic-only
  warning paths from rounds 3-4 with cryptographic evidence rather
  than just user-facing notes.

  As a side benefit, md5 dedup also lets decode_file_message return
  a result when WeChat creates "(1)/(2)" copies of the same file:
  same-md5 candidates are真同一文件副本 (user re-sent or auto-rename),
  any one of them is correct.

  When XML doesn't ship md5 (rare but possible), behavior reverts to
  the previous fail-closed-on-multiple-candidates path with an
  explicit "no md5 available, treating as heuristic" note.

[medium] _parse_app_message_outer was retrying every appmsg under
  the 500K cap on initial 20K rejection, which made history rendering
  O(content_size) on big non-record appmsgs. Added a substring
  `<type>19</type>` short-circuit so only true type=19 records pay
  the wider parser cost. Verified non-type=19 big XML now returns
  None in <0.01ms instead of doing a 500K parse.

35/35 existing tests still pass. Real-data smokes:
- decode_record_item 142,1 → " md5 校验通过,路径与 dataitem 唯一绑定"
- decode_file_message 171 (with same-name (1).pdf copy in cache) →
  md5 dedup recognizes both as same content, returns one with
  " md5 校验通过"
- non-type=19 big appmsg parses in <1ms (substring short-circuit)

* fix: address Codex round-6 adversarial findings — strict md5 binding + chunked hash

[high] decode_file_message / decode_record_item now fail-closed when
  the message XML has no md5/fullmd5 field — instead of returning a
  heuristic single-candidate path with a warning. The previous
  warning-only approach (rounds 4-5) didn't actually stop downstream
  Read/PDF callers from using the wrong path. Now: no md5 = no path
  returned, period. The error message lists the heuristic candidates
  with mtime so the user can manually pick if absolutely needed,
  but the tool itself does not commit to any of them.

  Behavioral consequence: messages where wechat omits md5 (rare but
  possible — e.g. some image/voice dataitems lack fullmd5) become
  not-resolvable via these tools. Acceptable safety/utility tradeoff
  per Codex's recommendation.

[medium] md5 verification was reading the entire candidate file into
  memory via `_hashlib.md5(_f.read()).hexdigest()`. For 100MB+
  attachments (videos in merged-record cards, large PDFs) this could
  spike RSS or stall the MCP process. Replaced with a streaming
  helper `_md5_file_chunked` (64KB chunks) plus a 500MB hard cap that
  returns an explicit error rather than attempting verification on
  oversized files.

35/35 existing tests still pass. Real-data smokes:
- decode_file_message 171 (with md5) → " md5 校验通过"
- decode_record_item 142,1 (with fullmd5) → " md5 校验通过"
- _md5_file_chunked size cap 1KB rejection works correctly

* fix: round-7 + revert round-6 over-strict — match real threat model

Two real bugs from Codex round-7 plus a partial revert of round-6
over-strictness that doesn't match this tool's actual threat model.

[high] Group type=19 with 'sender:<?xml...' (no newline) prefix not
  stripped (Codex round-7 high #1)
  _parse_message_content only split on ':\n', missing real-world
  group rows where wechat writes 'wxid_xxx:<?xml ...' or
  'wxid_xxx:<msg ...' inline. _format_app_message_text and
  decode_record_item both received the prefixed content, parsed it
  as raw XML, and failed. Now also strips on regex match against
  '<?xml|<msg|<msglist|<voipmsg|<sysmsg' immediately after a sender
  token. Verified with 5 prefix shape variants; legacy ':\n' still
  works.

[medium] Record images use flat 'Img/0_t' filenames, not 'Img/0/*'
  (Codex round-7 medium #2)
  decode_record_item's datatype=2 (image) branch globbed for
  '*/Rec/*/Img/{idx}/*' but real wechat caches store record images
  as flat files: '*/Rec/<id>/Img/0_t', '*/Rec/<id>/Img/0', or
  '*/Rec/<id>/Img/0.{ext}'. Added flat-pattern matching for
  datatype=2 with the four observed filename shapes. File/voice/
  video classes still use the F|A|V/{idx}/{filename} shape they
  always did.

[revert] Round-6's "no md5 → fail-closed" is too strict for this
  tool's actual usage
  This MCP server is invoked locally by the user, paths surface
  only in the local Claude conversation, and contacts are not
  hostile. Codex round-6's hard fail-closed-on-missing-md5 broke
  ergonomics for real wechat messages that lack md5 (some image
  and voice dataitems) without a corresponding security gain in
  this scenario. Reverted to round-5 behavior:
    - md5 present  → cryptographic verification, mismatch fails
    - md5 absent   → heuristic + ⚠️ warning, multiple-candidate
                     ambiguity still fails closed.
  Kept all other round-6 hardening: streaming chunked md5, 500MB
  cap, _safe_basename strict reject, _path_under_root realpath
  check, multi-shard ambiguity, substring short-circuit for
  non-type=19 big XML.

35/35 existing tests still pass; 5/5 group-prefix variants pass;
real-data smokes for both decoders still hit md5-verified paths.

* fix: round-8 — defer ambiguity until after md5 dedup + tighten file fallback

Two more findings, both real:

[high] decode_file_message no-md5 fallback was using `stem in f`
  substring matching — `stem='论文'` would happily accept
  `某老师论文.pdf`. Tightened to: exact match OR strict `(N)` copy
  variant (`xxx(1).pdf`, `xxx (1).pdf`) per wechat's auto-rename
  convention. 7/7 unit cases verify legitimate accept and false-
  positive reject behavior.

[medium/P1 from GitHub Codex] decode_record_item had a stale early
  `len(candidates) > 1 → ambiguity` check left over from round-7
  refactor — it ran BEFORE the fullmd5 filter, making the md5
  disambiguation block unreachable for the exact case where md5
  could safely pick the right file. Removed the early check; md5
  filter now runs first (and the post-md5 ambiguity check at line
  ~2467 still fails closed when md5 is missing AND multi-candidate).

35/35 tests pass. Real-data smokes (decode_file_message and
decode_record_item with their corresponding md5/fullmd5) still hit
the  md5-verified path.

* test: add 29 helper-level regression tests for record-decoder helpers

Locks in the bugs fixed across PR #65's many review rounds so they
don't silently regress:

- _safe_basename (7 cases): strict reject of absolute paths,
  parent-dir components, path separators, NUL — round-4 high #1.
- _md5_file_chunked (3 cases): streaming hash equals stdlib hashlib,
  size cap rejects oversized files, missing file → error — round-6.
- _parse_message_content (5 cases): both legacy `:\n` and round-7
  `:<?xml`/`:<msg` group-prefix shapes strip correctly; private
  chat does not strip; bytes content returns the binary marker.
- _parse_app_message_outer (3 cases): small XML uses default cap,
  oversized non-type=19 short-circuits (no 500K parse), oversized
  type=19 retries successfully — round-5 medium #3 + round-2 P2-1.
- _format_record_dataitem (7 cases): text / file / image / 视频号 /
  音乐 fall-through render correctly; unknown datatype falls back
  to datadesc or [未知类型 N].
- _format_record_message_text (4 cases): >20KB outer XML expands
  via _format_app_message_text end-to-end (regression for the
  "P2-1 was a fake fix because I tested helper in isolation" miss);
  empty datalist shows 待加载; chatroom marker appended; overflow
  produces "…还有 N 条未显示" line.

The two MCP-tool wrappers (decode_file_message / decode_record_item)
lean on module globals + 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 tree would dwarf the actual
logic under test.

64/64 total tests pass (35 existing + 29 new).

* refactor: simplify per /simplify code review (no behavior change)

Three review agents (reuse / quality / efficiency) flagged the
following high-confidence cleanups. All applied; all 64 tests still
pass; real-data smokes still hit md5-verified paths.

[quality] Remove PR-history references in comments
  CLAUDE.md is explicit about this: comments should explain
  non-obvious WHY, not narrate which Codex round caught what.
  Cleared "round-2 high #2", "Codex round-3 medium #1", "round-5",
  "round-6 强制", "round-7 实测", "round-8 high #1" from helper
  docstrings and inline comments. Kept the substantive WHY (e.g.
  "Reject 而不是 normalize because intent is suspicious").

[reuse + quality] Module-level datatype constants
  Three places maintained their own copy of the datatype → label /
  subdir mapping (_format_record_dataitem if-cascade, decode_record_
  item type_label dict, subdir_map literal). Extracted
  _RECORD_DATATYPE_LABEL and _RECORD_BINARY_SUBDIR to module top.
  Single source of truth.

[quality] Hoist local imports to module top
  Removed 7 inline `import glob as glob_mod` / `from datetime import
  datetime as _dt` / `from datetime import datetime as _dt, timedelta
  as _td` / `import hashlib as _hashlib` calls inside hot paths and
  helpers. Aliases collapsed to plain names (datetime, timedelta,
  glob, hashlib).

[efficiency] xpath: drop `.//` recursive descent for known-direct children
  _format_record_dataitem was using `.//appbranditem/sourcedisplayname`
  and `.//finderFeed/desc` even though both are direct children of
  the dataitem. Changed to direct-child paths — meaningful for big
  cards (50 items × subtree-walk per render).

[efficiency] md5 verification short-circuits on first match
  Multiple candidates sharing the same md5 are wechat re-named copies
  of the same file (e.g. `xxx (1).pdf`); any one is correct. Added
  `break` after the first md5 match to skip hashing remaining
  candidates (which can each be 100+ MB).

[quality] Compress _format_record_dataitem if-cascade
  Datatypes that just emit `[label]` (2/3/4/5/7/23/37) and the link/
  H5 pair (6/36) collapsed into membership checks against
  _RECORD_DATATYPE_LABEL.

64/64 tests still pass.

---------

Co-authored-by: jiangbowen <robin@jiangbowendeMacBook-Air.local>
2026-05-05 17:16:48 +08:00
Belugary
1841be6419 fix: config.json 路径字段支持 ~ / 环境变量展开 (#63)
## 问题

`load_config()` 目前对路径只做"绝对或项目根相对"的二分。如果用户在
config.json 里写 `~/Documents/wechat_decrypted` 或 `$HOME/wechat`,会被当成
项目相对,join 后变成 `<repo>/~/Documents/wechat_decrypted`(字面 `~` 目录),
静默错路径,无报错。

复现:
```json
{ "decrypted_dir": "~/Documents/wechat_decrypted" }
```
当前行为:解密文件落到 `<repo>/~/Documents/wechat_decrypted/`。

## 修改

`config.py` 中 `load_config()` 末段:对 `db_dir` / `keys_file` /
`decrypted_dir` / `decoded_image_dir` 四个字段先 `expanduser` + `expandvars`,
再判 `isabs`。+9 / -3 行,纯 stdlib。

顺手把 `if key in cfg` 改成 `if cfg.get(key)`,避免 `null` / `""` 触发
`TypeError`(原本就是边界 bug,这次顺手收掉)。

## 兼容性

- 已有绝对路径(`D:\\xwechat_files\\...` / `/Users/x/...`):不变
- 已有项目相对(`"all_keys.json"`):不变
- 新增支持:`~/...` / `$HOME/...` / `%USERPROFILE%\\...`
- 跨 Windows / Linux / macOS 一致(`expanduser` / `expandvars` 在三平台
  对无 `~` / 无 `$` / 无 `%` 的路径都是 no-op)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 17:07:01 +08:00
Belugary
c29e8dd868 fix: ImageResolver 支持微信 4.0+ V2 加密图片格式 (#61)
ImageResolver.decode_image 之前只调 xor_decrypt_file(老格式 XOR-only
路径),微信 4.0+(2025-08+)已经改用 V2 AES-128-ECB + XOR 混合加密,
导致 mcp_server.py 注册的 decode_image MCP 工具对 V2 .dat 文件返回的
"解密"内容是错的——Claude AI 通过 MCP 调用看不到 V2 时代的图片。

monitor_web.py 早已正确处理 V2(line 41-42, 791-795:从 _cfg 读
image_aes_key / image_xor_key 后调 decrypt_dat_file 自动 magic 分发),
本次把 MCP 路径补齐,行为与 monitor_web.py 对齐。

改动:
- ImageResolver.__init__ 增加 aes_key=None, xor_key=0x88 关键字参数
  (默认值保持向后兼容,老调用方无需改动)
- ImageResolver.decode_image 把 xor_decrypt_file 换成 decrypt_dat_file,
  按 magic 自动分发 V2 / V1 / 老 XOR
- V2 文件 + 缺 aes_key 时早期返回结构化错误信息,避免在 v2_decrypt_file
  内静默失败成笼统的"解密失败"
- v2_decrypt_file 入口接受 xor_key 字符串形式(int(_, 0) 解析),
  与 aes_key 已有的 str→bytes 处理对称,允许 config.json 写 "0x88"
- mcp_server.py 实例化时从 _cfg 读 image_aes_key / image_xor_key 注入

兼容性:
- ImageResolver 老调用方(不传 keys)继续走老 XOR 路径,零 breaking
- V1 magic(\x07\x08V1)不会被 is_v2_format 拦截,走 decrypt_dat_file
  内置固定 key,所以 aes_key=None 也能解 V1 文件
- 整 repo 只有 mcp_server.py 一处生产调用 ImageResolver(...),已 grep 确认

测试覆盖(11 个新测试,tests/test_decode_image_v2.py):
- v2_decrypt_file 合成数据 round-trip 字节级相等
- decrypt_dat_file 按 magic 自动分发 V2 / V1 / 老 XOR 三条路径
- aes_key 接受 str(来自 config.json)和 bytes 两种形式
- xor_key 接受 str(如 "0x88")和 int 两种形式
- V2 wxgf 裸流返回 fmt='hevc'(HEVC→JPEG 转换是 monitor_web 职责,
  不在 ImageResolver 内做,保留 .hevc 输出)
- ImageResolver 端到端:from local_id to decrypted file
- ImageResolver(aes_key=None) + V1 文件走固定 key 路径
- ImageResolver(aes_key=None) + V2 文件返回 success=False + 友好错误
- ImageResolver 默认参数 + 老 XOR .dat 保持向后兼容

测试 46 个全部通过(11 新 + 35 旧)。

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 17:05:53 +08:00
Belugary
49356e1692 feat: macOS 图片 AES key 从磁盘 kvcomm 缓存派生(解决 #23) (#60)
* feat: macOS 图片 AES key 从磁盘 kvcomm 缓存派生(issue #23)

macOS 用户长期无法用 C 版 find_image_key_macos 从微信进程内存提取
V2 图片密钥(issue #23 报告 197K 候选全部失败)。新增
find_image_key_macos.py 走完全不同的路径:从磁盘 kvcomm 缓存
文件名派生密钥,无需扫描内存、无需 root、无需重签名。

派生算法
--------
- 扫 ~/.../app_data/net/kvcomm/key_<code>_*.statistic 文件名
- 对每个 (code, wxid) 候选:
    xor_key = code & 0xFF
    aes_key = MD5(str(code) + cleaned_wxid).hex()[:16]   # ASCII 字符串
- 用 V2 _t.dat 文件 [0xF:0x1F] 16 字节做 AES-128-ECB 模板验证:
  解出来必须是图像 magic(JPEG / PNG / GIF / WebP / wxgf)
- 为防短 magic 偶然命中,要求多个不同模板都通过验证才算成功
- 命中后写回 config.json 的 image_aes_key / image_xor_key,
  monitor_web.py 自动加载

致谢
----
派生算法源自 @hicccc77 在 issue #23 的评论;参考实现见其 WeFlow
项目 (CC BY-NC-SA 4.0)。本模块是独立的 Python clean-room 实现,
未复制其 TypeScript 源码;函数边界与变量命名沿用算法的自然结构
(regex 模式 / MD5 调用顺序 / magic 字节表等不可避免地相同)。

健壮性细节
----------
- 多候选 kvcomm 路径:枚举 5 个不同的 macOS 微信版本路径布局
- 多模板交叉验证:默认收集 3 个不同密文,全部通过才算命中
- 已有 image_aes_key 仍有效时短路返回,不重写 config
- 原子写 config.json:tmp + os.replace + finally 清理 .tmp
- 多 wxid 候选:同时试 raw 和归一化后的 wxid(A_Hare_626a → A_Hare)
- print(flush=True) 逐次显式(与 find_image_key.py 风格一致)

测试
----
新增 tests/test_find_image_key_macos.py,53 个测试覆盖:
派生算法 / wxid 归一化 / kvcomm 路径推算(含多候选)/ 模板收集
(去重 / 子目录 / max_files 边界)/ AES 验证(5 种 magic / 短输入
/ 空 key)/ 多模板交叉验证 / 端到端集成(命中 / 各种失败分支)/
原子写 / main 短路(已有有效 key 不重写 / 已有错 key 落到派生)。
全部通过:python -m unittest discover tests → 88/88。

兼容性
------
- 无新增依赖(pycryptodome 已在 requirements.txt)
- 不改任何现有 Python 文件,零回归风险
- 现有 Windows / Linux 路径 (find_image_key.py / find_image_key_monitor.py) 不受影响

* feat: macOS 图片 AES key 加方案2 fallback (issue #68 思路)

PR #60 的方案1 (kvcomm 缓存派生) 在 kvcomm 缺失 / 多账号歧义 / 首次
启动等场景下会失败。@H3CoF6 在 issue #68 提出关键洞察:

  wxid 目录后 4 位 hex == md5(str(uin))[:4]

意味着不需要 kvcomm,可以从 wxid 目录名 + 任意 V2 .dat 反推 uin。
本 commit 在保留 PR #60 方案1 不变的前提下,加方案2 作为 dispatcher
fallback。

方案2 算法
----------
1. 从 db_dir 提 wxid 后 4 位 hex 作为 md5 前缀目标
2. 扫多个 V2 .dat 末字节投票反推 xor_key (假设 JPG EOI 0xD9,
   默认至少 3 个样本投票)
3. 枚举 0~2^32 中 (uin & 0xff == xor_key) 的 2^24 个候选,
   md5(str(uin))[:4] 匹配 wxid 后缀 → 得 ~256 个 uin 候选
4. 对每个候选算 aes_key, 用 PR #60 的 verify_aes_key_against_all
   做 AES 模板交叉验证, 唯一定位 uin

实现
----
- find_image_key_macos 重构为 dispatcher: 先方案1 (kvcomm),
  失败 fallback 方案2 (候选搜索); 模板收集移到 dispatcher 共享
- 新增 helper: extract_wxid_parts, derive_xor_key_from_v2_dat,
  bruteforce_uin_candidates
- 模块顶部 docstring 加方案2 算法说明 + @H3CoF6 致谢
  (保留 PR #60 对 @hicccc77 的方案1 致谢)

clean-room 声明
---------------
方案2 按 issue #68 的算法描述独立实现,未引用 @H3CoF6 任何代码。
方案1 仍沿用 PR #60 实现 (其 clean-room 声明对 @hicccc77 / WeFlow
保持不变)。

健壮性细节
----------
- xor_key 反推默认 min_samples=3, 样本不足直接放弃方案2 (避免
  1-2 个样本时一旦撞到非 JPG 就 lock 错 xor_key)
- wxid 后缀正则收紧为 [0-9a-fA-F]{4} (md5 hex), 非 hex 后缀直接
  返回 None 而非误导用户跑空候选搜索
- 投票分歧时打印 warning, 但仍试取多数 (兼容 attach 含少量非 JPG)
- 删除重构后未用的 import glob; Counter 统一在模块顶部 import

测试
----
新增 17 个测试 (53 → 70), 全部 7.4s 内通过:
- ExtractWxidPartsTests (5)
- DeriveXorKeyFromV2DatTests (7, 含新增 below_min_samples 边界)
- BruteforceUinCandidatesTests (1, 真跑全空间金标准验证)
- FindViaBruteforceTests (3)
- DispatcherFallbackTests (1, mock 加速)

顺手修复 2 个 pre-existing 测试 fail
------------------------------------
test_account_with_4char_alnum_suffix_stripped 与
test_returns_raw_and_normalized_when_different 用 6-char 后缀
your_wxid_a1b2c3, 但 normalize_wxid 只去 4-char 后缀 (匹配真实
macOS 路径) → 测试期望与代码不一致, 长期 fail。统一改用 4-char
后缀让测试与 macOS 现实对齐。

兼容性
------
- API 不变: find_image_key_macos(db_dir) 签名 / 返回值不变
- 现有 53 个测试全部仍通过 (含 happy path / 各种返回 None 分支 /
  main 短路 / 原子写)
- 真实数据验证: 在本地 macOS 微信 4.x 上方案2 端到端跑通, 结果
  与方案1 完全一致

* fix: replace test fixture with synthetic uin/wxid (privacy hardening)

PR #60 测试 fixture 与 docstring 示例之前用了真实 uin (8 位十进制)
作为 golden value,并在 docstring 里把 wxid 后缀作为示例展示。虽然
单独的 uin/suffix 不直接 unlock 任何资产 (需要配合真实 wxid + 物理
访问加密文件),但行业最佳实践 (yt-dlp / openssl / Linux kernel test
fixture) 都明确要求用合成确定性值, 不绑定任何真实账号。

合成方案
--------
- uin: 12345678 (8 位, 一目了然 placeholder)
- suffix: md5("12345678")[:4] = "25d5" (派生, self-consistent)
- wxid_full 示例: your_wxid_25d5
- wxid_norm 示例: your_wxid
- aes_key_test_value: a0c093edddc98490 = md5("12345678your_wxid")[:16]
- xor_key: 0x4E (= 12345678 & 0xFF)

改动范围
--------
- tests/test_find_image_key_macos.py: 全部 fixture 改用合成值,
  bruteforce 测试的 xor 也对应更新 (0x7F → 0x4E)
- find_image_key_macos.py:260 docstring 示例: 真实 wxid 字符串
  替换为 placeholder
- 长 kvcomm 缓存文件名 fixture 同步合成 (避免暴露真实时间戳 / 内部 ID)

测试
----
70/70 仍通过 (7.1s), 合成 fixture self-consistent。

非范围 (历史 commit b37d440 仍含真 uin fixture)
-----------------------------------------------
按行业惯例不 force push 重写 PR history (代价: PR 显得有问题; 收益:
真 uin alone 不构成 unlock — 需配真 wxid + 物理设备)。本 commit 保证
未来 review 看到的是干净版本; 历史 commit 保留以维护 review 链完整性。

* feat: 方案2 多进程加速 (~60x speedup, 借鉴 PR #69)

吸收 @H3CoF6 在 PR #69 (https://github.com/ylytdeng/wechat-decrypt/pull/69)
的 3 个加速优化, 让方案2 fallback 从单核 ~7s 降到多核 ~0.1-1s 量级。

加速优化
--------
1. 多进程: cpu_count 个 worker 并行扫 0~2^32 候选 (multiprocessing)
2. 二进制 md5 比较: digest()[:2] 替代 hexdigest()[:4], 省 hex 转换开销
3. 内联 AES 验证 + 早停: worker 内 md5 命中 → 直接 AES cross-validate →
   推 queue → 主进程 terminate 其他 worker (任一进程命中即胜, 无两 pass)

与 PR #69 的差异
----------------
- 保留 PR #60 的多模板 AES 交叉验证 (PR #69 单模板; 本实现不退化防短
  magic 偶然命中的能力)
- 集成在 dispatcher 的 fallback 路径 (PR #60 双方案架构), 而非 main()
  自动跑
- 保留 bruteforce_uin_candidates 单进程版本作为算法金标准 (测试 +
  parallel 不可用时的 fallback)

实现细节
--------
- 模块顶层 _bruteforce_worker_chunk + _aes_template_match (multiprocessing
  pickle 要求 worker 必须是 module-level 函数)
- 60s timeout + daemon=True worker (主进程异常退出时 worker 不变僵尸)
- _bruteforce_with_aes_parallel 是新生产入口

性能
----
本地 macOS 实数据验证: 多核 (M2 16 workers) ~0.1s, 单核基线 ~7s = 60x
加速。合成 fixture 命中更早, 70 测试总时长 7.4s 不变 (单进程金标准
test_real_bruteforce_against_golden 仍单跑 ~7s)。

致谢
----
方案2 加速三连 (multiprocessing + 二进制 md5 + 早停 queue) 思路源自
@H3CoF6 在 PR #69 的实现 (find_all_keys.py)。本 commit 按其算法思路
独立实现 (worker 函数 / chunk 划分 / Queue 通信 / terminate 等技术
模式是 multiprocessing 的自然结构), 未引用其源码。

* test: clean dead bruteforce mocks + add direct parallel coverage

B refactor 让 _find_via_bruteforce 不再调 bruteforce_uin_candidates,
原 mock 变成空跑 dead code。同时 _bruteforce_with_aes_parallel 之前
没有针对性单测, 覆盖只来自集成路径。

清理
----
- FindViaBruteforceTests.test_full_flow_with_mocked_bruteforce →
  test_full_flow_finds_synthetic_uin (移除 dead mock + 改名反映真实行为)
- DispatcherFallbackTests.test_kvcomm_missing_falls_back_to_bruteforce
  移除 dead mock (HOME patch 仍保留, 强制方案1 失败走 fallback)

新增 BruteforceParallelTests (4 个测试)
--------------------------------------
- test_worker_finds_known_uin_in_chunk: 直调 worker, 验证算法核心
- test_worker_no_match_returns_silently: 区间不含命中 → queue 保持空
- test_worker_skips_when_aes_fails: md5 命中但 AES 验证失败不入队
  (防止短 magic / 单 gate 假阳)
- test_parallel_workers_1_finds_synthetic_uin: workers=1 验证 spawn +
  pickle + queue 跨进程通信链路

Worker 直调 (无 process spawn) 跑 ms 级。Workers=1 spawn 测试 ~1s。
全套 74 个测试 (此前 70 + 4 新) 跑 8.5s。

设计选择
--------
- 不 mock multiprocessing.Process / Queue (会变成测 mock 库自己, 不测算法)
- multiprocessing.Queue.put 通过 feeder thread 异步刷, get_nowait() 会 race;
  用 q.get(timeout=...) 给 feeder 充足时间
- 多进程 e2e 由 FindViaBruteforceTests / DispatcherFallbackTests 间接覆盖
  (cpu_count workers, 真实 fixture), 这里只测函数契约避免重复 spawn 开销
2026-05-05 17:04:11 +08:00
btc-z
66eddaff0e feat: transcribe_voice 新增 OpenAI Whisper API 后端 (#66)
默认 local,零行为变化。opt-in 双因素:transcription_backend=openai
且 openai_api_key 都齐才生效;任一缺失静默回退 local + stderr 一行警告。
首次进入云路径会 stderr 警告"语音将上传至 OpenAI 服务器"。

新增 config.json 字段:
- transcription_backend: "local" (默认) | "openai"
- local_whisper_model: "base" (替换 mcp_server.py 里硬编码 DEFAULT_WHISPER_MODEL)
- openai_api_key: "" (默认空;openai 包为 optional,按需 pip install)

关键技术选择:
- _transcribe(wav, backend) 单一 if/else 分发,不引入插件/工厂层
  (Rule of Three —— 只有一个云后端时不值得抽象)
- 文件 > 25MB 在 OpenAI() 实例化之前提前拒绝,避免无谓上传
- 错误分类清晰: 缺 key / 缺 openai 包 / 401 / 429 / APIError 各自的提示
- PR #58 缓存 schema 自然扩展: 条目加 backend 字段,命中需 backend+model_size 都匹配
- 旧条目缺 backend 字段视为 "local",向前兼容 PR #58 已落盘的所有数据
- transcribe_chat.py 批量 CLI 与 MCP 工具共享同一份配置,保持一致

新增 2 个测试 (tests/test_openai_backend.py),只覆盖回归风险最高的两条:
- 文件 > 25MB 必须在 SDK 实例化前拒绝(隐私契约的防线)
- backend 不匹配的旧条目不命中(避免切后端时返回错后端结果)

其余路径要么琐碎(默认值读取)、要么坏掉时声音很大(SDK 错误、ImportError),
要么已被 PR #58 现有测试隐式覆盖(缺 backend 字段的旧条目),不再单独写测试。

顺手把 README 里 PR #53 漏掉的 voice 三件套(get_voice_messages /
decode_voice / transcribe_voice)补进 MCP 工具表,并新增"⚠️ 语音转录隐私"
章节说清数据流向、成本(约 \$0.006/分钟)、25MB 上限、回退行为。

Closes ylytdeng/wechat-decrypt#59
2026-05-01 13:56:32 +08:00
Belugary
989badd14f feat: 给 transcribe_voice 工具加持久化缓存 (#58)
Whisper 本地推理在 CPU 下每条语音数秒到数十秒,且同一段 voice_data
产出相同 text,非常适合缓存。新增 voice_transcriptions.json 持久化
存储,命中时跳过 DB 查询、SILK 解码和 Whisper 推理全链路。

关键技术选择:
- 缓存 key 用 json.dumps([username, local_id]),即使 username 含
  分隔符也不冲突
- 写入走 tmp + os.replace 原子替换,进程中断不会损坏主文件
- 条目记录 model_size,Whisper 默认模型升级后旧条目自动失效
- 空转录也缓存(配合 model_size 失效),避免静音片段每次重跑
- threading.Lock 防御并发 load/save 竞态
- 首次 OSError 写 stderr 警告一次,后续静默避免刷屏

小的行为改进:resolve_username 移到 whisper/pysilk 导入探测之前,
bad chat_name 情况下不再需要 whisper 已安装也能给出"找不到聊天对象"
的错误提示。

15 个新测试:持久化 roundtrip、UTF-8 保留、corrupt JSON 容错、原子
写、写前失败不污染主文件、并发 load/save、缓存命中跳过重活、model
不匹配视为 miss、key 对含分隔符 username 的防御。全部通过。
2026-04-25 00:19:08 +08:00
btc-z
edf2c0940a feat: 新增聊天导出与语音转录 CLI 脚本 (#57)
* feat: 新增聊天导出与语音转录 CLI 脚本

新增两个独立 CLI 脚本,用于将单个聊天导出为结构化 JSON、并批量
填充语音消息的 Whisper 转录。区别于 MCP 工具:这些脚本面向离线
导出/归档,适合一次性拉取大量消息,或在会话外喂给其他 LLM/索引
管线使用。

- export_chat.py:跨分片合并某个聊天的全部消息,按时间排序后输出
  紧凑 JSON(type 为 text 时省略,is_group 仅群聊保留等)。复用
  mcp_server 中的消息解析/发送者解析辅助函数。
- transcribe_chat.py:读入 export_chat.py 产出的 JSON,对所有尚
  未转录的 voice 消息调用 Whisper,原地写回 transcription 字段。
  幂等(已有 transcription 的消息跳过)、崩溃安全(每条写回一次
  输出文件)。
- .gitignore:新增 *.json 通配,避免本地导出文件被误提交。
  config.example.json 已被跟踪,不受影响。

修复:transcribe_chat.py 原先调用 _silk_to_wav 时缺少 local_id
参数(commit c149389 将 local_id 加入签名用于文件名唯一化),
本 PR 中已补齐。

* docs: 新增聊天导出 JSON 数据格式文档

新增 docs/chat_export_format.md,描述 export_chat.py 与
transcribe_chat.py 产出的 JSON schema:顶层字段、消息对象的必填/
可选字段、默认值省略规则,以及加载与过滤的 Python 示例。

与现有 docs/macos-*.md 指南风格一致,避免在脚本 docstring 中堆叠
大段表格。export_chat.py 的 docstring 加一行指针指向本文档。

* docs: 聊天导出格式文档翻译为中文

与 docs/macos-*.md 既有指南保持一致的语言风格,将
docs/chat_export_format.md 翻译为中文。JSON 字段名、Python
代码示例等技术标识保持英文不变。

* fix: 回应 PR #57 review — 崩溃处理、幂等性、schema 补全

根据 review (#57) 的反馈:

- export_chat.py: _resolve_chat_context 返回 None 时的崩溃改为友好
  退出,并在 resolve 成功后打印 display_name (username),便于用户
  核对 resolve_username 的模糊匹配结果。
- export_chat.py: _query_messages 的 limit=999999 改为 None,避免
  超长历史被悄悄截断(_query_messages 对 None 会省略 LIMIT 子句)。
- export_chat.py: 输出 JSON 顶层新增 username 字段,让
  transcribe_chat.py 可以跳过二次模糊匹配,避免同名联系人漂移。
- transcribe_chat.py: 优先读取 JSON 顶层的 username,旧导出文件
  (无 username)回退到按 chat 名解析,保持向后兼容。
- transcribe_chat.py: 删除未使用的 import io / import wave,将循环
  内的 import datetime 提至模块顶部。
- export_chat.py: _decode_sticker_desc 的 varint 单字节简化给出
  注释说明局限,以及对 create_time 排序加 "or 0" 防御。
- export_chat.py / transcribe_chat.py: 模块 docstring 翻译为中文,
  与 docs/macos-*.md 保持一致。
- docs/chat_export_format.md: 同步补充 username 字段说明。
- .gitignore: 将 *.json 收窄为 *_export*.json / *_transcribed*.json,
  避免误屏蔽未来的 config/fixtures,同时匹配导出工具实际产出的
  文件名。
2026-04-25 00:16:37 +08:00
btc-z
02bc9c1840 feat: 新增语音 MCP 工具 + macOS 密钥提取修复 (#53)
* feat: 新增语音 MCP 工具 + macOS 密钥提取修复

- 新增 get_voice_messages / decode_voice / transcribe_voice MCP 工具
  - 语音数据存储在 media_0.db VoiceInfo 表(SILK v3 格式)
  - decode_voice 解码为 WAV 文件(saved to decoded_voices/)
  - transcribe_voice 通过 Whisper 自动识别语言转录
- 新增 get_chat_history oldest_first 参数,支持从最早消息开始分页
- 修复 macOS 下 check_wechat_running / ensure_keys 逻辑
  - 改用 pgrep 检测微信进程,绕过不支持 macOS 的 Python 扫描器
  - 无 all_keys.json 时打印清晰引导,提示运行 C 版扫描器
- 新增 Makefile(build / keys / decrypt / web 快捷命令)
- .gitignore 补充 find_all_keys_macos 二进制和 decoded_voices/

* fix: 语音查询支持多分片 media DB + 文件名唯一化

解决 PR #53 review 的阻塞项 #1,顺手修 #3、#6。

#1 `_get_media_db_path()` 硬编码 `media_0.db`
  - 新增模块级 `MEDIA_DB_KEYS`,镜像 `MSG_DB_KEYS` 的分片发现逻辑
  - `_fetch_voice_row` 遍历所有分片,按 `(chat_name_id, local_id)`
    首个命中即返回;单条语音在 media DB 家族内唯一,命中即可停
  - `get_voice_messages` 从每个分片各取 `LIMIT limit`,合并排序后
    截断到 `limit`。选择"每分片取 limit 条再合并"而非"按
    max(create_time) 排序后逐个取到 limit 即停止":后者假设分片
    间时间不重叠,一旦 WeChat 改分片策略就会静默丢消息;前者工作
    量 O(N 分片 × limit),在任何分片布局下都正确

#3 输出文件名冲突
  - `_silk_to_wav` 增加 `local_id` 参数,输出 `{user}_{time}_{lid}.wav`,
    同一秒内两条语音不会互相覆盖;两个调用方都已在作用域内持有
    `local_id`

#6 `_fetch_voice_row` 的 `local_id=None` 死分支
  - 随 #1 的重写一并删除,`local_id` 改为必填位置参数

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor: macOS 密钥提取分层下沉到 find_all_keys.py

解决 PR #53 review 的阻塞项 #2。

review 里提到"跟 PR #51 冲突"实测不存在 —— PR #51 当前 0 文件改动
(fork 分支已与上游同步),但架构建议本身是对的:macOS 处理应集中
在 `find_all_keys.py`,而不是在 `main.py` 提前 return 截胡。

- `main.py:ensure_keys()` 移除 darwin 专属提前返回分支,macOS 走
  和其他平台相同的 `extract_keys()` 路径
- `find_all_keys.py:_load_impl()` 在 darwin 分支抛出带
  `sudo ./find_all_keys_macos` 操作指引的 RuntimeError;非 macOS
  的平台兜底分支保留
- `main.py` 里已有 `except RuntimeError` 会打印并 `sys.exit(1)`,
  用户可见行为不变

未来若有 PR 在 `find_all_keys.py` 加 macOS 自动编译 / dispatch,
直接替换这段 RuntimeError 即可,不再需要改 `main.py`。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore: Makefile 支持 PYTHON 变量覆盖

解决 PR #53 review 的非阻塞项 #7。

原 Makefile 硬编码 `.venv/bin/python3`,没有 venv 的用户跑 `make
decrypt` 直接报错。引入 `PYTHON ?= .venv/bin/python3`:默认行为
不变(仍走 venv),想用系统 Python 的用户 `PYTHON=python3 make
decrypt` 即可。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs: 回应 PR #53 review #4 — 澄清 silk-python 与 pysilk 包名关系

验证:本项目 import 的 `pysilk` 实际由 `pip install silk-python`
(synodriver/pysilk) 提供;pypi 上另有同名 `pysilk==0.0.1` 是无内容
的占位包,不可用。错误消息里 `pip install silk-python` 已经是对的,
但 reader 看到 `import pysilk` 仍会困惑,所以:

- `_silk_to_wav` 的 import 处加一行注释,点名所用的是
  synodriver 版本,并提醒 pypi 上还有 pilk / pysilk 两个同类包
- `decode_voice` / `transcribe_voice` 的 docstring 加 "依赖:" 行,
  明确 "pip install silk-python (import 名为 pysilk)",MCP 客户端
  读 tool 描述就能看到正确的安装命令

未新增 requirements.txt 条目:voice 支持是可选功能(tool 内
try/except ImportError 懒加载),保持非必需依赖的语义。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 14:10:22 +08:00
ylytdeng
e86e00df87 fix: 新联系人/新群名称不刷新(issue #46)
之前的修复 load_contact_names() 读的是 decrypted/contact/contact.db
静态快照,新加联系人不在里面,所以"自动刷新"实际不生效。

现改为通过 db_cache 实时解密源 contact.db 再加载,确保新增联系人
即时可见。db_cache 内部靠 mtime 检测变化,微信写入后下次查询会触发
重新解密。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 13:55:51 +08:00
ylytdeng
a8cf64c0a6 docs: 补充 README macOS 操作说明
- 环境要求和快速开始章节新增 macOS 小节
- 添加 macOS 版 config.json 示例
- 明确 codesign、编译、扫描、解密四步流程

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-22 20:56:31 +08:00
ylytdeng
69a2f44240 feat: /api/history 支持按群过滤和增量拉取,更新 README API 文档
- /api/history 新增 chat、since、limit 参数
- README 新增 HTTP API 端点说明和联系人标签工具文档

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:43:41 +08:00
xincheng
b9f3161f84 Add SNS/image export and GUI contact export
Introduce SNS (朋友圈) and batch image tooling and enhance the GUI export workflow. Added new scripts: decrypt_sns.py (decrypt WeChat SNS cache) and batch_decrypt_images.py (bulk .dat image decrypt). Update build scripts and PyInstaller spec to include the new files. app_gui.py: add contact discovery, contact selection/export options dialog, new buttons (find image key, SNS), orchestrate combined export/voice/SNS tasks via subprocesses and env flags, and auto-run export after decryption. config.py: add output_base_dir, auto-detect WeChat Files path, and expose msgattach/xwechat cache dirs. export_messages.py: switch to output_base_dir, add image resource lookup and .dat locating/decryption helpers, and support environment-driven contact/format/image filters. Misc: update build.bat and packaging datas to include the new modules.

Co-Authored-By: Copilot <198982749+Copilot@users.noreply.github.com>
2026-04-06 23:57:51 +08:00
ylytdeng
7eb29b03e8 feat: 新增联系人标签查询功能
解析 contact.db 的 contact_label 表和 extra_buffer protobuf Field #30,
支持查询标签列表及指定标签下的成员。

- mcp_server.py: 新增 get_contact_tags / get_tag_members MCP 工具
- monitor_web.py: 新增 /api/tags JSON 端点,支持 ?name= 过滤

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 09:54:21 +08:00
xincheng
ebbea8c895 feat: 添加企业微信相关配置和功能支持 2026-04-05 02:19:11 +08:00