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:
Belugary
2026-05-12 21:03:08 +08:00
committed by GitHub
parent 8ea7e61a07
commit f03df51561
7 changed files with 546 additions and 15 deletions

View File

@@ -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;