Files
dy/backend/rpa_engine/douyin_im/reply_payload.py
T
2026-07-23 17:56:25 +08:00

331 lines
11 KiB
Python

"""自动回复内容解析与 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,
)