From 276ed5c399d247022e5b033808daade2c8969ae1 Mon Sep 17 00:00:00 2001 From: Teknium Date: Wed, 15 Apr 2026 17:35:52 -0700 Subject: [PATCH] fix(send_message): deliver Matrix media via adapter Matrix media delivery was silently dropped by send_message because Matrix wasn't wired into the native adapter-backed media path. Only Telegram, Discord, and Weixin had native media support. Adds _send_matrix_via_adapter() which creates a MatrixAdapter instance, connects, sends text + media via the adapter's native upload methods (send_document, send_image_file, send_video, send_voice), then disconnects. Also fixes a stale URL-encoding assertion in test_send_message_missing_platforms that broke after PR #10151 added quote() to room IDs. Cherry-picked from PR #10486 by helix4u. --- .../test_send_message_missing_platforms.py | 2 +- tests/tools/test_send_message_tool.py | 79 ++++++++++++++++++ tools/send_message_tool.py | 81 ++++++++++++++++++- 3 files changed, 159 insertions(+), 3 deletions(-) diff --git a/tests/tools/test_send_message_missing_platforms.py b/tests/tools/test_send_message_missing_platforms.py index a6741e16..cda43aad 100644 --- a/tests/tools/test_send_message_missing_platforms.py +++ b/tests/tools/test_send_message_missing_platforms.py @@ -123,7 +123,7 @@ class TestSendMatrix: session.put.assert_called_once() call_kwargs = session.put.call_args url = call_kwargs[0][0] - assert url.startswith("https://matrix.example.com/_matrix/client/v3/rooms/!room:example.com/send/m.room.message/") + assert url.startswith("https://matrix.example.com/_matrix/client/v3/rooms/%21room%3Aexample.com/send/m.room.message/") assert call_kwargs[1]["headers"]["Authorization"] == "Bearer syt_tok" payload = call_kwargs[1]["json"] assert payload["msgtype"] == "m.text" diff --git a/tests/tools/test_send_message_tool.py b/tests/tools/test_send_message_tool.py index 07a1a9be..17c95d79 100644 --- a/tests/tools/test_send_message_tool.py +++ b/tests/tools/test_send_message_tool.py @@ -12,6 +12,7 @@ from gateway.config import Platform from tools.send_message_tool import ( _parse_target_ref, _send_discord, + _send_matrix_via_adapter, _send_telegram, _send_to_platform, send_message_tool, @@ -594,6 +595,84 @@ class TestSendToPlatformChunking: assert all(call == [] for call in sent_calls[:-1]) assert sent_calls[-1] == media + def test_matrix_media_uses_native_adapter_helper(self): + + doc_path = Path("/tmp/test-send-message-matrix.pdf") + doc_path.write_bytes(b"%PDF-1.4 test") + + try: + helper = AsyncMock(return_value={"success": True, "platform": "matrix", "chat_id": "!room:example.com", "message_id": "$evt"}) + with patch("tools.send_message_tool._send_matrix_via_adapter", helper): + result = asyncio.run( + _send_to_platform( + Platform.MATRIX, + SimpleNamespace(enabled=True, token="tok", extra={"homeserver": "https://matrix.example.com"}), + "!room:example.com", + "here you go", + media_files=[(str(doc_path), False)], + ) + ) + + assert result["success"] is True + helper.assert_awaited_once() + call = helper.await_args + assert call.args[1] == "!room:example.com" + assert call.args[2] == "here you go" + assert call.kwargs["media_files"] == [(str(doc_path), False)] + finally: + doc_path.unlink(missing_ok=True) + + def test_send_matrix_via_adapter_sends_document(self, tmp_path): + file_path = tmp_path / "report.pdf" + file_path.write_bytes(b"%PDF-1.4 test") + + calls = [] + + class FakeAdapter: + def __init__(self, _config): + self.connected = False + + async def connect(self): + self.connected = True + calls.append(("connect",)) + return True + + async def send(self, chat_id, message, metadata=None): + calls.append(("send", chat_id, message, metadata)) + return SimpleNamespace(success=True, message_id="$text") + + async def send_document(self, chat_id, file_path, metadata=None): + calls.append(("send_document", chat_id, file_path, metadata)) + return SimpleNamespace(success=True, message_id="$file") + + async def disconnect(self): + calls.append(("disconnect",)) + + fake_module = SimpleNamespace(MatrixAdapter=FakeAdapter) + + with patch.dict(sys.modules, {"gateway.platforms.matrix": fake_module}): + result = asyncio.run( + _send_matrix_via_adapter( + SimpleNamespace(enabled=True, token="tok", extra={"homeserver": "https://matrix.example.com"}), + "!room:example.com", + "report attached", + media_files=[(str(file_path), False)], + ) + ) + + assert result == { + "success": True, + "platform": "matrix", + "chat_id": "!room:example.com", + "message_id": "$file", + } + assert calls == [ + ("connect",), + ("send", "!room:example.com", "report attached", None), + ("send_document", "!room:example.com", str(file_path), None), + ("disconnect",), + ] + # --------------------------------------------------------------------------- # HTML auto-detection in Telegram send diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 1c641710..cc681adc 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -404,11 +404,28 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, last_result = result return last_result + # --- Matrix: use the native adapter helper for text + media --- + if platform == Platform.MATRIX: + last_result = None + for i, chunk in enumerate(chunks): + is_last = (i == len(chunks) - 1) + result = await _send_matrix_via_adapter( + pconfig, + chat_id, + chunk, + media_files=media_files if is_last else [], + thread_id=thread_id, + ) + if isinstance(result, dict) and result.get("error"): + return result + last_result = result + return last_result + # --- Non-Telegram/Discord platforms --- if media_files and not message.strip(): return { "error": ( - f"send_message MEDIA delivery is currently only supported for telegram, discord, and weixin; " + f"send_message MEDIA delivery is currently only supported for telegram, discord, matrix, and weixin; " f"target {platform.value} had only media attachments" ) } @@ -416,7 +433,7 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, if media_files: warning = ( f"MEDIA attachments were omitted for {platform.value}; " - "native send_message media delivery is currently only supported for telegram, discord, and weixin" + "native send_message media delivery is currently only supported for telegram, discord, matrix, and weixin" ) last_result = None @@ -907,6 +924,66 @@ async def _send_matrix(token, extra, chat_id, message): return _error(f"Matrix send failed: {e}") +async def _send_matrix_via_adapter(pconfig, chat_id, message, media_files=None, thread_id=None): + """Send via the Matrix adapter so native Matrix media uploads are preserved.""" + try: + from gateway.platforms.matrix import MatrixAdapter + except ImportError: + return {"error": "Matrix dependencies not installed. Run: pip install 'mautrix[encryption]'"} + + media_files = media_files or [] + + try: + adapter = MatrixAdapter(pconfig) + connected = await adapter.connect() + if not connected: + return _error("Matrix connect failed") + + metadata = {"thread_id": thread_id} if thread_id else None + last_result = None + + if message.strip(): + last_result = await adapter.send(chat_id, message, metadata=metadata) + if not last_result.success: + return _error(f"Matrix send failed: {last_result.error}") + + for media_path, is_voice in media_files: + if not os.path.exists(media_path): + return _error(f"Media file not found: {media_path}") + + ext = os.path.splitext(media_path)[1].lower() + if ext in _IMAGE_EXTS: + last_result = await adapter.send_image_file(chat_id, media_path, metadata=metadata) + elif ext in _VIDEO_EXTS: + last_result = await adapter.send_video(chat_id, media_path, metadata=metadata) + elif ext in _VOICE_EXTS and is_voice: + last_result = await adapter.send_voice(chat_id, media_path, metadata=metadata) + elif ext in _AUDIO_EXTS: + last_result = await adapter.send_voice(chat_id, media_path, metadata=metadata) + else: + last_result = await adapter.send_document(chat_id, media_path, metadata=metadata) + + if not last_result.success: + return _error(f"Matrix media send failed: {last_result.error}") + + if last_result is None: + return {"error": "No deliverable text or media remained after processing MEDIA tags"} + + return { + "success": True, + "platform": "matrix", + "chat_id": chat_id, + "message_id": last_result.message_id, + } + except Exception as e: + return _error(f"Matrix send failed: {e}") + finally: + try: + await adapter.disconnect() + except Exception: + pass + + async def _send_homeassistant(token, extra, chat_id, message): """Send via Home Assistant notify service.""" try: