diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 761eba90e29d..bf3da1a0edeb 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -33,6 +33,8 @@ # delivered as a regular document. _TELEGRAM_AUDIO_ATTACHMENT_EXTS = frozenset({'.mp3', '.m4a'}) _TELEGRAM_VOICE_EXTS = frozenset({'.ogg', '.opus'}) +_MEDIA_IMAGE_EXTS = frozenset({'.jpg', '.jpeg', '.png', '.webp', '.gif'}) +_MEDIA_VIDEO_EXTS = frozenset({'.mp4', '.mov', '.avi', '.mkv', '.webm', '.3gp'}) def _platform_name(platform) -> str: @@ -487,6 +489,32 @@ def is_host_excluded_by_no_proxy(hostname: str, no_proxy_value: str | None = Non from hermes_constants import get_default_hermes_root, get_hermes_dir, get_hermes_home +class MediaKind(Enum): + IMAGE = "image" + VIDEO = "video" + VOICE = "voice" + DOCUMENT = "document" + + +def classify_media_kind(path, is_voice=False, platform=None, force_document=False) -> MediaKind: + """Map a local media path to the MediaKind a dispatcher should send it as. + + Mirrors the reply path's routing (image/video by extension, audio via + should_send_media_as_audio). ``force_document`` wins, matching the + ``[[as_document]]`` directive; unknown extensions fall back to DOCUMENT. + """ + if force_document: + return MediaKind.DOCUMENT + ext = Path(path).suffix.lower() + if ext in _MEDIA_IMAGE_EXTS: + return MediaKind.IMAGE + if ext in _MEDIA_VIDEO_EXTS: + return MediaKind.VIDEO + if should_send_media_as_audio(platform, ext, is_voice=is_voice): + return MediaKind.VOICE + return MediaKind.DOCUMENT + + GATEWAY_SECRET_CAPTURE_UNSUPPORTED_MESSAGE = ( "Secure secret entry is not supported over messaging. " "Load this skill in the local CLI to be prompted, or add the key to ~/.hermes/.env manually." @@ -2033,6 +2061,13 @@ async def send( # property) so the stream consumer knows not to short-circuit. REQUIRES_EDIT_FINALIZE: bool = False + # Media kinds this adapter natively DELIVERS via its send_* overrides. + # Fail-closed default: an adapter advertises nothing until it declares. + # Single source of truth for every media-dispatch site — declaring a kind + # you don't truly deliver re-opens the path-as-text leak, so the declared + # set is pinned by tests/gateway/test_media_kinds.py. + MEDIA_KINDS: frozenset[MediaKind] = frozenset() + async def create_handoff_thread( self, parent_chat_id: str, diff --git a/gateway/platforms/bluebubbles.py b/gateway/platforms/bluebubbles.py index 2fc4102b6666..97329b397f1c 100644 --- a/gateway/platforms/bluebubbles.py +++ b/gateway/platforms/bluebubbles.py @@ -23,6 +23,7 @@ from gateway.config import Platform, PlatformConfig from gateway.platforms.base import ( + MediaKind, BasePlatformAdapter, MessageEvent, MessageType, @@ -101,6 +102,7 @@ def _normalize_server_url(raw: str) -> str: # --------------------------------------------------------------------------- class BlueBubblesAdapter(BasePlatformAdapter): + MEDIA_KINDS = frozenset({MediaKind.IMAGE, MediaKind.VIDEO, MediaKind.VOICE, MediaKind.DOCUMENT}) platform = Platform.BLUEBUBBLES SUPPORTS_MESSAGE_EDITING = False MAX_MESSAGE_LENGTH = MAX_TEXT_LENGTH diff --git a/gateway/platforms/dingtalk.py b/gateway/platforms/dingtalk.py index 0b3c7f52ace9..3682d7c876bf 100644 --- a/gateway/platforms/dingtalk.py +++ b/gateway/platforms/dingtalk.py @@ -90,6 +90,7 @@ from gateway.config import Platform, PlatformConfig from gateway.platforms.helpers import MessageDeduplicator from gateway.platforms.base import ( + MediaKind, BasePlatformAdapter, MessageEvent, MessageType, @@ -157,6 +158,7 @@ class DingTalkAdapter(BasePlatformAdapter): - Session webhook caching with expiry tracking - Markdown formatted replies """ + MEDIA_KINDS = frozenset() MAX_MESSAGE_LENGTH = MAX_MESSAGE_LENGTH diff --git a/gateway/platforms/email.py b/gateway/platforms/email.py index 0fffb82d0b94..b07449d9ec81 100644 --- a/gateway/platforms/email.py +++ b/gateway/platforms/email.py @@ -34,6 +34,7 @@ from typing import Any, Dict, List, Optional, Tuple from gateway.platforms.base import ( + MediaKind, BasePlatformAdapter, MessageEvent, MessageType, @@ -244,6 +245,7 @@ def _extract_attachments( class EmailAdapter(BasePlatformAdapter): """Email gateway adapter using IMAP (receive) and SMTP (send).""" + MEDIA_KINDS = frozenset({MediaKind.IMAGE, MediaKind.DOCUMENT}) def __init__(self, config: PlatformConfig): super().__init__(config, Platform.EMAIL) @@ -684,6 +686,39 @@ def _send_email_with_attachments( logger.info("[Email] Sent multi-attachment email to %s (%d files)", to_addr, len(file_paths)) return msg_id + async def send_image_file( + self, + chat_id: str, + image_path: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + **kwargs, + ) -> SendResult: + """Send a local image as a real MIME attachment. + + The dispatcher's per-file IMAGE path (``_dispatch_media_one``) calls + ``send_image_file``; without this override email would inherit the + base stub and leak the local path as body text. Delegates to the same + attachment helper as ``send_document`` so the IMAGE and DOCUMENT paths + stay symmetric and honor the ``SendResult`` contract. (The batch + ``send_multiple_images`` override handles the reply/kanban paths.) + """ + try: + loop = asyncio.get_running_loop() + message_id = await loop.run_in_executor( + None, + self._send_email_with_attachment, + chat_id, + caption or "", + image_path, + None, + ) + return SendResult(success=True, message_id=message_id) + except Exception as e: + logger.error("[Email] Send image file failed: %s", e) + return SendResult(success=False, error=str(e)) + async def send_document( self, chat_id: str, diff --git a/gateway/platforms/feishu.py b/gateway/platforms/feishu.py index 12ad62b5a7e9..cee8585e9d04 100644 --- a/gateway/platforms/feishu.py +++ b/gateway/platforms/feishu.py @@ -129,6 +129,7 @@ from gateway.config import Platform, PlatformConfig from gateway.platforms.base import ( + MediaKind, BasePlatformAdapter, MessageEvent, MessageType, @@ -1408,6 +1409,7 @@ def _import(): class FeishuAdapter(BasePlatformAdapter): """Feishu/Lark bot adapter.""" + MEDIA_KINDS = frozenset({MediaKind.IMAGE, MediaKind.VIDEO, MediaKind.VOICE, MediaKind.DOCUMENT}) MAX_MESSAGE_LENGTH = 8000 # Max distinct chat IDs retained in _chat_locks before LRU eviction kicks in. diff --git a/gateway/platforms/homeassistant.py b/gateway/platforms/homeassistant.py index e7ea762e2e73..518cc1b7b3db 100644 --- a/gateway/platforms/homeassistant.py +++ b/gateway/platforms/homeassistant.py @@ -30,6 +30,7 @@ from gateway.config import Platform, PlatformConfig from gateway.platforms.base import ( + MediaKind, BasePlatformAdapter, MessageEvent, MessageType, @@ -56,6 +57,7 @@ class HomeAssistantAdapter(BasePlatformAdapter): MessageEvent objects. Supports domain/entity filtering and per-entity cooldowns to avoid event floods. """ + MEDIA_KINDS = frozenset() MAX_MESSAGE_LENGTH = 4096 diff --git a/gateway/platforms/matrix.py b/gateway/platforms/matrix.py index 5c1cb9a182e0..c13e8fff690e 100644 --- a/gateway/platforms/matrix.py +++ b/gateway/platforms/matrix.py @@ -95,6 +95,7 @@ class _TrustStateStub: # type: ignore[no-redef] from gateway.config import Platform, PlatformConfig from gateway.platforms.base import ( + MediaKind, BasePlatformAdapter, MessageEvent, MessageType, @@ -350,6 +351,7 @@ async def find_shared_rooms(self, user_id: str) -> list: class MatrixAdapter(BasePlatformAdapter): """Gateway adapter for Matrix (any homeserver).""" + MEDIA_KINDS = frozenset({MediaKind.IMAGE, MediaKind.VIDEO, MediaKind.VOICE, MediaKind.DOCUMENT}) # Threshold for detecting Matrix client-side message splits. # When a chunk is near the ~4000-char practical limit, a continuation diff --git a/gateway/platforms/msgraph_webhook.py b/gateway/platforms/msgraph_webhook.py index d1d48996d734..e374c2212a25 100644 --- a/gateway/platforms/msgraph_webhook.py +++ b/gateway/platforms/msgraph_webhook.py @@ -21,6 +21,7 @@ from gateway.config import Platform, PlatformConfig from gateway.platforms.base import ( + MediaKind, BasePlatformAdapter, MessageEvent, MessageType, @@ -44,6 +45,7 @@ def check_msgraph_webhook_requirements() -> bool: class MSGraphWebhookAdapter(BasePlatformAdapter): """Receive Microsoft Graph change notifications and surface them internally.""" + MEDIA_KINDS = frozenset() def __init__(self, config: PlatformConfig): super().__init__(config, Platform.MSGRAPH_WEBHOOK) diff --git a/gateway/platforms/qqbot/adapter.py b/gateway/platforms/qqbot/adapter.py index 5b4a396ed2fd..57615fa7cee7 100644 --- a/gateway/platforms/qqbot/adapter.py +++ b/gateway/platforms/qqbot/adapter.py @@ -62,6 +62,7 @@ from gateway.config import Platform, PlatformConfig from gateway.platforms.base import ( + MediaKind, BasePlatformAdapter, MessageEvent, MessageType, @@ -153,6 +154,7 @@ def _coerce_list(value: Any) -> List[str]: class QQAdapter(BasePlatformAdapter): """QQ Bot adapter backed by the official QQ Bot WebSocket Gateway + REST API.""" + MEDIA_KINDS = frozenset({MediaKind.IMAGE, MediaKind.VIDEO, MediaKind.VOICE, MediaKind.DOCUMENT}) # QQ Bot API does not support editing sent messages. SUPPORTS_MESSAGE_EDITING = False diff --git a/gateway/platforms/signal.py b/gateway/platforms/signal.py index 975b701571ba..37ec4cf64156 100644 --- a/gateway/platforms/signal.py +++ b/gateway/platforms/signal.py @@ -28,6 +28,7 @@ from gateway.config import Platform, PlatformConfig from gateway.platforms.base import ( + MediaKind, BasePlatformAdapter, MessageEvent, MessageType, @@ -173,6 +174,7 @@ def check_signal_requirements() -> bool: class SignalAdapter(BasePlatformAdapter): """Signal messenger adapter using signal-cli HTTP daemon.""" + MEDIA_KINDS = frozenset({MediaKind.IMAGE, MediaKind.VIDEO, MediaKind.VOICE, MediaKind.DOCUMENT}) platform = Platform.SIGNAL # Signal has no real edit API for already-sent messages. Mark it explicitly diff --git a/gateway/platforms/slack.py b/gateway/platforms/slack.py index 13564f1e6e2a..c03091b3e48a 100644 --- a/gateway/platforms/slack.py +++ b/gateway/platforms/slack.py @@ -39,6 +39,7 @@ from gateway.config import Platform, PlatformConfig from gateway.platforms.helpers import MessageDeduplicator from gateway.platforms.base import ( + MediaKind, BasePlatformAdapter, MessageEvent, MessageType, @@ -315,6 +316,7 @@ class SlackAdapter(BasePlatformAdapter): - Slash commands (/hermes) - Typing indicators (not natively supported by Slack bots) """ + MEDIA_KINDS = frozenset({MediaKind.IMAGE, MediaKind.VIDEO, MediaKind.VOICE, MediaKind.DOCUMENT}) MAX_MESSAGE_LENGTH = 39000 # Slack API allows 40,000 chars; leave margin diff --git a/gateway/platforms/sms.py b/gateway/platforms/sms.py index 9d9957d5ea16..2439586a4c72 100644 --- a/gateway/platforms/sms.py +++ b/gateway/platforms/sms.py @@ -29,6 +29,7 @@ from gateway.config import Platform, PlatformConfig from gateway.platforms.base import ( + MediaKind, BasePlatformAdapter, MessageEvent, MessageType, @@ -60,6 +61,7 @@ class SmsAdapter(BasePlatformAdapter): Each inbound phone number gets its own Hermes session (multi-tenant). Replies are always sent from the configured TWILIO_PHONE_NUMBER. """ + MEDIA_KINDS = frozenset() MAX_MESSAGE_LENGTH = MAX_SMS_LENGTH diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 14820c0fe7c5..5b2ad4894442 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -66,6 +66,7 @@ class _MockContextTypes: from gateway.config import Platform, PlatformConfig from gateway.platforms.base import ( + MediaKind, BasePlatformAdapter, MessageEvent, MessageType, @@ -341,6 +342,7 @@ class TelegramAdapter(BasePlatformAdapter): - Forum topics (thread_id support) - Media messages """ + MEDIA_KINDS = frozenset({MediaKind.IMAGE, MediaKind.VIDEO, MediaKind.VOICE, MediaKind.DOCUMENT}) # Telegram message limits MAX_MESSAGE_LENGTH = 4096 diff --git a/gateway/platforms/webhook.py b/gateway/platforms/webhook.py index 32c6e8109bd9..9366ff6fb3b9 100644 --- a/gateway/platforms/webhook.py +++ b/gateway/platforms/webhook.py @@ -48,6 +48,7 @@ from gateway.config import Platform, PlatformConfig from gateway.platforms.base import ( + MediaKind, BasePlatformAdapter, MessageEvent, MessageType, @@ -99,6 +100,7 @@ def check_webhook_requirements() -> bool: class WebhookAdapter(BasePlatformAdapter): """Generic webhook receiver that triggers agent runs from HTTP POSTs.""" + MEDIA_KINDS = frozenset() def __init__(self, config: PlatformConfig): super().__init__(config, Platform.WEBHOOK) diff --git a/gateway/platforms/wecom.py b/gateway/platforms/wecom.py index c11756430191..16912c5ac2cc 100644 --- a/gateway/platforms/wecom.py +++ b/gateway/platforms/wecom.py @@ -61,6 +61,7 @@ from gateway.config import Platform, PlatformConfig from gateway.platforms.helpers import MessageDeduplicator from gateway.platforms.base import ( + MediaKind, BasePlatformAdapter, MessageEvent, MessageType, @@ -141,6 +142,7 @@ def _entry_matches(entries: List[str], target: str) -> bool: class WeComAdapter(BasePlatformAdapter): """WeCom AI Bot adapter backed by a persistent WebSocket connection.""" + MEDIA_KINDS = frozenset({MediaKind.IMAGE, MediaKind.VIDEO, MediaKind.VOICE, MediaKind.DOCUMENT}) MAX_MESSAGE_LENGTH = MAX_MESSAGE_LENGTH SUPPORTS_MESSAGE_EDITING = False diff --git a/gateway/platforms/weixin.py b/gateway/platforms/weixin.py index 36bb3dd21c2c..c5924246cb78 100644 --- a/gateway/platforms/weixin.py +++ b/gateway/platforms/weixin.py @@ -58,6 +58,7 @@ from gateway.config import Platform, PlatformConfig from gateway.platforms.helpers import MessageDeduplicator from gateway.platforms.base import ( + MediaKind, BasePlatformAdapter, MessageEvent, MessageType, @@ -1129,6 +1130,7 @@ async def qr_login( class WeixinAdapter(BasePlatformAdapter): """Native Hermes adapter for Weixin personal accounts.""" + MEDIA_KINDS = frozenset({MediaKind.IMAGE, MediaKind.VIDEO, MediaKind.VOICE, MediaKind.DOCUMENT}) MAX_MESSAGE_LENGTH = 2000 diff --git a/gateway/platforms/whatsapp.py b/gateway/platforms/whatsapp.py index 703f774323f4..cb83066d1f11 100644 --- a/gateway/platforms/whatsapp.py +++ b/gateway/platforms/whatsapp.py @@ -181,6 +181,7 @@ def _terminate_bridge_process(proc, *, force: bool = False) -> None: from gateway.config import Platform, PlatformConfig from gateway.platforms.base import ( + MediaKind, BasePlatformAdapter, MessageEvent, MessageType, @@ -238,6 +239,7 @@ class WhatsAppAdapter(BasePlatformAdapter): - group_policy: "open" | "allowlist" | "disabled" — which groups are processed (default: "open") - group_allow_from: List of group JIDs allowed (when group_policy="allowlist") """ + MEDIA_KINDS = frozenset({MediaKind.IMAGE, MediaKind.VIDEO, MediaKind.VOICE, MediaKind.DOCUMENT}) # WhatsApp message limits — practical UX limit, not protocol max. # WhatsApp allows ~65K but long messages are unreadable on mobile. diff --git a/gateway/platforms/yuanbao.py b/gateway/platforms/yuanbao.py index 6dc54dbcd502..81b92c4432b1 100644 --- a/gateway/platforms/yuanbao.py +++ b/gateway/platforms/yuanbao.py @@ -49,6 +49,7 @@ from gateway.config import Platform, PlatformConfig from gateway.platforms.base import ( + MediaKind, BasePlatformAdapter, MessageEvent, MessageType, @@ -4563,6 +4564,7 @@ async def close(self) -> None: class YuanbaoAdapter(BasePlatformAdapter): """Yuanbao AI Bot adapter backed by a persistent WebSocket connection.""" + MEDIA_KINDS = frozenset({MediaKind.IMAGE, MediaKind.DOCUMENT}) PLATFORM = Platform.YUANBAO MAX_TEXT_CHUNK: int = 4000 # Yuanbao single message character limit diff --git a/gateway/run.py b/gateway/run.py index 64eb8eb560e1..3c484a130884 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -5563,7 +5563,7 @@ def _add(path: str) -> None: if not candidates: return - from gateway.platforms.base import BasePlatformAdapter + from gateway.platforms.base import BasePlatformAdapter, MediaKind candidates = BasePlatformAdapter.filter_local_delivery_paths(candidates) if not candidates: return @@ -5573,12 +5573,19 @@ def _add(path: str) -> None: from urllib.parse import quote as _quote + # Skip-and-warn for kinds the adapter can't natively deliver, so the + # base send_* stub never leaks a local file path as chat text. + declared_kinds = getattr(adapter, "MEDIA_KINDS", frozenset()) + # Partition images so they ride a single send_multiple_images call # on platforms that support batch image uploads (Signal/Slack RPCs). image_paths = [p for p in candidates if _Path(p).suffix.lower() in _IMAGE_EXTS] other_paths = [p for p in candidates if _Path(p).suffix.lower() not in _IMAGE_EXTS] - if image_paths: + if image_paths and MediaKind.IMAGE not in declared_kinds: + logger.warning("kanban notifier: %s cannot deliver image; skipping %d artifact(s)", + getattr(adapter, "name", "?"), len(image_paths)) + elif image_paths: try: batch = [(f"file://{_quote(p)}", "") for p in image_paths] await adapter.send_multiple_images( @@ -5591,8 +5598,13 @@ def _add(path: str) -> None: for path in other_paths: ext = _Path(path).suffix.lower() + kind = MediaKind.VIDEO if ext in _VIDEO_EXTS else MediaKind.DOCUMENT + if kind not in declared_kinds: + logger.warning("kanban notifier: %s cannot deliver %s; skipping %s", + getattr(adapter, "name", "?"), kind.value, path) + continue try: - if ext in _VIDEO_EXTS: + if kind is MediaKind.VIDEO: await adapter.send_video( chat_id=chat_id, video_path=path, metadata=metadata, ) @@ -12143,7 +12155,7 @@ async def _deliver_media_from_response( # send_multiple_images (Telegram sendPhoto recompresses to ~1280px). force_document_attachments = "[[as_document]]" in response - from gateway.platforms.base import BasePlatformAdapter, should_send_media_as_audio + from gateway.platforms.base import BasePlatformAdapter, MediaKind, should_send_media_as_audio media_files, cleaned = adapter.extract_media(response) media_files = BasePlatformAdapter.filter_media_delivery_paths(media_files) @@ -12186,7 +12198,14 @@ async def _deliver_media_from_response( else: non_image_local.append(file_path) - if image_paths: + # Skip-and-warn for kinds the adapter doesn't natively deliver, so + # the base send_* stub never leaks a local file path as chat text. + declared_kinds = getattr(adapter, "MEDIA_KINDS", frozenset()) + + if image_paths and MediaKind.IMAGE not in declared_kinds: + logger.warning("reply media: %s cannot deliver image; skipping %d image(s)", + event.source.platform, len(image_paths)) + elif image_paths: try: images = [(f"file://{_quote(p)}", "") for p in image_paths] await adapter.send_multiple_images( @@ -12201,12 +12220,22 @@ async def _deliver_media_from_response( try: ext = Path(media_path).suffix.lower() if should_send_media_as_audio(event.source.platform, ext, is_voice=is_voice): + kind = MediaKind.VOICE + elif ext in _VIDEO_EXTS: + kind = MediaKind.VIDEO + else: + kind = MediaKind.DOCUMENT + if kind not in declared_kinds: + logger.warning("reply media: %s cannot deliver %s; skipping %s", + event.source.platform, kind.value, media_path) + continue + if kind is MediaKind.VOICE: await adapter.send_voice( chat_id=event.source.chat_id, audio_path=media_path, metadata=_thread_meta, ) - elif ext in _VIDEO_EXTS: + elif kind is MediaKind.VIDEO: await adapter.send_video( chat_id=event.source.chat_id, video_path=media_path, @@ -12224,7 +12253,12 @@ async def _deliver_media_from_response( for file_path in non_image_local: try: ext = Path(file_path).suffix.lower() - if ext in _VIDEO_EXTS: + kind = MediaKind.VIDEO if ext in _VIDEO_EXTS else MediaKind.DOCUMENT + if kind not in declared_kinds: + logger.warning("reply media: %s cannot deliver %s; skipping %s", + event.source.platform, kind.value, file_path) + continue + if kind is MediaKind.VIDEO: await adapter.send_video( chat_id=event.source.chat_id, video_path=file_path, diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index 12cf05c38c9e..14249f1461b3 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -53,6 +53,7 @@ from gateway.platforms.helpers import MessageDeduplicator, ThreadParticipationTracker from utils import atomic_json_write from gateway.platforms.base import ( + MediaKind, BasePlatformAdapter, MessageEvent, MessageType, @@ -569,6 +570,7 @@ class DiscordAdapter(BasePlatformAdapter): - Auto-threading for long conversations - Reaction-based feedback """ + MEDIA_KINDS = frozenset({MediaKind.IMAGE, MediaKind.VIDEO, MediaKind.VOICE, MediaKind.DOCUMENT}) # Discord message limits MAX_MESSAGE_LENGTH = 2000 diff --git a/plugins/platforms/google_chat/adapter.py b/plugins/platforms/google_chat/adapter.py index 0fdf1ea9d867..fcad9dea5a50 100644 --- a/plugins/platforms/google_chat/adapter.py +++ b/plugins/platforms/google_chat/adapter.py @@ -129,6 +129,7 @@ def _load_google_modules() -> bool: Platform("google_chat") from gateway.platforms.helpers import MessageDeduplicator from gateway.platforms.base import ( + MediaKind, BasePlatformAdapter, MessageEvent, MessageType, @@ -441,6 +442,7 @@ class GoogleChatAdapter(BasePlatformAdapter): GOOGLE_CHAT_MAX_MESSAGES (FlowControl, default 1) GOOGLE_CHAT_MAX_BYTES (FlowControl, default 16_777_216 = 16 MiB) """ + MEDIA_KINDS = frozenset({MediaKind.IMAGE, MediaKind.VIDEO, MediaKind.VOICE, MediaKind.DOCUMENT}) MAX_MESSAGE_LENGTH = _MAX_TEXT_LENGTH # Pub/Sub supervisor configuration. diff --git a/plugins/platforms/irc/adapter.py b/plugins/platforms/irc/adapter.py index 2d06cffbdeb6..a12e58197c5c 100644 --- a/plugins/platforms/irc/adapter.py +++ b/plugins/platforms/irc/adapter.py @@ -44,6 +44,7 @@ # --------------------------------------------------------------------------- from gateway.platforms.base import ( + MediaKind, BasePlatformAdapter, SendResult, MessageEvent, @@ -98,6 +99,7 @@ class IRCAdapter(BasePlatformAdapter): This class is instantiated by the adapter_factory passed to register_platform(). """ + MEDIA_KINDS = frozenset() def __init__(self, config, **kwargs): platform = Platform("irc") diff --git a/plugins/platforms/line/adapter.py b/plugins/platforms/line/adapter.py index 00663702ea16..1fed290d7924 100644 --- a/plugins/platforms/line/adapter.py +++ b/plugins/platforms/line/adapter.py @@ -88,6 +88,7 @@ # --------------------------------------------------------------------------- from gateway.platforms.base import ( + MediaKind, BasePlatformAdapter, MessageEvent, MessageType, @@ -622,6 +623,7 @@ def _truthy_env(name: str, default: bool = False) -> bool: class LineAdapter(BasePlatformAdapter): """LINE Messaging API gateway adapter.""" + MEDIA_KINDS = frozenset({MediaKind.IMAGE, MediaKind.VIDEO, MediaKind.VOICE}) # LINE has its own message-edit story (none) — we always send fresh # bubbles, never edit, so REQUIRES_EDIT_FINALIZE stays False. diff --git a/plugins/platforms/mattermost/adapter.py b/plugins/platforms/mattermost/adapter.py index bb6dc9b81f24..ecde9c7ca60d 100644 --- a/plugins/platforms/mattermost/adapter.py +++ b/plugins/platforms/mattermost/adapter.py @@ -24,6 +24,7 @@ from gateway.config import Platform, PlatformConfig from gateway.platforms.helpers import MessageDeduplicator from gateway.platforms.base import ( + MediaKind, BasePlatformAdapter, MessageEvent, MessageType, @@ -70,6 +71,7 @@ def check_mattermost_requirements() -> bool: class MattermostAdapter(BasePlatformAdapter): """Gateway adapter for Mattermost (self-hosted or cloud).""" + MEDIA_KINDS = frozenset({MediaKind.IMAGE, MediaKind.VIDEO, MediaKind.VOICE, MediaKind.DOCUMENT}) def __init__(self, config: PlatformConfig): super().__init__(config, Platform.MATTERMOST) diff --git a/plugins/platforms/ntfy/adapter.py b/plugins/platforms/ntfy/adapter.py index 4ab46cecfb27..0df1e17fa32d 100644 --- a/plugins/platforms/ntfy/adapter.py +++ b/plugins/platforms/ntfy/adapter.py @@ -62,6 +62,7 @@ from gateway.config import Platform, PlatformConfig from gateway.platforms.base import ( + MediaKind, BasePlatformAdapter, MessageEvent, MessageType, @@ -155,6 +156,7 @@ class NtfyAdapter(BasePlatformAdapter): Subscribes to a topic via HTTP streaming (``/json`` endpoint) and publishes replies via HTTP POST. No external SDK — only httpx. """ + MEDIA_KINDS = frozenset() MAX_MESSAGE_LENGTH = MAX_MESSAGE_LENGTH diff --git a/plugins/platforms/simplex/adapter.py b/plugins/platforms/simplex/adapter.py index 9c3d22a429fa..b93554e756f7 100644 --- a/plugins/platforms/simplex/adapter.py +++ b/plugins/platforms/simplex/adapter.py @@ -45,6 +45,7 @@ # external dependency that would block the plugin from loading. from gateway.config import Platform, PlatformConfig from gateway.platforms.base import ( + MediaKind, BasePlatformAdapter, MessageEvent, MessageType, @@ -118,6 +119,7 @@ class SimplexAdapter(BasePlatformAdapter): Instantiated by the ``adapter_factory`` passed to ``ctx.register_platform()`` in :func:`register`. """ + MEDIA_KINDS = frozenset() def __init__(self, config: PlatformConfig, **kwargs): platform = Platform("simplex") diff --git a/plugins/platforms/teams/adapter.py b/plugins/platforms/teams/adapter.py index 975ef5b40933..f5d80f133994 100644 --- a/plugins/platforms/teams/adapter.py +++ b/plugins/platforms/teams/adapter.py @@ -91,6 +91,7 @@ from gateway.config import Platform, PlatformConfig from gateway.platforms.helpers import MessageDeduplicator from gateway.platforms.base import ( + MediaKind, BasePlatformAdapter, MessageEvent, MessageType, @@ -621,6 +622,7 @@ async def _standalone_send( class TeamsAdapter(BasePlatformAdapter): """Microsoft Teams adapter using the microsoft-teams-apps SDK.""" + MEDIA_KINDS = frozenset({MediaKind.IMAGE}) MAX_MESSAGE_LENGTH = 28000 # Teams text message limit (~28 KB) diff --git a/tests/gateway/test_media_kinds.py b/tests/gateway/test_media_kinds.py new file mode 100644 index 000000000000..cc6383acec46 --- /dev/null +++ b/tests/gateway/test_media_kinds.py @@ -0,0 +1,110 @@ +import importlib + +import pytest + +from gateway.platforms.base import BasePlatformAdapter, MediaKind, classify_media_kind + +I, V, A, D = MediaKind.IMAGE, MediaKind.VIDEO, MediaKind.VOICE, MediaKind.DOCUMENT +FULL = frozenset({I, V, A, D}) + +# The single source of truth for what each adapter natively delivers. A kind +# listed here MUST be backed by a real send_* override; the descriptor exists +# so dispatch sites can skip-and-warn rather than leak a path as chat text. +PINNED_MEDIA_KINDS = { + "gateway.platforms.telegram:TelegramAdapter": FULL, + "gateway.platforms.slack:SlackAdapter": FULL, + "gateway.platforms.signal:SignalAdapter": FULL, + "gateway.platforms.matrix:MatrixAdapter": FULL, + "gateway.platforms.whatsapp:WhatsAppAdapter": FULL, + "gateway.platforms.wecom:WeComAdapter": FULL, + "gateway.platforms.bluebubbles:BlueBubblesAdapter": FULL, + "gateway.platforms.feishu:FeishuAdapter": FULL, + "gateway.platforms.qqbot.adapter:QQAdapter": FULL, + "gateway.platforms.weixin:WeixinAdapter": FULL, + "gateway.platforms.email:EmailAdapter": frozenset({I, D}), + "gateway.platforms.yuanbao:YuanbaoAdapter": frozenset({I, D}), + "gateway.platforms.dingtalk:DingTalkAdapter": frozenset(), + "gateway.platforms.sms:SmsAdapter": frozenset(), + "gateway.platforms.homeassistant:HomeAssistantAdapter": frozenset(), + "gateway.platforms.webhook:WebhookAdapter": frozenset(), + "gateway.platforms.msgraph_webhook:MSGraphWebhookAdapter": frozenset(), + "plugins.platforms.discord.adapter:DiscordAdapter": FULL, + "plugins.platforms.google_chat.adapter:GoogleChatAdapter": FULL, + "plugins.platforms.mattermost.adapter:MattermostAdapter": FULL, + "plugins.platforms.line.adapter:LineAdapter": frozenset({I, V, A}), + "plugins.platforms.teams.adapter:TeamsAdapter": frozenset({I}), + "plugins.platforms.simplex.adapter:SimplexAdapter": frozenset(), + "plugins.platforms.irc.adapter:IRCAdapter": frozenset(), + "plugins.platforms.ntfy.adapter:NtfyAdapter": frozenset(), +} + + +@pytest.mark.parametrize("ref,expected", PINNED_MEDIA_KINDS.items()) +def test_media_kinds_pinned(ref, expected): + mod, cls = ref.split(":") + adapter_cls = getattr(importlib.import_module(mod), cls) + assert adapter_cls.MEDIA_KINDS == expected + + +# The per-file method ``_dispatch_media_one`` (tools/send_message_tool.py) +# actually calls for each kind. This is the path with NO batch fallback, so +# the declared kind MUST override the exact method here — otherwise the base +# stub runs and leaks the local path as chat text. (send_multiple_images is an +# optional batch optimization; base.send_multiple_images loops file:// back to +# send_image_file, so send_image_file is the true terminal IMAGE method.) +_DISPATCH_METHOD = { + I: "send_image_file", + V: "send_video", + A: "send_voice", + D: "send_document", +} + + +@pytest.mark.parametrize("ref,expected", PINNED_MEDIA_KINDS.items()) +def test_declared_kinds_are_backed_by_real_overrides(ref, expected): + """Every declared kind must override the method its dispatch site calls. + + Asserts the descriptor's documented invariant ("MUST be backed by a real + send_* override") executably, so a future adapter that declares a kind it + doesn't deliver fails loudly in CI instead of leaking a path as chat text. + """ + mod, cls = ref.split(":") + adapter_cls = getattr(importlib.import_module(mod), cls) + for kind in expected: + method = _DISPATCH_METHOD[kind] + assert getattr(adapter_cls, method) is not getattr(BasePlatformAdapter, method), ( + f"{cls} declares {kind.name} but inherits base {method} " + f"(the base stub leaks the local path as chat text)" + ) + + +def test_platform_entry_has_no_media_kinds_field(): + # The descriptor lives on the adapter class only. Mirroring it onto + # PlatformEntry would couple the ungated out-of-process standalone path + # to a capability it cannot honor. + from gateway.platform_registry import PlatformEntry + + assert "media_kinds" not in PlatformEntry.__dataclass_fields__ + + +def test_media_kind_has_four_members(): + assert {k.name for k in MediaKind} == {"IMAGE", "VIDEO", "VOICE", "DOCUMENT"} + + +def test_base_default_is_fail_closed_empty(): + assert BasePlatformAdapter.MEDIA_KINDS == frozenset() + + +def test_classify_image_video_document(): + assert classify_media_kind("/x/a.png", platform="qqbot") is MediaKind.IMAGE + assert classify_media_kind("/x/a.mp4", platform="qqbot") is MediaKind.VIDEO + assert classify_media_kind("/x/a.pdf", platform="qqbot") is MediaKind.DOCUMENT + + +def test_classify_audio_routes_to_voice_on_non_telegram(): + assert classify_media_kind("/x/a.mp3", platform="slack") is MediaKind.VOICE + assert classify_media_kind("/x/a.ogg", is_voice=True, platform="slack") is MediaKind.VOICE + + +def test_classify_force_document_overrides_image(): + assert classify_media_kind("/x/a.png", platform="qqbot", force_document=True) is MediaKind.DOCUMENT diff --git a/tests/gateway/test_reply_path_media_capability.py b/tests/gateway/test_reply_path_media_capability.py new file mode 100644 index 000000000000..5fb978b295b9 --- /dev/null +++ b/tests/gateway/test_reply_path_media_capability.py @@ -0,0 +1,112 @@ +"""The agent reply path and kanban notifier must consult MEDIA_KINDS before +dispatching attachments, skipping (with a warning) any kind the adapter does +not natively deliver — never leaking a local file path as chat text.""" + +import asyncio +import logging +from types import SimpleNamespace + +import pytest + +import gateway.run as run_mod +from gateway.platforms.base import BasePlatformAdapter, MediaKind + + +class _RecordingAdapter: + name = "fake" + extract_media = staticmethod(BasePlatformAdapter.extract_media) + extract_images = staticmethod(BasePlatformAdapter.extract_images) + extract_local_files = staticmethod(BasePlatformAdapter.extract_local_files) + + def __init__(self, kinds): + self.MEDIA_KINDS = kinds + self.calls = [] + + async def send_multiple_images(self, **kw): + self.calls.append(("image", kw)) + + async def send_voice(self, **kw): + self.calls.append(("voice", kw)) + + async def send_video(self, **kw): + self.calls.append(("video", kw)) + + async def send_document(self, **kw): + self.calls.append(("document", kw)) + + +class _StubRunner: + _thread_metadata_for_source = lambda self, *a, **k: None + _reply_anchor_for_event = lambda self, *a, **k: None + _deliver_media_from_response = run_mod.GatewayRunner._deliver_media_from_response + + +@pytest.fixture(autouse=True) +def _accept_all_media_paths(monkeypatch): + # Bypass the on-disk safety validator so tests exercise capability gating, + # not path validation (covered elsewhere). + monkeypatch.setattr("gateway.platforms.base.validate_media_delivery_path", lambda p: str(p)) + + +def _deliver(adapter, response): + event = SimpleNamespace(source=SimpleNamespace(platform="qqbot", chat_id="c", thread_id=None)) + asyncio.run(_StubRunner()._deliver_media_from_response(response, event, adapter)) + + +def test_undeclared_kinds_are_skipped_without_leaking_path(caplog): + adapter = _RecordingAdapter(frozenset()) + with caplog.at_level(logging.WARNING): + _deliver(adapter, "here you go\nMEDIA:/tmp/report.pdf") + assert adapter.calls == [] + assert any("/tmp/report.pdf" in r.message for r in caplog.records) + + +def test_declared_document_is_delivered(): + adapter = _RecordingAdapter(frozenset({MediaKind.DOCUMENT})) + _deliver(adapter, "here\nMEDIA:/tmp/report.pdf") + assert [c[0] for c in adapter.calls] == ["document"] + + +def test_image_skipped_when_image_not_declared(): + adapter = _RecordingAdapter(frozenset({MediaKind.DOCUMENT})) + _deliver(adapter, "pic\nMEDIA:/tmp/shot.png") + assert adapter.calls == [] + + +def test_partial_capability_delivers_only_declared_kind(): + adapter = _RecordingAdapter(frozenset({MediaKind.IMAGE})) + _deliver(adapter, "both\nMEDIA:/tmp/shot.png\nMEDIA:/tmp/report.pdf") + assert [c[0] for c in adapter.calls] == ["image"] + + +def _deliver_kanban(adapter, artifacts): + asyncio.run(run_mod.GatewayRunner._deliver_kanban_artifacts( + _StubRunner(), adapter=adapter, chat_id="c", metadata={}, + event_payload={"artifacts": [str(a) for a in artifacts]}, task=None, + )) + + +def test_kanban_undeclared_kinds_skipped_without_leak(tmp_path, caplog): + pdf = tmp_path / "report.pdf" + pdf.write_text("x", encoding="utf-8") + adapter = _RecordingAdapter(frozenset()) + with caplog.at_level(logging.WARNING): + _deliver_kanban(adapter, [pdf]) + assert adapter.calls == [] + assert any("report.pdf" in r.message for r in caplog.records) + + +def test_kanban_declared_document_delivered(tmp_path): + pdf = tmp_path / "report.pdf" + pdf.write_text("x", encoding="utf-8") + adapter = _RecordingAdapter(frozenset({MediaKind.DOCUMENT})) + _deliver_kanban(adapter, [pdf]) + assert [c[0] for c in adapter.calls] == ["document"] + + +def test_kanban_image_skipped_when_not_declared(tmp_path): + png = tmp_path / "shot.png" + png.write_bytes(b"\x89PNG\r\n\x1a\n") + adapter = _RecordingAdapter(frozenset({MediaKind.DOCUMENT})) + _deliver_kanban(adapter, [png]) + assert adapter.calls == [] diff --git a/tests/gateway/test_tts_media_routing.py b/tests/gateway/test_tts_media_routing.py index eaf9c5928089..10fce4889ed0 100644 --- a/tests/gateway/test_tts_media_routing.py +++ b/tests/gateway/test_tts_media_routing.py @@ -13,7 +13,7 @@ import pytest from gateway.config import Platform, PlatformConfig -from gateway.platforms.base import BasePlatformAdapter, MessageEvent, MessageType, SendResult +from gateway.platforms.base import BasePlatformAdapter, MediaKind, MessageEvent, MessageType, SendResult from gateway.run import GatewayRunner from gateway.session import SessionSource, build_session_key @@ -137,6 +137,7 @@ async def test_streaming_delivery_routes_telegram_flac_media_tag_to_document_sen media_file = _allowed_media_path(tmp_path, monkeypatch, "speech.flac") adapter = SimpleNamespace( name="test", + MEDIA_KINDS=frozenset(MediaKind), # telegram delivers all kinds extract_media=BasePlatformAdapter.extract_media, extract_images=BasePlatformAdapter.extract_images, extract_local_files=BasePlatformAdapter.extract_local_files, @@ -167,6 +168,7 @@ async def test_streaming_delivery_routes_non_voice_telegram_ogg_media_tag_to_doc media_file = _allowed_media_path(tmp_path, monkeypatch, "speech.ogg") adapter = SimpleNamespace( name="test", + MEDIA_KINDS=frozenset(MediaKind), # telegram delivers all kinds extract_media=BasePlatformAdapter.extract_media, extract_images=BasePlatformAdapter.extract_images, extract_local_files=BasePlatformAdapter.extract_local_files, @@ -199,6 +201,7 @@ async def test_streaming_delivery_routes_telegram_mp3_media_tag_to_voice_sender( media_file = _allowed_media_path(tmp_path, monkeypatch, "speech.mp3") adapter = SimpleNamespace( name="test", + MEDIA_KINDS=frozenset(MediaKind), # telegram delivers all kinds extract_media=BasePlatformAdapter.extract_media, extract_images=BasePlatformAdapter.extract_images, extract_local_files=BasePlatformAdapter.extract_local_files, @@ -243,6 +246,7 @@ async def test_streaming_delivery_blocks_media_path_outside_allowed_roots(tmp_pa monkeypatch.setenv("HERMES_MEDIA_TRUST_RECENT_FILES", "0") adapter = SimpleNamespace( name="test", + MEDIA_KINDS=frozenset(MediaKind), # telegram delivers all kinds extract_media=BasePlatformAdapter.extract_media, extract_images=BasePlatformAdapter.extract_images, extract_local_files=BasePlatformAdapter.extract_local_files, diff --git a/tests/hermes_cli/test_kanban_notify.py b/tests/hermes_cli/test_kanban_notify.py index f8109416cb5a..21cb1542cf33 100644 --- a/tests/hermes_cli/test_kanban_notify.py +++ b/tests/hermes_cli/test_kanban_notify.py @@ -6,6 +6,8 @@ from hermes_cli import kanban_db as kb from unittest.mock import AsyncMock, MagicMock, patch +from gateway.platforms.base import MediaKind + # --------------------------------------------------------------------------- # Fixtures @@ -538,6 +540,7 @@ async def test_notifier_uploads_artifacts_on_completion(kanban_home, tmp_path, m fake_adapter = MagicMock() fake_adapter.name = "telegram" + fake_adapter.MEDIA_KINDS = frozenset(MediaKind) # telegram delivers all kinds sends: list = [] images_uploaded: list = [] @@ -622,6 +625,7 @@ async def test_notifier_artifact_delivery_skips_missing_files(kanban_home, tmp_p fake_adapter = MagicMock() fake_adapter.name = "telegram" + fake_adapter.MEDIA_KINDS = frozenset(MediaKind) # telegram delivers all kinds documents_uploaded: list = [] diff --git a/tests/tools/test_send_message_tool.py b/tests/tools/test_send_message_tool.py index 10a4868655b3..7b4dc5b8c291 100644 --- a/tests/tools/test_send_message_tool.py +++ b/tests/tools/test_send_message_tool.py @@ -2767,3 +2767,329 @@ async def run_test(): finally: if media_path and os.path.exists(media_path): os.unlink(media_path) + + +class TestUnifiedCapabilityMedia: + """Media routing to any live adapter that declares MEDIA_KINDS. + + Covers the platforms that are media-capable but NOT in the seven + hand-tuned branches (qqbot is the headline #23760 case), and the + fail-loud skip-and-warn contract for kinds an adapter cannot deliver. + """ + + @staticmethod + def _install_runner(monkeypatch, adapter): + runner = SimpleNamespace(adapters={Platform.QQBOT: adapter}) + monkeypatch.setattr("gateway.run._gateway_runner_ref", lambda: runner) + + @staticmethod + def _good_path(tmp_path, name="a.png"): + p = tmp_path / name + p.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 32) + return str(p) + + def test_qqbot_media_dispatches_via_live_adapter(self, tmp_path, monkeypatch): + from gateway.platforms.base import MediaKind, SendResult + + sent = {} + + class FakeQQ: + MEDIA_KINDS = frozenset(MediaKind) + + async def send(self, chat_id, content, metadata=None): + sent["text"] = content + return SendResult(success=True, message_id="m0") + + async def send_image_file(self, chat_id, image_path, **kw): + sent["image"] = image_path + return SendResult(success=True, message_id="m1") + + self._install_runner(monkeypatch, FakeQQ()) + path = self._good_path(tmp_path) + res = asyncio.run(_send_to_platform( + Platform.QQBOT, SimpleNamespace(extra={}), "chan", "hi", + media_files=[(path, False)], + )) + assert sent["image"] == path + assert sent["text"] == "hi" + assert res["success"] + + def test_unsupported_kind_warns_and_skips_without_leak(self, tmp_path, monkeypatch): + from gateway.platforms.base import MediaKind, SendResult + + class FakeVoiceOnly: + MEDIA_KINDS = frozenset({MediaKind.VOICE}) + text = None + + async def send(self, chat_id, content, metadata=None): + self.text = content + return SendResult(success=True, message_id="m0") + + async def send_document(self, chat_id, file_path, **kw): + raise AssertionError("document must not be dispatched") + + adapter = FakeVoiceOnly() + self._install_runner(monkeypatch, adapter) + pdf = tmp_path / "a.pdf" + pdf.write_text("x", encoding="utf-8") + res = asyncio.run(_send_to_platform( + Platform.QQBOT, SimpleNamespace(extra={}), "c", "hi", + media_files=[(str(pdf), False)], + )) + assert "a.pdf" not in (adapter.text or "") + assert any("a.pdf" in w for w in res["warnings"]) + assert res["success"] + + def test_security_gate_drops_unsafe_path(self, tmp_path, monkeypatch): + from gateway.platforms.base import MediaKind, SendResult + + monkeypatch.setattr( + "gateway.platforms.base.validate_media_delivery_path", + lambda p: None if "EVIL" in str(p) else str(p), + ) + dispatched = [] + + class FakeQQ: + MEDIA_KINDS = frozenset(MediaKind) + + async def send(self, chat_id, content, metadata=None): + return SendResult(success=True, message_id="m0") + + async def send_image_file(self, chat_id, image_path, **kw): + dispatched.append(image_path) + return SendResult(success=True, message_id="m1") + + self._install_runner(monkeypatch, FakeQQ()) + good = self._good_path(tmp_path) + asyncio.run(_send_to_platform( + Platform.QQBOT, SimpleNamespace(extra={}), "c", "hi", + media_files=[("/tmp/EVIL.png", False), (good, False)], + )) + assert dispatched == [good] + + def test_force_document_routes_to_send_document(self, tmp_path, monkeypatch): + from gateway.platforms.base import MediaKind, SendResult + + sent = {} + + class FakeQQ: + MEDIA_KINDS = frozenset(MediaKind) + + async def send(self, chat_id, content, metadata=None): + return SendResult(success=True, message_id="m0") + + async def send_image_file(self, chat_id, image_path, **kw): + raise AssertionError("image_file must not be used under force_document") + + async def send_document(self, chat_id, file_path, **kw): + sent["doc"] = file_path + return SendResult(success=True, message_id="m1") + + self._install_runner(monkeypatch, FakeQQ()) + path = self._good_path(tmp_path) + asyncio.run(_send_to_platform( + Platform.QQBOT, SimpleNamespace(extra={}), "c", "hi", + media_files=[(path, False)], force_document=True, + )) + assert sent["doc"] == path + + def test_thread_id_forwarded_to_send_method(self, tmp_path, monkeypatch): + from gateway.platforms.base import MediaKind, SendResult + + seen = {} + + class FakeQQ: + MEDIA_KINDS = frozenset(MediaKind) + + async def send(self, chat_id, content, metadata=None): + return SendResult(success=True, message_id="m0") + + async def send_image_file(self, chat_id, image_path, metadata=None, **kw): + seen["metadata"] = metadata + return SendResult(success=True, message_id="m1") + + self._install_runner(monkeypatch, FakeQQ()) + path = self._good_path(tmp_path) + asyncio.run(_send_to_platform( + Platform.QQBOT, SimpleNamespace(extra={}), "c", "hi", + media_files=[(path, False)], thread_id="t-9", + )) + assert seen["metadata"] == {"thread_id": "t-9"} + + def test_out_of_process_no_live_adapter_falls_to_text_with_warning(self, tmp_path, monkeypatch): + monkeypatch.setattr("gateway.run._gateway_runner_ref", lambda: None) + with patch("tools.send_message_tool._send_qqbot", + new=AsyncMock(return_value={"success": True, "message_id": "t"})) as qq: + path = self._good_path(tmp_path) + res = asyncio.run(_send_to_platform( + Platform.QQBOT, SimpleNamespace(extra={}), "c", "hi", + media_files=[(path, False)], + )) + qq.assert_awaited_once() + assert any("omitted" in w for w in res["warnings"]) + + def test_adapter_send_exception_returns_error_dict(self, tmp_path, monkeypatch): + from gateway.platforms.base import MediaKind, SendResult + + class FakeQQ: + MEDIA_KINDS = frozenset(MediaKind) + + async def send(self, chat_id, content, metadata=None): + return SendResult(success=True, message_id="m0") + + async def send_image_file(self, chat_id, image_path, **kw): + raise RuntimeError("upload exploded") + + self._install_runner(monkeypatch, FakeQQ()) + path = self._good_path(tmp_path) + res = asyncio.run(_send_to_platform( + Platform.QQBOT, SimpleNamespace(extra={}), "c", "hi", + media_files=[(path, False)], + )) + assert "error" in res + assert "upload exploded" in res["error"] + + def test_media_only_all_dropped_returns_explicit_error(self, monkeypatch): + from gateway.platforms.base import MediaKind, SendResult + + monkeypatch.setattr( + "gateway.platforms.base.validate_media_delivery_path", lambda p: None + ) + + class FakeQQ: + MEDIA_KINDS = frozenset(MediaKind) + + async def send(self, chat_id, content, metadata=None): + raise AssertionError("no text to send") + + self._install_runner(monkeypatch, FakeQQ()) + res = asyncio.run(_send_to_platform( + Platform.QQBOT, SimpleNamespace(extra={}), "c", " ", + media_files=[("/tmp/EVIL.png", False)], + )) + assert res["error"] # non-empty, explains nothing was delivered + + def test_media_attaches_only_to_last_chunk(self, tmp_path, monkeypatch): + from gateway.platform_registry import platform_registry + from gateway.platforms.base import BasePlatformAdapter, MediaKind, SendResult + + # Force a deterministic two-chunk split (give qqbot a tiny length limit + # via the registry, then stub the splitter) so we can prove the image + # is dispatched once, AFTER the final text chunk — never glued to an + # earlier chunk. + monkeypatch.setattr(platform_registry, "get", + lambda name: SimpleNamespace(max_message_length=5)) + monkeypatch.setattr(BasePlatformAdapter, "truncate_message", + staticmethod(lambda *a, **k: ["c1", "c2"])) + events = [] + + class FakeQQ: + MEDIA_KINDS = frozenset(MediaKind) + + async def send(self, chat_id, content, metadata=None): + events.append(("text", content)) + return SendResult(success=True, message_id="m0") + + async def send_image_file(self, chat_id, image_path, **kw): + events.append(("image", image_path)) + return SendResult(success=True, message_id="m1") + + self._install_runner(monkeypatch, FakeQQ()) + path = self._good_path(tmp_path) + res = asyncio.run(_send_to_platform( + Platform.QQBOT, SimpleNamespace(extra={}), "c", "long message", + media_files=[(path, False)], + )) + assert events == [("text", "c1"), ("text", "c2"), ("image", path)] + assert res["success"] + + def test_partial_delivery_returns_success_with_skip_warning(self, tmp_path, monkeypatch): + from gateway.platforms.base import MediaKind, SendResult + + # IMAGE declared, DOCUMENT not: the image delivers, the pdf is skipped, + # and the successful result still carries the skip warning. + delivered = [] + + class FakeImageOnly: + MEDIA_KINDS = frozenset({MediaKind.IMAGE}) + + async def send(self, chat_id, content, metadata=None): + return SendResult(success=True, message_id="m0") + + async def send_image_file(self, chat_id, image_path, **kw): + delivered.append(image_path) + return SendResult(success=True, message_id="img-1") + + async def send_document(self, chat_id, file_path, **kw): + raise AssertionError("document must not be dispatched") + + self._install_runner(monkeypatch, FakeImageOnly()) + png = self._good_path(tmp_path, "shot.png") + pdf = tmp_path / "report.pdf" + pdf.write_text("x", encoding="utf-8") + res = asyncio.run(_send_to_platform( + Platform.QQBOT, SimpleNamespace(extra={}), "c", "hi", + media_files=[(png, False), (str(pdf), False)], + )) + assert delivered == [png] + assert res["success"] + assert res["message_id"] == "img-1" # last successful delivery wins + assert any("report.pdf" in w for w in res["warnings"]) + + +class TestSevenBranchMediaRegression: + """The seven hand-tuned media branches keep handling media themselves and + must never fall through to the unified capability branch (which is the + only path that consults _resolve_live_adapter).""" + + @pytest.mark.parametrize("platform,sender", [ + (Platform.TELEGRAM, "_send_telegram"), + (Platform.WEIXIN, "_send_weixin"), + (Platform.MATRIX, "_send_matrix_via_adapter"), + (Platform.SIGNAL, "_send_signal"), + (Platform.YUANBAO, "_send_yuanbao"), + (Platform.FEISHU, "_send_feishu"), + ]) + def test_dedicated_branch_handles_media(self, platform, sender, monkeypatch): + recorded = {} + + async def fake_sender(*args, **kwargs): + recorded["media"] = kwargs.get("media_files") + return {"success": True, "message_id": "x"} + + resolve_calls = [] + monkeypatch.setattr(f"tools.send_message_tool.{sender}", fake_sender) + monkeypatch.setattr("tools.send_message_tool._resolve_live_adapter", + lambda p: resolve_calls.append(p)) + + res = asyncio.run(_send_to_platform( + platform, SimpleNamespace(token="t", extra={}), "c", "hi", + media_files=[("/tmp/a.png", False)], + )) + assert recorded["media"] == [("/tmp/a.png", False)] + assert res["success"] + assert resolve_calls == [] # unified branch never entered + + def test_discord_branch_handles_media(self, monkeypatch): + resolve_calls = [] + monkeypatch.setattr("tools.send_message_tool._resolve_live_adapter", + lambda p: resolve_calls.append(p)) + send_mock = AsyncMock(return_value={"success": True, "message_id": "d1"}) + with _patch_discord_sender(send_mock): + res = asyncio.run(_send_to_platform( + Platform.DISCORD, SimpleNamespace(token="tok", extra={}), "chat", "hi", + media_files=[("/tmp/a.png", False)], + )) + assert res["success"] + assert send_mock.await_args.kwargs["media_files"] == [("/tmp/a.png", False)] + assert resolve_calls == [] + + def test_incapable_platform_no_live_adapter_warns_omitted_media(self, monkeypatch): + monkeypatch.setattr("tools.send_message_tool._resolve_live_adapter", lambda p: None) + with patch("tools.send_message_tool._send_dingtalk", + new=AsyncMock(return_value={"success": True, "message_id": "t"})): + res = asyncio.run(_send_to_platform( + Platform.DINGTALK, SimpleNamespace(extra={}), "c", "hi", + media_files=[("/tmp/a.png", False)], + )) + assert any("omitted" in w for w in res["warnings"]) diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 88bcb4005c00..efb84d34bd11 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -481,6 +481,38 @@ def _maybe_skip_cron_duplicate_send(platform_name: str, chat_id: str, thread_id: } +def _resolve_live_adapter(platform): + """Return the live in-process adapter for *platform*, or None. + + None covers both the out-of-process case (no gateway runner) and an + unconnected platform; callers fall back to the standalone path or warn. + """ + try: + from gateway.run import _gateway_runner_ref + runner = _gateway_runner_ref() + return runner.adapters.get(platform) if runner is not None else None + except Exception: + return None + + +async def _dispatch_media_one(adapter, kind, chat_id, path, metadata): + """Deliver one media file via the adapter's native send_* for *kind*.""" + from gateway.platforms.base import MediaKind + + match kind: + case MediaKind.IMAGE: + result = await adapter.send_image_file(chat_id=chat_id, image_path=path, metadata=metadata) + case MediaKind.VIDEO: + result = await adapter.send_video(chat_id=chat_id, video_path=path, metadata=metadata) + case MediaKind.VOICE: + result = await adapter.send_voice(chat_id=chat_id, audio_path=path, metadata=metadata) + case _: + result = await adapter.send_document(chat_id=chat_id, file_path=path, metadata=metadata) + if result.success: + return {"success": True, "message_id": result.message_id} + return {"error": f"Adapter media send failed: {result.error}"} + + async def _send_via_adapter( platform, pconfig, @@ -502,29 +534,18 @@ async def _send_via_adapter( the runner weakref is ``None``). 3. A descriptive error explaining both options. """ - runner = None - try: - from gateway.run import _gateway_runner_ref - runner = _gateway_runner_ref() - except Exception: - runner = None - - if runner is not None: + adapter = _resolve_live_adapter(platform) + if adapter is not None: try: - adapter = runner.adapters.get(platform) - except Exception: - adapter = None - if adapter is not None: - try: - metadata = {"thread_id": thread_id} if thread_id else None - result = await adapter.send(chat_id=chat_id, content=chunk, metadata=metadata) - except asyncio.CancelledError: - raise - except Exception as e: - return {"error": f"Plugin platform send failed: {e}"} - if result.success: - return {"success": True, "message_id": result.message_id} - return {"error": f"Adapter send failed: {result.error}"} + metadata = {"thread_id": thread_id} if thread_id else None + result = await adapter.send(chat_id=chat_id, content=chunk, metadata=metadata) + except asyncio.CancelledError: + raise + except Exception as e: + return {"error": f"Plugin platform send failed: {e}"} + if result.success: + return {"success": True, "message_id": result.message_id} + return {"error": f"Adapter send failed: {result.error}"} platform_name = platform.value if hasattr(platform, "value") else str(platform) entry = None @@ -749,6 +770,45 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, last_result = result return last_result + # --- Unified capability-gated media: any platform with a live adapter + # that declares the kind in MEDIA_KINDS. Runs AFTER the seven hand-tuned + # branches and BEFORE the text-only fallback, covering qqbot/slack/ + # whatsapp/wecom/bluebubbles/email and in-process plugins. Undeliverable + # kinds are skipped with a warning rather than leaked as path-as-text. --- + if media_files: + adapter = _resolve_live_adapter(platform) + if adapter is not None and getattr(adapter, "MEDIA_KINDS", frozenset()): + from gateway.platforms.base import BasePlatformAdapter, classify_media_kind + + safe = BasePlatformAdapter.filter_media_delivery_paths(media_files) + metadata = {"thread_id": thread_id} if thread_id else None + warnings, last_result = [], None + try: + for i, chunk in enumerate(chunks): + attach = safe if i == len(chunks) - 1 else [] + if chunk.strip(): + result = await adapter.send(chat_id=chat_id, content=chunk, metadata=metadata) + if not result.success: + return {"error": f"Adapter send failed: {result.error}"} + last_result = {"success": True, "message_id": result.message_id} + for path, is_voice in attach: + kind = classify_media_kind(path, is_voice, platform.value, force_document) + if kind not in adapter.MEDIA_KINDS: + warnings.append(f"{platform.value} cannot deliver {kind.value}: {path} (skipped)") + continue + last_result = await _dispatch_media_one(adapter, kind, chat_id, path, metadata) + if last_result.get("error"): + return last_result + except asyncio.CancelledError: + raise + except Exception as e: + return {"error": f"Adapter media send failed: {e}"} + if last_result is None: # nothing delivered: every file unsupported or dropped as unsafe + return {"error": "; ".join(warnings) or f"No deliverable media for {platform.value} (attachments were unsafe or unsupported)"} + if warnings and last_result.get("success"): + last_result["warnings"] = [*last_result.get("warnings", []), *warnings] + return last_result + # --- Non-media platforms --- if media_files and not message.strip(): return {