Files
zWorkFlow/export_chat.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

135 lines
4.5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
将单个聊天的全部消息导出为 JSON。
用法:
.venv/bin/python3 export_chat.py <chat_name> [output.json]
参数:
<chat_name> 联系人显示名、备注名、群名或 wxid。
[output.json] 可选输出路径,默认 "<chat_name>_export.json"
示例:
.venv/bin/python3 export_chat.py <contact_name>
.venv/bin/python3 export_chat.py <group_name> /tmp/out.json
输出 JSON 的紧凑结构:
{
"chat": "<display name>",
"username": "<wxid 或 @chatroom>",
"exported_at": "YYYY-MM-DD HH:MM:SS",
"is_group": true, // 仅群聊出现
"messages": [
{"local_id": 1, "timestamp": 1713..., "sender": "me", "content": "..."},
{"local_id": 2, "timestamp": 1713..., "sender": "<name>", "type": "voice"}
]
}
默认值/空值会被省略: text 消息省略 "type",无可提取内容时省略 "content"
1-on-1 聊天省略 "is_group"
语音消息以 type "voice" 导出且不带 transcription 字段;运行
transcribe_chat.py 可用 Whisper 补齐转录。
需先完成 WeChat DB 解密(详见 README
完整 schema、字段语义与加载示例: docs/chat_export_format.md
"""
import json
import sqlite3
import sys
from contextlib import closing
from datetime import datetime
import mcp_server
from chat_export_helpers import (
_extract_content,
_msg_type_str,
_resolve_sender,
)
def export_chat(chat_name, output_path):
ctx = mcp_server._resolve_chat_context(chat_name)
if ctx is None:
print(f"Could not resolve chat: {chat_name}")
sys.exit(1)
username = ctx["username"]
display_name = ctx["display_name"]
# resolve_username 对模糊匹配会静默选第一个命中,打印一下便于用户核对。
print(f"Resolved to: {display_name} ({username})")
if not ctx["message_tables"]:
print(f"No message tables found for {username}")
sys.exit(1)
names = mcp_server.get_contact_names()
# Each shard has its own Name2Id table, so we must pair rows with the
# id_to_username map from their source DB.
all_rows = []
for table_info in ctx["message_tables"]:
db_path = table_info["db_path"]
table_name = table_info["table_name"]
with closing(sqlite3.connect(db_path)) as conn:
id_to_username = mcp_server._load_name2id_maps(conn)
rows = mcp_server._query_messages(conn, table_name, limit=None, oldest_first=True)
for row in rows:
all_rows.append((row, id_to_username))
# Sort across shards by create_time (defensive "or 0" in case a row has NULL).
all_rows.sort(key=lambda pair: pair[0][2] or 0)
messages = []
for row, id_to_username in all_rows:
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, 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.
msg = {
"local_id": local_id,
"timestamp": create_time,
"sender": sender,
}
# 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 = {
"chat": display_name,
"username": username,
"exported_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"messages": messages,
}
if ctx["is_group"]:
output["is_group"] = True
with open(output_path, "w", encoding="utf-8") as f:
json.dump(output, f, ensure_ascii=False, indent=2)
print(f"Exported {len(messages)} messages to {output_path}")
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python3 export_chat.py <chat_name> [output.json]")
sys.exit(1)
chat = sys.argv[1]
out = sys.argv[2] if len(sys.argv) > 2 else f"{chat}_export.json"
export_chat(chat, out)