feat: support file/image/richText message receiving for DingTalk
This commit is contained in:
@@ -63,6 +63,49 @@ class NanobotDingTalkHandler(CallbackHandler):
|
|||||||
if not content:
|
if not content:
|
||||||
content = message.data.get("text", {}).get("content", "").strip()
|
content = message.data.get("text", {}).get("content", "").strip()
|
||||||
|
|
||||||
|
# Handle file/image messages
|
||||||
|
file_paths = []
|
||||||
|
if chatbot_msg.message_type == "picture" and chatbot_msg.image_content:
|
||||||
|
download_code = chatbot_msg.image_content.download_code
|
||||||
|
if download_code:
|
||||||
|
sender_uid = chatbot_msg.sender_staff_id or chatbot_msg.sender_id or "unknown"
|
||||||
|
fp = await self.channel._download_dingtalk_file(download_code, "image.jpg", sender_uid)
|
||||||
|
if fp:
|
||||||
|
file_paths.append(fp)
|
||||||
|
content = content or "[Image]"
|
||||||
|
|
||||||
|
elif chatbot_msg.message_type == "file":
|
||||||
|
download_code = message.data.get("content", {}).get("downloadCode") or message.data.get("downloadCode")
|
||||||
|
fname = message.data.get("content", {}).get("fileName") or message.data.get("fileName") or "file"
|
||||||
|
if download_code:
|
||||||
|
sender_uid = chatbot_msg.sender_staff_id or chatbot_msg.sender_id or "unknown"
|
||||||
|
fp = await self.channel._download_dingtalk_file(download_code, fname, sender_uid)
|
||||||
|
if fp:
|
||||||
|
file_paths.append(fp)
|
||||||
|
content = content or "[File]"
|
||||||
|
|
||||||
|
elif chatbot_msg.message_type == "richText" and chatbot_msg.rich_text_content:
|
||||||
|
rich_list = chatbot_msg.rich_text_content.rich_text_list or []
|
||||||
|
for item in rich_list:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
if item.get("type") == "text":
|
||||||
|
t = item.get("text", "").strip()
|
||||||
|
if t:
|
||||||
|
content = (content + " " + t).strip() if content else t
|
||||||
|
elif item.get("downloadCode"):
|
||||||
|
dc = item["downloadCode"]
|
||||||
|
fname = item.get("fileName") or "file"
|
||||||
|
sender_uid = chatbot_msg.sender_staff_id or chatbot_msg.sender_id or "unknown"
|
||||||
|
fp = await self.channel._download_dingtalk_file(dc, fname, sender_uid)
|
||||||
|
if fp:
|
||||||
|
file_paths.append(fp)
|
||||||
|
content = content or "[File]"
|
||||||
|
|
||||||
|
if file_paths:
|
||||||
|
file_list = "\n".join("- " + p for p in file_paths)
|
||||||
|
content = content + "\n\nReceived files:\n" + file_list
|
||||||
|
|
||||||
if not content:
|
if not content:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Received empty or unsupported message type: {}",
|
"Received empty or unsupported message type: {}",
|
||||||
@@ -488,3 +531,50 @@ class DingTalkChannel(BaseChannel):
|
|||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error publishing DingTalk message: {}", e)
|
logger.error("Error publishing DingTalk message: {}", e)
|
||||||
|
|
||||||
|
async def _download_dingtalk_file(
|
||||||
|
self,
|
||||||
|
download_code: str,
|
||||||
|
filename: str,
|
||||||
|
sender_id: str,
|
||||||
|
) -> str | None:
|
||||||
|
"""Download a DingTalk file to a local temp directory, return local path."""
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
try:
|
||||||
|
token = await self._get_access_token()
|
||||||
|
if not token or not self._http:
|
||||||
|
logger.error("DingTalk file download: no token or http client")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Step 1: Exchange downloadCode for a temporary download URL
|
||||||
|
api_url = "https://api.dingtalk.com/v1.0/robot/messageFiles/download"
|
||||||
|
headers = {"x-acs-dingtalk-access-token": token, "Content-Type": "application/json"}
|
||||||
|
payload = {"downloadCode": download_code, "robotCode": self.config.client_id}
|
||||||
|
resp = await self._http.post(api_url, json=payload, headers=headers)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
logger.error("DingTalk get download URL failed: status={}, body={}", resp.status_code, resp.text)
|
||||||
|
return None
|
||||||
|
|
||||||
|
result = resp.json()
|
||||||
|
download_url = result.get("downloadUrl")
|
||||||
|
if not download_url:
|
||||||
|
logger.error("DingTalk download URL not found in response: {}", result)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Step 2: Download the file content
|
||||||
|
file_resp = await self._http.get(download_url, follow_redirects=True)
|
||||||
|
if file_resp.status_code != 200:
|
||||||
|
logger.error("DingTalk file download failed: status={}", file_resp.status_code)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Save to local temp directory
|
||||||
|
download_dir = Path(tempfile.gettempdir()) / "nanobot_dingtalk" / sender_id
|
||||||
|
download_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
file_path = download_dir / filename
|
||||||
|
await asyncio.to_thread(file_path.write_bytes, file_resp.content)
|
||||||
|
logger.info("DingTalk file saved: {}", file_path)
|
||||||
|
return str(file_path)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("DingTalk file download error: {}", e)
|
||||||
|
return None
|
||||||
|
|||||||
@@ -14,19 +14,31 @@ class _FakeResponse:
|
|||||||
self.status_code = status_code
|
self.status_code = status_code
|
||||||
self._json_body = json_body or {}
|
self._json_body = json_body or {}
|
||||||
self.text = "{}"
|
self.text = "{}"
|
||||||
|
self.content = b""
|
||||||
|
self.headers = {"content-type": "application/json"}
|
||||||
|
|
||||||
def json(self) -> dict:
|
def json(self) -> dict:
|
||||||
return self._json_body
|
return self._json_body
|
||||||
|
|
||||||
|
|
||||||
class _FakeHttp:
|
class _FakeHttp:
|
||||||
def __init__(self) -> None:
|
def __init__(self, responses: list[_FakeResponse] | None = None) -> None:
|
||||||
self.calls: list[dict] = []
|
self.calls: list[dict] = []
|
||||||
|
self._responses = list(responses) if responses else []
|
||||||
|
|
||||||
async def post(self, url: str, json=None, headers=None):
|
def _next_response(self) -> _FakeResponse:
|
||||||
self.calls.append({"url": url, "json": json, "headers": headers})
|
if self._responses:
|
||||||
|
return self._responses.pop(0)
|
||||||
return _FakeResponse()
|
return _FakeResponse()
|
||||||
|
|
||||||
|
async def post(self, url: str, json=None, headers=None, **kwargs):
|
||||||
|
self.calls.append({"method": "POST", "url": url, "json": json, "headers": headers})
|
||||||
|
return self._next_response()
|
||||||
|
|
||||||
|
async def get(self, url: str, **kwargs):
|
||||||
|
self.calls.append({"method": "GET", "url": url})
|
||||||
|
return self._next_response()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_group_message_keeps_sender_id_and_routes_chat_id() -> None:
|
async def test_group_message_keeps_sender_id_and_routes_chat_id() -> None:
|
||||||
@@ -109,3 +121,90 @@ async def test_handler_uses_voice_recognition_text_when_text_is_empty(monkeypatc
|
|||||||
assert msg.content == "voice transcript"
|
assert msg.content == "voice transcript"
|
||||||
assert msg.sender_id == "user1"
|
assert msg.sender_id == "user1"
|
||||||
assert msg.chat_id == "group:conv123"
|
assert msg.chat_id == "group:conv123"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_handler_processes_file_message(monkeypatch) -> None:
|
||||||
|
"""Test that file messages are handled and forwarded with downloaded path."""
|
||||||
|
bus = MessageBus()
|
||||||
|
channel = DingTalkChannel(
|
||||||
|
DingTalkConfig(client_id="app", client_secret="secret", allow_from=["user1"]),
|
||||||
|
bus,
|
||||||
|
)
|
||||||
|
handler = NanobotDingTalkHandler(channel)
|
||||||
|
|
||||||
|
class _FakeFileChatbotMessage:
|
||||||
|
text = None
|
||||||
|
extensions = {}
|
||||||
|
image_content = None
|
||||||
|
rich_text_content = None
|
||||||
|
sender_staff_id = "user1"
|
||||||
|
sender_id = "fallback-user"
|
||||||
|
sender_nick = "Alice"
|
||||||
|
message_type = "file"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_dict(_data):
|
||||||
|
return _FakeFileChatbotMessage()
|
||||||
|
|
||||||
|
async def fake_download(download_code, filename, sender_id):
|
||||||
|
return f"/tmp/nanobot_dingtalk/{sender_id}/{filename}"
|
||||||
|
|
||||||
|
monkeypatch.setattr(dingtalk_module, "ChatbotMessage", _FakeFileChatbotMessage)
|
||||||
|
monkeypatch.setattr(dingtalk_module, "AckMessage", SimpleNamespace(STATUS_OK="OK"))
|
||||||
|
monkeypatch.setattr(channel, "_download_dingtalk_file", fake_download)
|
||||||
|
|
||||||
|
status, body = await handler.process(
|
||||||
|
SimpleNamespace(
|
||||||
|
data={
|
||||||
|
"conversationType": "1",
|
||||||
|
"content": {"downloadCode": "abc123", "fileName": "report.xlsx"},
|
||||||
|
"text": {"content": ""},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
await asyncio.gather(*list(channel._background_tasks))
|
||||||
|
msg = await bus.consume_inbound()
|
||||||
|
|
||||||
|
assert (status, body) == ("OK", "OK")
|
||||||
|
assert "[File]" in msg.content
|
||||||
|
assert "/tmp/nanobot_dingtalk/user1/report.xlsx" in msg.content
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_download_dingtalk_file(tmp_path, monkeypatch) -> None:
|
||||||
|
"""Test the two-step file download flow (get URL then download content)."""
|
||||||
|
channel = DingTalkChannel(
|
||||||
|
DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"]),
|
||||||
|
MessageBus(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mock access token
|
||||||
|
async def fake_get_token():
|
||||||
|
return "test-token"
|
||||||
|
|
||||||
|
monkeypatch.setattr(channel, "_get_access_token", fake_get_token)
|
||||||
|
|
||||||
|
# Mock HTTP: first POST returns downloadUrl, then GET returns file bytes
|
||||||
|
file_content = b"fake file content"
|
||||||
|
channel._http = _FakeHttp(responses=[
|
||||||
|
_FakeResponse(200, {"downloadUrl": "https://example.com/tmpfile"}),
|
||||||
|
_FakeResponse(200),
|
||||||
|
])
|
||||||
|
channel._http._responses[1].content = file_content
|
||||||
|
|
||||||
|
# Redirect temp dir to tmp_path
|
||||||
|
monkeypatch.setattr("tempfile.gettempdir", lambda: str(tmp_path))
|
||||||
|
|
||||||
|
result = await channel._download_dingtalk_file("code123", "test.xlsx", "user1")
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result.endswith("test.xlsx")
|
||||||
|
assert (tmp_path / "nanobot_dingtalk" / "user1" / "test.xlsx").read_bytes() == file_content
|
||||||
|
|
||||||
|
# Verify API calls
|
||||||
|
assert channel._http.calls[0]["method"] == "POST"
|
||||||
|
assert "messageFiles/download" in channel._http.calls[0]["url"]
|
||||||
|
assert channel._http.calls[0]["json"]["downloadCode"] == "code123"
|
||||||
|
assert channel._http.calls[1]["method"] == "GET"
|
||||||
|
|||||||
Reference in New Issue
Block a user