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).
135 lines
4.5 KiB
Python
135 lines
4.5 KiB
Python
"""
|
||
将单个聊天的全部消息导出为 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)
|