"""自动回复内容解析与 IM 消息体构造(文本 / 网址 / 卡片)。""" from __future__ import annotations import json from typing import Any, Tuple from .message_content import ( MSG_TYPE_IMAGE, MSG_TYPE_STICKER, MSG_TYPE_TEXT, message_preview, parse_stored_content, serialize_message_content, ) def _normalize_reply_spec(data: dict[str, Any]) -> dict[str, Any] | None: if not isinstance(data, dict): return None reply_type = data.get("type") if reply_type in ("text", "link", "card", "image", "sticker"): return data return None def parse_reply_messages(raw: str) -> list[dict[str, Any]]: """解析规则中的 reply_content,支持单条或多条回复。""" raw = (raw or "").strip() if not raw: return [{"type": "text", "text": ""}] if raw.startswith("{") or raw.startswith("["): try: data = json.loads(raw) if isinstance(data, dict) and isinstance(data.get("messages"), list): specs = [_normalize_reply_spec(item) for item in data["messages"]] specs = [item for item in specs if item] if specs: return specs if isinstance(data, list): specs = [_normalize_reply_spec(item) for item in data] specs = [item for item in specs if item] if specs: return specs if isinstance(data, dict): spec = _normalize_reply_spec(data) if spec: return [spec] except json.JSONDecodeError: pass return [{"type": "text", "text": raw}] def parse_reply_content(raw: str) -> dict[str, Any]: """解析发送/规则中的 content。纯字符串视为文本,JSON 为结构化消息。""" raw = (raw or "").strip() if raw.startswith("{"): try: data = json.loads(raw) if isinstance(data, dict): spec = _normalize_reply_spec(data) if spec: return spec except json.JSONDecodeError: pass parsed = parse_stored_content(raw) if parsed.get("type") != "text": return parsed return parse_reply_messages(raw)[0] def _format_reply_spec(spec: dict[str, Any]) -> str: reply_type = spec.get("type", "text") if reply_type in ("image", "sticker", "voice", "video"): return message_preview(serialize_message_content(spec)) if reply_type == "text": return (spec.get("text") or "").strip() if reply_type == "link": text = (spec.get("text") or spec.get("title") or "").strip() url = (spec.get("url") or "").strip() if text and url: return f"{text} → {url}" return text or url if reply_type == "card": title = (spec.get("title") or "").strip() desc = (spec.get("desc") or spec.get("description") or "").strip() page_url = (spec.get("url") or "").strip() if title and page_url: return f"[卡片] {title} → {page_url}" return title or desc or page_url or "[卡片]" return message_preview(serialize_message_content(spec)) def format_reply_display(raw: str) -> str: """将 reply_content 格式化为日志/列表中的可读摘要。""" preview = message_preview(raw) if preview: return preview specs = parse_reply_messages(raw) parts = [_format_reply_spec(spec) for spec in specs] parts = [part for part in parts if part] if not parts: return (raw or "").strip() if len(parts) == 1: return parts[0] return " | ".join(parts) def serialize_reply_messages(specs: list[dict[str, Any]]) -> str: cleaned = [spec for spec in specs if _normalize_reply_spec(spec)] if not cleaned: cleaned = [{"type": "text", "text": ""}] if len(cleaned) == 1: return serialize_reply_content(cleaned[0]) return json.dumps({"messages": cleaned}, ensure_ascii=False, separators=(",", ":")) def _extract_link_card_media_path(url: str) -> str: value = (url or "").strip() idx = value.find("/api/media/link-cards/") return value[idx:] if idx >= 0 else "" def expand_card_spec(spec: dict[str, Any]) -> list[dict[str, Any]]: """卡片专属发送规则:展开为「封面图片 + 标题/内容/可点击链接文字」两条消息。 抖音 Web 协议无法发原生合并卡片(type=70 带图→8004/不带图→空白),因此卡片以 「图片消息(横幅) + 文本(标题/内容/链接)」组合呈现:图片提供视觉、文本提供可点击跳转。 图片消息仅互关用户可收(陌生人会被 8003 拦截,但文本仍可送达,不影响链接触达)。 """ title = (spec.get("title") or "").strip() desc = (spec.get("desc") or spec.get("description") or "").strip() target = ( spec.get("target_url") or spec.get("url") or spec.get("link_url") or "" ).strip() if "localhost" in target or "127.0.0.1" in target: target = (spec.get("target_url") or spec.get("link_url") or "").strip() or target cover = (spec.get("image_path") or "").strip() if not cover: cover = _extract_link_card_media_path(spec.get("cover_url") or "") out: list[dict[str, Any]] = [] if cover: out.append({"type": "image", "url": cover, "text": "[图片]"}) lines = [x for x in (title, desc, target) if x] text = "\n".join(lines) if lines else target if text: out.append({"type": "text", "text": text}) if not out: out.append({"type": "text", "text": target}) return out def split_reply_payloads(raw: str) -> list[str]: """将规则 reply_content 拆成可逐条发送的 payload 列表。卡片单独展开为图片+文本。""" payloads: list[str] = [] for spec in parse_reply_messages(raw): if spec.get("type") == "card": payloads.extend(serialize_reply_content(s) for s in expand_card_spec(spec)) else: payloads.append(serialize_reply_content(spec)) return payloads def serialize_reply_log(payloads: list[str]) -> str: """把实际逐条发送的 payload(JSON 字符串)合并为结构化的日志内容。 单条直接返回该 payload;多条用 {"messages":[...]} 包裹,便于前端逐条渲染 (图片/表情正常显示为媒体,而不是被压扁成 "图片" 这样的占位文本)。 """ specs: list[dict[str, Any]] = [] for payload in payloads: raw = (payload or "").strip() if not raw: continue spec: dict[str, Any] | None = None if raw.startswith("{"): try: data = json.loads(raw) spec = _normalize_reply_spec(data) except json.JSONDecodeError: spec = None if spec is None: spec = {"type": "text", "text": raw} specs.append(spec) return serialize_reply_messages(specs) def serialize_reply_content(spec: dict[str, Any]) -> str: return json.dumps(spec, ensure_ascii=False, separators=(",", ":")) def build_msg_payload(spec: dict[str, Any]) -> Tuple[dict[str, Any], int]: """根据回复规格构造 IM msg_content 与 message_type。""" reply_type = spec.get("type", "text") if reply_type == "text": text = (spec.get("text") or "").strip() return ( { "mention_users": [], "aweType": 700, "richTextInfos": [], "text": text, }, 7, ) if reply_type == "link": display = (spec.get("text") or spec.get("title") or "").strip() url = (spec.get("url") or "").strip() if not display: display = url msg_content: dict[str, Any] = { "mention_users": [], "aweType": 700, "richTextInfos": [], "text": display, } if url and display: msg_content["richTextInfos"] = [ { "start": 0, "end": len(display), "type": 2, "link": url, "text": display, } ] return msg_content, 7 if reply_type == "card": # 卡片在 split_reply_payloads 阶段已展开为「图片 + 文本」两条,正常不会走到这里。 # 兜底:万一收到未展开的卡片 spec,退化为「标题/内容/链接」文本,确保可送达。 title = (spec.get("title") or "").strip() desc = (spec.get("desc") or spec.get("description") or "").strip() target = ( spec.get("target_url") or spec.get("url") or spec.get("link_url") or "" ).strip() lines = [x for x in (title, desc, target) if x] text = "\n".join(lines) if lines else target return ( {"mention_users": [], "aweType": 700, "richTextInfos": [], "text": text}, MSG_TYPE_TEXT, ) if reply_type == "image": uri = (spec.get("uri") or "").strip().lstrip("/") url = (spec.get("url") or "").strip() width = spec.get("width") height = spec.get("height") md5 = (spec.get("md5") or "").strip() url_list = spec.get("url_list") if not isinstance(url_list, list): url_list = [url] if url.startswith("http") else [] clean_urls = [str(u).strip() for u in url_list if str(u).strip().startswith("http")] resource_url: dict[str, Any] = {} if uri: resource_url["uri"] = uri if clean_urls: resource_url["url_list"] = clean_urls if md5: resource_url["md5"] = md5 if width: try: resource_url["width"] = int(width) except (TypeError, ValueError): pass if height: try: resource_url["height"] = int(height) except (TypeError, ValueError): pass msg_content: dict[str, Any] = { "aweType": 2702, "from_gallery": 1, "create_type": 0, } if resource_url: msg_content["resource_url"] = resource_url if uri: msg_content["local_path"] = uri if md5: msg_content["md5"] = md5 if width: try: msg_content["cover_width"] = int(width) except (TypeError, ValueError): pass if height: try: msg_content["cover_height"] = int(height) except (TypeError, ValueError): pass return msg_content, MSG_TYPE_IMAGE if reply_type == "sticker": url = (spec.get("url") or "").strip() sticker_id = spec.get("sticker_id") or spec.get("id") msg_content = { "display_name": (spec.get("name") or spec.get("text") or "[表情包]").strip(), } if sticker_id: msg_content["id"] = sticker_id msg_content["sticker_id"] = sticker_id if url: msg_content["static_url"] = {"url_list": [url]} msg_content["animate_url"] = {"url_list": [url]} return msg_content, MSG_TYPE_STICKER text = format_reply_display(serialize_reply_content(spec)) return ( { "mention_users": [], "aweType": 700, "richTextInfos": [], "text": text, }, 7, )