243 lines
10 KiB
Python
243 lines
10 KiB
Python
"""Pure parsers for inbound DingTalk ``ChatbotMessage`` payloads (no I/O, no adapter state)."""
|
|
|
|
import json
|
|
from typing import Any, List, Optional, Tuple
|
|
|
|
from gateway.platforms.base import MessageType
|
|
|
|
# DingTalk rich-text item type → runtime content type
|
|
DINGTALK_TYPE_MAPPING = {"picture": "image", "voice": "audio"}
|
|
|
|
# File extension → MIME type for DingTalk file/image messages. image/* MIMEs
|
|
# make ``extract_media`` classify msgtype='image'/'file' payloads as PHOTO.
|
|
EXT_MAP = {
|
|
"pdf": "application/pdf", "doc": "application/msword", "xls": "application/vnd.ms-excel",
|
|
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
"xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg", "gif": "image/gif", "webp": "image/webp",
|
|
"md": "text/markdown", "txt": "text/plain", "csv": "text/csv", "zip": "application/zip", "mp4": "video/mp4",
|
|
}
|
|
|
|
# rich-text runtime type → (media_types entry, MessageType promotion when still TEXT)
|
|
_RICH_MEDIA = {
|
|
"image": ("image", MessageType.PHOTO),
|
|
"video": ("video", MessageType.VIDEO),
|
|
"file": ("application/octet-stream", MessageType.DOCUMENT),
|
|
}
|
|
|
|
|
|
def _extensions(message: Any) -> Any:
|
|
return getattr(message, "extensions", {}) or {}
|
|
|
|
|
|
def _ext_content(message: Any) -> Optional[dict]:
|
|
"""``extensions['content']`` when it is a dict, else None."""
|
|
content = _extensions(message).get("content", {})
|
|
return content if isinstance(content, dict) else None
|
|
|
|
|
|
def _rich_list(message: Any) -> Optional[list]:
|
|
"""Rich-text item list from either SDK shape (``rich_text_content.rich_text_list`` or legacy ``rich_text``)."""
|
|
rich_text = getattr(message, "rich_text_content", None) or getattr(message, "rich_text", None)
|
|
if not rich_text:
|
|
return None
|
|
rich_list = getattr(rich_text, "rich_text_list", None) or rich_text
|
|
return rich_list if isinstance(rich_list, list) else None
|
|
|
|
|
|
def _card_text(message: Any) -> str:
|
|
"""msgtype='card' (钉钉文档分享卡片 / link card): title + doc URL from ``extensions['card']``."""
|
|
extensions = _extensions(message)
|
|
content = ""
|
|
card = extensions.get("card", {})
|
|
if isinstance(card, dict):
|
|
title = card.get("title", "")
|
|
raw_content = card.get("content", "")
|
|
doc_url = ""
|
|
if isinstance(raw_content, dict):
|
|
doc_url = raw_content.get("url", "") or raw_content.get("docUrl", "")
|
|
elif isinstance(raw_content, str) and raw_content.strip():
|
|
try:
|
|
parsed = json.loads(raw_content.strip())
|
|
if isinstance(parsed, dict):
|
|
doc_url = parsed.get("url", "") or parsed.get("docUrl", "")
|
|
except (ValueError, TypeError):
|
|
doc_url = raw_content
|
|
parts = ([f"[文档] {title}"] if title else []) + ([doc_url] if doc_url else [])
|
|
if parts:
|
|
content = " ".join(parts)
|
|
if not content:
|
|
# Last-resort: raw text field from extensions (if present)
|
|
ext_text = extensions.get("text", {})
|
|
if isinstance(ext_text, dict):
|
|
content = (ext_text.get("content", "") or "").strip()
|
|
return content
|
|
|
|
|
|
def _interactive_card_text(message: Any) -> str:
|
|
"""msgtype='interactiveCard': ``extensions['content']`` carries title + biz_custom_action_url."""
|
|
ext_content = _ext_content(message)
|
|
if not ext_content:
|
|
return ""
|
|
doc_url = ext_content.get("biz_custom_action_url", "")
|
|
title = ext_content.get("title", "")
|
|
if not (doc_url or title):
|
|
return ""
|
|
parts = [f"[文档卡片] {title}" if title else "[文档卡片]"] + ([doc_url] if doc_url else [])
|
|
return " ".join(parts)
|
|
|
|
|
|
def _ext_field(message: Any, field: str) -> Any:
|
|
ext_content = _ext_content(message)
|
|
return ext_content.get(field, "") if ext_content else ""
|
|
|
|
|
|
def _audio_text(message: Any) -> str:
|
|
"""msgtype='audio': DingTalk-provided speech recognition text."""
|
|
recognition = _ext_field(message, "recognition")
|
|
return recognition.strip() if recognition else ""
|
|
|
|
|
|
def _file_text(message: Any) -> str:
|
|
"""msgtype='file': use fileName as text."""
|
|
fname = _ext_field(message, "fileName")
|
|
return f"[文件] {fname}" if fname else ""
|
|
|
|
|
|
# Fallbacks by msgtype when no plain/rich text was found (types are exclusive).
|
|
_EMPTY_TEXT_FALLBACKS = (
|
|
("audio", _audio_text),
|
|
("file", _file_text),
|
|
("card", _card_text),
|
|
("interactiveCard", _interactive_card_text),
|
|
)
|
|
|
|
|
|
def extract_text(message: Any) -> str:
|
|
"""Extract plain text from a DingTalk chatbot message.
|
|
|
|
Handles both SDK payload shapes: legacy ``message.text`` dict ``{"content": ...}`` and
|
|
>= 0.20 ``TextContent`` (whose ``__str__`` is ``"TextContent(content=...)"`` — always read
|
|
``.content`` first); rich text via ``rich_text_content.rich_text_list`` or legacy ``rich_text``.
|
|
"""
|
|
text = getattr(message, "text", None) or ""
|
|
if hasattr(text, "content"):
|
|
content = (text.content or "").strip()
|
|
elif isinstance(text, dict):
|
|
content = text.get("content", "").strip()
|
|
else:
|
|
content = str(text).strip()
|
|
|
|
if not content:
|
|
rich_list = _rich_list(message)
|
|
if rich_list is not None:
|
|
parts = []
|
|
for item in rich_list:
|
|
if isinstance(item, dict):
|
|
t = item.get("text") or item.get("content") or ""
|
|
if t:
|
|
parts.append(t)
|
|
elif hasattr(item, "text") and item.text:
|
|
parts.append(item.text)
|
|
content = " ".join(parts).strip()
|
|
|
|
if not content:
|
|
msg_type = getattr(message, "message_type", "")
|
|
for kind, fallback in _EMPTY_TEXT_FALLBACKS:
|
|
if msg_type == kind:
|
|
content = fallback(message)
|
|
break
|
|
|
|
# Do NOT strip "@bot": the mention is routed structurally (callback ``isInAtList``), and
|
|
# regex-stripping @handles would damage e-mails, SSH URLs and literal "@openai" references.
|
|
return content
|
|
|
|
|
|
def extract_media(message: Any) -> Tuple[MessageType, List[str], List[str]]:
|
|
"""Return ``(MessageType, [download codes/urls], [mime types])`` for a message."""
|
|
msg_type = MessageType.TEXT
|
|
media_urls: List[str] = []
|
|
media_types: List[str] = []
|
|
|
|
image_content = getattr(message, "image_content", None)
|
|
if image_content:
|
|
download_code = getattr(image_content, "download_code", None)
|
|
if download_code:
|
|
media_urls.append(download_code)
|
|
media_types.append("image")
|
|
msg_type = MessageType.PHOTO
|
|
|
|
for item in _rich_list(message) or ():
|
|
if not isinstance(item, dict):
|
|
continue
|
|
dl_code = item.get("downloadCode") or item.get("download_code") or ""
|
|
item_type = item.get("type", "")
|
|
if not dl_code:
|
|
continue
|
|
mapped = DINGTALK_TYPE_MAPPING.get(item_type, "file")
|
|
media_urls.append(dl_code)
|
|
if mapped == "audio":
|
|
media_types.append("audio")
|
|
if msg_type == MessageType.TEXT:
|
|
# "voice" items are native voice notes → STT (VOICE); "audio" file uploads stay AUDIO.
|
|
msg_type = MessageType.VOICE if item_type == "voice" else MessageType.AUDIO
|
|
else:
|
|
mime, promoted = _RICH_MEDIA[mapped]
|
|
media_types.append(mime)
|
|
if msg_type == MessageType.TEXT:
|
|
msg_type = promoted
|
|
|
|
msg_type_str = getattr(message, "message_type", "") or ""
|
|
if msg_type_str == "picture" and not media_urls:
|
|
msg_type = MessageType.PHOTO
|
|
elif msg_type_str == "richText":
|
|
# Only re-derive when the scan above left TEXT — resetting a VOICE/AUDIO/VIDEO/DOCUMENT
|
|
# promotion here dropped native voice notes back to TEXT and skipped STT.
|
|
if msg_type == MessageType.TEXT and any("image" in t for t in media_types):
|
|
msg_type = MessageType.PHOTO
|
|
elif msg_type_str == "audio":
|
|
# Voice message: recognition text is already in the text. Do NOT add media_urls, or
|
|
# run.py's transcription enrichment overwrites it with a failed STT attempt.
|
|
if msg_type == MessageType.TEXT:
|
|
msg_type = MessageType.VOICE
|
|
elif msg_type_str in ("file", "image"):
|
|
ext_content = _ext_content(message)
|
|
if ext_content:
|
|
dl_code = ext_content.get("downloadCode") or ""
|
|
fname = ext_content.get("fileName", "")
|
|
if dl_code:
|
|
media_urls.append(dl_code)
|
|
mime = "application/octet-stream"
|
|
if fname:
|
|
ext = fname.rsplit(".", 1)[-1].lower() if "." in fname else ""
|
|
mime = EXT_MAP.get(ext, mime)
|
|
media_types.append(mime)
|
|
if msg_type == MessageType.TEXT:
|
|
# Image messages, and files with image MIME (a .png sent as attachment), → PHOTO.
|
|
if msg_type_str == "image" or mime.startswith("image/"):
|
|
msg_type = MessageType.PHOTO
|
|
else:
|
|
msg_type = MessageType.DOCUMENT
|
|
|
|
return msg_type, media_urls, media_types
|
|
|
|
|
|
def collect_download_codes(message: Any) -> List[Tuple[Any, str]]:
|
|
"""Return ``(container, key)`` pairs whose download code should be resolved to a URL."""
|
|
codes: List[Tuple[Any, str]] = []
|
|
img_content = getattr(message, "image_content", None)
|
|
if img_content and getattr(img_content, "download_code", None):
|
|
codes.append((img_content, "download_code"))
|
|
rich_text = getattr(message, "rich_text_content", None)
|
|
if rich_text:
|
|
for item in getattr(rich_text, "rich_text_list", []) or []:
|
|
if isinstance(item, dict):
|
|
for key in ("downloadCode", "pictureDownloadCode", "download_code"):
|
|
if item.get(key):
|
|
codes.append((item, key))
|
|
if (getattr(message, "message_type", "") or "") in ("file", "image"):
|
|
ext_content = _ext_content(message)
|
|
if ext_content and ext_content.get("downloadCode"):
|
|
codes.append((ext_content, "downloadCode"))
|
|
return codes
|