Files
dy/backend/rpa_engine/douyin_im/message_content.py
T
2026-07-23 17:56:25 +08:00

747 lines
25 KiB
Python
Raw 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.
"""私信消息内容解析、存储与展示(文本 / 图片 / 表情 / 语音 / 视频)。"""
from __future__ import annotations
import json
import re
from typing import Any
MSG_TYPE_TEXT = 7
MSG_TYPE_STICKER = 5
MSG_TYPE_VOICE = 17
MSG_TYPE_IMAGE = 27
MSG_TYPE_VIDEO = 8
MSG_TYPE_LINK_CARD = 70
_TYPE_LABELS = {
"text": "文本",
"image": "图片",
"sticker": "表情",
"voice": "语音",
"video": "视频",
"link": "链接",
"link_card": "链接卡片",
}
_PLACEHOLDER_MARKERS = {
"[表情包]",
"[语音]",
"[图片]",
"[视频]",
"[未读消息]",
}
_URI_HINT_RE = re.compile(
r"(tos-cn|aweme-|voice/|ies-music|\.mp3|\.m4a|\.aac|\.mpeg|\.webp|\.jpeg|\.jpg|\.png|\.gif)",
re.I,
)
_AUDIO_URL_RE = re.compile(
r"(douyin-user-audio|/audio/|sc=audio|voice/|ies-music|\.mp3|\.m4a|\.aac|\.mpeg|\.wav|\.ogg)",
re.I,
)
_VIDEO_URL_RE = re.compile(
r"(sc=video|/video/|\.mp4|\.mov|\.webm|\.m3u8)",
re.I,
)
_MEDIA_STRING_KEYS = (
"url",
"uri",
"main_url",
"download_url",
"remote_url",
"encrypt_url",
"play_url",
"secret_url",
"audio_url",
"video_url",
"cover_url",
"local_path",
)
_MEDIA_NESTED_KEYS = (
"resource_url",
"static_url",
"animate_url",
"cover_url",
"thumb_url",
"origin_url",
"image",
"picture",
"pic",
"sticker",
"emoji",
"audio",
"voice",
"video",
"media",
"large_url",
"medium_url",
"thumb",
"avatar_thumb",
"play_url",
)
def _normalize_uri_path(raw: str) -> str:
path = (raw or "").strip().lstrip("/")
if path.startswith("obj/"):
path = path[4:]
return path
def uri_to_cdn_urls(uri: str, *, prefer_voice: bool = False) -> list[str]:
"""将抖音 IM 中的 uri / tos 路径转为可访问的 CDN URL 候选列表。"""
raw = (uri or "").strip()
if not raw:
return []
if raw.startswith("//"):
return [f"https:{raw}"]
if raw.startswith("http://") or raw.startswith("https://"):
return [raw]
path = _normalize_uri_path(raw)
if not path:
return []
candidates: list[str] = []
seen: set[str] = set()
def add(url: str) -> None:
url = (url or "").strip()
if url and url not in seen:
seen.add(url)
candidates.append(url)
lower = path.lower()
is_voice = prefer_voice or lower.startswith("voice/") or lower.endswith((".mp3", ".m4a", ".aac"))
is_image = (
not is_voice
and (
"tos-cn-i" in lower
or "aweme-" in lower
or lower.endswith((".jpeg", ".jpg", ".png", ".webp", ".gif"))
)
)
if is_voice:
for host in (
"sf6-cdn-tos.douyinstatic.com",
"sf3-cdn-tos.douyinstatic.com",
"lf3-static.bytednsdoc.com",
):
add(f"https://{host}/obj/{path}")
add(f"https://p3.douyinpic.com/obj/{path}")
if is_image or "tos-cn" in lower or "aweme" in lower:
add(f"https://p3.douyinpic.com/obj/{path}")
for size in ("720x720", "480x480", "300x300", "200x200", "100x100"):
add(f"https://p3.douyinpic.com/aweme/{size}/{path}")
add(f"https://p9-dy.byteimg.com/img/{path}")
add(f"https://p6-dy.byteimg.com/img/{path}")
add(f"https://p3.douyinpic.com/obj/{path}")
add(f"https://p3-sign.douyinpic.com/obj/{path}".replace("-sign", ""))
return candidates
def resolve_media_uri(uri: str, *, prefer_voice: bool = False) -> str:
urls = uri_to_cdn_urls(uri, prefer_voice=prefer_voice)
return urls[0] if urls else ""
def _looks_like_audio_url(value: str) -> bool:
raw = (value or "").strip()
return bool(raw and _AUDIO_URL_RE.search(raw))
def _looks_like_video_url(value: str) -> bool:
raw = (value or "").strip()
return bool(raw and _VIDEO_URL_RE.search(raw))
def _infer_media_type_from_url(url: str) -> str:
"""根据 URL 特征推断媒体类型(语音/视频/图片)。"""
if _looks_like_audio_url(url):
return "voice"
if _looks_like_video_url(url):
return "video"
return "image"
def _looks_like_media_uri(value: str) -> bool:
raw = (value or "").strip()
if not raw or raw.startswith("{"):
return False
if raw.startswith("http://") or raw.startswith("https://") or raw.startswith("//"):
return True
return bool(_URI_HINT_RE.search(raw))
def _resolve_string_media(value: str, *, prefer_voice: bool = False) -> tuple[str, str]:
raw = (value or "").strip()
if not raw:
return "", ""
if raw.startswith("//"):
return f"https:{raw}", raw
if raw.startswith("http://") or raw.startswith("https://"):
return raw, ""
if _looks_like_media_uri(raw):
return resolve_media_uri(raw, prefer_voice=prefer_voice), raw
return "", ""
def _valid_sticker_id(value: Any) -> str:
if value is None or value == "":
return ""
try:
if int(value) == 0:
return ""
except (TypeError, ValueError):
pass
sticker_id = str(value).strip()
return "" if sticker_id in ("0", "null", "None") else sticker_id
def _collect_media_candidates(value: Any, out: list[str], *, prefer_voice: bool = False, depth: int = 0) -> None:
if depth > 12:
return
if isinstance(value, str):
raw = value.strip()
if raw.startswith("http://") or raw.startswith("https://") or raw.startswith("//"):
url, _ = _resolve_string_media(raw, prefer_voice=prefer_voice)
if url:
out.append(url)
return
if isinstance(value, dict):
for list_key in ("url_list", "urls", "urlList"):
urls = value.get(list_key)
if isinstance(urls, list):
for item in urls:
_collect_media_candidates(item, out, prefer_voice=prefer_voice, depth=depth + 1)
for key in _MEDIA_STRING_KEYS:
direct = value.get(key)
if isinstance(direct, str):
url, _ = _resolve_string_media(direct, prefer_voice=prefer_voice)
if url:
out.append(url)
for nested_key in _MEDIA_NESTED_KEYS:
_collect_media_candidates(value.get(nested_key), out, prefer_voice=prefer_voice, depth=depth + 1)
for nested in value.values():
if isinstance(nested, (dict, list)):
_collect_media_candidates(nested, out, prefer_voice=prefer_voice, depth=depth + 1)
return
if isinstance(value, list):
for item in value:
_collect_media_candidates(item, out, prefer_voice=prefer_voice, depth=depth + 1)
def _pick_http_url(value: Any, *, prefer_voice: bool = False) -> str:
candidates: list[str] = []
_collect_media_candidates(value, candidates, prefer_voice=prefer_voice)
return candidates[0] if candidates else ""
def _pick_media_uri(value: Any) -> str:
if isinstance(value, str) and _looks_like_media_uri(value) and not value.strip().startswith("http"):
return _normalize_uri_path(value)
if isinstance(value, dict):
for key in ("uri", "local_path", "remote_url"):
direct = value.get(key)
if isinstance(direct, str) and _looks_like_media_uri(direct) and not direct.strip().startswith("http"):
return _normalize_uri_path(direct)
for nested_key in _MEDIA_NESTED_KEYS:
uri = _pick_media_uri(value.get(nested_key))
if uri:
return uri
for nested in value.values():
if isinstance(nested, (dict, list)):
uri = _pick_media_uri(nested)
if uri:
return uri
if isinstance(value, list):
for item in value:
uri = _pick_media_uri(item)
if uri:
return uri
return ""
def _coerce_message_type(value: Any, default: int = MSG_TYPE_TEXT) -> int:
try:
if value is None or value == "":
return default
return int(value)
except (TypeError, ValueError):
return default
def _parse_content_json(content_raw: str | dict | None) -> dict[str, Any]:
if isinstance(content_raw, dict):
data = content_raw
else:
raw = str(content_raw or "").strip()
if raw.startswith("{"):
try:
parsed = json.loads(raw)
if isinstance(parsed, dict):
data = parsed
else:
return {}
except json.JSONDecodeError:
return {}
else:
return {}
for nested_key in ("ext", "ai_ext", "extra", "payload", "data"):
nested = data.get(nested_key)
if isinstance(nested, str) and nested.strip().startswith("{"):
try:
nested_data = json.loads(nested)
if isinstance(nested_data, dict):
merged = {**nested_data, **data}
data = merged
except json.JSONDecodeError:
pass
return data
def _safe_int(value: Any) -> int | None:
try:
if value is None or value == "":
return None
return int(value)
except (TypeError, ValueError):
return None
def format_im_message(content_raw: str | dict | None, message_type: int = MSG_TYPE_TEXT) -> dict[str, Any]:
"""将 IM 原始 content 解析为统一结构 {type, text, url, ...}。"""
content_json = _parse_content_json(content_raw)
embedded_type = _coerce_message_type(
content_json.get("message_type")
or content_json.get("messageType")
or content_json.get("msg_type"),
message_type,
)
if embedded_type != MSG_TYPE_TEXT:
message_type = embedded_type
_EMPTY_MEDIA_DEFAULTS = {
MSG_TYPE_IMAGE: ("image", "[图片]"),
MSG_TYPE_STICKER: ("sticker", "[表情包]"),
MSG_TYPE_VOICE: ("voice", "[语音]"),
MSG_TYPE_VIDEO: ("video", "[视频]"),
MSG_TYPE_LINK_CARD: ("link_card", "[链接卡片]"),
}
if not content_json:
raw = str(content_raw or "").strip()
if not raw:
# 抖音相册图片(type 27)/部分语音等会以「空 content」推送,URL 不随推送下发。
# 不能直接丢弃,否则消息「收不到」;这里按类型返回占位,URL 留空待后续补取。
if message_type in _EMPTY_MEDIA_DEFAULTS:
t, txt = _EMPTY_MEDIA_DEFAULTS[message_type]
return {"type": t, "text": txt}
return {"type": "text", "text": ""}
if raw in _PLACEHOLDER_MARKERS:
mapping = {
"[图片]": "image",
"[表情包]": "sticker",
"[语音]": "voice",
"[视频]": "video",
}
return {"type": mapping.get(raw, "text"), "text": raw}
if message_type == MSG_TYPE_TEXT:
return {"type": "text", "text": raw}
content_json = {"text": raw}
prefer_voice = message_type == MSG_TYPE_VOICE or bool(content_json.get("audio") or content_json.get("voice"))
url = _pick_http_url(content_json, prefer_voice=prefer_voice)
media_uri = _pick_media_uri(content_json)
if not url and media_uri:
url = resolve_media_uri(media_uri, prefer_voice=prefer_voice)
duration = _safe_int(
content_json.get("duration")
or content_json.get("audio_duration")
or content_json.get("video_duration")
)
width = _safe_int(content_json.get("width") or content_json.get("w"))
height = _safe_int(content_json.get("height") or content_json.get("h"))
def _media_payload(msg_type: str, text: str, **extra: Any) -> dict[str, Any]:
payload: dict[str, Any] = {"type": msg_type, "text": text}
if url:
payload["url"] = url
elif media_uri:
payload["uri"] = media_uri
payload.update({k: v for k, v in extra.items() if v not in (None, "", 0)})
return payload
# 语音/视频也会带 resource_url,不能仅凭该字段判为图片;优先按类型与 URL 特征识别。
is_voice = (
message_type == MSG_TYPE_VOICE
or bool(content_json.get("audio") or content_json.get("voice"))
or (url and _looks_like_audio_url(url))
)
is_video = (
message_type == MSG_TYPE_VIDEO
or bool(content_json.get("video"))
or (url and _looks_like_video_url(url))
)
if is_voice and not is_video:
return _media_payload("voice", "[语音]", duration=duration)
if is_video:
return _media_payload("video", "[视频]", duration=duration, width=width, height=height)
has_image_hint = (
message_type == MSG_TYPE_IMAGE
or content_json.get("image")
or content_json.get("inline_pic")
or (
content_json.get("resource_url")
and not (url and (_looks_like_audio_url(url) or _looks_like_video_url(url)))
)
)
if has_image_hint:
# 抖音相册私图(biz_tag=aweme_im)的大图 URL 是加密内容,浏览器无法直接渲染;
# 但 content 内嵌 inline_picbase64 WEBP 缩略图),直接转 data URI 即可显示。
inline = content_json.get("inline_pic")
if isinstance(inline, str) and inline.strip():
b64 = re.sub(r"\s+", "", inline)
data_uri = f"data:image/webp;base64,{b64}"
payload = {"type": "image", "text": "[图片]", "url": data_uri}
if width:
payload["width"] = width
if height:
payload["height"] = height
return payload
return _media_payload("image", "[图片]", width=width, height=height)
if message_type == MSG_TYPE_STICKER or content_json.get("static_url") or content_json.get("animate_url") or _valid_sticker_id(
content_json.get("sticker_id") or content_json.get("id")
):
sticker_id = _valid_sticker_id(content_json.get("sticker_id") or content_json.get("id"))
return _media_payload(
"sticker",
"[表情包]",
sticker_id=sticker_id,
name=str(content_json.get("display_name") or content_json.get("name") or ""),
)
link_card = _parse_link_card_payload(content_json, message_type)
if link_card:
return link_card
rich_link = _parse_rich_text_link(content_json)
if rich_link:
return rich_link
text = (
str(content_json.get("text") or content_json.get("content") or content_json.get("message") or "")
).strip()
if not text and url:
inferred = _infer_media_type_from_url(url)
if inferred == "voice":
return {"type": "voice", "text": "[语音]", "url": url, "duration": duration}
if inferred == "video":
return {"type": "video", "text": "[视频]", "url": url, "duration": duration}
if message_type == MSG_TYPE_IMAGE:
return {"type": "image", "text": "[图片]", "url": url, "width": width, "height": height}
if message_type == MSG_TYPE_STICKER:
return {"type": "sticker", "text": "[表情包]", "url": url}
if message_type == MSG_TYPE_VOICE:
return {"type": "voice", "text": "[语音]", "url": url, "duration": duration}
if message_type == MSG_TYPE_VIDEO:
return {"type": "video", "text": "[视频]", "url": url, "duration": duration}
final_text = text or str(content_raw or "").strip()
emoji = _resolve_text_emoji(final_text)
if emoji:
return emoji
return {"type": "text", "text": final_text}
def _parse_link_card_payload(content_json: dict[str, Any], message_type: int) -> dict[str, Any] | None:
link_info = content_json.get("link_info")
if not isinstance(link_info, dict):
link_info = {}
has_link = (
message_type == MSG_TYPE_LINK_CARD
or link_info
or content_json.get("link_url")
or content_json.get("cover_url")
)
if not has_link:
return None
title = str(content_json.get("title") or link_info.get("title") or "").strip()
desc = str(
content_json.get("desc")
or content_json.get("description")
or link_info.get("desc")
or link_info.get("description")
or ""
).strip()
url = str(
content_json.get("link_url")
or content_json.get("url")
or link_info.get("url")
or link_info.get("link_url")
or ""
).strip()
cover = str(content_json.get("cover_url") or link_info.get("cover_url") or "").strip()
text = title or desc or url or "[链接卡片]"
payload: dict[str, Any] = {
"type": "link_card",
"text": text,
"title": title,
"desc": desc,
}
if url:
payload["url"] = url
if cover:
payload["cover_url"] = cover
return payload
def _parse_rich_text_link(content_json: dict[str, Any]) -> dict[str, Any] | None:
rich = content_json.get("richTextInfos")
if not isinstance(rich, list):
return None
for item in rich:
if not isinstance(item, dict):
continue
link = str(item.get("link") or item.get("url") or "").strip()
if not link:
continue
text = str(item.get("text") or item.get("display_text") or link).strip()
return {"type": "link", "text": text or link, "url": link}
return None
def _resolve_text_emoji(text: str) -> dict[str, Any] | None:
"""文字表情 [酷拽] 等:查标准表情表,命中则转成可显示的贴纸。"""
stripped = (text or "").strip()
if not stripped or not (stripped.startswith("[") and stripped.endswith("]")):
return None
try:
from .emoji_pack import looks_like_emoji_token, lookup_emoji_url
if not looks_like_emoji_token(stripped):
return None
url = lookup_emoji_url(stripped)
if url:
return {
"type": "sticker",
"text": stripped,
"url": url,
"name": stripped.strip("[]"),
}
except Exception:
pass
return None
def parse_incoming_message(data: dict[str, Any]) -> str:
"""从 IM API / WebSocket 消息 dict 提取并序列化展示内容。"""
if not isinstance(data, dict):
return str(data or "").strip()
msg_type = _coerce_message_type(
data.get("message_type") or data.get("messageType") or data.get("msg_type"),
MSG_TYPE_TEXT,
)
content_raw = (
data.get("content")
or data.get("message")
or data.get("msg")
or data.get("lastMessage")
or data.get("last_msg")
or data.get("preview")
or data.get("brief")
or ""
)
if isinstance(content_raw, dict):
if not msg_type or msg_type == MSG_TYPE_TEXT:
msg_type = _coerce_message_type(
content_raw.get("message_type")
or content_raw.get("messageType")
or content_raw.get("msg_type"),
msg_type,
)
parsed = format_im_message(content_raw, msg_type)
return serialize_message_content(parsed)
if isinstance(content_raw, str):
raw = content_raw.strip()
if raw.startswith("{"):
parsed = format_im_message(raw, msg_type)
if parsed.get("type") != "text" or parsed.get("url") or msg_type != MSG_TYPE_TEXT:
return serialize_message_content(parsed)
if raw in _PLACEHOLDER_MARKERS and msg_type != MSG_TYPE_TEXT:
parsed = format_im_message(raw, msg_type)
return serialize_message_content(parsed)
if msg_type != MSG_TYPE_TEXT:
parsed = format_im_message(raw, msg_type)
return serialize_message_content(parsed)
return raw
return ""
def serialize_message_content(msg: dict[str, Any]) -> str:
"""序列化写入 message_logs / reply_content。"""
msg_type = (msg.get("type") or "text").strip()
if msg_type == "text":
text = str(msg.get("text") or "").strip()
return text
cleaned = {k: v for k, v in msg.items() if v not in (None, "", [], {})}
return json.dumps(cleaned, ensure_ascii=False, separators=(",", ":"))
def parse_stored_content(raw: str | None) -> dict[str, Any]:
"""解析数据库中的 message_content / reply_content。"""
text = (raw or "").strip()
if not text:
return {"type": "text", "text": ""}
if text.startswith("{"):
try:
data = json.loads(text)
if isinstance(data, dict) and data.get("type"):
if not data.get("url") and data.get("uri"):
prefer_voice = data.get("type") == "voice"
resolved = resolve_media_uri(str(data["uri"]), prefer_voice=prefer_voice)
if resolved:
data = {**data, "url": resolved}
url = str(data.get("url") or "").strip()
if url:
inferred = _infer_media_type_from_url(url)
current = data.get("type")
if current == "image" and inferred in ("voice", "video"):
data = {
**data,
"type": inferred,
"text": "[语音]" if inferred == "voice" else "[视频]",
}
elif current not in ("voice", "video", "image", "sticker") and inferred:
data = {**data, "type": inferred}
return data
except json.JSONDecodeError:
pass
if text in _PLACEHOLDER_MARKERS:
mapping = {
"[图片]": "image",
"[表情包]": "sticker",
"[语音]": "voice",
"[视频]": "video",
}
return {"type": mapping.get(text, "text"), "text": text}
return {"type": "text", "text": text}
def message_preview(raw: str | None) -> str:
"""会话列表/日志摘要。"""
msg = parse_stored_content(raw)
msg_type = msg.get("type") or "text"
if msg_type == "text":
return str(msg.get("text") or "")
label = _TYPE_LABELS.get(msg_type, msg.get("text") or "[消息]")
extra = str(msg.get("name") or "").strip()
if extra and msg_type == "sticker":
return f"[表情] {extra}"
return str(msg.get("text") or label)
def normalize_outgoing_content(
content: str = "",
message_type: str | None = None,
media_url: str | None = None,
sticker_url: str | None = None,
width: int | None = None,
height: int | None = None,
sticker_id: str | None = None,
) -> str:
"""构造可发送/可落库的 content 字符串。"""
raw = (content or "").strip()
parsed_json: dict[str, Any] | None = None
if raw.startswith("{"):
try:
data = json.loads(raw)
if isinstance(data, dict) and data.get("type"):
parsed_json = data
except json.JSONDecodeError:
pass
if parsed_json:
merged = dict(parsed_json)
if width and not merged.get("width"):
merged["width"] = width
if height and not merged.get("height"):
merged["height"] = height
if sticker_id and not merged.get("sticker_id"):
merged["sticker_id"] = sticker_id
url = (media_url or sticker_url or "").strip()
if url and not merged.get("url"):
merged["url"] = url
return serialize_message_content(merged)
explicit_type = (message_type or "").strip().lower()
if explicit_type in ("image", "sticker", "voice", "video", "text"):
if explicit_type == "text":
return (content or "").strip()
payload: dict[str, Any] = {"type": explicit_type}
url = (media_url or sticker_url or "").strip()
if url:
payload["url"] = url
if explicit_type == "sticker" and sticker_id:
payload["sticker_id"] = sticker_id
if width:
payload["width"] = width
if height:
payload["height"] = height
text = (content or "").strip()
if text:
payload["text"] = text
elif explicit_type == "image":
payload["text"] = "[图片]"
elif explicit_type == "sticker":
payload["text"] = "[表情包]"
return serialize_message_content(payload)
raw = (content or "").strip()
if raw.startswith("{"):
try:
data = json.loads(raw)
if isinstance(data, dict) and data.get("type"):
return serialize_message_content(data)
except json.JSONDecodeError:
pass
return raw
def is_media_message(raw: str | None) -> bool:
return parse_stored_content(raw).get("type") not in (None, "text")
def format_system_log_message(raw: str | None) -> str:
"""系统诊断日志中的消息摘要。"""
msg = parse_stored_content(raw)
msg_type = msg.get("type") or "text"
if msg_type == "text":
return str(msg.get("text") or "")
parts = [message_preview(raw)]
url = str(msg.get("url") or "").strip()
if url:
parts.append(f"URL: {url}")
duration = msg.get("duration")
if duration:
parts.append(f"时长: {duration}s")
return " | ".join(parts)
def extract_urls_from_detail(detail: str | None) -> list[str]:
if not detail:
return []
return re.findall(r"https?://[^\s\]|)\"']+", detail)