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

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

51
decode_transfer.py Normal file
View File

@@ -0,0 +1,51 @@
"""
读取微信转账消息appmsg type=2000的结构化字段。
用法:
python3 decode_transfer.py <chat_name> <local_id> [<ts>]
参数:
<chat_name> 联系人显示名、备注名或 wxid仅 1v1 聊天有转账消息)。
<local_id> 转账消息的 local_id从 export_chat 输出 / monitor_web 等地方获取)。
[<ts>] 可选 unix 时间戳。当 local_id 在多个分片冲突时用它唯一定位。
输出: 多行可读文本,含方向(发起/收款/退还)、金额、备注、付款/收款 wxid、
交易号、发起/失效时间。
需先完成 WeChat DB 解密(详见 README。本 CLI 是 mcp_server.decode_transfer
工具的命令行包装,输出格式与 MCP 工具一致。
"""
import argparse
import sys
import mcp_server
def main() -> int:
parser = argparse.ArgumentParser(
prog="python3 decode_transfer.py",
description="读取微信转账消息的结构化字段",
)
parser.add_argument("chat_name", help="联系人名/备注/wxid")
parser.add_argument("local_id", type=int, help="转账消息的 local_id")
parser.add_argument(
"ts",
nargs="?",
type=int,
default=0,
help="消息的 unix 时间戳(跨分片唯一定位时需要,可省略)",
)
args = parser.parse_args()
result = mcp_server.decode_transfer(args.chat_name, args.local_id, args.ts)
print(result)
# 如果工具返回错误文案,退出码非 0 便于 shell 脚本判断
if result.startswith(("错误:", "找不到", "不是转账消息", "无法解析", "消息中没有", "消息 content")):
return 1
if "无法唯一定位" in result:
return 2
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -62,13 +62,21 @@ def export_one(username, output_dir, names):
local_id, local_type, create_time, real_sender_id, content, ct = row
sender = _resolve_sender(row, ctx, names, id_to_username)
type_str = _msg_type_str(local_type)
rendered = _extract_content(local_id, local_type, content, ct, username, display_name)
rendered, extras = _extract_content(
local_id, local_type, content, ct, username, display_name
)
msg = {"local_id": local_id, "timestamp": create_time, "sender": sender}
if type_str != "text":
msg["type"] = type_str
effective_type = (extras or {}).get("type") or type_str
if effective_type != "text":
msg["type"] = effective_type
if rendered is not None:
msg["content"] = rendered
if extras:
for k, v in extras.items():
if k == "type":
continue
msg[k] = v
messages.append(msg)
if not messages:

View File

