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
|
||||
|
||||
51
decode_transfer.py
Normal file
51
decode_transfer.py
Normal 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())
|
||||
@@ -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:
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
220
mcp_server.py
220
mcp_server.py
@@ -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 是微信内部转账 id,paymsgid 偶见于旧版本
|
||||
'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=2000;type=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:
|
||||
"""列出某个聊天中的图片消息。
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user