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>
This commit is contained in:
800
mcp_server.py
800
mcp_server.py
@@ -7,10 +7,11 @@ Runs on Windows Python (needs access to D:\ WeChat databases).
|
|||||||
|
|
||||||
import io
|
import io
|
||||||
import os, sys, json, time, sqlite3, tempfile, struct, hashlib, atexit, re, threading
|
import os, sys, json, time, sqlite3, tempfile, struct, hashlib, atexit, re, threading
|
||||||
|
import glob
|
||||||
import wave
|
import wave
|
||||||
import hmac as hmac_mod
|
import hmac as hmac_mod
|
||||||
from contextlib import closing
|
from contextlib import closing
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
from Crypto.Cipher import AES
|
from Crypto.Cipher import AES
|
||||||
from mcp.server.fastmcp import FastMCP
|
from mcp.server.fastmcp import FastMCP
|
||||||
@@ -449,7 +450,11 @@ def _decompress_content(content, ct):
|
|||||||
|
|
||||||
|
|
||||||
def _parse_message_content(content, local_type, is_group):
|
def _parse_message_content(content, local_type, is_group):
|
||||||
"""解析消息内容,返回 (sender_id, text)"""
|
"""解析消息内容,返回 (sender_id, text)。
|
||||||
|
|
||||||
|
群消息 content 形如 'wxid_xxx:\n<xml...>';某些 type=19 合并转发也会
|
||||||
|
写成 'wxid_xxx:<?xml...' 或 'wxid_xxx:<msg...' 不带换行——剥离逻辑两种都要处理。
|
||||||
|
"""
|
||||||
if content is None:
|
if content is None:
|
||||||
return '', ''
|
return '', ''
|
||||||
if isinstance(content, bytes):
|
if isinstance(content, bytes):
|
||||||
@@ -457,8 +462,15 @@ def _parse_message_content(content, local_type, is_group):
|
|||||||
|
|
||||||
sender = ''
|
sender = ''
|
||||||
text = content
|
text = content
|
||||||
if is_group and ':\n' in content:
|
if is_group:
|
||||||
sender, text = content.split(':\n', 1)
|
if ':\n' in content:
|
||||||
|
sender, text = content.split(':\n', 1)
|
||||||
|
else:
|
||||||
|
# 'sender:<?xml...' / 'sender:<msg...' 等无换行 case
|
||||||
|
m = re.match(r'^([A-Za-z0-9_\-@.]+):(<\?xml|<msg|<msglist|<voipmsg|<sysmsg)', content)
|
||||||
|
if m:
|
||||||
|
sender = m.group(1)
|
||||||
|
text = content[len(sender) + 1:]
|
||||||
|
|
||||||
return sender, text
|
return sender, text
|
||||||
|
|
||||||
@@ -555,8 +567,77 @@ def _resolve_quote_sender_label(ref_user, ref_display_name, is_group, chat_usern
|
|||||||
return ''
|
return ''
|
||||||
|
|
||||||
|
|
||||||
def _parse_xml_root(content):
|
# 合并转发消息(含 recorditem 内嵌 XML)在 dataitem 数量多时显著超过默认 20K 上限,
|
||||||
if not content or len(content) > _XML_PARSE_MAX_LEN or _XML_UNSAFE_RE.search(content):
|
# 实测真实 outer XML 可达 ~500KB。caller 可通过 max_len 参数为 type=19 类大消息放宽限制。
|
||||||
|
_RECORD_XML_PARSE_MAX_LEN = 500_000
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_basename(name):
|
||||||
|
"""对 user-derived filename(从消息 XML 来,不可信)做严格 sanitize。
|
||||||
|
|
||||||
|
Reject 而不是 normalize:哪怕 os.path.basename 把 '../foo' 剥成 'foo' 是
|
||||||
|
safe 的,意图依然可疑,应该显式失败让用户看到。
|
||||||
|
"""
|
||||||
|
if not name:
|
||||||
|
return ''
|
||||||
|
if '\x00' in name:
|
||||||
|
return ''
|
||||||
|
if os.path.isabs(name):
|
||||||
|
return ''
|
||||||
|
# 任何 path separator 或 .. component 直接拒(不做 normalize)
|
||||||
|
parts = name.replace('\\', '/').split('/')
|
||||||
|
if any(p in ('', '.', '..') for p in parts) and len(parts) > 1:
|
||||||
|
return ''
|
||||||
|
if len(parts) > 1:
|
||||||
|
return ''
|
||||||
|
if name in ('.', '..'):
|
||||||
|
return ''
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def _path_under_root(path, root):
|
||||||
|
"""resolve realpath 后确认仍在 root 下(防 symlink 跳出)。"""
|
||||||
|
try:
|
||||||
|
real_path = os.path.realpath(path)
|
||||||
|
real_root = os.path.realpath(root)
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
return real_path == real_root or real_path.startswith(real_root + os.sep)
|
||||||
|
|
||||||
|
|
||||||
|
# 大附件 md5 校验时的安全上限:超过此 size 直接拒绝校验(避免 MCP 进程
|
||||||
|
# 在 100MB+ 视频/附件上一次性 read() 整文件爆内存或长时间阻塞)。
|
||||||
|
_MD5_VERIFY_MAX_SIZE = 500 * 1024 * 1024 # 500 MB
|
||||||
|
_MD5_CHUNK_SIZE = 64 * 1024 # 64 KB
|
||||||
|
|
||||||
|
|
||||||
|
def _md5_file_chunked(path, max_size=_MD5_VERIFY_MAX_SIZE):
|
||||||
|
"""流式分块计算文件 md5,避免大文件一次读完爆内存。
|
||||||
|
|
||||||
|
超过 max_size 直接拒绝(DoS 防御 + 大附件 md5 校验现实意义不大)。
|
||||||
|
返回 (md5_hex, error);成功时 error 为 None。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
size = os.path.getsize(path)
|
||||||
|
except OSError as e:
|
||||||
|
return None, f"无法读取文件 size: {e}"
|
||||||
|
if size > max_size:
|
||||||
|
return None, f"文件 size {size:,} 超过 md5 校验上限 {max_size:,}(防 DoS)"
|
||||||
|
h = hashlib.md5()
|
||||||
|
try:
|
||||||
|
with open(path, 'rb') as f:
|
||||||
|
while True:
|
||||||
|
chunk = f.read(_MD5_CHUNK_SIZE)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
h.update(chunk)
|
||||||
|
except OSError as e:
|
||||||
|
return None, f"读取文件失败: {e}"
|
||||||
|
return h.hexdigest().lower(), None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_xml_root(content, max_len=_XML_PARSE_MAX_LEN):
|
||||||
|
if not content or len(content) > max_len or _XML_UNSAFE_RE.search(content):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -572,12 +653,25 @@ def _parse_int(value, fallback=0):
|
|||||||
return fallback
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_app_message_outer(content):
|
||||||
|
"""Parse outer appmsg XML,对 type=19 合并卡片自动放宽到 _RECORD_XML_PARSE_MAX_LEN。
|
||||||
|
|
||||||
|
所有解析 outer appmsg 的 caller(get_chat_history 渲染 / decode_file_message /
|
||||||
|
decode_record_item)共用此 helper,避免同一条大消息在不同 caller 上行为不一致。
|
||||||
|
Substring 短路保证非 type=19 的大 appmsg 不付出 500K parse 代价。"""
|
||||||
|
root = _parse_xml_root(content)
|
||||||
|
if root is None and content and len(content) <= _RECORD_XML_PARSE_MAX_LEN:
|
||||||
|
if '<type>19</type>' in content:
|
||||||
|
root = _parse_xml_root(content, max_len=_RECORD_XML_PARSE_MAX_LEN)
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
def _format_app_message_text(content, local_type, is_group, chat_username, chat_display_name, names):
|
def _format_app_message_text(content, local_type, is_group, chat_username, chat_display_name, names):
|
||||||
if not content or '<appmsg' not in content:
|
if not content or '<appmsg' not in content:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
_, sub_type = _split_msg_type(local_type)
|
_, sub_type = _split_msg_type(local_type)
|
||||||
root = _parse_xml_root(content)
|
root = _parse_app_message_outer(content)
|
||||||
if root is None:
|
if root is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -610,6 +704,9 @@ def _format_app_message_text(content, local_type, is_group, chat_username, chat_
|
|||||||
quote_text += f"\n ↳ {prefix}{ref_content}"
|
quote_text += f"\n ↳ {prefix}{ref_content}"
|
||||||
return quote_text
|
return quote_text
|
||||||
|
|
||||||
|
if app_type == 19:
|
||||||
|
return _format_record_message_text(appmsg, title)
|
||||||
|
|
||||||
if app_type == 6:
|
if app_type == 6:
|
||||||
return f"[文件] {title}" if title else "[文件]"
|
return f"[文件] {title}" if title else "[文件]"
|
||||||
if app_type == 5:
|
if app_type == 5:
|
||||||
@@ -621,6 +718,108 @@ def _format_app_message_text(content, local_type, is_group, chat_username, chat_
|
|||||||
return "[链接/文件]"
|
return "[链接/文件]"
|
||||||
|
|
||||||
|
|
||||||
|
_RECORD_MAX_ITEMS = 50
|
||||||
|
_RECORD_MAX_LINE_LEN = 200
|
||||||
|
|
||||||
|
# 合并转发 dataitem 的 datatype → wechat 缓存子目录映射。仅这 4 类有真本地
|
||||||
|
# binary 文件;其他 datatype(链接/名片/小程序/视频号 等)只有 metadata。
|
||||||
|
_RECORD_BINARY_SUBDIR = {'8': 'F', '2': 'Img', '5': 'V', '4': 'A'}
|
||||||
|
|
||||||
|
# datatype → 中文标签,散在多处使用:渲染合并卡片 / decode_record_item 的
|
||||||
|
# 错误提示 / 单元测试。统一在模块顶部维护避免漂移。
|
||||||
|
_RECORD_DATATYPE_LABEL = {
|
||||||
|
'1': '文本', '2': '图片', '3': '名片', '4': '语音',
|
||||||
|
'5': '视频', '6': '链接', '7': '位置', '8': '文件',
|
||||||
|
'17': '聊天记录', '19': '小程序', '22': '视频号',
|
||||||
|
'23': '视频号直播', '29': '音乐', '36': '小程序/H5',
|
||||||
|
'37': '表情包',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _format_record_dataitem(item):
|
||||||
|
"""格式化合并记录中的单个 dataitem,返回展示文本。"""
|
||||||
|
datatype = (item.get('datatype') or '').strip()
|
||||||
|
|
||||||
|
if datatype == '1':
|
||||||
|
return _collapse_text(item.findtext('datadesc') or '') or '[文本]'
|
||||||
|
if datatype in ('2', '3', '4', '5', '7', '23', '37'):
|
||||||
|
return f"[{_RECORD_DATATYPE_LABEL[datatype]}]"
|
||||||
|
if datatype in ('6', '36'):
|
||||||
|
link_title = _collapse_text(item.findtext('datatitle') or '')
|
||||||
|
label = _RECORD_DATATYPE_LABEL[datatype]
|
||||||
|
return f"[{label}] {link_title}" if link_title else f"[{label}]"
|
||||||
|
if datatype == '8':
|
||||||
|
file_title = _collapse_text(item.findtext('datatitle') or '')
|
||||||
|
return f"[文件] {file_title}" if file_title else '[文件]'
|
||||||
|
if datatype == '17':
|
||||||
|
nested_title = _collapse_text(item.findtext('datatitle') or '')
|
||||||
|
return f"[聊天记录] {nested_title}" if nested_title else '[聊天记录]'
|
||||||
|
if datatype == '19':
|
||||||
|
# 小程序:appbranditem/sourcedisplayname 是直接子代,不需要 .// 递归
|
||||||
|
app_name = _collapse_text(item.findtext('appbranditem/sourcedisplayname') or '')
|
||||||
|
item_title = _collapse_text(item.findtext('datatitle') or '')
|
||||||
|
label = item_title or app_name or '小程序'
|
||||||
|
return f"[小程序] {label}"
|
||||||
|
if datatype == '22':
|
||||||
|
feed_desc = _collapse_text(item.findtext('finderFeed/desc') or '')
|
||||||
|
return f"[视频号] {feed_desc[:80]}" if feed_desc else '[视频号]'
|
||||||
|
if datatype == '29':
|
||||||
|
song = _collapse_text(item.findtext('datatitle') or '')
|
||||||
|
artist = _collapse_text(item.findtext('datadesc') or '')
|
||||||
|
if song and artist:
|
||||||
|
return f"[音乐] {song} - {artist}"
|
||||||
|
return f"[音乐] {song}" if song else '[音乐]'
|
||||||
|
|
||||||
|
desc = _collapse_text(item.findtext('datadesc') or '')
|
||||||
|
title_text = _collapse_text(item.findtext('datatitle') or '')
|
||||||
|
fallback = desc or title_text
|
||||||
|
return fallback if fallback else f"[未知类型 {datatype}]"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_record_message_text(appmsg, title):
|
||||||
|
"""解析合并转发的聊天记录卡片(appmsg type=19, recorditem)。"""
|
||||||
|
fallback_title = title or '聊天记录'
|
||||||
|
record_node = appmsg.find('recorditem')
|
||||||
|
if record_node is None or not record_node.text:
|
||||||
|
return f"[聊天记录] {fallback_title}(待加载)"
|
||||||
|
|
||||||
|
inner = _parse_xml_root(record_node.text, max_len=_RECORD_XML_PARSE_MAX_LEN)
|
||||||
|
if inner is None:
|
||||||
|
return f"[聊天记录] {fallback_title}"
|
||||||
|
|
||||||
|
record_title = _collapse_text(inner.findtext('title') or '') or fallback_title
|
||||||
|
is_chatroom = (inner.findtext('isChatRoom') or '').strip() == '1'
|
||||||
|
datalist = inner.find('datalist')
|
||||||
|
items = list(datalist.findall('dataitem')) if datalist is not None else []
|
||||||
|
if not items:
|
||||||
|
suffix = "(群聊转发,待加载)" if is_chatroom else "(待加载)"
|
||||||
|
return f"[聊天记录] {record_title}{suffix}"
|
||||||
|
|
||||||
|
header = f"[聊天记录] {record_title}"
|
||||||
|
if is_chatroom:
|
||||||
|
header += "(群聊转发)"
|
||||||
|
header += f",共 {len(items)} 条"
|
||||||
|
|
||||||
|
lines = [header + ":"]
|
||||||
|
for idx, item in enumerate(items[:_RECORD_MAX_ITEMS]):
|
||||||
|
sender = _collapse_text(item.findtext('sourcename') or '')
|
||||||
|
when = _collapse_text(item.findtext('sourcetime') or '')
|
||||||
|
content = _format_record_dataitem(item)
|
||||||
|
|
||||||
|
if len(content) > _RECORD_MAX_LINE_LEN:
|
||||||
|
content = content[:_RECORD_MAX_LINE_LEN] + '…'
|
||||||
|
|
||||||
|
# 0-based index 让用户能用 decode_record_item(chat, local_id, item_index) 引用
|
||||||
|
prefix_parts = [f"[{idx}]"] + [p for p in (when, sender) if p]
|
||||||
|
prefix = ' '.join(prefix_parts)
|
||||||
|
lines.append(f" {prefix}: {content}")
|
||||||
|
|
||||||
|
if len(items) > _RECORD_MAX_ITEMS:
|
||||||
|
lines.append(f" …(还有 {len(items) - _RECORD_MAX_ITEMS} 条未显示)")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
def _format_voip_message_text(content):
|
def _format_voip_message_text(content):
|
||||||
if not content or '<voip' not in content:
|
if not content or '<voip' not in content:
|
||||||
return None
|
return None
|
||||||
@@ -650,20 +849,37 @@ def _format_voip_message_text(content):
|
|||||||
return f"[通话] {status_map.get(raw_text, raw_text)}"
|
return f"[通话] {status_map.get(raw_text, raw_text)}"
|
||||||
|
|
||||||
|
|
||||||
def _format_message_text(local_id, local_type, content, is_group, chat_username, chat_display_name, names):
|
def _format_message_text(local_id, local_type, content, is_group, chat_username, chat_display_name, names, create_time=0):
|
||||||
sender_from_content, text = _parse_message_content(content, local_type, is_group)
|
sender_from_content, text = _parse_message_content(content, local_type, is_group)
|
||||||
base_type, _ = _split_msg_type(local_type)
|
base_type, _ = _split_msg_type(local_type)
|
||||||
|
|
||||||
|
# 同一 chat 的消息可能跨 message_N.db 分片,导致 local_id 跨分片冲突。
|
||||||
|
# 把 create_time 一起注入到输出,让 decode_file_message / decode_record_item
|
||||||
|
# 能用 (local_id, create_time) 唯一定位 row。
|
||||||
|
def _id_suffix():
|
||||||
|
return f"(local_id={local_id}, ts={create_time})" if create_time else f"(local_id={local_id})"
|
||||||
|
|
||||||
if base_type == 3:
|
if base_type == 3:
|
||||||
text = f"[图片] (local_id={local_id})"
|
text = f"[图片] {_id_suffix()}"
|
||||||
elif base_type == 47:
|
elif base_type == 47:
|
||||||
text = "[表情]"
|
text = "[表情]"
|
||||||
elif base_type == 50:
|
elif base_type == 50:
|
||||||
text = _format_voip_message_text(text) or "[通话]"
|
text = _format_voip_message_text(text) or "[通话]"
|
||||||
elif base_type == 49:
|
elif base_type == 49:
|
||||||
text = _format_app_message_text(
|
formatted = _format_app_message_text(
|
||||||
text, local_type, is_group, chat_username, chat_display_name, names
|
text, local_type, is_group, chat_username, chat_display_name, names
|
||||||
) or "[链接/文件]"
|
) or "[链接/文件]"
|
||||||
|
if formatted.startswith('[文件]'):
|
||||||
|
formatted = f"{formatted} {_id_suffix()}"
|
||||||
|
elif formatted.startswith('[聊天记录]'):
|
||||||
|
# 多行:把 ID 后缀放在 header 末尾,":" 之前
|
||||||
|
if '\n' in formatted:
|
||||||
|
first_line, rest = formatted.split('\n', 1)
|
||||||
|
first_line_no_colon = first_line.rstrip(':').rstrip()
|
||||||
|
formatted = f"{first_line_no_colon} {_id_suffix()}:\n{rest}"
|
||||||
|
else:
|
||||||
|
formatted = f"{formatted} {_id_suffix()}"
|
||||||
|
text = formatted
|
||||||
elif base_type != 1:
|
elif base_type != 1:
|
||||||
type_label = format_msg_type(local_type)
|
type_label = format_msg_type(local_type)
|
||||||
text = f"[{type_label}] {text}" if text else f"[{type_label}]"
|
text = f"[{type_label}] {text}" if text else f"[{type_label}]"
|
||||||
@@ -924,7 +1140,8 @@ def _build_search_entry(row, ctx, names, id_to_username):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
sender, text = _format_message_text(
|
sender, text = _format_message_text(
|
||||||
local_id, local_type, content, ctx['is_group'], ctx['username'], ctx['display_name'], names
|
local_id, local_type, content, ctx['is_group'], ctx['username'], ctx['display_name'], names,
|
||||||
|
create_time=create_time,
|
||||||
)
|
)
|
||||||
if text and len(text) > 300:
|
if text and len(text) > 300:
|
||||||
text = text[:300] + '...'
|
text = text[:300] + '...'
|
||||||
@@ -954,7 +1171,8 @@ def _build_history_line(row, ctx, names, id_to_username):
|
|||||||
content = '(无法解压)'
|
content = '(无法解压)'
|
||||||
|
|
||||||
sender, text = _format_message_text(
|
sender, text = _format_message_text(
|
||||||
local_id, local_type, content, ctx['is_group'], ctx['username'], ctx['display_name'], names
|
local_id, local_type, content, ctx['is_group'], ctx['username'], ctx['display_name'], names,
|
||||||
|
create_time=create_time,
|
||||||
)
|
)
|
||||||
|
|
||||||
sender_label = _resolve_sender_label(
|
sender_label = _resolve_sender_label(
|
||||||
@@ -1703,6 +1921,564 @@ def decode_image(chat_name: str, local_id: int) -> str:
|
|||||||
return f"解密失败: {error}"
|
return f"解密失败: {error}"
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def decode_file_message(chat_name: str, local_id: int, create_time: int = 0) -> str:
|
||||||
|
"""获取微信聊天中外层文件消息(PDF/docx/xlsx 等)的本地副本路径。
|
||||||
|
|
||||||
|
微信会把对方发来的文件下载到 ~/Library/.../msg/file/{YYYY-MM}/原文件名.{ext}
|
||||||
|
(macOS)。本工具从消息记录解析出文件名/大小,在本地缓存中精确定位,
|
||||||
|
然后返回原始路径,可直接交给 Read/PDF 工具读取。
|
||||||
|
|
||||||
|
使用流程:先用 get_chat_history 找到 [文件] xxx.pdf (local_id=N, ts=T),
|
||||||
|
把 N 和 T 一起传给本工具。create_time(ts) 用于跨分片场景下唯一定位。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
chat_name: 聊天对象的名字、备注名或wxid
|
||||||
|
local_id: 文件消息的 local_id(从 get_chat_history 获取)
|
||||||
|
create_time: 消息的 unix 时间戳,从 get_chat_history 输出 ts=N 部分获取。
|
||||||
|
用于在 local_id 跨分片冲突时唯一定位;传 0 时若多个分片含同 local_id 会报歧义错误
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
local_id = int(local_id)
|
||||||
|
create_time = int(create_time)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return "错误: local_id 和 create_time 必须是整数"
|
||||||
|
|
||||||
|
username = resolve_username(chat_name)
|
||||||
|
if not username:
|
||||||
|
return f"找不到聊天对象: {chat_name}"
|
||||||
|
|
||||||
|
# 同一 chat 的消息可能分散在多个 message_N.db 分片中。扫所有分片收集 row,
|
||||||
|
# 多于一条就报歧义错误(避免 silent decoding wrong message)。
|
||||||
|
shards = _find_msg_tables_for_user(username)
|
||||||
|
if not shards:
|
||||||
|
return f"找不到 {chat_name} 的消息表"
|
||||||
|
|
||||||
|
# 扫所有分片收集 row。如果调用者传了 create_time,用 (local_id, create_time)
|
||||||
|
# 精确匹配;否则只按 local_id 收集,多匹配时报歧义并提示加 create_time。
|
||||||
|
matches = []
|
||||||
|
for shard in shards:
|
||||||
|
if not _is_safe_msg_table_name(shard['table_name']):
|
||||||
|
continue
|
||||||
|
with closing(sqlite3.connect(shard['db_path'])) as conn:
|
||||||
|
if create_time:
|
||||||
|
candidate_row = conn.execute(
|
||||||
|
f"SELECT local_type, create_time, message_content, WCDB_CT_message_content "
|
||||||
|
f"FROM [{shard['table_name']}] WHERE local_id=? AND create_time=?",
|
||||||
|
(local_id, create_time)
|
||||||
|
).fetchone()
|
||||||
|
else:
|
||||||
|
candidate_row = conn.execute(
|
||||||
|
f"SELECT local_type, create_time, message_content, WCDB_CT_message_content "
|
||||||
|
f"FROM [{shard['table_name']}] WHERE local_id=?",
|
||||||
|
(local_id,)
|
||||||
|
).fetchone()
|
||||||
|
if candidate_row:
|
||||||
|
matches.append((shard['db_path'], candidate_row))
|
||||||
|
if not matches:
|
||||||
|
if create_time:
|
||||||
|
return f"找不到 (local_id={local_id}, create_time={create_time}) 的消息(已扫描 {len(shards)} 个分片)"
|
||||||
|
return f"找不到 local_id={local_id} 的消息(已扫描 {len(shards)} 个分片)"
|
||||||
|
if len(matches) > 1:
|
||||||
|
details = []
|
||||||
|
for db_p, r in matches:
|
||||||
|
ct = r[1]
|
||||||
|
ts_str = datetime.fromtimestamp(ct).isoformat() if ct else '?'
|
||||||
|
details.append(f"{os.path.basename(db_p)} create_time={ct} ({ts_str})")
|
||||||
|
return (
|
||||||
|
f"local_id={local_id} 在 {len(matches)} 个分片中都存在,无法唯一定位:\n "
|
||||||
|
+ '\n '.join(details)
|
||||||
|
+ f"\n请加 create_time 参数:decode_file_message(chat_name, local_id={local_id}, create_time=N)"
|
||||||
|
)
|
||||||
|
|
||||||
|
_, row = matches[0]
|
||||||
|
local_type, create_time, content, ct_compress = row
|
||||||
|
base_type, _ = _split_msg_type(local_type)
|
||||||
|
if base_type != 49:
|
||||||
|
return (
|
||||||
|
f"不是文件消息(local_type={local_type},base_type={base_type}),"
|
||||||
|
f"文件消息应为 base_type=49 且 appmsg type=6"
|
||||||
|
)
|
||||||
|
|
||||||
|
xml_text = _decompress_content(content, ct_compress)
|
||||||
|
if not xml_text:
|
||||||
|
return "消息 content 为空或无法解码"
|
||||||
|
|
||||||
|
# 复用项目内现有 helper 剥离群聊 sender 前缀,避免自己写启发式
|
||||||
|
is_group = username.endswith('@chatroom')
|
||||||
|
_, xml_text = _parse_message_content(xml_text, local_type, is_group)
|
||||||
|
|
||||||
|
root = _parse_app_message_outer(xml_text)
|
||||||
|
if root is None:
|
||||||
|
return "无法解析消息 XML"
|
||||||
|
|
||||||
|
appmsg = root.find('.//appmsg')
|
||||||
|
if appmsg is None:
|
||||||
|
return "消息中没有 appmsg 段(可能不是文件类型)"
|
||||||
|
|
||||||
|
# 必须是 appmsg type=6 (文件),否则可能是链接/小程序/合并转发等带 title 的卡片,
|
||||||
|
# 按 title/size 全盘搜会误命中无关本地文件并伪装成"找到了"。
|
||||||
|
app_type_in_msg = _parse_int(_collapse_text(appmsg.findtext('type') or ''), 0)
|
||||||
|
if app_type_in_msg != 6:
|
||||||
|
return (
|
||||||
|
f"不是文件消息(appmsg type={app_type_in_msg})。"
|
||||||
|
f"文件消息要求 appmsg type=6;type=19 请用 decode_record_item,"
|
||||||
|
f"type=5/33/36/44 等是链接/小程序,没有可下载的本地文件"
|
||||||
|
)
|
||||||
|
|
||||||
|
raw_title = _collapse_text(appmsg.findtext('title') or '')
|
||||||
|
fileext = _collapse_text(appmsg.findtext('.//fileext') or '')
|
||||||
|
totallen = _parse_int(_collapse_text(appmsg.findtext('.//totallen') or ''), 0)
|
||||||
|
# md5 字段在 type=6 外层(不是 appattach 子节点)—— 用于强校验候选文件归属
|
||||||
|
expected_md5 = _collapse_text(appmsg.findtext('md5') or '').lower()
|
||||||
|
|
||||||
|
# 没有 appattach 节点 = 不是真正的文件消息(type=6 必带 appattach)
|
||||||
|
if appmsg.find('appattach') is None:
|
||||||
|
return "消息没有 appattach 节点(可能 schema 异常或不是真文件消息)"
|
||||||
|
|
||||||
|
if not raw_title:
|
||||||
|
return "消息中没有文件名 (title)"
|
||||||
|
|
||||||
|
# title 来自不可信的 message XML,对方可能发恶意消息(含绝对路径或 ../)。
|
||||||
|
# 必须 sanitize 成 safe basename 才能拼路径 + glob,否则有 path-traversal 风险。
|
||||||
|
title = _safe_basename(raw_title)
|
||||||
|
if not title:
|
||||||
|
return f"消息中的文件名 {raw_title!r} 不安全(含绝对路径/路径分隔符/..),拒绝处理"
|
||||||
|
|
||||||
|
# 性能优化:先按消息时间精确定位 msg/file/{YYYY-MM}/,命中即返回;
|
||||||
|
# 否则才退回 walk 全盘 os.walk(msg/attach 含数十万小文件,全盘扫描可达数秒)
|
||||||
|
candidates = []
|
||||||
|
msg_file_dir = os.path.join(WECHAT_BASE_DIR, 'msg/file')
|
||||||
|
if create_time and os.path.isdir(msg_file_dir):
|
||||||
|
# 同名文件可能落到收到消息的当月、上一月或下一月(罕见跨月边界)
|
||||||
|
ts_dt = datetime.fromtimestamp(create_time)
|
||||||
|
candidate_months = {
|
||||||
|
ts_dt.strftime('%Y-%m'),
|
||||||
|
(ts_dt - timedelta(days=31)).strftime('%Y-%m'),
|
||||||
|
(ts_dt + timedelta(days=31)).strftime('%Y-%m'),
|
||||||
|
}
|
||||||
|
escaped_stem = glob.escape(os.path.splitext(title)[0])
|
||||||
|
ext = os.path.splitext(title)[1]
|
||||||
|
for ym in candidate_months:
|
||||||
|
month_dir = os.path.join(msg_file_dir, ym)
|
||||||
|
if not os.path.isdir(month_dir):
|
||||||
|
continue
|
||||||
|
# 精确匹配 + 同名 (1)(2) 后缀变体
|
||||||
|
for pattern in (
|
||||||
|
glob.escape(title),
|
||||||
|
f"{escaped_stem}*{glob.escape(ext)}" if ext else f"{escaped_stem}*",
|
||||||
|
):
|
||||||
|
for hit in glob.glob(os.path.join(month_dir, pattern)):
|
||||||
|
# 有 totallen 时立刻 size 验证:避免月扫命中"同名但 size 不对"的副本
|
||||||
|
# 阻塞 walk 兜底,最终返回错误文件
|
||||||
|
if totallen:
|
||||||
|
try:
|
||||||
|
if os.path.getsize(hit) != totallen:
|
||||||
|
continue
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if hit not in candidates:
|
||||||
|
candidates.append(hit)
|
||||||
|
|
||||||
|
# 退路:未命中或没 create_time 时只 walk msg/file(slow path 兜底)。
|
||||||
|
# 文件名匹配严格化:只接受精确匹配或 wechat 自动加副本的 "(N)" 后缀变体,
|
||||||
|
# 不做 stem 子串匹配——避免 "某某论文.pdf" 被当成 "论文.pdf"。
|
||||||
|
if not candidates:
|
||||||
|
d = os.path.join(WECHAT_BASE_DIR, 'msg/file')
|
||||||
|
stem, ext = os.path.splitext(title)
|
||||||
|
copy_pattern = re.compile(
|
||||||
|
r'^' + re.escape(stem) + r' ?\(\d+\)' + re.escape(ext) + r'$'
|
||||||
|
)
|
||||||
|
if os.path.isdir(d):
|
||||||
|
for root_dir, _, files in os.walk(d):
|
||||||
|
for f in files:
|
||||||
|
if f.startswith('.'):
|
||||||
|
continue
|
||||||
|
full = os.path.join(root_dir, f)
|
||||||
|
is_exact = (f == title)
|
||||||
|
is_copy_variant = bool(copy_pattern.match(f))
|
||||||
|
if not (is_exact or is_copy_variant):
|
||||||
|
continue
|
||||||
|
if totallen:
|
||||||
|
try:
|
||||||
|
if os.path.getsize(full) != totallen:
|
||||||
|
continue
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
candidates.append(full)
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
return (
|
||||||
|
f"在本地缓存中找不到 {title}\n"
|
||||||
|
f" 期望路径模式: {WECHAT_BASE_DIR}/msg/file/YYYY-MM/{title}\n"
|
||||||
|
f" 可能原因:从未在 PC/Mac 微信打开过 / 已被清理"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 严格 size 过滤(如果 totallen 已知,不匹配的全淘汰)
|
||||||
|
if totallen:
|
||||||
|
candidates = [c for c in candidates if os.path.getsize(c) == totallen]
|
||||||
|
if not candidates:
|
||||||
|
return (
|
||||||
|
f"在本地缓存中找不到 {title} (期望 size={totallen:,})\n"
|
||||||
|
f" 说明:找到了同名文件但 size 都不匹配——可能从未真正下载完整 / 已被清理"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 路径绑定策略:有 md5 → cryptographic verify;没 md5 → heuristic +
|
||||||
|
# warning。本工具是用户主动通过 MCP 调用,path 只在本地对话显示,所以
|
||||||
|
# 没 md5 时不强制 fail-closed。
|
||||||
|
cache_root = os.path.join(WECHAT_BASE_DIR, 'msg')
|
||||||
|
md5_verified = False
|
||||||
|
if expected_md5 and len(expected_md5) == 32:
|
||||||
|
# 用 md5 过滤候选——同 md5 = 真同一文件副本。
|
||||||
|
md5_match = []
|
||||||
|
md5_errors = []
|
||||||
|
for c in candidates:
|
||||||
|
if not _path_under_root(c, cache_root):
|
||||||
|
md5_errors.append(f"{c}: 不在 {cache_root} 下,跳过")
|
||||||
|
continue
|
||||||
|
actual_md5, err = _md5_file_chunked(c)
|
||||||
|
if err:
|
||||||
|
md5_errors.append(f"{c}: {err}")
|
||||||
|
continue
|
||||||
|
if actual_md5 == expected_md5:
|
||||||
|
md5_match.append(c)
|
||||||
|
break # 多候选共享同 md5 = 同一文件副本,第一个命中即停
|
||||||
|
if not md5_match:
|
||||||
|
info = (
|
||||||
|
f"⚠️ 候选文件 md5 都不匹配,拒绝返回错文件:\n"
|
||||||
|
f" 期望 md5: {expected_md5}\n"
|
||||||
|
f" 说明:找到 {len(candidates)} 个同名同 size 的本地文件但 md5 都不对。"
|
||||||
|
f"目标文件可能未在 wechat 客户端打开过,或已被清理。"
|
||||||
|
)
|
||||||
|
if md5_errors:
|
||||||
|
info += "\n 校验异常:\n " + "\n ".join(md5_errors)
|
||||||
|
return info
|
||||||
|
candidates = md5_match
|
||||||
|
md5_verified = True
|
||||||
|
|
||||||
|
# 没 md5 时多 candidates 仍 fail-closed(避免 silent mtime pick)
|
||||||
|
if len(candidates) > 1 and not md5_verified:
|
||||||
|
details = []
|
||||||
|
for c in candidates:
|
||||||
|
try:
|
||||||
|
mt = datetime.fromtimestamp(os.path.getmtime(c)).isoformat()
|
||||||
|
except OSError:
|
||||||
|
mt = '?'
|
||||||
|
details.append(f"{c} (mtime={mt})")
|
||||||
|
return (
|
||||||
|
f"在本地缓存找到 {len(candidates)} 个匹配的副本,无法唯一定位"
|
||||||
|
f"(同名同 size 多份,且消息 XML 没含 md5 用于强校验):\n "
|
||||||
|
+ '\n '.join(details)
|
||||||
|
+ f"\n请人工 inspect mtime / 上下文区分"
|
||||||
|
)
|
||||||
|
|
||||||
|
chosen = candidates[0]
|
||||||
|
if not _path_under_root(chosen, cache_root):
|
||||||
|
return f"匹配到的路径 {chosen!r} 不在 {cache_root} 下,拒绝返回(可能是 symlink 攻击)"
|
||||||
|
|
||||||
|
binding_note = (
|
||||||
|
"✅ md5 校验通过,路径与消息唯一绑定"
|
||||||
|
if md5_verified else
|
||||||
|
f"⚠️ 消息 XML 没含 md5,路径基于 (filename+size) 启发式匹配——"
|
||||||
|
f"如果同 chat 缓存里另有同名同 size 的不相关文件,可能返回错副本,请人工验证。"
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f"找到本地文件:\n"
|
||||||
|
f" 路径: {chosen}\n"
|
||||||
|
f" 大小: {os.path.getsize(chosen):,} bytes\n"
|
||||||
|
f" 扩展名: {fileext or os.path.splitext(title)[1].lstrip('.') or '?'}\n"
|
||||||
|
f" 期望大小: {totallen:,} bytes\n"
|
||||||
|
f" {binding_note}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def decode_record_item(chat_name: str, local_id: int, item_index: int, create_time: int = 0) -> str:
|
||||||
|
"""获取合并转发聊天记录中某个内嵌文件/图片的本地副本路径。
|
||||||
|
|
||||||
|
使用流程:
|
||||||
|
1. 先用 get_chat_history 找到 [聊天记录] xxx (local_id=N, ts=T) 卡片,记下 N 和 T,
|
||||||
|
以及展开行里 [item_index] 前缀(0-based)
|
||||||
|
2. 用本工具拿本地路径,create_time 传 history 里的 ts 部分
|
||||||
|
3. 如果未下载,工具会精确告诉你去 wechat 客户端点击合并卡片里的第几项触发下载
|
||||||
|
|
||||||
|
注意:合并转发里的内嵌文件只有在用户**点击查看**后 wechat 才会下载到本地。
|
||||||
|
没点过的 dataitem 用本工具会得到"未下载"提示。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
chat_name: 聊天对象的名字、备注名或wxid
|
||||||
|
local_id: 合并转发消息(带"[聊天记录]"标记)的 local_id
|
||||||
|
item_index: dataitem 在 datalist 里的 0-based 索引(history 输出里的 [N] 前缀)
|
||||||
|
create_time: 消息的 unix 时间戳;用于跨分片唯一定位,传 0 时多匹配会报歧义
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
local_id = int(local_id)
|
||||||
|
item_index = int(item_index)
|
||||||
|
create_time = int(create_time)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return "错误: local_id / item_index / create_time 必须是整数"
|
||||||
|
|
||||||
|
username = resolve_username(chat_name)
|
||||||
|
if not username:
|
||||||
|
return f"找不到聊天对象: {chat_name}"
|
||||||
|
|
||||||
|
# 多分片扫描 + ambiguity 检测(避免 silent decoding wrong message,参考 decode_file_message)
|
||||||
|
shards = _find_msg_tables_for_user(username)
|
||||||
|
if not shards:
|
||||||
|
return f"找不到 {chat_name} 的消息表"
|
||||||
|
|
||||||
|
matches = []
|
||||||
|
for shard in shards:
|
||||||
|
if not _is_safe_msg_table_name(shard['table_name']):
|
||||||
|
continue
|
||||||
|
with closing(sqlite3.connect(shard['db_path'])) as conn:
|
||||||
|
if create_time:
|
||||||
|
candidate_row = conn.execute(
|
||||||
|
f"SELECT local_type, create_time, message_content, WCDB_CT_message_content "
|
||||||
|
f"FROM [{shard['table_name']}] WHERE local_id=? AND create_time=?",
|
||||||
|
(local_id, create_time)
|
||||||
|
).fetchone()
|
||||||
|
else:
|
||||||
|
candidate_row = conn.execute(
|
||||||
|
f"SELECT local_type, create_time, message_content, WCDB_CT_message_content "
|
||||||
|
f"FROM [{shard['table_name']}] WHERE local_id=?",
|
||||||
|
(local_id,)
|
||||||
|
).fetchone()
|
||||||
|
if candidate_row:
|
||||||
|
matches.append((shard['table_name'], candidate_row))
|
||||||
|
if not matches:
|
||||||
|
if create_time:
|
||||||
|
return f"找不到 (local_id={local_id}, create_time={create_time}) 的消息(已扫描 {len(shards)} 个分片)"
|
||||||
|
return f"找不到 local_id={local_id} 的消息(已扫描 {len(shards)} 个分片)"
|
||||||
|
if len(matches) > 1:
|
||||||
|
details = []
|
||||||
|
for tn, r in matches:
|
||||||
|
ts_str = datetime.fromtimestamp(r[1]).isoformat() if r[1] else '?'
|
||||||
|
details.append(f"table={tn[:12]}... create_time={r[1]} ({ts_str})")
|
||||||
|
return (
|
||||||
|
f"local_id={local_id} 在 {len(matches)} 个分片中都存在,无法唯一定位:\n "
|
||||||
|
+ '\n '.join(details)
|
||||||
|
+ f"\n请加 create_time 参数:decode_record_item(chat_name, local_id={local_id}, item_index={item_index}, create_time=N)"
|
||||||
|
)
|
||||||
|
|
||||||
|
table_name, row = matches[0]
|
||||||
|
local_type, _create_time, content, ct_compress = row
|
||||||
|
base_type, _ = _split_msg_type(local_type)
|
||||||
|
if base_type != 49:
|
||||||
|
return (
|
||||||
|
f"不是合并转发消息(local_type={local_type}, base_type={base_type}),"
|
||||||
|
f"合并转发应为 base_type=49 + appmsg type=19"
|
||||||
|
)
|
||||||
|
|
||||||
|
xml_text = _decompress_content(content, ct_compress)
|
||||||
|
if not xml_text:
|
||||||
|
return "消息 content 为空或无法解码"
|
||||||
|
|
||||||
|
# 复用项目内现有 helper 剥离群聊 sender 前缀,避免自己写启发式
|
||||||
|
is_group = username.endswith('@chatroom')
|
||||||
|
_, xml_text = _parse_message_content(xml_text, local_type, is_group)
|
||||||
|
|
||||||
|
root = _parse_app_message_outer(xml_text)
|
||||||
|
if root is None:
|
||||||
|
return "无法解析消息 XML(可能不是合并转发消息)"
|
||||||
|
appmsg = root.find('.//appmsg')
|
||||||
|
if appmsg is None:
|
||||||
|
return "消息中没有 appmsg 段"
|
||||||
|
|
||||||
|
app_type = _parse_int(_collapse_text(appmsg.findtext('type') or ''), 0)
|
||||||
|
if app_type != 19:
|
||||||
|
return (
|
||||||
|
f"不是合并转发消息(appmsg type={app_type}),"
|
||||||
|
f"合并转发应为 type=19。请用 decode_file_message 处理外层独立文件"
|
||||||
|
)
|
||||||
|
|
||||||
|
record_node = appmsg.find('recorditem')
|
||||||
|
if record_node is None or not record_node.text:
|
||||||
|
return "消息中没有 recorditem(datalist 还未加载,请在 wechat 中点开此卡片让客户端拉取)"
|
||||||
|
|
||||||
|
inner = _parse_xml_root(record_node.text, max_len=_RECORD_XML_PARSE_MAX_LEN)
|
||||||
|
if inner is None:
|
||||||
|
return "无法解析 recorditem 内嵌 XML"
|
||||||
|
|
||||||
|
datalist = inner.find('datalist')
|
||||||
|
items = list(datalist.findall('dataitem')) if datalist is not None else []
|
||||||
|
if not items:
|
||||||
|
return "datalist 为空(合并记录还未加载内容)"
|
||||||
|
if item_index < 0 or item_index >= len(items):
|
||||||
|
return f"item_index={item_index} 超出范围(共 {len(items)} 条 dataitem,0-based)"
|
||||||
|
|
||||||
|
item = items[item_index]
|
||||||
|
datatype = (item.get('datatype') or '').strip()
|
||||||
|
raw_datatitle = _collapse_text(item.findtext('datatitle') or '')
|
||||||
|
# datatitle 来自不可信 XML,sanitize 防 path traversal
|
||||||
|
datatitle = _safe_basename(raw_datatitle) if raw_datatitle else ''
|
||||||
|
if raw_datatitle and not datatitle:
|
||||||
|
return f"该 dataitem 的 datatitle {raw_datatitle!r} 不安全(含绝对路径/分隔符/..),拒绝处理"
|
||||||
|
datasize = _parse_int(_collapse_text(item.findtext('datasize') or ''), 0)
|
||||||
|
datafmt = _collapse_text(item.findtext('datafmt') or '')
|
||||||
|
sourcename = _collapse_text(item.findtext('sourcename') or '')
|
||||||
|
# fullmd5 是文件内容唯一标识,用于把候选绑定到这条 record,避免误命中
|
||||||
|
# 同 chat 内别条 record 的同名同 size 文件。
|
||||||
|
expected_md5 = _collapse_text(item.findtext('fullmd5') or '').lower()
|
||||||
|
|
||||||
|
type_label = _RECORD_DATATYPE_LABEL.get(datatype, f'datatype={datatype}')
|
||||||
|
|
||||||
|
if datatype == '1':
|
||||||
|
text_content = _collapse_text(item.findtext('datadesc') or '')
|
||||||
|
return (
|
||||||
|
f"该 dataitem 是文本,无需下载:\n"
|
||||||
|
f" 发送者: {sourcename}\n"
|
||||||
|
f" 内容: {text_content}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 仅以下 datatype 在 wechat 缓存里有真本地 binary(图片/语音/视频/文件);
|
||||||
|
# 其他类型如链接/位置/名片/小程序/视频号/嵌套聊天记录等只是 metadata,
|
||||||
|
# 没有可下载的本地副本。不在白名单里的 datatype 直接拒绝,避免 wildcard
|
||||||
|
# sub='*' 通配命中无关 record 的同名文件。
|
||||||
|
subdir_map = _RECORD_BINARY_SUBDIR
|
||||||
|
if datatype not in subdir_map:
|
||||||
|
return (
|
||||||
|
f"该 dataitem 类型 [{type_label}] 没有本地 binary 文件,无需下载\n"
|
||||||
|
f" 发送者: {sourcename}\n"
|
||||||
|
f" 标题: {datatitle or '(无)'}\n"
|
||||||
|
f" 说明:仅 datatype=2/4/5/8(图片/语音/视频/文件)有可下载内容;"
|
||||||
|
f"链接/位置/名片/小程序/视频号/嵌套聊天记录等是 metadata-only。"
|
||||||
|
f"\n如果你需要这条 dataitem 的 metadata 详情,看 get_chat_history 输出里"
|
||||||
|
f"已展开的 [{item_index}] 行内容即可。"
|
||||||
|
)
|
||||||
|
|
||||||
|
table_hash = table_name.replace('Msg_', '', 1)
|
||||||
|
attach_dir = os.path.join(WECHAT_BASE_DIR, 'msg/attach', table_hash)
|
||||||
|
|
||||||
|
candidates = []
|
||||||
|
if os.path.isdir(attach_dir):
|
||||||
|
import glob as glob_mod
|
||||||
|
sub = subdir_map.get(datatype, '*')
|
||||||
|
idx_str = str(item_index)
|
||||||
|
|
||||||
|
# datatype=2 图片走 flat 文件命名 (Img/0_t / Img/0 / Img/0.{ext}),
|
||||||
|
# 不像文件类的 F/{idx}/{filename}。
|
||||||
|
if datatype == '2':
|
||||||
|
flat_patterns = [
|
||||||
|
f"{idx_str}_t",
|
||||||
|
idx_str,
|
||||||
|
f"{idx_str}.*",
|
||||||
|
f"{idx_str}_*",
|
||||||
|
]
|
||||||
|
for fp in flat_patterns:
|
||||||
|
for hit in glob.glob(os.path.join(attach_dir, '*/Rec/*', sub, fp)):
|
||||||
|
if datasize:
|
||||||
|
try:
|
||||||
|
if os.path.getsize(hit) != datasize:
|
||||||
|
continue
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if hit not in candidates:
|
||||||
|
candidates.append(hit)
|
||||||
|
|
||||||
|
# 文件 / 视频 / 语音类: F|V|A/{idx}/{filename}
|
||||||
|
if datatype != '2' and datatitle:
|
||||||
|
escaped_title = glob.escape(datatitle)
|
||||||
|
for hit in glob.glob(os.path.join(attach_dir, '*/Rec/*', sub, idx_str, escaped_title)):
|
||||||
|
if datasize:
|
||||||
|
try:
|
||||||
|
if os.path.getsize(hit) != datasize:
|
||||||
|
continue
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if hit not in candidates:
|
||||||
|
candidates.append(hit)
|
||||||
|
|
||||||
|
# size only 兜底:仅在 datatitle 缺失且非 image(image 已上面处理)时启用
|
||||||
|
if not candidates and not datatitle and datasize and datatype != '2':
|
||||||
|
for hit in glob.glob(os.path.join(attach_dir, '*/Rec/*', sub, idx_str, '*')):
|
||||||
|
try:
|
||||||
|
if os.path.getsize(hit) == datasize:
|
||||||
|
candidates.append(hit)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
return (
|
||||||
|
f"在本地缓存中找不到此 dataitem(很可能未在 wechat 客户端点击查看过)\n"
|
||||||
|
f" 消息: {chat_name} 的 local_id={local_id}\n"
|
||||||
|
f" dataitem[{item_index}]: {sourcename}: [{type_label}] {datatitle or '(无标题)'}\n"
|
||||||
|
f" 期望大小: {datasize:,} bytes\n"
|
||||||
|
f" 期望路径模式: {attach_dir}/YYYY-MM/Rec/*/{subdir_map.get(datatype, '?')}/{item_index}/{datatitle}\n"
|
||||||
|
f" 解决方法: 在 wechat 客户端打开此合并记录卡片,点击第 {item_index + 1} 项让客户端下载,再试"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 注意:早 ambiguity check(在 md5 filter 之前)已经被删除——它会让有 fullmd5
|
||||||
|
# 但多 candidates 的合理 case silent 失败。md5 filter 后再做歧义判断(见下方)。
|
||||||
|
# 威胁模型:本工具是用户主动通过 MCP 调用 + path 只在本地显示。
|
||||||
|
# 跟 decode_file_message 一致路线:有 md5 强校验,没 md5 fallback 到
|
||||||
|
# heuristic + warning(实用 over 严格)。
|
||||||
|
cache_root = os.path.join(WECHAT_BASE_DIR, 'msg')
|
||||||
|
md5_verified = False
|
||||||
|
if expected_md5 and len(expected_md5) == 32:
|
||||||
|
md5_match = []
|
||||||
|
md5_errors = []
|
||||||
|
for c in candidates:
|
||||||
|
if not _path_under_root(c, cache_root):
|
||||||
|
md5_errors.append(f"{c}: 不在 {cache_root} 下,跳过")
|
||||||
|
continue
|
||||||
|
actual_md5, err = _md5_file_chunked(c)
|
||||||
|
if err:
|
||||||
|
md5_errors.append(f"{c}: {err}")
|
||||||
|
continue
|
||||||
|
if actual_md5 == expected_md5:
|
||||||
|
md5_match.append(c)
|
||||||
|
break # 多候选共享同 md5 = 同一文件副本,第一个命中即停
|
||||||
|
if not md5_match:
|
||||||
|
info = (
|
||||||
|
f"⚠️ 候选文件 md5 都不匹配,拒绝返回错文件:\n"
|
||||||
|
f" 期望 md5: {expected_md5}\n"
|
||||||
|
f" 说明:候选 {len(candidates)} 个,md5 都不对。"
|
||||||
|
f"目标 dataitem 可能未在 wechat 客户端点开过,请点击第 {item_index + 1} 项触发下载。"
|
||||||
|
)
|
||||||
|
if md5_errors:
|
||||||
|
info += "\n 校验异常:\n " + "\n ".join(md5_errors)
|
||||||
|
return info
|
||||||
|
candidates = md5_match
|
||||||
|
md5_verified = True
|
||||||
|
|
||||||
|
# 没 fullmd5 时多 candidates 仍 fail-closed
|
||||||
|
if len(candidates) > 1 and not md5_verified:
|
||||||
|
details = []
|
||||||
|
for c in candidates:
|
||||||
|
try:
|
||||||
|
mt = datetime.fromtimestamp(os.path.getmtime(c)).isoformat()
|
||||||
|
except OSError:
|
||||||
|
mt = '?'
|
||||||
|
details.append(f"{c} (mtime={mt})")
|
||||||
|
return (
|
||||||
|
f"找到 {len(candidates)} 个匹配的本地副本,无法唯一定位"
|
||||||
|
f"(同位置同名同 size 多份,且 dataitem XML 没含 fullmd5 用于强校验):\n "
|
||||||
|
+ '\n '.join(details)
|
||||||
|
+ f"\n请人工 inspect mtime / 上下文区分"
|
||||||
|
)
|
||||||
|
|
||||||
|
chosen = candidates[0]
|
||||||
|
if not _path_under_root(chosen, cache_root):
|
||||||
|
return f"匹配到的路径 {chosen!r} 不在 {cache_root} 下,拒绝返回(可能是 symlink 攻击)"
|
||||||
|
|
||||||
|
binding_note = (
|
||||||
|
"✅ md5 校验通过,路径与 dataitem 唯一绑定"
|
||||||
|
if md5_verified else
|
||||||
|
f"⚠️ 此 dataitem XML 没含 fullmd5,路径基于 (item_index+filename+size) 启发式匹配——"
|
||||||
|
f"如果同 chat 内多条合并卡片碰巧含同位置同名同 size 的文件,可能返回别条 record 的副本,请人工验证。"
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f"找到本地文件:\n"
|
||||||
|
f" 路径: {chosen}\n"
|
||||||
|
f" 大小: {os.path.getsize(chosen):,} bytes\n"
|
||||||
|
f" 期望大小: {datasize:,} bytes\n"
|
||||||
|
f" 发送者: {sourcename}\n"
|
||||||
|
f" 类型: [{type_label}] {datatitle or '(无标题)'}\n"
|
||||||
|
f" {binding_note}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def get_chat_images(chat_name: str, limit: int = 20) -> str:
|
def get_chat_images(chat_name: str, limit: int = 20) -> str:
|
||||||
"""列出某个聊天中的图片消息。
|
"""列出某个聊天中的图片消息。
|
||||||
|
|||||||
313
tests/test_record_decoders.py
Normal file
313
tests/test_record_decoders.py
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
"""Helper-level regression tests for the recorditem / decoder additions.
|
||||||
|
|
||||||
|
Focused on locking in the bugs fixed across PR #65's many review rounds so
|
||||||
|
they don't regress. Covers helpers that are easy to call in isolation:
|
||||||
|
|
||||||
|
- `_safe_basename` path-traversal sanitize (round-4 high #1)
|
||||||
|
- `_md5_file_chunked` streaming hash + size cap (round-6 medium #3)
|
||||||
|
- `_parse_message_content` group prefix stripping for both `:\n` and
|
||||||
|
`:<?xml`/`:<msg` shapes (round-7 high #1)
|
||||||
|
- `_parse_app_message_outer` retry-with-wider-limit only fires for
|
||||||
|
`<type>19</type>` content (round-5 medium #3)
|
||||||
|
- `_format_record_message_text` end-to-end expansion of a >20KB outer
|
||||||
|
type-19 message (round-5 high #1, round-2 P2-1)
|
||||||
|
- `_format_record_dataitem` per-datatype rendering for the 14 known
|
||||||
|
types incl. text / file / image / 视频号 etc.
|
||||||
|
|
||||||
|
The two MCP-tool wrappers (decode_file_message / decode_record_item) lean
|
||||||
|
heavily on module globals (WECHAT_BASE_DIR, _cache, MSG_DB_KEYS) and the
|
||||||
|
real wechat cache layout. They are exercised by real-data smoke runs in
|
||||||
|
the PR description rather than mocked here — mocking the entire wechat
|
||||||
|
cache tree would dwarf the actual logic under test.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import mcp_server
|
||||||
|
|
||||||
|
|
||||||
|
# -------- _safe_basename ----------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class SafeBasenameTests(unittest.TestCase):
|
||||||
|
def test_normal_filename_passes(self):
|
||||||
|
self.assertEqual(mcp_server._safe_basename('normal.pdf'), 'normal.pdf')
|
||||||
|
self.assertEqual(
|
||||||
|
mcp_server._safe_basename('Lec 4- 零和.pdf'), 'Lec 4- 零和.pdf'
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
mcp_server._safe_basename('file (1).pdf'), 'file (1).pdf'
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_absolute_path_rejected(self):
|
||||||
|
self.assertEqual(mcp_server._safe_basename('/etc/passwd'), '')
|
||||||
|
|
||||||
|
def test_parent_dir_rejected(self):
|
||||||
|
# Strict reject — should not return the basename 'sensitive'.
|
||||||
|
self.assertEqual(mcp_server._safe_basename('../../sensitive'), '')
|
||||||
|
self.assertEqual(mcp_server._safe_basename('..'), '')
|
||||||
|
|
||||||
|
def test_path_separator_rejected(self):
|
||||||
|
self.assertEqual(mcp_server._safe_basename('subdir/x.pdf'), '')
|
||||||
|
self.assertEqual(mcp_server._safe_basename('a\\b\\c.pdf'), '')
|
||||||
|
|
||||||
|
def test_nul_rejected(self):
|
||||||
|
self.assertEqual(mcp_server._safe_basename('with\x00nul.pdf'), '')
|
||||||
|
|
||||||
|
def test_empty_or_dot_rejected(self):
|
||||||
|
self.assertEqual(mcp_server._safe_basename(''), '')
|
||||||
|
self.assertEqual(mcp_server._safe_basename('.'), '')
|
||||||
|
|
||||||
|
def test_inner_dots_pass(self):
|
||||||
|
# 'file...with..dots.pdf' has no separator → fine.
|
||||||
|
self.assertEqual(
|
||||||
|
mcp_server._safe_basename('file...with..dots.pdf'),
|
||||||
|
'file...with..dots.pdf',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -------- _md5_file_chunked -------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class Md5FileChunkedTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.NamedTemporaryFile(delete=False)
|
||||||
|
self.tmp.write(b'x' * 1000)
|
||||||
|
self.tmp.close()
|
||||||
|
self.addCleanup(lambda: os.unlink(self.tmp.name))
|
||||||
|
|
||||||
|
def test_happy_path_matches_hashlib(self):
|
||||||
|
md5, err = mcp_server._md5_file_chunked(self.tmp.name)
|
||||||
|
self.assertIsNone(err)
|
||||||
|
self.assertEqual(md5, hashlib.md5(b'x' * 1000).hexdigest())
|
||||||
|
|
||||||
|
def test_size_cap_rejects_oversized_file(self):
|
||||||
|
md5, err = mcp_server._md5_file_chunked(self.tmp.name, max_size=500)
|
||||||
|
self.assertIsNone(md5)
|
||||||
|
self.assertIn('超过 md5 校验上限', err)
|
||||||
|
|
||||||
|
def test_missing_file_returns_error(self):
|
||||||
|
md5, err = mcp_server._md5_file_chunked('/tmp/no/such/path/here_xxx')
|
||||||
|
self.assertIsNone(md5)
|
||||||
|
self.assertIsNotNone(err)
|
||||||
|
|
||||||
|
|
||||||
|
# -------- _parse_message_content --------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class ParseMessageContentTests(unittest.TestCase):
|
||||||
|
def test_legacy_newline_prefix_in_group(self):
|
||||||
|
sender, text = mcp_server._parse_message_content(
|
||||||
|
'wxid_abc:\n<msg>hi</msg>', 1, is_group=True
|
||||||
|
)
|
||||||
|
self.assertEqual(sender, 'wxid_abc')
|
||||||
|
self.assertEqual(text, '<msg>hi</msg>')
|
||||||
|
|
||||||
|
def test_xml_decl_inline_prefix_in_group(self):
|
||||||
|
# round-7 high #1: 'sender:<?xml...' without newline
|
||||||
|
sender, text = mcp_server._parse_message_content(
|
||||||
|
'wxid_abc:<?xml version="1.0"?><msg>x</msg>', 1, is_group=True
|
||||||
|
)
|
||||||
|
self.assertEqual(sender, 'wxid_abc')
|
||||||
|
self.assertTrue(text.startswith('<?xml'))
|
||||||
|
|
||||||
|
def test_msg_inline_prefix_in_group(self):
|
||||||
|
sender, text = mcp_server._parse_message_content(
|
||||||
|
'wxid_abc:<msg>x</msg>', 1, is_group=True
|
||||||
|
)
|
||||||
|
self.assertEqual(sender, 'wxid_abc')
|
||||||
|
self.assertEqual(text, '<msg>x</msg>')
|
||||||
|
|
||||||
|
def test_private_chat_does_not_strip(self):
|
||||||
|
sender, text = mcp_server._parse_message_content(
|
||||||
|
'wxid_abc:<msg>x</msg>', 1, is_group=False
|
||||||
|
)
|
||||||
|
self.assertEqual(sender, '')
|
||||||
|
self.assertEqual(text, 'wxid_abc:<msg>x</msg>')
|
||||||
|
|
||||||
|
def test_bytes_content_returns_marker(self):
|
||||||
|
sender, text = mcp_server._parse_message_content(b'\x00\x01', 1, is_group=False)
|
||||||
|
self.assertEqual(sender, '')
|
||||||
|
self.assertEqual(text, '(二进制内容)')
|
||||||
|
|
||||||
|
|
||||||
|
# -------- _parse_app_message_outer ------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class ParseAppMessageOuterTests(unittest.TestCase):
|
||||||
|
def test_small_xml_uses_default_path(self):
|
||||||
|
outer = '<msg><appmsg><type>5</type><title>x</title></appmsg></msg>'
|
||||||
|
root = mcp_server._parse_app_message_outer(outer)
|
||||||
|
self.assertIsNotNone(root)
|
||||||
|
|
||||||
|
def test_oversized_non_record_xml_short_circuits(self):
|
||||||
|
# round-5 medium #3: only <type>19</type> content should retry under
|
||||||
|
# the wider 500K cap. A 25KB non-type-19 message must NOT be parsed
|
||||||
|
# under the wider limit.
|
||||||
|
outer = '<msg><appmsg><type>5</type><title>' + 'X' * 25000 + '</title></appmsg></msg>'
|
||||||
|
root = mcp_server._parse_app_message_outer(outer)
|
||||||
|
self.assertIsNone(root)
|
||||||
|
|
||||||
|
def test_oversized_record_xml_retries(self):
|
||||||
|
# type=19 content > 20KB should succeed under the wider cap.
|
||||||
|
big_desc = 'A' * 25000
|
||||||
|
outer = (
|
||||||
|
'<msg><appmsg><type>19</type><title>x</title>'
|
||||||
|
f'<recorditem><![CDATA[<recordinfo><title>x</title>'
|
||||||
|
f'<datalist count="1"><dataitem datatype="1">'
|
||||||
|
f'<datadesc>{big_desc}</datadesc></dataitem></datalist>'
|
||||||
|
f'</recordinfo>]]></recorditem></appmsg></msg>'
|
||||||
|
)
|
||||||
|
self.assertGreater(len(outer), 20000)
|
||||||
|
root = mcp_server._parse_app_message_outer(outer)
|
||||||
|
self.assertIsNotNone(root)
|
||||||
|
|
||||||
|
|
||||||
|
# -------- _format_record_dataitem ------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class FormatRecordDataitemTests(unittest.TestCase):
|
||||||
|
def _item(self, xml):
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
return ET.fromstring(xml)
|
||||||
|
|
||||||
|
def test_text(self):
|
||||||
|
item = self._item(
|
||||||
|
'<dataitem datatype="1"><datadesc>hello world</datadesc></dataitem>'
|
||||||
|
)
|
||||||
|
self.assertEqual(mcp_server._format_record_dataitem(item), 'hello world')
|
||||||
|
|
||||||
|
def test_file_with_title(self):
|
||||||
|
item = self._item(
|
||||||
|
'<dataitem datatype="8"><datatitle>report.pdf</datatitle></dataitem>'
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
mcp_server._format_record_dataitem(item), '[文件] report.pdf'
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_image(self):
|
||||||
|
item = self._item('<dataitem datatype="2"></dataitem>')
|
||||||
|
self.assertEqual(mcp_server._format_record_dataitem(item), '[图片]')
|
||||||
|
|
||||||
|
def test_finder_feed(self):
|
||||||
|
# round-2 datatype 22 视频号
|
||||||
|
item = self._item(
|
||||||
|
'<dataitem datatype="22"><finderFeed><desc>video desc</desc></finderFeed></dataitem>'
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
mcp_server._format_record_dataitem(item), '[视频号] video desc'
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_music(self):
|
||||||
|
item = self._item(
|
||||||
|
'<dataitem datatype="29"><datatitle>song</datatitle><datadesc>artist</datadesc></dataitem>'
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
mcp_server._format_record_dataitem(item), '[音乐] song - artist'
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unknown_datatype_falls_back_to_desc(self):
|
||||||
|
item = self._item(
|
||||||
|
'<dataitem datatype="99"><datadesc>fallback content</datadesc></dataitem>'
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
mcp_server._format_record_dataitem(item), 'fallback content'
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unknown_datatype_with_no_desc_uses_label(self):
|
||||||
|
item = self._item('<dataitem datatype="999"></dataitem>')
|
||||||
|
self.assertEqual(
|
||||||
|
mcp_server._format_record_dataitem(item), '[未知类型 999]'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -------- _format_record_message_text end-to-end ---------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class FormatRecordMessageTextTests(unittest.TestCase):
|
||||||
|
def _outer_with_items(self, items_xml, title='Big card', is_chatroom=False):
|
||||||
|
chatroom = '<isChatRoom>1</isChatRoom>' if is_chatroom else ''
|
||||||
|
recordinfo = (
|
||||||
|
f'<recordinfo><title>{title}</title>{chatroom}'
|
||||||
|
f'<datalist count="{items_xml.count("<dataitem")}">{items_xml}</datalist>'
|
||||||
|
f'</recordinfo>'
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
'<?xml version="1.0"?><msg><appmsg><title>x</title><type>19</type>'
|
||||||
|
f'<recorditem><![CDATA[{recordinfo}]]></recorditem>'
|
||||||
|
'</appmsg></msg>'
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_large_outer_expands_via_app_message_path(self):
|
||||||
|
# round-2 P2-1 + round-5 high #1: 大 outer 端到端必须能展开
|
||||||
|
items_xml = ''.join(
|
||||||
|
f'<dataitem datatype="1"><sourcename>S{i}</sourcename>'
|
||||||
|
f'<sourcetime>2025-01-01 00:00</sourcetime>'
|
||||||
|
f'<datadesc>{"X" * 600}</datadesc></dataitem>'
|
||||||
|
for i in range(40)
|
||||||
|
)
|
||||||
|
outer = self._outer_with_items(items_xml)
|
||||||
|
self.assertGreater(len(outer), 20000)
|
||||||
|
out = mcp_server._format_app_message_text(
|
||||||
|
outer,
|
||||||
|
(19 << 32) | 49,
|
||||||
|
False,
|
||||||
|
'wxid_dummy',
|
||||||
|
'dummy',
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(out)
|
||||||
|
self.assertIn('[聊天记录]', out)
|
||||||
|
self.assertIn('共 40 条', out)
|
||||||
|
# 每行带 0-based index
|
||||||
|
self.assertIn('[0] ', out)
|
||||||
|
self.assertIn('[1] ', out)
|
||||||
|
|
||||||
|
def test_empty_datalist_marks_loading(self):
|
||||||
|
# 空 datalist 应展示"(待加载)"而非"共 0 条"
|
||||||
|
outer = (
|
||||||
|
'<?xml version="1.0"?><msg><appmsg><title>x</title><type>19</type>'
|
||||||
|
'<recorditem><![CDATA[<recordinfo><title>x</title>'
|
||||||
|
'<isChatRoom>0</isChatRoom></recordinfo>]]></recorditem>'
|
||||||
|
'</appmsg></msg>'
|
||||||
|
)
|
||||||
|
out = mcp_server._format_app_message_text(
|
||||||
|
outer, (19 << 32) | 49, False, 'd', 'd', {}
|
||||||
|
)
|
||||||
|
self.assertIn('待加载', out)
|
||||||
|
|
||||||
|
def test_chatroom_marker_appended(self):
|
||||||
|
items_xml = (
|
||||||
|
'<dataitem datatype="1"><sourcename>A</sourcename>'
|
||||||
|
'<datadesc>hi</datadesc></dataitem>'
|
||||||
|
)
|
||||||
|
outer = self._outer_with_items(items_xml, title='G', is_chatroom=True)
|
||||||
|
out = mcp_server._format_app_message_text(
|
||||||
|
outer, (19 << 32) | 49, True, 'd', 'd', {}
|
||||||
|
)
|
||||||
|
self.assertIn('群聊转发', out)
|
||||||
|
|
||||||
|
def test_overflow_truncation_marker(self):
|
||||||
|
# > _RECORD_MAX_ITEMS dataitems should produce a
|
||||||
|
# "…(还有 N 条未显示)" line.
|
||||||
|
original_max = mcp_server._RECORD_MAX_ITEMS
|
||||||
|
try:
|
||||||
|
mcp_server._RECORD_MAX_ITEMS = 3
|
||||||
|
items_xml = ''.join(
|
||||||
|
f'<dataitem datatype="1"><datadesc>m{i}</datadesc></dataitem>'
|
||||||
|
for i in range(7)
|
||||||
|
)
|
||||||
|
outer = self._outer_with_items(items_xml)
|
||||||
|
out = mcp_server._format_app_message_text(
|
||||||
|
outer, (19 << 32) | 49, False, 'd', 'd', {}
|
||||||
|
)
|
||||||
|
self.assertIn('还有 4 条未显示', out)
|
||||||
|
finally:
|
||||||
|
mcp_server._RECORD_MAX_ITEMS = original_max
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user