mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-21 10:22:18 +00:00
feat(teams): native send_video/send_voice/send_document attachments (#49308)
Teams overrode send_image/send_image_file but not send_video, send_voice, or send_document — so when the gateway dispatched a video/voice/document reply to a Teams chat it fell through to the base-class text fallback and sent the local file path as plain text (same broken-UX class as the LINE URL-image gap in #49298). Extract the existing send_image attachment logic into a shared _send_media_attachment helper (remote URL by reference, local file as a base64 data URI, MIME guessed from the path) and route all four media kinds through it. 5 new tests cover remote-URL, local-file base64, no-app, and missing-file paths.
This commit is contained in:
parent
1e40b21b2e
commit
5f55f0ff85
2 changed files with 144 additions and 10 deletions
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue