""" 微盛群消息 → InboundMessage。 本文件职责:从管家 records 抽出 sender_id / chat_id / 正文 / 附件名。 禁止:把存档席「询价机器人」当成发言人入业务;禁止再向企微换 ID。 """ from __future__ import annotations import json from typing import Any, Mapping, Optional, Sequence from agent.channel.wecom.models import InboundMessage # 存档席在群里的显示名;from 偶发不是 userid 时也要丢掉 SEAT_DISPLAY_NAMES = frozenset({"询价机器人"}) _SKIP_TYPES = frozenset({"revoke", "recall", "event", "unknown"}) _FILE_TYPES = frozenset({"file", "image", "video", "emotion", "voice"}) def _as_mapping(raw: Any) -> dict[str, Any]: if isinstance(raw, Mapping): return dict(raw) if isinstance(raw, str) and raw.strip().startswith("{"): try: parsed = json.loads(raw) except json.JSONDecodeError: return {} return dict(parsed) if isinstance(parsed, Mapping) else {} return {} def _create_time(msgtime: Any) -> int: """微盛 msgtime 常是毫秒;InboundMessage.create_time 用秒。""" try: n = int(msgtime or 0) except (TypeError, ValueError): return 0 if n > 10**12: return n // 1000 return n def _unwrap_text(raw: Any) -> str: """ 从字符串或嵌套对象取出正文。 管家 content 经常是摘要;企微官方结构是 text.content。空、null 不当正文。 """ if raw is None: return "" if isinstance(raw, str): text = raw.strip() if not text or text.lower() in {"null", "none"}: return "" if text.startswith("{") or text.startswith("["): try: return _unwrap_text(json.loads(text)) except json.JSONDecodeError: return text return text if isinstance(raw, Mapping): for key in ("content", "text", "msg"): got = _unwrap_text(raw.get(key)) if got: return got return "" return str(raw).strip() def _best_text(record: Mapping[str, Any], payload: Mapping[str, Any]) -> str: """多处正文里取最长的一份,避免摘要盖掉带金额的原文。""" candidates = [ _unwrap_text(record.get("content")), _unwrap_text(payload.get("content")), _unwrap_text(payload.get("text")), _unwrap_text(payload.get("msg")), ] texts = [x for x in candidates if x] if not texts: return "" return max(texts, key=len) def _filename(msg_type: str, content: str, payload: Mapping[str, Any]) -> str: for key in ("filename", "file_name", "FileName", "title", "name"): val = str(payload.get(key) or "").strip() if val: return val if msg_type in _FILE_TYPES: return str(content or "").strip() return "" def archive_record_to_inbound( record: Mapping[str, Any], *, seat_userids: Sequence[str] = (), ) -> Optional[InboundMessage]: """ 一条管家记录变成群入站。 返回 None:不是群、没有发言人、发言人是存档席、或撤回类消息。 message_id 带 wshoto: 前缀,避免和回调 MsgId 撞车。 """ room_id = str(record.get("room_id") or record.get("roomid") or "").strip() sender_id = str(record.get("from") or record.get("from_id") or "").strip() if not room_id or not sender_id: return None seats = {str(x).strip() for x in seat_userids if str(x).strip()} if sender_id in seats or sender_id in SEAT_DISPLAY_NAMES: return None msg_type = str(record.get("msgtype") or record.get("msg_type") or "text").strip().lower() if msg_type in _SKIP_TYPES: return None payload = _as_mapping(record.get("msg_content") or record.get("msgcontent")) content = _best_text(record, payload) msgid = str(record.get("msgid") or record.get("msg_id") or "").strip() seq = record.get("seq") if not msgid: msgid = f"{room_id}:{seq or 0}:{sender_id}" media: dict[str, Any] = {} inbound_type = "text" if msg_type in _FILE_TYPES: inbound_type = "file" if msg_type == "file" else msg_type media = { "filename": _filename(msg_type, content, payload), "media_id": str(payload.get("sdkfileid") or payload.get("sdk_file_id") or ""), "file_ext": str(payload.get("fileext") or payload.get("file_ext") or ""), "content_type": "", "filesize": payload.get("filesize") or payload.get("file_size") or 0, } if not content: content = str(media.get("filename") or "") return InboundMessage( sender_id=sender_id, message_id=f"wshoto:{msgid}", content=content, msg_type=inbound_type, chat_id=room_id, chat_type="group", create_time=_create_time(record.get("msgtime") or record.get("msg_time")), media=media, raw={ "source": "wshoto_archive", "seq": seq, "msgtype": msg_type, "direction": record.get("direction") or "", }, )