747 lines
25 KiB
Python
747 lines
25 KiB
Python
"""私信消息内容解析、存储与展示(文本 / 图片 / 表情 / 语音 / 视频)。"""
|
||
|
||
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_pic(base64 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)
|