mirror of
https://github.com/ylytdeng/wechat-decrypt.git
synced 2026-11-06 03:56:49 +08:00
main
27
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
467f1cdbe7 |
feat: 渲染聊天里独立的视频号分享 (appmsg type=51)
独立的视频号分享(appmsg type=51)在 get_chat_history 里走 generic 分支,渲染成
无信息的 [链接/文件];而同一条视频号被引用回复(type=57 → inner 51)嵌套时反而有
[视频号] 标签(_INNER_APPMSG_TYPE_LABEL['51']),两条路径体验不一致。
新增 _format_finder_message_text(appmsg, title),把合并转发记录
_format_record_dataitem 的 datatype == '22' 已用的 finderFeed 读法接到独立消息
dispatch:
- [视频号] <昵称>: <简介>(简介截断 80 字符,与 datatype==22 行为一致)
- 缺 nickname → [视频号] {title} 兜底
在 _format_app_message_text 的 app_type == 5 之后接 app_type == 51 分支。只用上游
原生 _collapse_text + findtext,不引入新依赖。
新增 tests/test_finder_message.py(6 个用例):昵称+简介、仅昵称、简介截断、缺昵称
fallback、bare label、dispatch 端到端。fixture 全部合成。
只碰聊天侧 mcp_server.py 渲染分支。不碰 sns / 朋友圈代码路径。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
f80bdada3f |
feat(mcp): 解析微信红包消息 (appmsg type=2001) (#133)
独立红包消息 (appmsg type=2001) 走 _format_app_message_text generic 分支, 渲染成无信息 [链接/文件], 丢场景/祝福语/发红包人. _INNER_APPMSG_TYPE_LABEL 里的 '2001':'红包' 只在引用回复嵌套路径生效. 新增 _format_redpacket_message_text, 跟已合并的 _format_transfer_message_text (#85) / _format_location_text (#124) 同款 type→结构化 inline 渲染: - [红包·<场景>] <祝福语> (发自 <wxid>) - 场景 scenetext, 祝福语 sendertitle - 发红包人从领取链接 nativeurl 的 sendusername= 参数提取 测试: tests/test_redpacket_message.py 9 case 全过. |
||
|
|
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 |
||
|
|
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".
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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),不依赖真实加密素材。 |
||
|
|
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 (引用回复渲染乱码) |
||
|
|
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 即可。 |
||
|
|
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 即可, 无逻辑冲突。 |
||
|
|
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% 向后兼容。 提示文案如不合适可直接改, 不影响行为。 |
||
|
|
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).
|
||
|
|
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. |
||
|
|
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). |
||
|
|
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. |
||
|
|
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. |
||
|
|
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> |
||
|
|
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. |
||
|
|
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).
|
||
|
|
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.
|
||
|
|
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> |
||
|
|
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 风格保持一致. |
||
|
|
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(), 跨平台自动选默认浏览器, 无新增依赖.
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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
|
||
|
|
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 的防御。全部通过。 |