This commit is contained in:
Your Name
2026-07-17 09:24:47 +08:00
commit 530e7f839d
4353 changed files with 731879 additions and 0 deletions
@@ -0,0 +1,670 @@
"""自动回复内容解析与 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=(",", ":"))