diff --git a/plugins/platforms/teams/adapter.py b/plugins/platforms/teams/adapter.py index f8175a6a621..30422bafbce 100644 --- a/plugins/platforms/teams/adapter.py +++ b/plugins/platforms/teams/adapter.py @@ -1189,14 +1189,22 @@ class TeamsAdapter(BasePlatformAdapter): except Exception: pass - async def send_image( + async def _send_media_attachment( self, chat_id: str, - image_url: str, + source: str, + default_mime: str, caption: Optional[str] = None, - reply_to: Optional[str] = None, - metadata: Optional[Dict[str, Any]] = None, + media_label: str = "media", ) -> SendResult: + """Send any media file/URL as a Teams attachment. + + Remote ``http(s)://`` URLs are attached by reference; local paths + (with optional ``file://`` prefix) are base64-encoded into a data + URI. MIME type is guessed from the path/extension, falling back to + ``default_mime``. Shared by send_image / send_video / send_voice / + send_document so every media kind uses the same Attachment path. + """ if not self._app: return SendResult(success=False, error="Teams app not initialized") @@ -1205,13 +1213,13 @@ class TeamsAdapter(BasePlatformAdapter): import mimetypes from microsoft_teams.api import Attachment, MessageActivityInput - if image_url.startswith("http://") or image_url.startswith("https://"): - content_url = image_url - mime_type = "image/png" + if source.startswith("http://") or source.startswith("https://"): + content_url = source + mime_type = mimetypes.guess_type(source.split("?")[0])[0] or default_mime else: # Local path — encode as base64 data URI - path = image_url.removeprefix("file://") - mime_type = mimetypes.guess_type(path)[0] or "image/png" + path = source.removeprefix("file://") + mime_type = mimetypes.guess_type(path)[0] or default_mime with open(path, "rb") as f: content_url = f"data:{mime_type};base64,{base64.b64encode(f.read()).decode()}" @@ -1228,9 +1236,25 @@ class TeamsAdapter(BasePlatformAdapter): return SendResult(success=True, message_id=getattr(result, "id", None)) except Exception as e: - logger.error("[teams] send_image failed: %s", e, exc_info=True) + logger.error("[teams] send_%s failed: %s", media_label, e, exc_info=True) return SendResult(success=False, error=str(e), retryable=True) + async def send_image( + self, + chat_id: str, + image_url: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + return await self._send_media_attachment( + chat_id=chat_id, + source=image_url, + default_mime="image/png", + caption=caption, + media_label="image", + ) + async def send_image_file( self, chat_id: str, @@ -1246,6 +1270,58 @@ class TeamsAdapter(BasePlatformAdapter): reply_to=reply_to, ) + async def send_video( + self, + chat_id: str, + video_path: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + **kwargs, + ) -> SendResult: + return await self._send_media_attachment( + chat_id=chat_id, + source=video_path, + default_mime="video/mp4", + caption=caption, + media_label="video", + ) + + async def send_voice( + self, + chat_id: str, + audio_path: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + **kwargs, + ) -> SendResult: + return await self._send_media_attachment( + chat_id=chat_id, + source=audio_path, + default_mime="audio/mpeg", + caption=caption, + media_label="voice", + ) + + async def send_document( + self, + chat_id: str, + file_path: str, + caption: Optional[str] = None, + file_name: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + **kwargs, + ) -> SendResult: + return await self._send_media_attachment( + chat_id=chat_id, + source=file_path, + default_mime="application/octet-stream", + caption=caption, + media_label="document", + ) + async def get_chat_info(self, chat_id: str) -> dict: return {"name": chat_id, "type": "unknown", "chat_id": chat_id} diff --git a/tests/gateway/test_teams.py b/tests/gateway/test_teams.py index 1ae10593cc6..e2ed005abab 100644 --- a/tests/gateway/test_teams.py +++ b/tests/gateway/test_teams.py @@ -86,6 +86,7 @@ def _ensure_teams_mock(): microsoft_teams_api.MessageActivity = MagicMock microsoft_teams_api.ConversationReference = MagicMock microsoft_teams_api.MessageActivityInput = MagicMock + microsoft_teams_api.Attachment = MagicMock # TypingActivityInput mock class MockTypingActivityInput: @@ -1067,3 +1068,60 @@ class TestTeamsStandaloneSend: assert "error" in result assert "Bot Framework conversation ID" in result["error"] assert len(session.calls) == 0 + + +class TestTeamsMediaAttachments: + """send_video / send_voice / send_document route through the same + Attachment mechanism as send_image so the gateway's media dispatch + (run.py) delivers native attachments instead of the base-class text + fallback (file path sent as plain text).""" + + def _make_adapter(self): + adapter = TeamsAdapter(_make_config( + client_id="bot-id", client_secret="secret", tenant_id="tenant", + )) + adapter._app = MagicMock() + adapter._app.id = "bot-id" + adapter._app.send = AsyncMock(return_value=MagicMock(id="msg-001")) + return adapter + + @pytest.mark.asyncio + async def test_send_video_remote_url_succeeds(self): + adapter = self._make_adapter() + result = await adapter.send_video("19:abc@thread.v2", "https://cdn.example.com/clip.mp4") + assert result.success + assert result.message_id == "msg-001" + adapter._app.send.assert_awaited_once() + + @pytest.mark.asyncio + async def test_send_voice_local_file_base64(self, tmp_path): + adapter = self._make_adapter() + audio = tmp_path / "reply.mp3" + audio.write_bytes(b"ID3fakeaudio") + result = await adapter.send_voice("19:abc@thread.v2", str(audio), caption="here you go") + assert result.success + adapter._app.send.assert_awaited_once() + + @pytest.mark.asyncio + async def test_send_document_local_file_base64(self, tmp_path): + adapter = self._make_adapter() + doc = tmp_path / "report.pdf" + doc.write_bytes(b"%PDF-1.4 fake") + result = await adapter.send_document("19:abc@thread.v2", str(doc)) + assert result.success + adapter._app.send.assert_awaited_once() + + @pytest.mark.asyncio + async def test_send_video_without_app_fails(self): + adapter = self._make_adapter() + adapter._app = None + result = await adapter.send_video("19:abc@thread.v2", "https://cdn.example.com/clip.mp4") + assert not result.success + assert "not initialized" in result.error + + @pytest.mark.asyncio + async def test_send_document_missing_file_fails_gracefully(self): + adapter = self._make_adapter() + result = await adapter.send_document("19:abc@thread.v2", "/no/such/file.pdf") + assert not result.success + adapter._app.send.assert_not_awaited()