mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
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:
parent
2c75c83a02
commit
c762314561
2 changed files with 323 additions and 47 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue