"""Tests for Discord incoming document/file attachment handling. Covers the document branch in DiscordAdapter._handle_message() — the `else` clause of the attachment content-type loop that was added to download, cache, and optionally inject text from non-image/audio files. """ import os import sys from datetime import datetime, timezone from types import SimpleNamespace from typing import Optional from unittest.mock import AsyncMock, MagicMock, patch import pytest from gateway.config import PlatformConfig from gateway.platforms.base import MessageType # --------------------------------------------------------------------------- # Discord mock setup (copied from test_discord_free_response.py) # --------------------------------------------------------------------------- def _ensure_discord_mock(): """Install a mock discord module when discord.py isn't available.""" if "discord" in sys.modules and hasattr(sys.modules["discord"], "__file__"): return discord_mod = MagicMock() discord_mod.Intents.default.return_value = MagicMock() discord_mod.Client = MagicMock discord_mod.File = MagicMock discord_mod.DMChannel = type("DMChannel", (), {}) discord_mod.Thread = type("Thread", (), {}) discord_mod.ForumChannel = type("ForumChannel", (), {}) discord_mod.ui = SimpleNamespace(View=object, button=lambda *a, **k: (lambda fn: fn), Button=object) discord_mod.ButtonStyle = SimpleNamespace(success=1, primary=2, secondary=2, danger=3, green=1, grey=2, blurple=2, red=3) discord_mod.Color = SimpleNamespace(orange=lambda: 1, green=lambda: 2, blue=lambda: 3, red=lambda: 4, purple=lambda: 5) discord_mod.Interaction = object discord_mod.Embed = MagicMock discord_mod.app_commands = SimpleNamespace( describe=lambda **kwargs: (lambda fn: fn), choices=lambda **kwargs: (lambda fn: fn), Choice=lambda **kwargs: SimpleNamespace(**kwargs), ) ext_mod = MagicMock() commands_mod = MagicMock() commands_mod.Bot = MagicMock ext_mod.commands = commands_mod sys.modules.setdefault("discord", discord_mod) sys.modules.setdefault("discord.ext", ext_mod) sys.modules.setdefault("discord.ext.commands", commands_mod) _ensure_discord_mock() import plugins.platforms.discord.adapter as discord_platform # noqa: E402 from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402 # --------------------------------------------------------------------------- # Fake channel / thread types # --------------------------------------------------------------------------- class FakeDMChannel: def __init__(self, channel_id: int = 1): self.id = channel_id self.name = "dm" class FakeThread: def __init__(self, channel_id: int = 10): self.id = channel_id self.name = "thread" self.parent = None self.parent_id = None self.guild = SimpleNamespace(name="TestServer") self.topic = None # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture(autouse=True) def _redirect_cache(tmp_path, monkeypatch): """Point document cache to tmp_path so tests never write to ~/.hermes.""" monkeypatch.setattr( "gateway.platforms.base.DOCUMENT_CACHE_DIR", tmp_path / "doc_cache" ) @pytest.fixture def adapter(monkeypatch): monkeypatch.setattr(discord_platform.discord, "DMChannel", FakeDMChannel, raising=False) monkeypatch.setattr(discord_platform.discord, "Thread", FakeThread, raising=False) config = PlatformConfig(enabled=True, token="fake-token") a = DiscordAdapter(config) a._client = SimpleNamespace(user=SimpleNamespace(id=999)) a.handle_message = AsyncMock() return a # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def make_attachment( *, filename: str, content_type: Optional[str], size: int = 1024, url: str = "https://cdn.discordapp.com/attachments/fake/file", ) -> SimpleNamespace: return SimpleNamespace( filename=filename, content_type=content_type, size=size, url=url, ) def make_message(attachments: list, content: str = "") -> SimpleNamespace: return SimpleNamespace( id=123, content=content, attachments=attachments, mentions=[], reference=None, created_at=datetime.now(timezone.utc), channel=FakeDMChannel(), author=SimpleNamespace(id=42, display_name="Tester", name="Tester"), ) def _mock_aiohttp_download(raw_bytes: bytes): """Return a patch context manager that makes aiohttp return raw_bytes.""" resp = AsyncMock() resp.status = 200 resp.read = AsyncMock(return_value=raw_bytes) resp.__aenter__ = AsyncMock(return_value=resp) resp.__aexit__ = AsyncMock(return_value=False) session = AsyncMock() session.get = MagicMock(return_value=resp) session.__aenter__ = AsyncMock(return_value=session) session.__aexit__ = AsyncMock(return_value=False) return patch("aiohttp.ClientSession", return_value=session) # --------------------------------------------------------------------------- # Tests # --------------------------------------------------------------------------- class TestIncomingDocumentHandling: @pytest.mark.asyncio async def test_pdf_document_cached(self, adapter): """A PDF attachment should be downloaded, cached, typed as DOCUMENT.""" pdf_bytes = b"%PDF-1.4 fake content" with _mock_aiohttp_download(pdf_bytes): msg = make_message([make_attachment(filename="report.pdf", content_type="application/pdf")]) await adapter._handle_message(msg) event = adapter.handle_message.call_args[0][0] assert event.message_type == MessageType.DOCUMENT assert len(event.media_urls) == 1 assert os.path.exists(event.media_urls[0]) assert event.media_types == ["application/pdf"] assert "[Content of" not in (event.text or "") @pytest.mark.asyncio async def test_txt_content_injected(self, adapter): """.txt file under 100KB should have its content injected into event.text.""" file_content = b"Hello from a text file" with _mock_aiohttp_download(file_content): msg = make_message( attachments=[make_attachment(filename="notes.txt", content_type="text/plain")], content="summarize this", ) await adapter._handle_message(msg) event = adapter.handle_message.call_args[0][0] assert "[Content of notes.txt]:" in event.text assert "Hello from a text file" in event.text assert "summarize this" in event.text # injection prepended before caption assert event.text.index("[Content of") < event.text.index("summarize this") @pytest.mark.asyncio async def test_md_content_injected(self, adapter): """.md file under 100KB should have its content injected.""" file_content = b"# Title\nSome markdown content" with _mock_aiohttp_download(file_content): msg = make_message( attachments=[make_attachment(filename="readme.md", content_type="text/markdown")], content="", ) await adapter._handle_message(msg) event = adapter.handle_message.call_args[0][0] assert "[Content of readme.md]:" in event.text assert "# Title" in event.text @pytest.mark.asyncio async def test_log_content_injected(self, adapter): """.log file under 100KB should be treated as text/plain and injected.""" file_content = b"BLE trace line 1\nBLE trace line 2" with _mock_aiohttp_download(file_content): msg = make_message( attachments=[make_attachment(filename="btsnoop_hci.log", content_type="text/plain")], content="please inspect this", ) await adapter._handle_message(msg) event = adapter.handle_message.call_args[0][0] assert "[Content of btsnoop_hci.log]:" in event.text assert "BLE trace line 1" in event.text assert "please inspect this" in event.text @pytest.mark.asyncio async def test_oversized_document_skipped(self, adapter): """A document over 32MB should be skipped — media_urls stays empty.""" msg = make_message([ make_attachment( filename="huge.pdf", content_type="application/pdf", size=33 * 1024 * 1024, ) ]) await adapter._handle_message(msg) event = adapter.handle_message.call_args[0][0] assert event.media_urls == [] # handler must still be called adapter.handle_message.assert_called_once() @pytest.mark.asyncio async def test_mid_sized_zip_under_32mb_is_cached(self, adapter): """A 25MB .zip should be accepted now that Discord documents allow up to 32MB.""" msg = make_message([ make_attachment( filename="bugreport.zip", content_type="application/zip", size=25 * 1024 * 1024, ) ]) with _mock_aiohttp_download(b"PK\x03\x04test"): await adapter._handle_message(msg) event = adapter.handle_message.call_args[0][0] assert len(event.media_urls) == 1 assert event.media_types == ["application/zip"] @pytest.mark.asyncio async def test_zip_document_cached(self, adapter): """A .zip file should be cached as a supported document.""" msg = make_message([ make_attachment(filename="archive.zip", content_type="application/zip") ]) with _mock_aiohttp_download(b"PK\x03\x04test"): await adapter._handle_message(msg) event = adapter.handle_message.call_args[0][0] assert len(event.media_urls) == 1 assert event.media_types == ["application/zip"] assert event.message_type == MessageType.DOCUMENT @pytest.mark.asyncio async def test_download_error_handled(self, adapter): """If the HTTP download raises, the handler should not crash.""" resp = AsyncMock() resp.__aenter__ = AsyncMock(side_effect=RuntimeError("connection reset")) resp.__aexit__ = AsyncMock(return_value=False) session = AsyncMock() session.get = MagicMock(return_value=resp) session.__aenter__ = AsyncMock(return_value=session) session.__aexit__ = AsyncMock(return_value=False) with patch("aiohttp.ClientSession", return_value=session): msg = make_message([ make_attachment(filename="report.pdf", content_type="application/pdf") ]) await adapter._handle_message(msg) # Must still deliver an event adapter.handle_message.assert_called_once() event = adapter.handle_message.call_args[0][0] assert event.media_urls == [] @pytest.mark.asyncio async def test_large_txt_cached_not_injected(self, adapter): """.txt over 100KB should be cached but NOT injected into event.text.""" large_content = b"x" * (200 * 1024) with _mock_aiohttp_download(large_content): msg = make_message( attachments=[make_attachment(filename="big.txt", content_type="text/plain", size=len(large_content))], content="", ) await adapter._handle_message(msg) event = adapter.handle_message.call_args[0][0] assert len(event.media_urls) == 1 assert os.path.exists(event.media_urls[0]) assert "[Content of" not in (event.text or "") @pytest.mark.asyncio async def test_multiple_text_files_both_injected(self, adapter): """Two text file attachments should both be injected into event.text in order.""" content1 = b"First file content" content2 = b"Second file content" call_count = 0 responses = [content1, content2] def make_session(_responses): idx = 0 class FakeSession: async def __aenter__(self): return self async def __aexit__(self, *_): pass def get(self, url, **kwargs): nonlocal idx data = _responses[idx % len(_responses)] idx += 1 resp = AsyncMock() resp.status = 200 resp.read = AsyncMock(return_value=data) resp.__aenter__ = AsyncMock(return_value=resp) resp.__aexit__ = AsyncMock(return_value=False) return resp return FakeSession() with patch("aiohttp.ClientSession", return_value=make_session([content1, content2])): msg = make_message( attachments=[ make_attachment(filename="file1.txt", content_type="text/plain"), make_attachment(filename="file2.txt", content_type="text/plain"), ], content="", ) await adapter._handle_message(msg) event = adapter.handle_message.call_args[0][0] assert "[Content of file1.txt]:" in event.text assert "First file content" in event.text assert "[Content of file2.txt]:" in event.text assert "Second file content" in event.text assert event.text.index("file1") < event.text.index("file2") @pytest.mark.asyncio async def test_image_attachment_unaffected(self, adapter): """Image attachments should still go through the image path, not the document path.""" with patch( "plugins.platforms.discord.adapter.cache_image_from_url", new_callable=AsyncMock, return_value="/tmp/cached_image.png", ): msg = make_message([ make_attachment(filename="photo.png", content_type="image/png") ]) await adapter._handle_message(msg) event = adapter.handle_message.call_args[0][0] assert event.message_type == MessageType.PHOTO assert event.media_urls == ["/tmp/cached_image.png"] assert event.media_types == ["image/png"] class TestAllowAnyAttachment: """Cover the discord.allow_any_attachment config flag. With the flag off (default), unknown file types are dropped. With it on, they get cached and surfaced to the agent as DOCUMENT events with application/octet-stream MIME so gateway/run.py emits a path-pointing context note. """ @pytest.mark.asyncio async def test_unknown_type_skipped_by_default(self, adapter): """Default (flag off): unknown extension is dropped. With no text + no cached media, the adapter may legitimately decline to dispatch the event at all, so we don't assert on call_args here — we just verify the file wasn't cached. """ with _mock_aiohttp_download(b"should not be cached"): msg = make_message([ make_attachment(filename="weird.xyz", content_type="application/x-custom") ]) await adapter._handle_message(msg) if adapter.handle_message.call_args is not None: event = adapter.handle_message.call_args[0][0] assert event.media_urls == [] @pytest.mark.asyncio async def test_unknown_type_cached_when_flag_on(self, adapter): """Flag on: unknown extension is cached as application/octet-stream.""" adapter.config.extra["allow_any_attachment"] = True with _mock_aiohttp_download(b"\x00\x01\x02 binary payload"): msg = make_message([ make_attachment(filename="weird.xyz", content_type="application/x-custom") ]) await adapter._handle_message(msg) event = adapter.handle_message.call_args[0][0] assert len(event.media_urls) == 1 assert os.path.exists(event.media_urls[0]) # Falls back to the source content_type when we have one. assert event.media_types == ["application/x-custom"] assert event.message_type == MessageType.DOCUMENT # We deliberately do NOT inline arbitrary bytes — run.py emits the # path-pointing note based on DOCUMENT + octet-stream MIME. assert "[Content of" not in (event.text or "") @pytest.mark.asyncio async def test_unknown_type_no_content_type_becomes_octet_stream(self, adapter): """Flag on + no content_type from discord: MIME falls back to octet-stream.""" adapter.config.extra["allow_any_attachment"] = True with _mock_aiohttp_download(b"raw bytes"): msg = make_message([ make_attachment(filename="mystery.bin", content_type=None) ]) await adapter._handle_message(msg) event = adapter.handle_message.call_args[0][0] assert event.message_type == MessageType.DOCUMENT assert event.media_types == ["application/octet-stream"] @pytest.mark.asyncio async def test_max_attachment_bytes_caps_uploads(self, adapter): """discord.max_attachment_bytes overrides the historical 32 MiB cap.""" adapter.config.extra["allow_any_attachment"] = True adapter.config.extra["max_attachment_bytes"] = 1024 # 1 KiB msg = make_message([ make_attachment( filename="too_big.xyz", content_type="application/x-custom", size=2048, ) ]) await adapter._handle_message(msg) event = adapter.handle_message.call_args[0][0] assert event.media_urls == [] @pytest.mark.asyncio async def test_max_attachment_bytes_zero_means_unlimited(self, adapter): """max_attachment_bytes=0 disables the size cap entirely.""" adapter.config.extra["allow_any_attachment"] = True adapter.config.extra["max_attachment_bytes"] = 0 # 64 MiB — would normally exceed the historical 32 MiB hardcoded cap. with _mock_aiohttp_download(b"x" * 16): msg = make_message([ make_attachment( filename="huge.xyz", content_type="application/x-custom", size=64 * 1024 * 1024, ) ]) await adapter._handle_message(msg) event = adapter.handle_message.call_args[0][0] assert len(event.media_urls) == 1 @pytest.mark.asyncio async def test_allowlisted_doc_unchanged_when_flag_on(self, adapter): """Flag on must not change handling of types already in SUPPORTED_DOCUMENT_TYPES. A .txt should still get its content inlined (the historical behavior), and the MIME should still be the canonical text/plain — not whatever discord guessed. """ adapter.config.extra["allow_any_attachment"] = True file_content = b"still a text file" with _mock_aiohttp_download(file_content): msg = make_message( attachments=[make_attachment(filename="notes.txt", content_type="text/plain")], content="check this", ) await adapter._handle_message(msg) event = adapter.handle_message.call_args[0][0] assert "[Content of notes.txt]:" in event.text assert "still a text file" in event.text assert event.media_types == ["text/plain"] def test_helper_reads_env_fallback(self, adapter, monkeypatch): """Helper falls back to DISCORD_ALLOW_ANY_ATTACHMENT env var.""" assert adapter._discord_allow_any_attachment() is False monkeypatch.setenv("DISCORD_ALLOW_ANY_ATTACHMENT", "true") assert adapter._discord_allow_any_attachment() is True monkeypatch.setenv("DISCORD_ALLOW_ANY_ATTACHMENT", "no") assert adapter._discord_allow_any_attachment() is False def test_helper_config_overrides_env(self, adapter, monkeypatch): """config.yaml setting wins over env var.""" monkeypatch.setenv("DISCORD_ALLOW_ANY_ATTACHMENT", "true") adapter.config.extra["allow_any_attachment"] = False assert adapter._discord_allow_any_attachment() is False def test_max_bytes_helper_invalid_value_falls_back(self, adapter): """Garbage in max_attachment_bytes config falls back to 32 MiB.""" adapter.config.extra["max_attachment_bytes"] = "not-a-number" assert adapter._discord_max_attachment_bytes() == 32 * 1024 * 1024