"""fmessage 系统消息 XML 解析器。 解析微信 4.x Linux 的 fmessage sysmsg XML,提取好友申请信息。 无状态函数,便于单测。 XML 格式(推断,需环境验证): xxx@stranger 申请人昵称 验证消息内容 ... """ from __future__ import annotations import logging import xml.etree.ElementTree as ET from typing import Optional from woc_bridge.models.contact import FriendRequestInfo logger = logging.getLogger("woc-bridge") # 可能的好友申请 XML 类型标记(大小写不敏感) _VERIFYUSER_TYPES = {"verifyuser", "verify_user", "verification"} def parse_friend_request( content: str, create_time: int, msg_local_id: int ) -> Optional[FriendRequestInfo]: """解析 fmessage 系统消息 XML,提取好友申请信息。 兼容多种实际格式: - ...... - ...... - ... 根节点 - 标签大小写/命名空间不一致 Args: content: message_content 原始文本(XML) create_time: 消息时间戳 msg_local_id: 消息 local_id Returns: FriendRequestInfo 或 None(解析失败 / 非 verifyUser 类型) """ if not content or not content.strip(): return None try: root = ET.fromstring(content) except ET.ParseError: # 部分消息可能不是合法 XML(如纯文本通知),跳过 return None # 1. 判断是否为好友申请类型 if not _is_verify_user_root(root): return None # 2. 提取字段:优先 Link 子树,其次 verifyuser/verification 子树 link = root.find(".//Link") if link is not None: stranger_wxid = _text(link, "UserName") nickname = _text(link, "NickName") verify_message = _text(link, "Content") scene = _text(link, "Scene") else: # 无 Link 节点时从任意子节点按候选标签名搜索 stranger_wxid = _find_text(root, ["UserName", "username", "v1", "ticket"]) nickname = _find_text(root, ["NickName", "nickname", "nick"]) verify_message = _find_text(root, ["Content", "content", "msg", "verifyContent"]) scene = _find_text(root, ["Scene", "scene", "sceneid", "scene_id"]) # 3. 从 ticket/encrypt username 中反推带 @stranger 的 wxid if not stranger_wxid: return None if "@" not in stranger_wxid: # 微信 ticket 常见为 wxid_xxx@stranger 或加密串,统一补后缀 # 若已含 @ 则保留原样(如 @stranger / @openim) stranger_wxid = f"{stranger_wxid}@stranger" return FriendRequestInfo( stranger_wxid=stranger_wxid, nickname=nickname, verify_message=verify_message, scene=scene, raw_xml=content, create_time=create_time, msg_local_id=msg_local_id, ) def _is_verify_user_root(root: ET.Element) -> bool: """判断 XML 根节点是否表示好友申请。""" tag = root.tag.split("}")[-1] if root.tag else "" if tag.lower() in _VERIFYUSER_TYPES: return True if tag.lower() == "sysmsg": msg_type = (root.get("type") or "").lower() if msg_type in _VERIFYUSER_TYPES: return True return False def _text(parent: ET.Element, tag: str) -> str: """安全提取直接子元素文本(None 安全,去掉命名空间)。""" tag_lower = tag.lower() for child in parent: child_tag = child.tag.split("}")[-1] if child.tag else "" if child_tag.lower() == tag_lower: return child.text.strip() if child.text else "" return "" def _find_child_recursive(parent: ET.Element, tag: str) -> Optional[ET.Element]: """按本地标签名递归查找后代元素(忽略命名空间)。""" tag_lower = tag.lower() for elem in parent.iter(): elem_tag = elem.tag.split("}")[-1] if elem.tag else "" if elem_tag.lower() == tag_lower: return elem return None def _find_text(parent: ET.Element, tags: list[str]) -> str: """按候选标签名列表顺序递归查找,返回首个非空文本。""" for tag in tags: el = _find_child_recursive(parent, tag) if el is not None and el.text: return el.text.strip() return ""