diff --git a/tests/tools/test_send_message_tool.py b/tests/tools/test_send_message_tool.py index 48bf2568aca51..446f3b7020f9c 100644 --- a/tests/tools/test_send_message_tool.py +++ b/tests/tools/test_send_message_tool.py @@ -623,6 +623,108 @@ def test_whatsapp_routes_via_local_bridge_sender(self): async_mock.assert_awaited_once_with({"bridge_port": 3000}, chat_id, "hello from hermes") +class TestSendToPlatformPluginMedia: + @staticmethod + def _register_plugin_platform(name="testmedia"): + from gateway.platform_registry import PlatformEntry, platform_registry + + platform_registry.register( + PlatformEntry( + name=name, + label="Test Media", + adapter_factory=lambda _cfg: None, + check_fn=lambda: True, + source="plugin", + ) + ) + return Platform(name) + + @staticmethod + def _unregister_plugin_platform(platform): + from gateway.platform_registry import platform_registry + + platform_registry.unregister(platform.value) + Platform._value2member_map_.pop(platform.value, None) + Platform._member_map_.pop(platform.name, None) + + def test_plugin_platform_routes_media_through_live_adapter(self, tmp_path, monkeypatch): + platform = self._register_plugin_platform() + try: + image = tmp_path / "chart.png" + audio = tmp_path / "voice.mp3" + doc = tmp_path / "report.pdf" + image.write_bytes(b"png") + audio.write_bytes(b"mp3") + doc.write_bytes(b"pdf") + + adapter = SimpleNamespace( + send=AsyncMock(return_value=SimpleNamespace(success=True, message_id="text-1")), + send_image_file=AsyncMock(return_value=SimpleNamespace(success=True, message_id="image-1")), + send_voice=AsyncMock(return_value=SimpleNamespace(success=True, message_id="voice-1")), + send_document=AsyncMock(return_value=SimpleNamespace(success=True, message_id="doc-1")), + ) + runner = SimpleNamespace(adapters={platform: adapter}) + + monkeypatch.setattr("gateway.run._gateway_runner_ref", lambda: runner) + + result = asyncio.run( + _send_to_platform( + platform, + SimpleNamespace(enabled=True, token=None, extra={}), + "room-1", + "attached", + media_files=[ + (str(image), False), + (str(audio), False), + (str(doc), False), + ], + ) + ) + + assert result == { + "success": True, + "platform": "testmedia", + "chat_id": "room-1", + "message_id": "doc-1", + } + adapter.send.assert_awaited_once_with(chat_id="room-1", content="attached") + adapter.send_image_file.assert_awaited_once_with("room-1", str(image)) + adapter.send_voice.assert_awaited_once_with("room-1", str(audio)) + adapter.send_document.assert_awaited_once_with("room-1", str(doc)) + finally: + self._unregister_plugin_platform(platform) + + def test_plugin_platform_allows_media_only_send(self, tmp_path, monkeypatch): + platform = self._register_plugin_platform("testmediaonly") + try: + image = tmp_path / "chart.png" + image.write_bytes(b"png") + adapter = SimpleNamespace( + send=AsyncMock(return_value=SimpleNamespace(success=True, message_id="text-1")), + send_image_file=AsyncMock(return_value=SimpleNamespace(success=True, message_id="image-1")), + ) + runner = SimpleNamespace(adapters={platform.value: adapter}) + + monkeypatch.setattr("gateway.run._gateway_runner_ref", lambda: runner) + + result = asyncio.run( + _send_to_platform( + platform, + SimpleNamespace(enabled=True, token=None, extra={}), + "room-1", + "", + media_files=[(str(image), False)], + ) + ) + + assert result["success"] is True + assert result["message_id"] == "image-1" + adapter.send.assert_not_awaited() + adapter.send_image_file.assert_awaited_once_with("room-1", str(image)) + finally: + self._unregister_plugin_platform(platform) + + class TestSendTelegramHtmlDetection: """Verify that messages containing HTML tags are sent with parse_mode=HTML and that plain / markdown messages use MarkdownV2.""" diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 62712e4581ffc..4fa21b90d76ff 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -436,6 +436,65 @@ async def _send_via_adapter(platform, pconfig, chat_id, chunk): return {"error": f"No live adapter for platform '{platform.value}'. Is the gateway running with this platform connected?"} +def _is_plugin_platform(platform) -> bool: + try: + from gateway.platform_registry import platform_registry + + entry = platform_registry.get(platform.value) + return bool(entry and entry.source == "plugin") + except Exception: + return False + + +async def _send_plugin_media_via_adapter(platform, chat_id, chunk, media_files): + """Send text/media for a plugin platform through its live gateway adapter.""" + try: + from gateway.run import _gateway_runner_ref + + runner = _gateway_runner_ref() + adapter = runner.adapters.get(platform) if runner else None + if adapter is None and runner: + adapter = runner.adapters.get(platform.value) + if adapter is None: + return { + "error": ( + f"No live adapter for platform '{platform.value}'. " + "Is the gateway running with this platform connected?" + ) + } + + last_result = None + if chunk.strip(): + text_result = await adapter.send(chat_id=chat_id, content=chunk) + if not text_result.success: + return {"error": f"Plugin text send failed: {text_result.error}"} + last_result = text_result + + for media_path, is_voice in media_files or []: + ext = os.path.splitext(media_path)[1].lower() + if ext in _IMAGE_EXTS: + result = await adapter.send_image_file(chat_id, media_path) + elif ext in _VOICE_EXTS and is_voice: + result = await adapter.send_voice(chat_id, media_path) + elif ext in _AUDIO_EXTS: + result = await adapter.send_voice(chat_id, media_path) + else: + result = await adapter.send_document(chat_id, media_path) + + if not result.success: + return {"error": f"Plugin media send failed: {result.error}"} + last_result = result + + return { + "success": True, + "platform": platform.value, + "chat_id": chat_id, + "message_id": getattr(last_result, "message_id", None), + } + except Exception as e: + return {"error": f"Plugin media send failed: {e}"} + + async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, media_files=None): """Route a message to the appropriate platform sender. @@ -588,6 +647,22 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, last_result = result return last_result + # --- Plugin platforms: route media through the live adapter interface --- + if media_files and _is_plugin_platform(platform): + last_result = None + for i, chunk in enumerate(chunks): + is_last = (i == len(chunks) - 1) + result = await _send_plugin_media_via_adapter( + platform, + chat_id, + chunk, + media_files if is_last else [], + ) + if isinstance(result, dict) and result.get("error"): + return result + last_result = result + return last_result + # --- Non-media platforms --- if media_files and not message.strip(): return {