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:
Teknium 2026-06-19 16:20:59 -07:00 committed by GitHub
parent 1e40b21b2e
commit 5f55f0ff85
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 144 additions and 10 deletions

View file

@ -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}

View file

@ -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()