Files
zWorkFlow/chat_export_helpers.py
Belugary f03df51561 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).
2026-05-12 21:03:08 +08:00

191 lines
6.2 KiB
Python

"""聊天导出共享工具函数。
本模块包含 export_chat.py 和 export_all_chats.py 共用的消息格式化函数。
统一维护,避免两处代码漂移。
"""
import base64
import mcp_server
MSG_TYPE_MAP = {
1: "text",
3: "image",
34: "voice",
42: "contact_card",
43: "video",
47: "sticker",
48: "location",
49: "link_or_file",
50: "call",
10000: "system",
10002: "recall",
}
def _msg_type_str(local_type):
base, _ = mcp_server._split_msg_type(local_type)
return MSG_TYPE_MAP.get(base, f"type_{local_type}")
def _resolve_sender(row, ctx, names, id_to_username):
"""Resolve the sender of a message.
Returns "me" for the logged-in user, or the sender's display name otherwise
(the contact's name in 1-on-1 chats, the member's name in groups). Empty
string for unattributable messages (e.g. system notifications).
"""
local_id, local_type, create_time, real_sender_id, content, ct = row
decoded = mcp_server._decompress_content(content, ct)
sender_from_content, _ = mcp_server._format_message_text(
local_id, local_type, decoded, ctx["is_group"], ctx["username"], ctx["display_name"], names
)
label = mcp_server._resolve_sender_label(
real_sender_id,
sender_from_content,
ctx["is_group"],
ctx["username"],
ctx["display_name"],
names,
id_to_username,
)
return label or ""
def _decode_sticker_desc(b64_desc):
"""WeChat encodes sticker labels as base64 protobuf: repeated (lang, text) pairs.
Returns the 'default' language label (usually Chinese), or None.
Limitation: treats the length byte as a single octet rather than a real protobuf
varint — labels >127 bytes would be misread. In practice sticker descriptions are
short (<30 chars), so this is adequate. Also sensitive to the bytes b"default"
appearing inside a preceding value; no such cases observed.
"""
try:
raw = base64.b64decode(b64_desc)
except Exception:
return None
# Find the 'default' marker; text follows as: \x12 <varint len> <utf-8>
i = raw.find(b"default")
if i < 0 or i + 7 >= len(raw) or raw[i + 7] != 0x12:
return None
try:
text_len = raw[i + 8]
text_bytes = raw[i + 9 : i + 9 + text_len]
return text_bytes.decode("utf-8") or None
except (IndexError, UnicodeDecodeError):
return None
def _format_sticker_message(content):
root = mcp_server._parse_xml_root(content) if content else None
if root is None:
return "[表情]"
emoji = root.find(".//emoji")
if emoji is None:
return "[表情]"
desc = emoji.get("desc") or ""
label = _decode_sticker_desc(desc) if desc else None
return f"[表情] {label}" if label else "[表情]"
def _format_system_message(content):
if not content:
return "[系统消息]"
if "<sysmsg" not in content:
return content
root = mcp_server._parse_xml_root(content)
if root is None:
return content
inner = root.findtext(".//content")
return inner.strip() if inner else content
def _format_video_message(content):
root = mcp_server._parse_xml_root(content) if content else None
if root is None:
return "[视频]"
video = root.find(".//videomsg")
if video is None:
return "[视频]"
playlength = video.get("playlength")
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, None
base, _ = mcp_server._split_msg_type(local_type)
if base == 1:
return (content or ""), None
if base == 43:
return _format_video_message(content), None
if base == 47:
return _format_sticker_message(content), None
if base == 49:
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), None
if base == 10000:
return _format_system_message(content), None
if base == 10002:
return "[撤回消息]", None
return None, None