"""M4: outbound media (agent -> app): ``media.offer`` emission. Mixin for ``adapter.IrisAdapter``. The gateway's dispatch partition (gateway/run.py) extracts MEDIA: tags / image URLs from the final response, filters them through ``filter_media_delivery_paths``, then calls the ``send_*`` overrides with local file paths. We re-validate each path (defense in depth), register it in the media registry, mint a ``media_id``, and emit ``media.offer``; the app fetches the bytes via ``media.pull``. """ import logging import os from typing import Any from gateway.platforms.base import SendResult, validate_media_delivery_path from . import media as media_bridge from . import protocol from .classify import _thread_id_from_metadata from .mixin_base import IrisAdapterBase logger = logging.getLogger(__name__) class MediaHandlers(IrisAdapterBase): """Outbound media (see module docstring).""" async def _offer_media( self, chat_id: str, path: str, kind: str, filename: str | None, metadata: dict[str, Any] | None, ) -> SendResult: safe = validate_media_delivery_path(path) if safe is None: logger.warning("iris: media path failed delivery validation: %s", path) return SendResult(success=False, error="iris: media path not deliverable") try: size = os.path.getsize(safe) except OSError as e: logger.warning("iris: media file unreadable %s: %s", safe, e) return SendResult(success=False, error="iris: media file unreadable") entry = self._media.register_outbound( safe, kind, media_bridge.mime_for_path(safe), filename or os.path.basename(safe), size ) thread_id = _thread_id_from_metadata(metadata) frame = protocol.media_offer( entry.media_id, entry.kind, entry.mime, entry.size, entry.filename, chat_id=chat_id, thread_id=thread_id, message_id=self._last_message_id.get(chat_id), ) await self._broadcast_or_log(chat_id, frame) return SendResult(success=True, message_id=entry.media_id) async def send_image( self, chat_id: str, image_url: str, caption: str | None = None, reply_to: str | None = None, metadata: dict[str, Any] | None = None, ) -> SendResult: """Send an image (M4: local files offered over WS; remote URLs fall back to the base text rendering).""" if image_url.startswith("file://"): from urllib.parse import unquote return await self._offer_media(chat_id, unquote(image_url[7:]), "image", None, metadata) return await super().send_image( chat_id, image_url, caption=caption, reply_to=reply_to, metadata=metadata ) async def send_image_file( self, chat_id: str, image_path: str, caption: str | None = None, reply_to: str | None = None, metadata: dict[str, Any] | None = None, **kwargs: Any, ) -> SendResult: """Send a local image file (M4).""" return await self._offer_media(chat_id, image_path, "image", None, metadata) async def send_video( self, chat_id: str, video_path: str, caption: str | None = None, reply_to: str | None = None, metadata: dict[str, Any] | None = None, **kwargs: Any, ) -> SendResult: """Send a video (M4).""" return await self._offer_media(chat_id, video_path, "video", None, metadata) async def send_voice( self, chat_id: str, audio_path: str, caption: str | None = None, reply_to: str | None = None, metadata: dict[str, Any] | None = None, **kwargs: Any, ) -> SendResult: """Send a voice note / audio file (M4).""" return await self._offer_media(chat_id, audio_path, "voice", None, metadata) async def send_document( # noqa: PLR0913 self, chat_id: str, file_path: str, caption: str | None = None, file_name: str | None = None, reply_to: str | None = None, metadata: dict[str, Any] | None = None, **kwargs: Any, ) -> SendResult: """Send a document (M4).""" return await self._offer_media(chat_id, file_path, "document", file_name, metadata)