diff --git a/plugins/platforms/dingtalk/adapter.py b/plugins/platforms/dingtalk/adapter.py index 5868ab09636a..2e11f9d85feb 100644 --- a/plugins/platforms/dingtalk/adapter.py +++ b/plugins/platforms/dingtalk/adapter.py @@ -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 diff --git a/tests/gateway/test_dingtalk.py b/tests/gateway/test_dingtalk.py index 8e4cd8223276..c4ac504512b4 100644 --- a/tests/gateway/test_dingtalk.py +++ b/tests/gateway/test_dingtalk.py @@ -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)