@@ -85,7 +85,9 @@ def export_chat(chat_name, output_path):
local_id, local_type, create_time, real_sender_id, content, ct = row
sender = _resolve_sender(row, ctx, names, id_to_username)
type_str = _msg_type_str(local_type)
rendered = _extract_content(local_id, local_type, content, ct, username, display_name)
rendered, extras = _extract_content(
local_id, local_type, content, ct, username, display_name
)
# Compact format: omit defaults/nulls. type defaults to "text", transcription
# is added later by transcribe_chat.py only for voice messages. See CLAUDE.md.
@@ -94,10 +96,18 @@ def export_chat(chat_name, output_path):
"timestamp": create_time,
"sender": sender,
}
if type_str != "text":
msg["type"] = type_str
# extras may override type with a more specific value (e.g. "transfer"
# narrower than the generic "link_or_file" base=49 maps to).
effective_type = (extras or {}).get("type") or type_str
if effective_type != "text":
msg["type"] = effective_type
if rendered is not None:
msg["content"] = rendered
if extras:
for k, v in extras.items():
if k == "type":
continue
msg[k] = v
messages.append(msg)
output = {

View File

@@ -732,6 +732,9 @@ def _format_app_message_text(content, local_type, is_group, chat_username, chat_
if app_type == 19:
return _format_record_message_text(appmsg, title)
if app_type == 2000:
return _format_transfer_message_text(appmsg, title)
if app_type == 6:
return f"[文件] {title}" if title else "[文件]"
if app_type == 5:
@@ -845,6 +848,80 @@ def _format_record_message_text(appmsg, title):
return "\n".join(lines)
# 微信转账 (appmsg type=2000, <wcpayinfo>) paysubtype 含义。
# 微信官方无公开文档此表来自社区抓包归纳。1/3/4 在所有已知版本一致;
# 5/7/8 在不同版本存在变体("过期已退还"在某些抓包里也归为 4所以遇到
# 未识别值时降级显示原始数字,方便用户自行核对。
_TRANSFER_PAYSUBTYPE_LABEL = {
'1': '发起转账', # 发送方记录:等待对方收钱
'3': '已收款', # 双向:发送方看到"对方已收",接收方看到"已收钱"
'4': '已退还', # 主动退还或被退还
'5': '过期已退还', # 24h 未收,自动退还(发送方记录)
'7': '待领取', # 已发起未接收
'8': '已领取', # 部分版本:转账被领取(接收方记录)
}
def _extract_transfer_info(appmsg):
"""从 appmsg type=2000 解出 wcpayinfo 各字段,返回 dict 或 None。
字段大小写在不同微信版本间漂移(见过 feedesc/feeDesc, pay_memo/paymemo
用 lower-case 兜底。所有值用 _collapse_text 清掉换行/前后空白。
"""
info = appmsg.find('wcpayinfo')
if info is None:
return None
def _pick(*tags):
for t in tags:
v = _collapse_text(info.findtext(t) or '')
if v:
return v
return ''
paysubtype = _pick('paysubtype')
return {
'paysubtype': paysubtype,
'paysubtype_label': _TRANSFER_PAYSUBTYPE_LABEL.get(
paysubtype, f'未知(paysubtype={paysubtype})' if paysubtype else ''
),
# feedesc 通常是 "¥0.01" 风格的展示串feedescxml 是富文本变体
'fee_desc': _pick('feedesc', 'feeDesc'),
'pay_memo': _pick('pay_memo', 'paymemo'),
# 三种交易号transcationid 是微信支付侧(注意拼写是 transc 不是 trans
# transferid 是微信内部转账 idpaymsgid 偶见于旧版本
'transcation_id': _pick('transcationid', 'transcationId'),
'transfer_id': _pick('transferid', 'transferId'),
'pay_msg_id': _pick('paymsgid', 'payMsgId'),
'begin_transfer_time': _pick('begintransfertime', 'beginTransferTime'),
'invalid_time': _pick('invalidtime', 'invalidTime'),
'effective_date': _pick('effectivedate', 'effectiveDate'),
'payer_username': _pick('payer_username', 'payerUsername'),
'receiver_username': _pick('receiver_username', 'receiverUsername'),
}
def _format_transfer_message_text(appmsg, title):
"""渲染微信转账appmsg type=2000一行展示文本给 history / monitor_web 共用。
fallback 顺序:
1) wcpayinfo 缺失 → 只显示 title 兜底,避免吞数据
2) paysubtype 未知 → 显示原始数字让用户自查
3) 没有 fee_desc → 至少给个方向标签
"""
info = _extract_transfer_info(appmsg)
if not info:
return f"[转账] {title}" if title else "[转账]"
label = info['paysubtype_label'] or '转账'
parts = [f"[转账·{label}]"] if label != '转账' else ["[转账]"]
if info['fee_desc']:
parts.append(info['fee_desc'])
if info['pay_memo']:
parts.append(f"备注: {info['pay_memo']}")
return ' '.join(parts)
def _format_voip_message_text(content):
if not content or '<voip' not in content:
return None
@@ -2506,6 +2583,149 @@ def decode_record_item(chat_name: str, local_id: int, item_index: int, create_ti
)
@mcp.tool()
def decode_transfer(chat_name: str, local_id: int, create_time: int = 0) -> str:
"""读取微信转账消息appmsg type=2000的结构化信息。
返回方向(发起/收款/退还)、金额、备注、付款人/收款人 wxid、交易号、
发起/失效时间。仅 1v1 聊天有转账消息(微信不支持群转账)。
使用流程:先用 get_chat_history 找到 [转账·xxx] 行 (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}"
# 多分片扫描 + ambiguity 检测,跟 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['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_transfer(chat_name, local_id={local_id}, create_time=N)"
)
_, row = matches[0]
local_type, msg_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=2000"
)
xml_text = _decompress_content(content, ct_compress)
if not xml_text:
return "消息 content 为空或无法解码"
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 != 2000:
return (
f"不是转账消息appmsg type={app_type})。"
f"转账要求 appmsg type=2000type=6 是文件type=19 是合并转发,"
f"请用对应的 decode_file_message / decode_record_item 工具"
)
info = _extract_transfer_info(appmsg)
if info is None:
return "消息是 type=2000 但缺 <wcpayinfo> 节点schema 异常)"
def _fmt_ts(ts_str):
ts = _parse_int(ts_str, 0)
if not ts:
return ''
try:
return datetime.fromtimestamp(ts).isoformat()
except (ValueError, OSError, OverflowError):
return f'(无效 ts={ts_str})'
direction = info['paysubtype_label'] or '(未知)'
raw_paysubtype = info['paysubtype'] or '?'
title = _collapse_text(appmsg.findtext('title') or '') or '微信转账'
des = _collapse_text(appmsg.findtext('des') or '')
lines = [f"转账消息: {title}"]
if des:
lines.append(f" 描述: {des}")
lines.append(f" 方向: {direction} (paysubtype={raw_paysubtype})")
if info['fee_desc']:
lines.append(f" 金额: {info['fee_desc']}")
if info['pay_memo']:
lines.append(f" 备注: {info['pay_memo']}")
if info['payer_username']:
lines.append(f" 付款方 wxid: {info['payer_username']}")
if info['receiver_username']:
lines.append(f" 收款方 wxid: {info['receiver_username']}")
begin_ts = _fmt_ts(info['begin_transfer_time'])
if begin_ts:
lines.append(f" 发起时间: {begin_ts}")
invalid_ts = _fmt_ts(info['invalid_time'])
if invalid_ts:
lines.append(f" 失效时间: {invalid_ts}")
if info['transfer_id']:
lines.append(f" 转账 ID: {info['transfer_id']}")
if info['transcation_id']:
lines.append(f" 支付交易号: {info['transcation_id']}")
if info['pay_msg_id']:
lines.append(f" paymsgid: {info['pay_msg_id']}")
return "\n".join(lines)
@mcp.tool()
def get_chat_images(chat_name: str, limit: int = 20) -> str:
"""列出某个聊天中的图片消息。

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;

View File

@@ -309,5 +309,162 @@ class FormatRecordMessageTextTests(unittest.TestCase):
mcp_server._RECORD_MAX_ITEMS = original_max
# -------- WCPay transfer (appmsg type=2000) --------------------------------
#
# All fixtures use synthetic placeholder values — no real wxid / fee / id /
# memo. paysubtype semantics are community consensus from open-source wechat
# tooling; treat any "未识别" branch as forward-compatible degradation.
class TransferPaysubTypeLabelTests(unittest.TestCase):
def test_known_subtypes_present(self):
labels = mcp_server._TRANSFER_PAYSUBTYPE_LABEL
self.assertEqual(labels['1'], '发起转账')
self.assertEqual(labels['3'], '已收款')
self.assertEqual(labels['4'], '已退还')
# 5/7/8 are version-dependent variants — locked to current text so a
# silent rename in mcp_server.py would surface here.
self.assertEqual(labels['5'], '过期已退还')
self.assertEqual(labels['7'], '待领取')
self.assertEqual(labels['8'], '已领取')
def _transfer_appmsg(
paysubtype='1',
fee_desc='¥100.00',
pay_memo='',
payer='wxid_payer_synth',
receiver='wxid_recv_synth',
transferid='1' + '0' * 27,
transcationid='1' + '0' * 27,
begin_ts='1746528000',
invalid_ts='1746614400',
title='微信转账',
des='请收钱',
feedesc_tag='feedesc',
paymemo_tag='pay_memo',
):
"""Build a synthetic appmsg type=2000 root element. All values are
placeholder; tests never run against real wechat data."""
import xml.etree.ElementTree as ET
fee_node = f'<{feedesc_tag}>{fee_desc}</{feedesc_tag}>' if fee_desc else ''
memo_node = f'<{paymemo_tag}>{pay_memo}</{paymemo_tag}>' if pay_memo else ''
xml_text = (
f'<msg><appmsg><title>{title}</title><des>{des}</des>'
f'<type>2000</type>'
f'<wcpayinfo>'
f'<paysubtype>{paysubtype}</paysubtype>'
f'{fee_node}{memo_node}'
f'<transferid>{transferid}</transferid>'
f'<transcationid>{transcationid}</transcationid>'
f'<begintransfertime>{begin_ts}</begintransfertime>'
f'<invalidtime>{invalid_ts}</invalidtime>'
f'<payer_username>{payer}</payer_username>'
f'<receiver_username>{receiver}</receiver_username>'
f'</wcpayinfo></appmsg></msg>'
)
return ET.fromstring(xml_text), xml_text
class ExtractTransferInfoTests(unittest.TestCase):
def test_full_fields_round_trip(self):
root, _ = _transfer_appmsg(paysubtype='3', pay_memo='lunch split')
appmsg = root.find('.//appmsg')
info = mcp_server._extract_transfer_info(appmsg)
self.assertIsNotNone(info)
self.assertEqual(info['paysubtype'], '3')
self.assertEqual(info['paysubtype_label'], '已收款')
self.assertEqual(info['fee_desc'], '¥100.00')
self.assertEqual(info['pay_memo'], 'lunch split')
self.assertEqual(info['payer_username'], 'wxid_payer_synth')
self.assertEqual(info['receiver_username'], 'wxid_recv_synth')
self.assertEqual(info['begin_transfer_time'], '1746528000')
self.assertEqual(info['invalid_time'], '1746614400')
self.assertTrue(info['transfer_id'].startswith('1'))
self.assertTrue(info['transcation_id'].startswith('1'))
def test_missing_wcpayinfo_returns_none(self):
import xml.etree.ElementTree as ET
root = ET.fromstring(
'<msg><appmsg><title>x</title><type>2000</type></appmsg></msg>'
)
appmsg = root.find('.//appmsg')
self.assertIsNone(mcp_server._extract_transfer_info(appmsg))
def test_camelcase_feedesc_falls_back(self):
# 部分微信版本字段名为 feeDesc 而非 feedesc
root, _ = _transfer_appmsg(feedesc_tag='feeDesc')
appmsg = root.find('.//appmsg')
info = mcp_server._extract_transfer_info(appmsg)
self.assertEqual(info['fee_desc'], '¥100.00')
def test_camelcase_paymemo_falls_back(self):
# paymemo (无下划线) 也是已知变体
root, _ = _transfer_appmsg(pay_memo='note', paymemo_tag='paymemo')
appmsg = root.find('.//appmsg')
info = mcp_server._extract_transfer_info(appmsg)
self.assertEqual(info['pay_memo'], 'note')
def test_unknown_paysubtype_label_degraded(self):
root, _ = _transfer_appmsg(paysubtype='99')
appmsg = root.find('.//appmsg')
info = mcp_server._extract_transfer_info(appmsg)
self.assertEqual(info['paysubtype'], '99')
self.assertIn('99', info['paysubtype_label'])
def test_empty_paysubtype_label_empty(self):
root, _ = _transfer_appmsg(paysubtype='')
appmsg = root.find('.//appmsg')
info = mcp_server._extract_transfer_info(appmsg)
self.assertEqual(info['paysubtype_label'], '')
class FormatTransferMessageTextTests(unittest.TestCase):
def test_initiate_with_amount(self):
root, _ = _transfer_appmsg(paysubtype='1')
appmsg = root.find('.//appmsg')
out = mcp_server._format_transfer_message_text(appmsg, '微信转账')
self.assertIn('[转账·发起转账]', out)
self.assertIn('¥100.00', out)
def test_received_with_memo(self):
root, _ = _transfer_appmsg(paysubtype='3', pay_memo='lunch')
appmsg = root.find('.//appmsg')
out = mcp_server._format_transfer_message_text(appmsg, '微信转账')
self.assertIn('[转账·已收款]', out)
self.assertIn('备注: lunch', out)
def test_missing_wcpayinfo_falls_back_to_title(self):
import xml.etree.ElementTree as ET
root = ET.fromstring(
'<msg><appmsg><title>微信转账</title><type>2000</type></appmsg></msg>'
)
appmsg = root.find('.//appmsg')
out = mcp_server._format_transfer_message_text(appmsg, '微信转账')
self.assertEqual(out, '[转账] 微信转账')
def test_missing_fee_desc_safe(self):
# 没有金额时也要给一行能看的输出,不能崩
root, _ = _transfer_appmsg(paysubtype='4', fee_desc='')
appmsg = root.find('.//appmsg')
out = mcp_server._format_transfer_message_text(appmsg, '微信转账')
self.assertIn('[转账·已退还]', out)
class AppMessageDispatchTransferTests(unittest.TestCase):
"""type=2000 must route through _format_transfer_message_text via
_format_app_message_text (so get_chat_history / export_chat both pick it up)."""
def test_dispatch_calls_transfer_helper(self):
_, xml_text = _transfer_appmsg(paysubtype='3', pay_memo='dinner')
out = mcp_server._format_app_message_text(
xml_text, 49, False, 'wxid_dummy', 'dummy', {}
)
self.assertIsNotNone(out)
self.assertIn('[转账·已收款]', out)
self.assertIn('¥100.00', out)
self.assertIn('备注: dinner', out)
if __name__ == '__main__':
unittest.main()