331 lines
11 KiB
Python
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,
|
|
)
|