This commit is contained in:
Your Name
2026-07-23 17:56:25 +08:00
parent a05dae8412
commit 4970d8f8d3
4262 changed files with 735221 additions and 0 deletions
@@ -0,0 +1,746 @@
"""私信消息内容解析、存储与展示(文本 / 图片 / 表情 / 语音 / 视频)。"""
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)