diff --git a/plugins/platforms/matrix/adapter.py b/plugins/platforms/matrix/adapter.py index 3a715ba2ab..5ed73db4af 100644 --- a/plugins/platforms/matrix/adapter.py +++ b/plugins/platforms/matrix/adapter.py @@ -2087,10 +2087,13 @@ class MatrixAdapter(BasePlatformAdapter): async def _build_inbound_event( self, room_id: str, sender: str, event_id: str, body: str, source_content: dict, relates_to: dict, - **extra) -> Optional[MessageEvent]: + ctx: Optional[tuple] = None, **extra) -> Optional[MessageEvent]: """Gate + normalise an inbound event into a MessageEvent (None => drop). Text body may - still change (reply-fallback strip); ``extra`` carries media fields / message_type.""" - ctx = await self._resolve_message_context(room_id, sender, event_id, body, source_content, relates_to) + still change (reply-fallback strip); ``extra`` carries media fields / message_type. + ``ctx`` is a pre-resolved ``_resolve_message_context`` result (media path gates before + downloading); resolving it twice would double the read receipt / thread mark.""" + if ctx is None: + ctx = await self._resolve_message_context(room_id, sender, event_id, body, source_content, relates_to) if ctx is None: return None body, _is_dm, _chat_type, _thread_id, display_name, source = ctx @@ -2154,6 +2157,11 @@ class MatrixAdapter(BasePlatformAdapter): return is_encrypted_media = bool(file_content and isinstance(file_content, dict) and file_content.get("url")) msg_type, media_type, is_voice_message = self._classify_inbound_media(msgtype, event_mimetype, source_content) + # Gate (require_mention / allowed rooms) BEFORE the download: an unmentioned or + # non-allowlisted room must not pull media onto the host only to drop it. + ctx = await self._resolve_message_context(room_id, sender, event_id, body, source_content, relates_to) + if ctx is None: + return # Cache locally so downstream tools get a real file path. cached_path = None if url: @@ -2167,7 +2175,7 @@ class MatrixAdapter(BasePlatformAdapter): http_url = self._mxc_to_http(url) if url and not is_encrypted_media else "" media_urls = [cached_path] if cached_path else ([http_url] if http_url else None) msg_event = await self._build_inbound_event( - room_id, sender, event_id, body, source_content, relates_to, message_type=msg_type, + room_id, sender, event_id, body, source_content, relates_to, ctx=ctx, message_type=msg_type, media_urls=media_urls, media_types=[media_type] if media_urls else None, media_msgtype=msgtype) if msg_event is not None: await self.handle_message(msg_event) diff --git a/tests/gateway/test_matrix_media_gate_before_download.py b/tests/gateway/test_matrix_media_gate_before_download.py new file mode 100644 index 0000000000..84ceab0e43 --- /dev/null +++ b/tests/gateway/test_matrix_media_gate_before_download.py @@ -0,0 +1,51 @@ +"""Matrix must gate (require_mention / allowed rooms) BEFORE downloading inbound media. + +An unmentioned ``m.image`` in a gated group room used to be fetched from the homeserver +(``_download_and_cache_media``) and only then dropped by ``_resolve_message_context``. +The invariant: a dropped media event performs zero downloads; a mentioned one still does. +""" + +import time +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + + +def _make_adapter(monkeypatch): + monkeypatch.setenv("MATRIX_REQUIRE_MENTION", "true") + monkeypatch.setenv("MATRIX_AUTO_THREAD", "false") + from gateway.config import PlatformConfig + from plugins.platforms.matrix.adapter import MatrixAdapter + + adapter = MatrixAdapter(PlatformConfig( + enabled=True, token="syt_test_token", + extra={"homeserver": "https://matrix.example.org", "user_id": "@hermes:example.org"})) + adapter._startup_ts = time.time() - 10 + adapter.handle_message = AsyncMock() + adapter._client = None + adapter._resolve_room_identity = AsyncMock(return_value=SimpleNamespace( + display_name="Group Room", room_topic=None, server_name="example.org", chat_type="group")) + adapter._is_dm_room = AsyncMock(return_value=False) + adapter._download_and_cache_media = AsyncMock(return_value="/tmp/cached.png") + return adapter + + +def _image_event(body): + return SimpleNamespace( + sender="@alice:example.org", event_id="$img1", room_id="!group:example.org", + timestamp=int(time.time() * 1000), + content={"body": body, "msgtype": "m.image", "url": "mxc://example.org/abc", + "info": {"mimetype": "image/png", "size": 1024}}) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("body, downloads, dispatched", [ + ("photo.png", 0, 0), # unmentioned group media: never fetched + ("@hermes:example.org look", 1, 1), # mentioned: fetched and dispatched +]) +async def test_unmentioned_group_media_is_not_downloaded(monkeypatch, body, downloads, dispatched): + adapter = _make_adapter(monkeypatch) + await adapter._on_room_message(_image_event(body)) + assert adapter._download_and_cache_media.await_count == downloads + assert adapter.handle_message.await_count == dispatched