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}