fix(dingtalk): add EXT_MAP constant, PHOTO classification for images, card/interactiveCard message handling

- Promote EXT_MAP to a module-level constant for reuse
- Classify DingTalk image messages and image file attachments as PHOTO
- Extract DingTalk card/interactiveCard document link content with defensive parsing
- Handle None, empty, JSON, and plain-string card content fields
- Add coverage: 8 card + 4 interactiveCard + 5 _extract_media = 17 test cases
- Remove a shadowed duplicate TestExtractText class (pytest collected only the later one)
This commit is contained in:
raywu 2026-07-15 08:15:25 +08:00 committed by Teknium
parent 2c75c83a02
commit c762314561
2 changed files with 323 additions and 47 deletions

View file

@ -116,6 +116,27 @@ DINGTALK_TYPE_MAPPING = {
"voice": "audio",
}
# File extension → MIME type mapping for DingTalk file/image messages.
# Image MIME types (image/*) are used below in _extract_media to classify
# incoming msgtype='image' payloads as MessageType.PHOTO (not DOCUMENT).
EXT_MAP = {
"pdf": "application/pdf",
"png": "image/png",
"jpg": "image/jpeg",
"jpeg": "image/jpeg",
"gif": "image/gif",
"webp": "image/webp",
"doc": "application/msword",
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"xls": "application/vnd.ms-excel",
"xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"md": "text/markdown",
"txt": "text/plain",
"csv": "text/csv",
"zip": "application/zip",
"mp4": "video/mp4",
}
def check_dingtalk_requirements() -> bool:
"""Check if DingTalk dependencies are available and configured.
@ -770,6 +791,68 @@ class DingTalkAdapter(BasePlatformAdapter):
if fname:
content = f"[文件] {fname}"
# Fallback: card message (钉钉文档分享卡片 / link card)
# When a user shares a DingTalk Doc to the bot, the msgtype is "card"
# and the card data lives in extensions['card'] (SDK's from_dict maps
# unhandled fields to extensions). Extract title + doc URL so the
# message isn't silently dropped as "empty".
if not content:
msg_type = getattr(message, "message_type", "")
# Handle card-type messages (文档分享卡片 / link card)
if msg_type == "card":
extensions = getattr(message, "extensions", {}) or {}
card = extensions.get("card", {})
if isinstance(card, dict):
title = card.get("title", "")
raw_content = card.get("content", "")
doc_url = ""
if raw_content is None:
doc_url = ""
elif isinstance(raw_content, dict):
doc_url = raw_content.get("url", "") or raw_content.get("docUrl", "")
elif isinstance(raw_content, str):
stripped = raw_content.strip()
if not stripped:
doc_url = ""
else:
try:
parsed = json.loads(stripped)
if isinstance(parsed, dict):
doc_url = parsed.get("url", "") or parsed.get("docUrl", "")
except (ValueError, TypeError):
doc_url = raw_content
parts = []
if title:
parts.append(f"[文档] {title}")
if doc_url:
parts.append(doc_url)
if parts:
content = " ".join(parts)
# Last-resort: raw text field from extensions (if present)
if not content:
ext_text = extensions.get("text", {})
if isinstance(ext_text, dict):
content = (ext_text.get("content", "") or "").strip()
# Handle interactiveCard messages (钉钉文档分享卡片 / doc link card)
# structure: extensions["content"]["biz_custom_action_url"] and
# extensions["content"]["title"] for the card title
if msg_type == "interactiveCard" and not content:
extensions = getattr(message, "extensions", {}) or {}
ext_content = extensions.get("content", {})
if isinstance(ext_content, dict):
doc_url = ext_content.get("biz_custom_action_url", "")
title = ext_content.get("title", "")
if doc_url or title:
parts = []
if title:
parts.append(f"[文档卡片] {title}")
else:
parts.append("[文档卡片]")
if doc_url:
parts.append(doc_url)
content = " ".join(parts)
# Do NOT strip "@bot" from the text. The mention is a routing
# signal (delivered structurally via callback `isInAtList`), and
# regex-stripping @handles would collateral-damage e-mails
@ -863,20 +946,19 @@ class DingTalkAdapter(BasePlatformAdapter):
# Map common extensions
if fname:
ext = fname.rsplit(".", 1)[-1].lower() if "." in fname else ""
EXT_MAP = {
"pdf": "application/pdf", "png": "image/png", "jpg": "image/jpeg",
"jpeg": "image/jpeg", "gif": "image/gif", "webp": "image/webp",
"doc": "application/msword",
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"xls": "application/vnd.ms-excel",
"xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"md": "text/markdown", "txt": "text/plain", "csv": "text/csv",
"zip": "application/zip", "mp4": "video/mp4",
}
mime = EXT_MAP.get(ext, mime)
media_types.append(mime)
if msg_type == MessageType.TEXT:
msg_type = MessageType.DOCUMENT
# Image messages → PHOTO (distinct busy-session handling
# in gateway/platforms/base.py).
# File messages with image MIME types (e.g. a .png sent
# as a file attachment) are also classified as PHOTO —
# the user's intent is to share an image regardless of
# how DingTalk delivers it.
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

View file

@ -148,42 +148,6 @@ class TestDingTalkAdapterInit:
assert adapter._client_secret == "env-secret"
# ---------------------------------------------------------------------------
# Message text extraction
# ---------------------------------------------------------------------------
class TestExtractText:
def test_extracts_dict_text(self):
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
msg = MagicMock()
msg.text = {"content": " hello world "}
msg.rich_text = None
assert DingTalkAdapter._extract_text(msg) == "hello world"
def test_extracts_string_text(self):
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
msg = MagicMock()
msg.text = "plain text"
msg.rich_text = None
assert DingTalkAdapter._extract_text(msg) == "plain text"
def test_falls_back_to_rich_text(self):
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
msg = MagicMock()
msg.text = ""
msg.rich_text = [{"text": "part1"}, {"text": "part2"}, {"image": "url"}]
assert DingTalkAdapter._extract_text(msg) == "part1 part2"
def test_returns_empty_for_no_content(self):
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
msg = MagicMock()
msg.text = ""
msg.rich_text = None
assert DingTalkAdapter._extract_text(msg) == ""
# ---------------------------------------------------------------------------
# Deduplication
# ---------------------------------------------------------------------------
@ -570,6 +534,178 @@ class TestExtractText:
msg.rich_text = None
assert DingTalkAdapter._extract_text(msg) == ""
# --- Card / interactiveCard message handling (文档分享卡片) ---
def test_card_with_dict_content_and_url(self):
"""card msgtype with extensions.card.content as dict with url key."""
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
msg = MagicMock()
msg.text = None
msg.rich_text = None
msg.message_type = "card"
msg.extensions = {
"card": {
"title": "Q3经营分析报告",
"content": {"url": "https://dingtalk.com/doc/abc123"},
}
}
assert DingTalkAdapter._extract_text(msg) == "[文档] Q3经营分析报告 https://dingtalk.com/doc/abc123"
def test_card_with_dict_content_docurl(self):
"""card msgtype with extensions.card.content as dict with docUrl key."""
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
msg = MagicMock()
msg.text = None
msg.rich_text = None
msg.message_type = "card"
msg.extensions = {
"card": {
"title": "周报模板",
"content": {"docUrl": "https://docs.dingtalk.com/xyz"},
}
}
assert DingTalkAdapter._extract_text(msg) == "[文档] 周报模板 https://docs.dingtalk.com/xyz"
def test_card_with_json_string_content(self):
"""card msgtype with extensions.card.content as JSON string."""
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
msg = MagicMock()
msg.text = None
msg.rich_text = None
msg.message_type = "card"
msg.extensions = {
"card": {
"title": "数据看板",
"content": '{"url": "https://dingtalk.com/doc/def456"}',
}
}
assert DingTalkAdapter._extract_text(msg) == "[文档] 数据看板 https://dingtalk.com/doc/def456"
def test_card_with_plain_string_content(self):
"""card msgtype with extensions.card.content as plain string (used as url)."""
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
msg = MagicMock()
msg.text = None
msg.rich_text = None
msg.message_type = "card"
msg.extensions = {
"card": {
"title": "分享链接",
"content": "https://dingtalk.com/doc/plain",
}
}
assert DingTalkAdapter._extract_text(msg) == "[文档] 分享链接 https://dingtalk.com/doc/plain"
def test_card_no_title_only_url(self):
"""card msgtype with url but no title."""
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
msg = MagicMock()
msg.text = None
msg.rich_text = None
msg.message_type = "card"
msg.extensions = {
"card": {
"content": {"url": "https://dingtalk.com/doc/onlyurl"},
}
}
assert DingTalkAdapter._extract_text(msg) == "https://dingtalk.com/doc/onlyurl"
def test_card_fallback_to_extensions_text(self):
"""card msgtype with no usable card data → fallback to extensions.text.content."""
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
msg = MagicMock()
msg.text = None
msg.rich_text = None
msg.message_type = "card"
msg.extensions = {
"card": {},
"text": {"content": "fallback-text"},
}
assert DingTalkAdapter._extract_text(msg) == "fallback-text"
def test_card_content_none_is_handled(self):
"""card msgtype with content: None → no crash, empty doc_url."""
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
msg = MagicMock()
msg.text = None
msg.rich_text = None
msg.message_type = "card"
msg.extensions = {
"card": {
"title": "某文档",
"content": None,
}
}
assert DingTalkAdapter._extract_text(msg) == "[文档] 某文档"
def test_card_content_empty_string_is_handled(self):
"""card msgtype with content: "" → no crash, empty doc_url."""
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
msg = MagicMock()
msg.text = None
msg.rich_text = None
msg.message_type = "card"
msg.extensions = {
"card": {
"title": "空内容文档",
"content": "",
}
}
assert DingTalkAdapter._extract_text(msg) == "[文档] 空内容文档"
def test_interactive_card_extracts_biz_custom_action_url(self):
"""interactiveCard msgtype with biz_custom_action_url."""
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
msg = MagicMock()
msg.text = None
msg.rich_text = None
msg.message_type = "interactiveCard"
msg.extensions = {
"content": {
"biz_custom_action_url": "https://dingtalk.com/doc/interactive",
}
}
assert DingTalkAdapter._extract_text(msg) == "[文档卡片] https://dingtalk.com/doc/interactive"
def test_interactive_card_no_url_returns_empty(self):
"""interactiveCard msgtype with no biz_custom_action_url → empty string."""
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
msg = MagicMock()
msg.text = None
msg.rich_text = None
msg.message_type = "interactiveCard"
msg.extensions = {"content": {}}
assert DingTalkAdapter._extract_text(msg) == ""
def test_interactive_card_with_title_and_url(self):
"""interactiveCard msgtype with both title and biz_custom_action_url."""
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
msg = MagicMock()
msg.text = None
msg.rich_text = None
msg.message_type = "interactiveCard"
msg.extensions = {
"content": {
"title": "项目看板",
"biz_custom_action_url": "https://dingtalk.com/doc/kanban",
}
}
assert DingTalkAdapter._extract_text(msg) == "[文档卡片] 项目看板 https://dingtalk.com/doc/kanban"
def test_interactive_card_title_only(self):
"""interactiveCard msgtype with title but no URL."""
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
msg = MagicMock()
msg.text = None
msg.rich_text = None
msg.message_type = "interactiveCard"
msg.extensions = {
"content": {
"title": "仅标题",
}
}
assert DingTalkAdapter._extract_text(msg) == "[文档卡片] 仅标题"
class TestExtractMedia:
"""_extract_media must split native voice rich-text items (auto-STT)
@ -622,6 +758,64 @@ class TestExtractMedia:
finally:
del DINGTALK_TYPE_MAPPING["audio"]
def test_file_extensions_content_downloadcode_resolved(self):
"""msgtype='file' with extensions.content.downloadCode → DOCUMENT."""
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
from gateway.platforms.base import MessageType
msg = MagicMock()
msg.text = None
msg.image_content = None
msg.rich_text_content = None
msg.rich_text = None
msg.message_type = "file"
msg.extensions = {"content": {"downloadCode": "dl_file_123", "fileName": "report.pdf"}}
msg_type, urls, mtypes = DingTalkAdapter._extract_media(
DingTalkAdapter, msg
)
assert msg_type == MessageType.DOCUMENT
assert urls == ["dl_file_123"]
assert mtypes == ["application/pdf"]
def test_image_extensions_content_classified_as_photo(self):
"""msgtype='image' with extensions.content → PHOTO (not DOCUMENT)."""
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
from gateway.platforms.base import MessageType
msg = MagicMock()
msg.text = None
msg.image_content = None
msg.rich_text_content = None
msg.rich_text = None
msg.message_type = "image"
msg.extensions = {"content": {"downloadCode": "dl_img_abc", "fileName": "photo.png"}}
msg_type, urls, mtypes = DingTalkAdapter._extract_media(
DingTalkAdapter, msg
)
assert msg_type == MessageType.PHOTO
assert urls == ["dl_img_abc"]
assert mtypes == ["image/png"]
def test_image_no_filename_still_photo(self):
"""msgtype='image' without fileName → still PHOTO (MIME heuristic)."""
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
from gateway.platforms.base import MessageType
msg = MagicMock()
msg.text = None
msg.image_content = None
msg.rich_text_content = None
msg.rich_text = None
msg.message_type = "image"
msg.extensions = {"content": {"downloadCode": "dl_img_noext"}}
msg_type, urls, mtypes = DingTalkAdapter._extract_media(
DingTalkAdapter, msg
)
assert msg_type == MessageType.PHOTO
assert urls == ["dl_img_noext"]
# Without fileName, mime defaults to octet-stream but msg_type_str=="image" still wins
assert mtypes == ["application/octet-stream"]
# ---------------------------------------------------------------------------
# Group gating — require_mention + allowed_users (parity with other platforms)