From 73d9e523ee989c1c5ab326d9914a44abdca778e1 Mon Sep 17 00:00:00 2001 From: firefly Date: Sun, 31 May 2026 00:05:14 -0400 Subject: [PATCH 01/12] feat(platforms): add MediaKind + MEDIA_KINDS capability descriptor (fail-closed) --- gateway/platforms/base.py | 14 ++++++++++++++ tests/gateway/test_media_kinds.py | 9 +++++++++ 2 files changed, 23 insertions(+) create mode 100644 tests/gateway/test_media_kinds.py diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 761eba90e29d..1f26711ad724 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -487,6 +487,13 @@ 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" + + 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 +2040,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/tests/gateway/test_media_kinds.py b/tests/gateway/test_media_kinds.py new file mode 100644 index 000000000000..62d7a8e0e729 --- /dev/null +++ b/tests/gateway/test_media_kinds.py @@ -0,0 +1,9 @@ +from gateway.platforms.base import BasePlatformAdapter, MediaKind + + +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() From 7a920bf7c590195cff7044b067675c833ad47ecb Mon Sep 17 00:00:00 2001 From: firefly Date: Sun, 31 May 2026 00:06:30 -0400 Subject: [PATCH 02/12] feat(platforms): add classify_media_kind helper --- gateway/platforms/base.py | 21 +++++++++++++++++++++ tests/gateway/test_media_kinds.py | 17 ++++++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 1f26711ad724..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: @@ -494,6 +496,25 @@ class MediaKind(Enum): 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." diff --git a/tests/gateway/test_media_kinds.py b/tests/gateway/test_media_kinds.py index 62d7a8e0e729..75f78ffea87a 100644 --- a/tests/gateway/test_media_kinds.py +++ b/tests/gateway/test_media_kinds.py @@ -1,4 +1,4 @@ -from gateway.platforms.base import BasePlatformAdapter, MediaKind +from gateway.platforms.base import BasePlatformAdapter, MediaKind, classify_media_kind def test_media_kind_has_four_members(): @@ -7,3 +7,18 @@ def test_media_kind_has_four_members(): 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 From 78ddb0376af60765e4e5a3e63a33689ad4906788 Mon Sep 17 00:00:00 2001 From: firefly Date: Sun, 31 May 2026 00:09:45 -0400 Subject: [PATCH 03/12] feat(platforms): declare MEDIA_KINDS per adapter + pin the capability map --- gateway/platforms/bluebubbles.py | 2 ++ gateway/platforms/dingtalk.py | 2 ++ gateway/platforms/email.py | 2 ++ gateway/platforms/feishu.py | 2 ++ gateway/platforms/homeassistant.py | 2 ++ gateway/platforms/matrix.py | 2 ++ gateway/platforms/msgraph_webhook.py | 2 ++ gateway/platforms/qqbot/adapter.py | 2 ++ gateway/platforms/signal.py | 2 ++ gateway/platforms/slack.py | 2 ++ gateway/platforms/sms.py | 2 ++ gateway/platforms/telegram.py | 2 ++ gateway/platforms/webhook.py | 2 ++ gateway/platforms/wecom.py | 2 ++ gateway/platforms/weixin.py | 2 ++ gateway/platforms/whatsapp.py | 2 ++ gateway/platforms/yuanbao.py | 2 ++ plugins/platforms/discord/adapter.py | 2 ++ plugins/platforms/google_chat/adapter.py | 2 ++ plugins/platforms/irc/adapter.py | 2 ++ plugins/platforms/line/adapter.py | 2 ++ plugins/platforms/mattermost/adapter.py | 2 ++ plugins/platforms/ntfy/adapter.py | 2 ++ plugins/platforms/simplex/adapter.py | 2 ++ plugins/platforms/teams/adapter.py | 2 ++ tests/gateway/test_media_kinds.py | 45 ++++++++++++++++++++++++ 26 files changed, 95 insertions(+) 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..250b871f57d2 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) 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/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 index 75f78ffea87a..2c31430d8a06 100644 --- a/tests/gateway/test_media_kinds.py +++ b/tests/gateway/test_media_kinds.py @@ -1,5 +1,50 @@ +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 + def test_media_kind_has_four_members(): assert {k.name for k in MediaKind} == {"IMAGE", "VIDEO", "VOICE", "DOCUMENT"} From 51f50a01bcad95b568952a05776a09cd5a69147f Mon Sep 17 00:00:00 2001 From: firefly Date: Sun, 31 May 2026 00:11:07 -0400 Subject: [PATCH 04/12] test(platforms): lock MEDIA_KINDS on adapter class --- tests/gateway/test_media_kinds.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/gateway/test_media_kinds.py b/tests/gateway/test_media_kinds.py index 2c31430d8a06..5710d2e29d4e 100644 --- a/tests/gateway/test_media_kinds.py +++ b/tests/gateway/test_media_kinds.py @@ -46,6 +46,15 @@ def test_media_kinds_pinned(ref, expected): assert adapter_cls.MEDIA_KINDS == expected +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"} From 1ee24d3795c4b53c28a17158aa46212727864130 Mon Sep 17 00:00:00 2001 From: firefly Date: Sun, 31 May 2026 00:16:06 -0400 Subject: [PATCH 05/12] fix(send_message): route media to any capability-declared adapter; fail-loud (Closes #18422, #23760) --- tests/tools/test_send_message_tool.py | 160 ++++++++++++++++++++++++++ tools/send_message_tool.py | 99 ++++++++++++---- 2 files changed, 237 insertions(+), 22 deletions(-) diff --git a/tests/tools/test_send_message_tool.py b/tests/tools/test_send_message_tool.py index 10a4868655b3..06433b58f3e4 100644 --- a/tests/tools/test_send_message_tool.py +++ b/tests/tools/test_send_message_tool.py @@ -2767,3 +2767,163 @@ 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"]) diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 88bcb4005c00..d152748b63e2 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,40 @@ 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 + 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 + if last_result is None: # media-only send, every file unsupported + return {"error": "; ".join(warnings)} + 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 { From 7e1aa24a4b7a9cec7296ef8ac80701c495d4f97c Mon Sep 17 00:00:00 2001 From: firefly Date: Sun, 31 May 2026 00:19:35 -0400 Subject: [PATCH 06/12] fix(send_message): honor error-dict contract on adapter exceptions; explicit error when all media dropped --- tests/tools/test_send_message_tool.py | 41 +++++++++++++++++++++++++++ tools/send_message_tool.py | 39 ++++++++++++++----------- 2 files changed, 63 insertions(+), 17 deletions(-) diff --git a/tests/tools/test_send_message_tool.py b/tests/tools/test_send_message_tool.py index 06433b58f3e4..e45083748706 100644 --- a/tests/tools/test_send_message_tool.py +++ b/tests/tools/test_send_message_tool.py @@ -2927,3 +2927,44 @@ def test_out_of_process_no_live_adapter_falls_to_text_with_warning(self, tmp_pat )) 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 diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index d152748b63e2..efb84d34bd11 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -783,23 +783,28 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, safe = BasePlatformAdapter.filter_media_delivery_paths(media_files) metadata = {"thread_id": thread_id} if thread_id else None warnings, last_result = [], None - 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 - if last_result is None: # media-only send, every file unsupported - return {"error": "; ".join(warnings)} + 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 From b7d21bf6de7f55e41f5f32eab23f8128317beb39 Mon Sep 17 00:00:00 2001 From: firefly Date: Sun, 31 May 2026 00:21:55 -0400 Subject: [PATCH 07/12] test(send_message): regression-lock the 7 media branches + omitted-media fallback --- tests/tools/test_send_message_tool.py | 58 +++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/tests/tools/test_send_message_tool.py b/tests/tools/test_send_message_tool.py index e45083748706..dee614c036a0 100644 --- a/tests/tools/test_send_message_tool.py +++ b/tests/tools/test_send_message_tool.py @@ -2968,3 +2968,61 @@ async def send(self, chat_id, content, metadata=None): media_files=[("/tmp/EVIL.png", False)], )) assert res["error"] # non-empty, explains nothing was delivered + + +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"]) From 006782ad523e27e74832c7fd0470d401ee88a00e Mon Sep 17 00:00:00 2001 From: firefly Date: Sun, 31 May 2026 00:24:34 -0400 Subject: [PATCH 08/12] fix(gateway): gate agent reply-path media on MEDIA_KINDS (no path-as-text leak) --- gateway/run.py | 30 ++++++- .../test_reply_path_media_capability.py | 79 +++++++++++++++++++ 2 files changed, 105 insertions(+), 4 deletions(-) create mode 100644 tests/gateway/test_reply_path_media_capability.py diff --git a/gateway/run.py b/gateway/run.py index 64eb8eb560e1..925e2f287f59 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -12143,7 +12143,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 +12186,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 +12208,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 +12241,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/tests/gateway/test_reply_path_media_capability.py b/tests/gateway/test_reply_path_media_capability.py new file mode 100644 index 000000000000..73645c8fe298 --- /dev/null +++ b/tests/gateway/test_reply_path_media_capability.py @@ -0,0 +1,79 @@ +"""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"] From 747fc3ca7aa8ff0ae9560a23301a340d2d9fcac3 Mon Sep 17 00:00:00 2001 From: firefly Date: Sun, 31 May 2026 00:25:53 -0400 Subject: [PATCH 09/12] fix(gateway): gate kanban notifier media on MEDIA_KINDS --- gateway/run.py | 18 ++++++++-- .../test_reply_path_media_capability.py | 33 +++++++++++++++++++ 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 925e2f287f59..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, ) diff --git a/tests/gateway/test_reply_path_media_capability.py b/tests/gateway/test_reply_path_media_capability.py index 73645c8fe298..5fb978b295b9 100644 --- a/tests/gateway/test_reply_path_media_capability.py +++ b/tests/gateway/test_reply_path_media_capability.py @@ -77,3 +77,36 @@ 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 == [] From fcbacad5f2b95ec6e51cee94f8cfad9db11375f6 Mon Sep 17 00:00:00 2001 From: firefly Date: Sun, 31 May 2026 00:32:14 -0400 Subject: [PATCH 10/12] test(gateway): declare MEDIA_KINDS on telegram fakes for capability-gated dispatch --- tests/gateway/test_tts_media_routing.py | 6 +++++- tests/hermes_cli/test_kanban_notify.py | 4 ++++ 2 files changed, 9 insertions(+), 1 deletion(-) 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 = [] From 2d887e32d9a07f925b188249fb3b8149e878426a Mon Sep 17 00:00:00 2001 From: firefly Date: Sun, 31 May 2026 00:55:22 -0400 Subject: [PATCH 11/12] fix(send_message): back email IMAGE with a real attachment; pin dispatch-method backing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unified media branch dispatches MediaKind.IMAGE via send_image_file (_dispatch_media_one), but EmailAdapter declared {IMAGE, DOCUMENT} while overriding only send_image/send_multiple_images — so a single image to email inherited the base stub and leaked the local path as body text (🖼️ Image: /path), the exact leak this epic closes. The reply/kanban paths were unaffected because they batch via send_multiple_images, which email does override. - Add EmailAdapter.send_image_file delegating to _send_email_with_attachment (the same helper send_document uses), so IMAGE and DOCUMENT deliver symmetrically and honor the SendResult contract. - Harden the MEDIA_KINDS pin: assert every declared kind overrides the method its dispatch site actually calls (IMAGE→send_image_file, etc.), making the descriptor's documented "must be backed by a real override" invariant executable so the next adapter can't silently re-open the leak. Closes #18422, #23760 --- gateway/platforms/email.py | 33 +++++++++++++++++++++++++++++++ tests/gateway/test_media_kinds.py | 32 ++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/gateway/platforms/email.py b/gateway/platforms/email.py index 250b871f57d2..b07449d9ec81 100644 --- a/gateway/platforms/email.py +++ b/gateway/platforms/email.py @@ -686,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/tests/gateway/test_media_kinds.py b/tests/gateway/test_media_kinds.py index 5710d2e29d4e..cc6383acec46 100644 --- a/tests/gateway/test_media_kinds.py +++ b/tests/gateway/test_media_kinds.py @@ -46,6 +46,38 @@ def test_media_kinds_pinned(ref, expected): 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 From 672f2493502b6ed7d5d0c9f520bfb9c2f6ee39bc Mon Sep 17 00:00:00 2001 From: firefly Date: Sun, 31 May 2026 01:09:30 -0400 Subject: [PATCH 12/12] test(send_message): cover last-chunk-only media attach + partial-delivery warning merge Two edge cases in the unified capability branch that lacked explicit coverage: - media attaches only to the final text chunk (never glued to an earlier one) - a partially-deliverable set returns success with the delivered message_id while still surfacing the skip warning for the undeliverable kind --- tests/tools/test_send_message_tool.py | 67 +++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/tests/tools/test_send_message_tool.py b/tests/tools/test_send_message_tool.py index dee614c036a0..7b4dc5b8c291 100644 --- a/tests/tools/test_send_message_tool.py +++ b/tests/tools/test_send_message_tool.py @@ -2969,6 +2969,73 @@ async def send(self, chat_id, content, metadata=None): )) 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