Commit Graph

28 Commits

Author SHA1 Message Date
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
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
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
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
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
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
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
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
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
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
ylytdeng
944546beb1 fix: 统一所有 JSON 文件读写为 UTF-8 编码
Windows 中文环境默认编码为 GBK,未指定 encoding 会导致
config.json/all_keys.json 解析失败。修复 9 个文件共 17 处。

Closes #32

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 14:32:37 +08:00
dsjzazs
7c42ff5d38 Investigate get_chat_history limit 2026-03-14 16:59:17 +08:00
dsjzazs
9ae558a31e Fix global search pagination 2026-03-14 16:36:55 +08:00
dsjzazs
b623711410 Add MCP search unit tests 2026-03-14 14:07:51 +08:00
dsjzazs
7e7f7a2516 feat: 增强消息查询功能,支持时间范围和分页 2026-03-14 10:21:21 +08:00
Banghao Chi
fd67536ef7 Refine chat history message parsing 2026-03-08 20:52:33 -05:00
Banghao Chi
fa273b810d Improve chat history formatting 2026-03-08 15:30:10 -05:00
PeanutSplash
6d9b2c0fe4 refactor(find_all_keys): extract shared key scan logic 2026-03-07 21:35:24 +08:00
PeanutSplash
872e3f58dc fix: handle exited PIDs and narrow message DB keys 2026-03-07 21:35:24 +08:00
PeanutSplash
f9c338b48d feat: add Linux support with cross-platform memory scanning
- Add Linux memory scanner (`find_all_keys_linux.py`) using `/proc/<pid>/mem`,
  same approach as Windows/macOS — no GDB, no function offsets, no restart needed
- Extract Windows-specific code to `find_all_keys_windows.py`
- Make `find_all_keys.py` a platform dispatcher (Windows / Linux)
- Add `key_utils.py` for cross-platform path matching (`/` vs `\` in all_keys.json)
- Update `config.py` with Linux auto-detection of db_storage paths
- Update all consumers (decrypt_db, monitor, monitor_web, mcp_server) to use
  `get_key_info()` for platform-agnostic key lookup

Tested on remote Linux container: 15/15 DBs scanned, decrypted, and verified.
2026-03-07 21:35:24 +08:00
ylytdeng
2b03a81a8f fix: 统一路径分隔符为正斜杠,修复 macOS/Linux 兼容性
all_keys.json 中的 key 统一使用 `/` 作为路径分隔符,
消除 Windows 反斜杠硬编码,确保跨平台兼容。

涉及文件: find_all_keys.py, decrypt_db.py, monitor.py,
monitor_web.py, mcp_server.py, decode_image.py, latency_test.py

Fixes #17

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 00:53:48 +08:00
ylytdeng
73598751a0 fix: MCP DBCache 使用固定文件名 + 持久化缓存,重启秒恢复
- 缓存文件改用 md5(rel_key) 固定命名,放在 Temp/wechat_mcp_cache/
- mtime 映射持久化到 _mtimes.json,重启后验证一致直接复用
- 避免 mkstemp 随机文件名导致崩溃后临时文件堆积

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 15:10:25 +08:00
ylytdeng
da7525db95 Add image decryption and inline preview for WeChat V2 format
Support all three .dat encryption formats:
- Old XOR format: single-byte XOR, auto-detect key from magic bytes
- V1 format: AES-ECB with fixed key (md5("0")[:16]) + XOR tail
- V2 format (2025-08+): AES-128-ECB + raw middle + XOR tail

New files:
- decode_image.py: unified image decryption module (XOR/V1/V2)
- find_image_key.py: extract AES key from WeChat process memory
- find_image_key_monitor.py: continuous monitoring version for key capture

monitor_web.py changes:
- Inline image preview in Web UI with async decryption
- MonitorDBCache for mtime-based DB decryption caching
- username-to-DB mapping for image resolution chain
- /img/ endpoint for serving decoded images
- SSE image_update events for real-time preview updates

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 00:30:01 +08:00
joshua-deng
5057206222 Add MCP server for Claude AI integration
New mcp_server.py provides 5 tools (get_recent_sessions, get_chat_history,
search_messages, get_contacts, get_new_messages) via FastMCP stdio transport.
Features on-demand decryption with mtime-based caching and WAL support.
2026-02-28 12:22:50 +08:00