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).
This commit is contained in:
@@ -113,26 +113,78 @@ def _format_video_message(content):
|
||||
return f"[视频] {playlength}秒" if playlength else "[视频]"
|
||||
|
||||
|
||||
def _extract_transfer_extras(content):
|
||||
"""Detect appmsg type=2000 and return structured transfer fields, else None.
|
||||
|
||||
Reuses mcp_server._extract_transfer_info so the schema/version-quirks logic
|
||||
lives in one place. Empty values are dropped to keep the export compact.
|
||||
Numeric timestamps are returned as ints (consistent with the top-level
|
||||
`timestamp` field), not iso strings — downstream consumers can format.
|
||||
"""
|
||||
if not content or '<appmsg' not in content:
|
||||
return None
|
||||
root = mcp_server._parse_app_message_outer(content)
|
||||
if root is None:
|
||||
return None
|
||||
appmsg = root.find('.//appmsg')
|
||||
if appmsg is None:
|
||||
return None
|
||||
app_type = mcp_server._parse_int(
|
||||
mcp_server._collapse_text(appmsg.findtext('type') or ''), 0
|
||||
)
|
||||
if app_type != 2000:
|
||||
return None
|
||||
|
||||
info = mcp_server._extract_transfer_info(appmsg)
|
||||
if not info:
|
||||
return None
|
||||
|
||||
out = {}
|
||||
if info['paysubtype_label']:
|
||||
out['direction'] = info['paysubtype_label']
|
||||
for k in ('paysubtype', 'fee_desc', 'pay_memo',
|
||||
'payer_username', 'receiver_username',
|
||||
'transfer_id', 'transcation_id', 'pay_msg_id'):
|
||||
v = info.get(k)
|
||||
if v:
|
||||
out[k] = v
|
||||
for k in ('begin_transfer_time', 'invalid_time'):
|
||||
v = mcp_server._parse_int(info.get(k) or '', 0)
|
||||
if v:
|
||||
out[k] = v
|
||||
return out or None
|
||||
|
||||
|
||||
def _extract_content(local_id, local_type, content, ct, chat_username, chat_display_name):
|
||||
"""Return (rendered_text, extras_dict). Either may be None.
|
||||
|
||||
extras carries structured fields for non-text message types where caller
|
||||
wants more than the human-readable string (currently: transfer). Future
|
||||
additions (video号 metadata, merged-forward expansion, …) can flow through
|
||||
the same channel without changing the caller signature.
|
||||
"""
|
||||
content = mcp_server._decompress_content(content, ct)
|
||||
if content is None:
|
||||
return None
|
||||
return None, None
|
||||
|
||||
base, _ = mcp_server._split_msg_type(local_type)
|
||||
if base == 1:
|
||||
return content or ""
|
||||
return (content or ""), None
|
||||
if base == 43:
|
||||
return _format_video_message(content)
|
||||
return _format_video_message(content), None
|
||||
if base == 47:
|
||||
return _format_sticker_message(content)
|
||||
return _format_sticker_message(content), None
|
||||
if base == 49:
|
||||
return mcp_server._format_app_message_text(
|
||||
rendered = mcp_server._format_app_message_text(
|
||||
content, local_type, False, chat_username, chat_display_name, {}
|
||||
)
|
||||
transfer = _extract_transfer_extras(content)
|
||||
extras = {'type': 'transfer', 'transfer': transfer} if transfer else None
|
||||
return rendered, extras
|
||||
if base == 50:
|
||||
return mcp_server._format_voip_message_text(content)
|
||||
return mcp_server._format_voip_message_text(content), None
|
||||
if base == 10000:
|
||||
return _format_system_message(content)
|
||||
return _format_system_message(content), None
|
||||
if base == 10002:
|
||||
return "[撤回消息]"
|
||||
return None
|
||||
return "[撤回消息]", None
|
||||
return None, None
|
||||
|
||||
Reference in New Issue
Block a user