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