fix(mcp): trim raw XML payload from namecard (type=42) chat output (#83)
When a chat history contains a name-card message (msg_type=42), the
dispatcher in `_format_message_text` had no case for `base_type == 42`,
so it fell through to the generic non-text branch:
elif base_type != 1:
type_label = format_msg_type(local_type)
text = f"[{type_label}] {text}" if text else f"[{type_label}]"
`text` for type=42 is the full raw `<msg ...>` element, so chat history
exports emitted `[名片] <msg username="..." antispamticket="v2_..."
brandIconUrl="https://wx.qlogo.cn/..." bigheadimgurl="..." ... />`.
That payload has two problems:
1. It leaks anti-spam tokens (`antispamticket`) and head-image CDN URLs
into chat logs that are routinely piped to LLMs and other downstream
tools.
2. The raw XML drowns out the actual signal — a human or an LLM reading
the chat just wants to know "X shared Y's contact".
This PR adds `_format_namecard_text(content)` that pulls only the three
useful attributes:
- `nickname` — display name
- `username` — wxid (annotated as "公众号" when prefixed `gh_`)
- `certinfo` — user-authored bio
and wires it into the dispatch chain via a new `elif base_type == 42:`
branch, sitting alongside the existing `49` (app message) handler. It
reuses `_parse_xml_root` and `_collapse_text` — no new helpers
introduced.
Tests: 7 cases in `tests/test_namecard_format.py` covering the realistic
shape (with antispamticket / brand URLs that must NOT appear in output),
official accounts (`gh_*`), missing certinfo, missing nickname, missing
both identifiers, and broken-XML fallthrough.
All 158 tests pass locally (151 baseline + 7 new).
This commit is contained in:
@@ -666,6 +666,31 @@ def _parse_app_message_outer(content):
|
|||||||
return root
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
def _format_namecard_text(content):
|
||||||
|
"""Parse type=42 (名片) XML into a compact human-readable line.
|
||||||
|
|
||||||
|
Source XML carries dozens of fields (antispamticket, biznamecardinfo,
|
||||||
|
brand URLs, image MD5s) but the useful signal is just three attrs:
|
||||||
|
``nickname`` (display name), ``username`` (wxid; ``gh_*`` for 公众号),
|
||||||
|
and ``certinfo`` (the user-authored bio). Everything else is either
|
||||||
|
auth tokens that should not be piped to downstream systems, or
|
||||||
|
rendering metadata that bloats the chat log without helping a human
|
||||||
|
or an LLM understand the conversation.
|
||||||
|
"""
|
||||||
|
root = _parse_xml_root(content)
|
||||||
|
if root is None:
|
||||||
|
return None
|
||||||
|
nickname = (root.get("nickname") or "").strip()
|
||||||
|
username = (root.get("username") or "").strip()
|
||||||
|
certinfo = _collapse_text(root.get("certinfo") or "")
|
||||||
|
if not nickname and not username:
|
||||||
|
return None
|
||||||
|
head = nickname or username
|
||||||
|
if username.startswith("gh_"):
|
||||||
|
head = f"{head} (公众号 {username})"
|
||||||
|
return f"[名片] {head}: {certinfo}" if certinfo else f"[名片] {head}"
|
||||||
|
|
||||||
|
|
||||||
def _format_app_message_text(content, local_type, is_group, chat_username, chat_display_name, names):
|
def _format_app_message_text(content, local_type, is_group, chat_username, chat_display_name, names):
|
||||||
if not content or '<appmsg' not in content:
|
if not content or '<appmsg' not in content:
|
||||||
return None
|
return None
|
||||||
@@ -865,6 +890,8 @@ def _format_message_text(local_id, local_type, content, is_group, chat_username,
|
|||||||
text = "[表情]"
|
text = "[表情]"
|
||||||
elif base_type == 50:
|
elif base_type == 50:
|
||||||
text = _format_voip_message_text(text) or "[通话]"
|
text = _format_voip_message_text(text) or "[通话]"
|
||||||
|
elif base_type == 42:
|
||||||
|
text = _format_namecard_text(text) or "[名片]"
|
||||||
elif base_type == 49:
|
elif base_type == 49:
|
||||||
formatted = _format_app_message_text(
|
formatted = _format_app_message_text(
|
||||||
text, local_type, is_group, chat_username, chat_display_name, names
|
text, local_type, is_group, chat_username, chat_display_name, names
|
||||||
|
|||||||
73
tests/test_namecard_format.py
Normal file
73
tests/test_namecard_format.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
"""Tests for `_format_namecard_text` (msg_type=42 鉴定).
|
||||||
|
|
||||||
|
Before this helper, type=42 messages fell through the generic non-text branch
|
||||||
|
and emitted `[名片] <raw XML>`, dumping the full `<msg .../>` element including
|
||||||
|
antispamticket, biznamecardinfo and head-image URLs. Those tokens are PII that
|
||||||
|
should not be piped to downstream LLM / log systems.
|
||||||
|
|
||||||
|
These tests pin the new behaviour: a compact `[名片] <head>: <bio>` line,
|
||||||
|
without any source-only XML fields.
|
||||||
|
"""
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import mcp_server
|
||||||
|
|
||||||
|
|
||||||
|
# Realistic-shape sample with the noisy / sensitive attrs that used to leak.
|
||||||
|
_REAL_NAMECARD = (
|
||||||
|
'<msg username="wxid_friend_demo" nickname="李雷" '
|
||||||
|
'antispamticket="v2_abc123def456_should_not_leak" '
|
||||||
|
'fullpy="lilei" shortpy="LL" alias="" '
|
||||||
|
'imagestatus="3" scene="17" province="北京" city="海淀" sign="" '
|
||||||
|
'sex="1" certflag="0" certinfo="搬砖工人 / 业余摄影" '
|
||||||
|
'brandIconUrl="https://wx.qlogo.cn/should_not_leak" '
|
||||||
|
'bigheadimgurl="https://wx.qlogo.cn/should_not_leak_big" '
|
||||||
|
'smallheadimgurl="https://wx.qlogo.cn/should_not_leak_small" />'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FormatNamecardTextTests(unittest.TestCase):
|
||||||
|
def test_compact_line_for_real_namecard(self):
|
||||||
|
out = mcp_server._format_namecard_text(_REAL_NAMECARD)
|
||||||
|
self.assertEqual(out, "[名片] 李雷: 搬砖工人 / 业余摄影")
|
||||||
|
|
||||||
|
def test_no_pii_or_url_in_output(self):
|
||||||
|
out = mcp_server._format_namecard_text(_REAL_NAMECARD)
|
||||||
|
self.assertNotIn("antispamticket", out)
|
||||||
|
self.assertNotIn("v2_abc123def456", out)
|
||||||
|
self.assertNotIn("qlogo.cn", out)
|
||||||
|
self.assertNotIn("brandIconUrl", out)
|
||||||
|
self.assertNotIn("headimgurl", out)
|
||||||
|
|
||||||
|
def test_official_account_marked(self):
|
||||||
|
xml = (
|
||||||
|
'<msg username="gh_some_official" nickname="Some Official Account" '
|
||||||
|
'certinfo="一个公众号" />'
|
||||||
|
)
|
||||||
|
out = mcp_server._format_namecard_text(xml)
|
||||||
|
self.assertEqual(
|
||||||
|
out, "[名片] Some Official Account (公众号 gh_some_official): 一个公众号"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_no_certinfo_falls_back_to_head_only(self):
|
||||||
|
xml = '<msg username="wxid_demo" nickname="韩梅梅" />'
|
||||||
|
out = mcp_server._format_namecard_text(xml)
|
||||||
|
self.assertEqual(out, "[名片] 韩梅梅")
|
||||||
|
|
||||||
|
def test_only_username_when_nickname_missing(self):
|
||||||
|
xml = '<msg username="wxid_demo" nickname="" />'
|
||||||
|
out = mcp_server._format_namecard_text(xml)
|
||||||
|
self.assertEqual(out, "[名片] wxid_demo")
|
||||||
|
|
||||||
|
def test_missing_both_identifiers_returns_none(self):
|
||||||
|
xml = '<msg nickname="" username="" />'
|
||||||
|
self.assertIsNone(mcp_server._format_namecard_text(xml))
|
||||||
|
|
||||||
|
def test_broken_xml_returns_none(self):
|
||||||
|
self.assertIsNone(mcp_server._format_namecard_text(""))
|
||||||
|
self.assertIsNone(mcp_server._format_namecard_text("<msg "))
|
||||||
|
self.assertIsNone(mcp_server._format_namecard_text("not xml at all"))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user