Files
2026-07-17 09:24:47 +08:00

671 lines
24 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""自动回复内容解析与 IM 消息体构造(文本 / 网址 / 卡片)。"""
from __future__ import annotations
import json
import logging
import os
import socket
import ssl
from typing import Any, Tuple
from urllib.parse import urlparse
logger = logging.getLogger("douyin_im.reply_payload")
_REPLY_SPEC_TYPES = ("text", "link", "hyperlink", "hyperlink_multiline", "card", "image", "raw_im", "interaction")
def _safe_int(value: Any, default: int = 0) -> int:
try:
if value is None or value == "":
return default
return int(value)
except (TypeError, ValueError):
return default
def _sanitize_im_content(value: Any) -> Any:
"""递归剔除 None,避免 JSON 出现 null 导致抖音客户端显示「null」。"""
if isinstance(value, dict):
return {k: _sanitize_im_content(v) for k, v in value.items() if v is not None}
if isinstance(value, list):
return [_sanitize_im_content(v) for v in value if v is not None]
return value
def _image_url_list(spec: dict[str, Any], uri: str) -> list[str]:
from .message_content import uri_to_cdn_urls
from .image_upload import is_douyin_cdn_url
raw_list = spec.get("url_list")
if isinstance(raw_list, list):
urls = [
str(u).strip()
for u in raw_list
if isinstance(u, str) and u.strip().startswith("http") and is_douyin_cdn_url(u)
]
if urls:
return urls
cover_url = str(spec.get("cover_url") or spec.get("douyin_url") or "").strip()
cdn_urls = uri_to_cdn_urls(uri)
if cdn_urls:
return cdn_urls
if cover_url.startswith("http") and is_douyin_cdn_url(cover_url):
return [cover_url]
return []
def _build_image_payload(spec: dict[str, Any]) -> tuple[dict[str, Any] | None, int | None]:
"""构造 IM 图片消息体(message_type=27),需已上传至抖音 CDN 的 uri。"""
from .message_content import MSG_TYPE_IMAGE
uri = str(spec.get("uri") or spec.get("cover_uri") or "").strip().lstrip("/")
if not uri:
return None, None
md5 = str(spec.get("md5") or spec.get("cover_md5") or "").strip()
url_list = _image_url_list(spec, uri)
resource_url: dict[str, Any] = {"uri": uri}
if url_list:
resource_url["url_list"] = url_list
if md5:
resource_url["md5"] = md5
msg_content: dict[str, Any] = {
"aweType": 2702,
"from_gallery": 1,
"create_type": 0,
"resource_url": resource_url,
"local_path": uri,
}
if md5:
msg_content["md5"] = md5
return msg_content, MSG_TYPE_IMAGE
def _build_card_cover_fields(spec: dict[str, Any]) -> dict[str, str | list[str]]:
"""链接卡片封面字段,对齐网页 IM 抓包(cover_url/image_url/image_list 等)。"""
from .image_upload import is_douyin_cdn_url
cover_uri = str(spec.get("cover_uri") or spec.get("uri") or "").strip().lstrip("/")
if not cover_uri:
return {}
image_list = _image_url_list(spec, cover_uri)
image_list = [u for u in image_list if is_douyin_cdn_url(u)]
if not image_list:
from .message_content import uri_to_cdn_urls
image_list = uri_to_cdn_urls(cover_uri)
if not image_list:
return {}
primary = image_list[0]
return {
"cover_url": primary,
"image_url": primary,
"image": primary,
"thumb_url": primary,
"image_list": image_list,
}
def _merge_link_card_cover_fields(
msg_content: dict[str, Any],
link_info: dict[str, Any],
cover_fields: dict[str, Any],
) -> None:
"""对齐成功抓包:封面详情在 link_info;顶层仅 cover_url + image_url。"""
if not cover_fields:
return
link_info.update(cover_fields)
msg_content["cover_url"] = cover_fields["cover_url"]
msg_content["image_url"] = cover_fields["image_url"]
def parse_reply_content(raw: str) -> dict[str, Any]:
"""解析规则中的 reply_content。纯字符串视为文本,JSON 为结构化回复。"""
raw = (raw or "").strip()
if not raw:
return {"type": "text", "text": ""}
if raw.startswith("{"):
try:
data = json.loads(raw)
if isinstance(data, dict) and data.get("type") in _REPLY_SPEC_TYPES:
return data
except json.JSONDecodeError:
pass
return {"type": "text", "text": raw}
def format_reply_display(raw: str) -> str:
"""将 reply_content 格式化为日志/列表中的可读摘要。"""
spec = parse_reply_content(raw)
reply_type = spec.get("type", "text")
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 == "hyperlink":
title = (spec.get("title") or "").strip()
url = (spec.get("url") or spec.get("target_url") or spec.get("link_url") or "").strip()
if title and url:
return f"[超链接] {title}"
return title or url or "[超链接]"
if reply_type == "card":
title = (spec.get("title") or "").strip()
url = (spec.get("url") or "").strip()
if title and url:
return f"[卡片] {title}{url}"
return title or url or "[卡片]"
if reply_type == "interaction":
title = (
str(spec.get("subject_title") or spec.get("push_internal_object_title") or "").strip()
or "点赞比心互动"
)
return f"[互动] {title}"
if reply_type == "raw_im":
mt = spec.get("message_type") or spec.get("im_message_type") or "?"
return f"[原始IM] message_type={mt}"
if reply_type == "image":
return (spec.get("text") or "").strip() or "[图片]"
return raw
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 == "raw_im":
payload = spec.get("payload") if spec.get("payload") is not None else spec.get("content")
if isinstance(payload, str):
payload = json.loads(payload)
if not isinstance(payload, dict):
raise ValueError("raw_im 缺少 payload/content 对象")
mt = _safe_int(spec.get("message_type") or spec.get("im_message_type"), 7)
return payload, mt
if reply_type == "interaction":
from .interaction_messages import build_like_heart_payload, resolve_interaction_message_type
variant = str(spec.get("variant") or "like_heart").strip()
if variant != "like_heart":
raise ValueError(f"不支持的互动类型 variant={variant!r}")
my_uid = str(spec.get("my_uid") or spec.get("uid") or "").strip()
msg_content = build_like_heart_payload(spec, my_uid=my_uid)
return msg_content, resolve_interaction_message_type(spec)
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 == "hyperlink":
from .message_content import MSG_TYPE_LINK_CARD
link_url = str(
spec.get("url") or spec.get("target_url") or spec.get("link_url") or ""
).strip()
if is_internal_http_url(link_url):
raise ValueError("超链接 URL 无效")
cover_fields = _build_card_cover_fields(spec)
if not cover_fields:
raise ValueError("网页链接卡封面未上传到抖音 CDN")
title = (spec.get("title") or "").strip() or "网页链接"
desc = (spec.get("desc") or spec.get("description") or "").strip() or title
link_info: dict[str, Any] = {
"title": title,
"desc": desc,
"description": desc,
"url": link_url,
"link_url": link_url,
}
msg_content = {
"mention_users": [],
"aweType": 0,
"richTextInfos": [],
"text": link_url or title,
"link_info": link_info,
"link_url": link_url,
"title": title,
"desc": desc,
"description": desc,
}
_merge_link_card_cover_fields(msg_content, link_info, cover_fields)
return _sanitize_im_content(msg_content), MSG_TYPE_LINK_CARD
if reply_type == "hyperlink_multiline":
title = (spec.get("title") or "").strip()
desc = (spec.get("desc") or spec.get("description") or "").strip()
url = str(
spec.get("url") or spec.get("target_url") or spec.get("link_url") or ""
).strip()
lines = [x for x in (title, desc, url) if x]
text = "\n".join(lines) if lines else url
return (
{
"mention_users": [],
"aweType": 700,
"richTextInfos": [],
"text": text,
},
7,
)
if reply_type == "card":
from .message_content import MSG_TYPE_LINK_CARD
link_url = _resolve_card_link_url(spec)
cover_fields = _build_card_cover_fields(spec)
if not cover_fields:
raise ValueError("链接卡片封面未上传到抖音 CDN")
title = (spec.get("title") or "").strip() or "链接卡片"
desc = (spec.get("desc") or spec.get("description") or "").strip() or title
link_info: dict[str, Any] = {
"title": title,
"desc": desc,
"description": desc,
"url": link_url,
"link_url": link_url,
}
msg_content: dict[str, Any] = {
"mention_users": [],
"aweType": 0,
"richTextInfos": [],
"text": link_url or title,
"link_info": link_info,
"link_url": link_url,
"title": title,
"desc": desc,
"description": desc,
}
_merge_link_card_cover_fields(msg_content, link_info, cover_fields)
return _sanitize_im_content(msg_content), MSG_TYPE_LINK_CARD
if reply_type == "image":
msg_content, message_type = _build_image_payload(spec)
if msg_content is not None and message_type is not None:
return msg_content, message_type
text = (spec.get("text") or "").strip() or "[图片]"
return (
{
"mention_users": [],
"aweType": 700,
"richTextInfos": [],
"text": text,
},
7,
)
text = format_reply_display(serialize_reply_content(spec))
return (
{
"mention_users": [],
"aweType": 700,
"richTextInfos": [],
"text": text,
},
7,
)
def parse_reply_messages(raw: str) -> list:
"""解析多条回复(messages数组)或单条回复 spec。"""
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) and data["messages"]:
return [m for m in data["messages"] if isinstance(m, dict) and m.get("type") in _REPLY_SPEC_TYPES]
if isinstance(data, list):
return [m for m in data if isinstance(m, dict) and m.get("type") in _REPLY_SPEC_TYPES]
if isinstance(data, dict) and data.get("type") in _REPLY_SPEC_TYPES:
return [data]
except json.JSONDecodeError:
pass
return [{"type": "text", "text": raw}]
def _abs_public_url(path_or_url: str) -> str:
value = (path_or_url or "").strip()
if not value:
return ""
if value.startswith("/"):
try:
from .image_upload import _public_base_url
base = _public_base_url().rstrip("/")
if base:
return f"{base}{value}"
except Exception:
pass
if value and not value.startswith("http"):
return f"https://{value.lstrip('/')}"
return value
def is_internal_http_url(url: str) -> bool:
"""localhost / 127.0.0.1 / 非 http(s) 均视为不可用于抖音卡片外显。"""
raw = (url or "").strip()
if not raw:
return True
if raw.startswith("/"):
return True
lower = raw.lower()
if not lower.startswith("http://") and not lower.startswith("https://"):
return True
return "localhost" in lower or "127.0.0.1" in lower
_DISALLOWED_IM_LINK_HOSTS = (
"e.douyin.com",
"developer.open-douyin.com",
"partner.open-douyin.com",
"open.douyin.com",
"imapi.douyin.com",
)
_DISALLOWED_IM_LINK_PATH_PARTS = (
"im-manage/card",
"operation-center/im-manage",
"douyin-mp/operation-center",
)
def disallowed_im_link_reason(url: str) -> str:
"""抖音不允许把后台/开放平台地址当作私信网页链接跳转目标。"""
raw = (url or "").strip()
if not raw:
return ""
try:
parsed = urlparse(raw)
except Exception:
return ""
host = (parsed.hostname or "").lower()
path = (parsed.path or "").lower()
if host in _DISALLOWED_IM_LINK_HOSTS:
return (
f"链接指向抖音后台/开放平台({host}),不能作为私信超链接跳转地址。"
"请填写您自己的业务页,例如企微获客链接、官网、表单页等。"
)
if host.endswith(".douyin.com") and any(p in path for p in _DISALLOWED_IM_LINK_PATH_PARTS):
return (
"链接指向抖音运营后台「IM 卡片管理」页面,不是给用户点击的业务 URL。"
"请改为 https://work.weixin.qq.com/... 或您自己的 HTTPS 业务页。"
)
if "example.com" in host:
return "请填写真实可访问的业务 HTTPS 链接,不要使用 example.com 示例域名。"
return ""
def public_https_url_ok(url: str, *, timeout: float = 8.0) -> tuple[bool, str]:
"""检测 HTTPS 链接是否证书有效且可连接(抖音发卡前会校验域名)。"""
raw = (url or "").strip()
if not raw:
return False, "URL 为空"
parsed = urlparse(raw)
if parsed.scheme != "https":
return False, "非 HTTPS 链接"
host = parsed.hostname
if not host:
return False, "无效 URL"
try:
ctx = ssl.create_default_context()
with socket.create_connection((host, 443), timeout=timeout) as sock:
with ctx.wrap_socket(sock, server_hostname=host) as ssock:
ssock.getpeercert()
return True, ""
except ssl.SSLError as exc:
return False, f"SSL 证书与域名不匹配或未信任({exc})"
except OSError as exc:
return False, f"无法连接 {host}{exc})"
def _card_im_link_mode() -> str:
"""page=落地页;target=业务 URL;auto=落地页证书有效时用落地页,否则用 target。"""
return (os.getenv("KEFU_CARD_IM_LINK") or "auto").strip().lower()
def _resolve_card_target_url(spec: dict[str, Any]) -> str:
target_url = str(spec.get("target_url") or "").strip()
if is_internal_http_url(target_url):
return ""
resolved = _abs_public_url(target_url)
return "" if is_internal_http_url(resolved) else resolved
def _resolve_card_page_url(spec: dict[str, Any]) -> str:
"""SEO 落地页地址(/p/slug)。"""
page_url = str(spec.get("url") or spec.get("page_url") or "").strip()
if is_internal_http_url(page_url):
return ""
resolved = _abs_public_url(page_url)
return "" if is_internal_http_url(resolved) else resolved
def _resolve_card_link_url(spec: dict[str, Any]) -> str:
"""IM 卡片点击跳转 URL。发送重试可通过 _im_link_url 临时覆盖。"""
override = str(spec.get("_im_link_url") or "").strip()
if override and not is_internal_http_url(override):
return override
page_url = _resolve_card_page_url(spec)
target_url = _resolve_card_target_url(spec)
mode = _card_im_link_mode()
if mode == "target":
return target_url or page_url
if mode == "page":
return page_url or target_url
# auto:落地页 HTTPS 证书有效则优先落地页,否则回退业务 URL
if page_url:
ok, reason = public_https_url_ok(page_url)
if ok:
return page_url
if target_url:
logger.warning(
"Card landing page HTTPS check failed (%s), using target_url for IM link: %s",
reason,
target_url[:120],
)
return target_url
logger.warning("Card landing page HTTPS check failed: %s", reason)
return page_url
return target_url
def validate_hyperlink_send_spec(spec: dict[str, Any], *, require_cover_uri: bool = False) -> str:
"""超链接(网页链接卡):直连业务 URL,无需 SEO 落地页。"""
if spec.get("type") != "hyperlink":
return ""
if not (spec.get("title") or "").strip():
return "缺少超链接标题"
cover_ref = str(
spec.get("cover_url") or spec.get("cover") or spec.get("image_path") or ""
).strip()
if not cover_ref:
return "网页链接卡必须上传封面图,否则客户端只会显示为多行纯文字"
link_url = str(
spec.get("url") or spec.get("target_url") or spec.get("link_url") or ""
).strip()
if not link_url or is_internal_http_url(link_url):
return "超链接 URL 无效,请填写公网 HTTPS 地址"
if not link_url.lower().startswith("https://"):
return "超链接须为 HTTPS 地址"
blocked = disallowed_im_link_reason(link_url)
if blocked:
return blocked
ok, reason = public_https_url_ok(link_url)
if not ok:
return f"超链接 HTTPS 不可用({reason}"
if require_cover_uri:
uri = str(spec.get("cover_uri") or spec.get("uri") or "").strip().lstrip("/")
if not uri:
return "超链接封面尚未上传到抖音 CDN"
return ""
def validate_card_send_spec(spec: dict[str, Any], *, require_cover_uri: bool = False) -> str:
"""校验卡片回复是否满足发送条件,返回错误文案;空串表示通过。"""
if spec.get("type") != "card":
return ""
if not (spec.get("title") or "").strip():
return "缺少卡片标题"
cover_ref = str(
spec.get("cover_url") or spec.get("cover") or spec.get("image_path") or ""
).strip()
if not cover_ref:
return "缺少卡片封面图"
target = str(spec.get("target_url") or spec.get("link_url") or "").strip()
blocked = disallowed_im_link_reason(target)
if blocked:
return blocked
link_url = _resolve_card_link_url(spec)
if not link_url:
return (
"卡片跳转链接无效:落地页为 localhost 或未配置公网地址。"
"请设置环境变量 KEFU_PUBLIC_BASE_URL=https://你的公网域名 后重新保存规则"
)
page_url = _resolve_card_page_url(spec)
if page_url and _card_im_link_mode() != "target":
ok, reason = public_https_url_ok(page_url)
if not ok and not _resolve_card_target_url(spec):
return (
f"落地页 HTTPS 不可用({reason}),且无有效 target_url 可回退。"
"请为 KEFU_PUBLIC_BASE_URL 配置有效 SSL 证书,或设置 KEFU_CARD_IM_LINK=target"
)
if require_cover_uri:
uri = str(spec.get("cover_uri") or spec.get("uri") or "").strip().lstrip("/")
if not uri:
return "卡片封面尚未上传到抖音 CDN"
cover_fields = _build_card_cover_fields(spec)
if not cover_fields:
return "卡片封面 CDN 地址无效,请重新上传封面或检查 IM 凭证"
return ""
def _extract_link_card_media_path(url: str) -> str:
value = (url or "").strip()
if not value:
return ""
idx = value.find("/api/media/link-cards/")
if idx >= 0:
return value[idx:]
idx = value.find("/api/media/messages/")
if idx >= 0:
return value[idx:]
return ""
def expand_card_spec(spec: dict) -> list:
"""已废弃:勿将卡片拆成「图片 + 多行文字」,对方看不到网页链接卡。
保留函数仅供排查旧数据;发送链路不再调用。
"""
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 = []
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:
"""将规则 reply_content 拆成可逐条发送的 payload。卡片以原生链接卡(type 70)单条发送。
多条消息时先发文/图再发卡片,降低陌生人会话首条即结构化消息被拒(8004)的概率。
"""
specs = parse_reply_messages(raw)
non_cards = [s for s in specs if s.get("type") not in ("card", "hyperlink")]
cards = [s for s in specs if s.get("type") in ("card", "hyperlink")]
ordered = non_cards + cards
has_lead = bool(non_cards)
out: list[str] = []
for spec in ordered:
item = dict(spec)
if has_lead and item.get("type") in ("card", "hyperlink"):
item["_skip_preface"] = True
out.append(serialize_reply_content(item))
return out
def serialize_reply_log(payloads: list) -> str:
"""把实际逐条发送的 payload 合并为结构化日志内容。"""
specs = []
for payload in payloads:
raw_s = (payload or "").strip()
if not raw_s:
continue
spec = None
if raw_s.startswith("{"):
try:
data = json.loads(raw_s)
if isinstance(data, dict) and data.get("type") in _REPLY_SPEC_TYPES:
spec = data
except json.JSONDecodeError:
pass
if spec is None:
spec = {"type": "text", "text": raw_s}
specs.append(spec)
if len(specs) == 1:
return serialize_reply_content(specs[0])
return json.dumps({"messages": specs}, ensure_ascii=False, separators=(",", ":"))