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:
@@ -1317,6 +1317,29 @@ class SessionMonitor:
|
||||
'des': des[:200] if des else '',
|
||||
'items': items,
|
||||
}
|
||||
elif app_type == 2000:
|
||||
# 微信转账 — paysubtype 含义为社区共识表,1/3/4 跨版本一致;
|
||||
# 字段名在不同版本有 snake/camel 漂移,逐个尝试
|
||||
info = appmsg.find('wcpayinfo')
|
||||
paysubtype = ''
|
||||
fee_desc = ''
|
||||
pay_memo = ''
|
||||
if info is not None:
|
||||
paysubtype = (info.findtext('paysubtype') or '').strip()
|
||||
fee_desc = (info.findtext('feedesc') or info.findtext('feeDesc') or '').strip()
|
||||
pay_memo = (info.findtext('pay_memo') or info.findtext('paymemo') or '').strip()
|
||||
direction = {
|
||||
'1': '发起转账', '3': '已收款', '4': '已退还',
|
||||
'5': '过期已退还', '7': '待领取', '8': '已领取',
|
||||
}.get(paysubtype, '')
|
||||
return {
|
||||
'type': 'transfer',
|
||||
'title': title or '微信转账',
|
||||
'direction': direction,
|
||||
'paysubtype': paysubtype,
|
||||
'fee_desc': fee_desc,
|
||||
'pay_memo': pay_memo[:200] if pay_memo else '',
|
||||
}
|
||||
else:
|
||||
# 其他子类型: 用 title 显示
|
||||
if title:
|
||||
@@ -1657,6 +1680,10 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;b
|
||||
.chatlog-item{font-size:12px;color:#999;line-height:1.5;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.chatlog-item b{color:#bbb;font-weight:500}
|
||||
.chatlog-more{font-size:11px;color:#555;margin-top:4px}
|
||||
.msg-transfer{display:inline-block;background:rgba(255,170,60,.1);border:1px solid rgba(255,170,60,.25);border-radius:8px;padding:8px 14px;margin-top:4px;min-width:180px}
|
||||
.msg-transfer-head{font-size:13px;color:#ffb84d;font-weight:500}
|
||||
.msg-transfer-amount{font-size:18px;color:#ffd28a;font-weight:600;margin-top:4px}
|
||||
.msg-transfer-memo{font-size:11px;color:#999;margin-top:4px}
|
||||
a.msg-link{text-decoration:none;color:inherit}
|
||||
#lightbox{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.92);z-index:1000;cursor:zoom-out;justify-content:center;align-items:center}
|
||||
#lightbox.show{display:flex}
|
||||
@@ -1772,6 +1799,12 @@ function renderRich(r){
|
||||
}
|
||||
return `<div class="msg-chatlog"><div class="msg-link-title">📋 ${esc(r.title)}</div>${body}</div>`;
|
||||
}
|
||||
if(r.type==='transfer') {
|
||||
let dirLabel = r.direction || '微信转账';
|
||||
let amount = r.fee_desc ? '<div class="msg-transfer-amount">'+esc(r.fee_desc)+'</div>' : '';
|
||||
let memo = r.pay_memo ? '<div class="msg-transfer-memo">备注: '+esc(r.pay_memo)+'</div>' : '';
|
||||
return `<div class="msg-transfer"><div class="msg-transfer-head">💸 ${esc(dirLabel)}</div>${amount}${memo}</div>`;
|
||||
}
|
||||
if(r.type==='voice') return `<div class="msg-voice">🎤 语音 ${r.duration}s</div>`;
|
||||
if(r.type==='video') return `<div class="msg-video">🎬 视频${r.duration?' '+r.duration+'s':''}</div>`;
|
||||
return null;
|
||||
|
||||
Reference in New Issue
Block a user