更新
This commit is contained in:
@@ -0,0 +1,330 @@
|
||||
"""自动回复内容解析与 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,
|
||||
)
|
||||
Reference in New Issue
Block a user