From 32d7dd5fe8152547af3d2c92115e18c37d932ed2 Mon Sep 17 00:00:00 2001 From: Li-john1021 <1748201686@qq.com> Date: Wed, 14 Oct 2026 23:01:02 +0800 Subject: [PATCH] fix(discord): accept inbound attachments and file-only messages (#6857) * fix(discord): accept inbound attachments and file-only messages Empty content no longer drops messages that have attachments. Capture CDN descriptors on inbound, download in receive_file via Bot auth, and hand bytes through INBOUND_FILE_CONTENT_KEY like Slack/Telegram. Closes #6856 * fix(discord): gate bot auth and share inbound file helpers Address review on #6857: attach Bot Authorization only for Discord CDN hosts, report Content-Encoding refusals as download failed, and hoist filename/size helpers into app.channels.inbound_files (Telegram delegates to the same module). * fix(discord): sort telegram imports for ruff I001 CI lint-backend failed on unsorted imports in telegram.py after the shared inbound_files helper was introduced. * style: ruff-format discord inbound attachments test * fix(discord): show the enforced attachment download limit --------- Co-authored-by: Li-john1021 Co-authored-by: Willem Jiang --- README.md | 3 + backend/app/channels/discord.py | 202 ++++++++- backend/app/channels/inbound_files.py | 41 ++ backend/app/channels/telegram.py | 20 +- .../tests/test_discord_inbound_attachments.py | 414 ++++++++++++++++++ 5 files changed, 661 insertions(+), 19 deletions(-) create mode 100644 backend/app/channels/inbound_files.py create mode 100644 backend/tests/test_discord_inbound_attachments.py diff --git a/README.md b/README.md index 8bdac66561..db77c362d5 100644 --- a/README.md +++ b/README.md @@ -952,6 +952,9 @@ configured value yielding no valid ID denies every guild and logs an error. of IDs exempt from `mention_only`, within allowed guilds. An empty value gives no exemptions, so `mention_only` applies everywhere when enabled. +Discord inbound attachments have a 25 MiB download limit. Oversized-file notices +show the enforced limit so senders can resize or compress the attachment. + ```yaml channels: # LangGraph-compatible Gateway API base URL (default: http://localhost:8001/api) diff --git a/backend/app/channels/discord.py b/backend/app/channels/discord.py index e85ea80033..da902f9fc5 100644 --- a/backend/app/channels/discord.py +++ b/backend/app/channels/discord.py @@ -10,12 +10,24 @@ import threading from concurrent.futures import Future from pathlib import Path from typing import Any +from urllib.parse import urlsplit + +import httpx from app.channels.allowed_users import parse_channel_ids from app.channels.base import Channel from app.channels.commands import is_known_channel_command from app.channels.connection_identity import attach_connection_identity -from app.channels.message_bus import InboundMessage, InboundMessageType, InboundReservation, MessageBus, OutboundMessage, ResolvedAttachment +from app.channels.inbound_files import coerce_file_size, safe_inbound_filename +from app.channels.message_bus import ( + INBOUND_FILE_CONTENT_KEY, + InboundMessage, + InboundMessageType, + InboundReservation, + MessageBus, + OutboundMessage, + ResolvedAttachment, +) from deerflow.utils.file_io import await_drained logger = logging.getLogger(__name__) @@ -35,6 +47,11 @@ DISCORD_OUTBOUND_TIMEOUT_SECONDS = 30.0 # only exists to convert a wedged client into a logged failure. DISCORD_UPLOAD_TIMEOUT_SECONDS = 120.0 +# Match Discord's default non-Nitro upload ceiling for inbound attachments. +DISCORD_MAX_INBOUND_FILE_BYTES = 25 * 1024 * 1024 +# Bot Authorization is only attached for these CDN host suffixes. +DISCORD_CDN_HOST_SUFFIXES = frozenset({"discordapp.net", "discordapp.com", "discord.com"}) + def _parse_discord_guild_id(entry: Any) -> int | None: """Return a Discord guild (snowflake) ID, or ``None`` if ``entry`` is not one.""" @@ -558,7 +575,9 @@ class DiscordChannel(Channel): return text = (message.content or "").strip() - if not text: + files = self._extract_inbound_files(message) + # File-only messages have empty content; still admit them when attachments exist. + if not text and not files: return if self._discord_module is None: @@ -580,6 +599,7 @@ class DiscordChannel(Channel): if has_mention: text = text.replace(bot_mention or "", "").replace(alt_mention or "", "").replace(standard_mention or "", "").strip() # Don't return early if text is empty — still process the mention (e.g., create thread) + # or a captionless attachment that arrived with an @mention. connect_code = self._pending_connect_code(text) if connect_code and await self._bind_connection_from_connect_code(message, connect_code): @@ -596,6 +616,7 @@ class DiscordChannel(Channel): user_id=str(message.author.id), text=text, msg_type=msg_type, + files=files, metadata={ "guild_id": str(guild.id) if guild else None, "channel_id": str(message.channel.id), @@ -613,6 +634,7 @@ class DiscordChannel(Channel): guild=guild, has_mention=bool(has_mention), reservation=reservation, + files=files, ) async def _handle_admitted_message( @@ -624,9 +646,11 @@ class DiscordChannel(Channel): guild, has_mention: bool, reservation: InboundReservation, + files: list[dict[str, Any]] | None = None, ) -> None: """Finish Discord routing while owning one bounded intake slot.""" reservation_transferred = False + files = files or [] try: # --- Determine thread/channel routing and typing target --- thread_id = None @@ -648,6 +672,7 @@ class DiscordChannel(Channel): text=text, msg_type=msg_type, thread_ts=thread_id, + files=files, metadata={ "guild_id": str(guild.id) if guild else None, "channel_id": str(message.channel.id), @@ -754,6 +779,7 @@ class DiscordChannel(Channel): text=text, msg_type=msg_type, thread_ts=thread_id, + files=files, metadata={ "guild_id": str(guild.id) if guild else None, "channel_id": str(message.channel.id), @@ -924,6 +950,178 @@ class DiscordChannel(Channel): except Exception: logger.exception("[Discord] failed to send connection reply") + async def receive_file(self, msg: InboundMessage, thread_id: str, *, user_id: str | None = None) -> InboundMessage: + """Download inbound Discord attachments for the shared upload pipeline. + + Attachment CDN URLs are captured on the message event (they expire). + Bytes cross to ``ChannelManager`` through ``INBOUND_FILE_CONTENT_KEY`` + (same shape as Slack/Telegram). Prefer a Bot Authorization header when + the channel has a token — Discord has been rolling authenticated CDN + access — but signed URLs still download without one. + """ + del thread_id, user_id + if not msg.files: + return msg + + token = self._bot_token or None + materialized: list[dict[str, Any]] = [] + unavailable: list[str] = [] + size_limit_notice = f"exceeds the {DISCORD_MAX_INBOUND_FILE_BYTES / (1024 * 1024):g} MiB download limit" + + for file_info in msg.files: + if not isinstance(file_info, dict): + continue + + filename = self._safe_inbound_filename(file_info.get("filename"), "attachment") + declared_size = self._file_size(file_info.get("size")) + if declared_size is not None and declared_size > DISCORD_MAX_INBOUND_FILE_BYTES: + logger.warning("[Discord] inbound file exceeds download limit, skipping: %s", filename) + unavailable.append(f"{filename} ({size_limit_notice})") + continue + + url = file_info.get("url") + if not isinstance(url, str) or not url.strip(): + logger.error("[Discord] cannot download inbound file without url: %s", filename) + unavailable.append(f"{filename} (download unavailable)") + continue + + try: + content = await self._download_attachment(url.strip(), token) + except Exception as exc: + logger.error( + "[Discord] failed to download inbound file %s: %s", + filename, + type(exc).__name__, + ) + unavailable.append(f"{filename} (download failed)") + continue + + if content is None: + # Stream aborted for size; encoding refusals raise above. + unavailable.append(f"{filename} ({size_limit_notice})") + continue + + if len(content) > DISCORD_MAX_INBOUND_FILE_BYTES: + logger.warning("[Discord] downloaded inbound file exceeds limit, skipping: %s", filename) + unavailable.append(f"{filename} ({size_limit_notice})") + continue + + materialized.append( + { + "type": "image" if file_info.get("type") == "image" else "file", + "filename": filename, + "mime_type": file_info.get("mime_type") if isinstance(file_info.get("mime_type"), str) else "application/octet-stream", + "size": len(content), + INBOUND_FILE_CONTENT_KEY: content, + } + ) + + msg.files = materialized + if unavailable: + notice = f"[Discord attachment unavailable: {', '.join(unavailable)}]" + msg.text = f"{msg.text}\n\n{notice}" if msg.text else notice + return msg + + @classmethod + def _is_discord_cdn_url(cls, url: str) -> bool: + """True when *url* targets a Discord CDN host (dot-boundary aware).""" + try: + host = (urlsplit(url).hostname or "").lower() + except ValueError: + return False + if not host: + return False + return any(host == suffix or host.endswith(f".{suffix}") for suffix in DISCORD_CDN_HOST_SUFFIXES) + + async def _download_attachment(self, url: str, token: str | None) -> bytes | None: + """Stream a Discord attachment CDN URL; never log the URL. + + Bot Authorization is attached only for Discord CDN hosts so a foreign + URL on the bus can never carry the bot token off-platform. Returns + ``None`` only when the streamed body exceeds the per-file cap; a + non-identity ``Content-Encoding`` raises so callers report + ``(download failed)`` instead of a misleading size-limit notice. + """ + headers = {"Accept-Encoding": "identity"} + if token and self._is_discord_cdn_url(url): + headers["Authorization"] = f"Bot {token}" + chunks: list[bytes] = [] + total = 0 + async with httpx.AsyncClient(timeout=httpx.Timeout(20.0)) as client: + async with client.stream("GET", url, headers=headers) as response: + response.raise_for_status() + encoding = (response.headers.get("content-encoding") or "").strip().lower() + if encoding and encoding != "identity": + logger.warning( + "[Discord] inbound file response uses Content-Encoding %r, dropping before decode", + encoding, + ) + raise RuntimeError(f"unsupported content-encoding: {encoding}") + async for chunk in response.aiter_raw(): + total += len(chunk) + if total > DISCORD_MAX_INBOUND_FILE_BYTES: + logger.warning( + "[Discord] inbound file exceeds %d bytes download limit, dropping", + DISCORD_MAX_INBOUND_FILE_BYTES, + ) + return None + chunks.append(chunk) + return b"".join(chunks) + + @classmethod + def _extract_inbound_files(cls, message) -> list[dict[str, Any]]: + """Build file descriptors from a discord.py Message's attachments.""" + raw_attachments = getattr(message, "attachments", None) or [] + files: list[dict[str, Any]] = [] + for index, raw in enumerate(raw_attachments): + if isinstance(raw, dict): + url = raw.get("url") + filename_raw = raw.get("filename") + content_type = raw.get("content_type") or raw.get("mime_type") + size_raw = raw.get("size") + file_id = raw.get("id") or raw.get("file_id") + else: + url = getattr(raw, "url", None) + filename_raw = getattr(raw, "filename", None) + content_type = getattr(raw, "content_type", None) + size_raw = getattr(raw, "size", None) + file_id = getattr(raw, "id", None) + + if not isinstance(url, str) or not url.strip(): + continue + + if isinstance(file_id, int): + file_id_str = str(file_id) + elif isinstance(file_id, str) and file_id: + file_id_str = file_id + else: + file_id_str = str(index) + + mime_type = content_type if isinstance(content_type, str) and content_type else "application/octet-stream" + fallback = f"discord-file-{file_id_str}.bin" + filename = cls._safe_inbound_filename(filename_raw, fallback) + is_image = mime_type.startswith("image/") + files.append( + { + "type": "image" if is_image else "file", + "file_id": file_id_str, + "filename": filename, + "mime_type": mime_type, + "size": cls._file_size(size_raw), + # CDN URL only — Authorization is attached in receive_file when available. + "url": url.strip(), + } + ) + return files + + @staticmethod + def _safe_inbound_filename(value: Any, fallback: str) -> str: + return safe_inbound_filename(value, fallback) + + @staticmethod + def _file_size(value: Any) -> int | None: + return coerce_file_size(value) + def _run_client(self) -> None: self._discord_loop = asyncio.new_event_loop() asyncio.set_event_loop(self._discord_loop) diff --git a/backend/app/channels/inbound_files.py b/backend/app/channels/inbound_files.py new file mode 100644 index 0000000000..ec7cb19f30 --- /dev/null +++ b/backend/app/channels/inbound_files.py @@ -0,0 +1,41 @@ +"""Shared helpers for inbound channel file descriptors. + +Used by Telegram/Discord (and any future channel) so filename sanitization and +size coercion cannot drift between adapters that feed the same upload pipeline. +""" + +from __future__ import annotations + +from typing import Any + +from deerflow.uploads.manager import is_upload_staging_file, normalize_filename + + +def safe_inbound_filename(value: Any, fallback: str) -> str: + """Normalize a user-supplied filename or return *fallback* when unsafe.""" + if not isinstance(value, str): + return fallback + try: + candidate = normalize_filename(value.strip()) + except (UnicodeError, ValueError): + return fallback + if is_upload_staging_file(candidate) or any(ord(char) < 32 for char in candidate): + return fallback + return candidate + + +def coerce_file_size(value: Any) -> int | None: + """Coerce a payload size field to a non-negative int, or ``None``. + + Whitelists bool/int/float/str shapes so accidental truthy values (and + truncated floats) never become sizes. + """ + if isinstance(value, bool): + return None + if isinstance(value, int): + return value if value >= 0 else None + if isinstance(value, float) and value.is_integer() and value >= 0: + return int(value) + if isinstance(value, str) and value.strip().isdigit(): + return int(value.strip()) + return None diff --git a/backend/app/channels/telegram.py b/backend/app/channels/telegram.py index 7aa0a6648c..1444a658ee 100644 --- a/backend/app/channels/telegram.py +++ b/backend/app/channels/telegram.py @@ -14,6 +14,7 @@ from typing import Any from app.channels.allowed_users import parse_allowed_users from app.channels.base import Channel, ChannelStopTimeout from app.channels.connection_identity import attach_connection_identity +from app.channels.inbound_files import coerce_file_size, safe_inbound_filename from app.channels.message_bus import ( INBOUND_FILE_CONTENT_KEY, InboundMessage, @@ -23,7 +24,6 @@ from app.channels.message_bus import ( OutboundMessage, ResolvedAttachment, ) -from deerflow.uploads.manager import is_upload_staging_file, normalize_filename logger = logging.getLogger(__name__) @@ -848,25 +848,11 @@ class TelegramChannel(Channel): @staticmethod def _file_size(value: Any) -> int | None: - if isinstance(value, bool): - return None - try: - size = int(value) - except (TypeError, ValueError): - return None - return size if size >= 0 else None + return coerce_file_size(value) @staticmethod def _safe_inbound_filename(value: Any, fallback: str) -> str: - if not isinstance(value, str): - return fallback - try: - candidate = normalize_filename(value.strip()) - except (UnicodeError, ValueError): - return fallback - if is_upload_staging_file(candidate) or any(ord(char) < 32 for char in candidate): - return fallback - return candidate + return safe_inbound_filename(value, fallback) @classmethod def _extract_inbound_files(cls, message: Any) -> list[dict[str, Any]]: diff --git a/backend/tests/test_discord_inbound_attachments.py b/backend/tests/test_discord_inbound_attachments.py new file mode 100644 index 0000000000..fec964cb1b --- /dev/null +++ b/backend/tests/test_discord_inbound_attachments.py @@ -0,0 +1,414 @@ +"""Discord inbound attachment handling (file-only messages + receive_file).""" + +from __future__ import annotations + +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from app.channels.discord import DiscordChannel +from app.channels.message_bus import INBOUND_FILE_CONTENT_KEY, InboundMessage, InboundMessageType, MessageBus + + +def _attachment(**overrides): + base = SimpleNamespace( + id=9001, + filename="report.pdf", + url="https://cdn.discordapp.com/attachments/1/2/report.pdf?ex=abc", + content_type="application/pdf", + size=4, + ) + for key, value in overrides.items(): + setattr(base, key, value) + return base + + +def _make_discord_message(text: str = "", *, attachments=None): + return SimpleNamespace( + id=111, + content=text, + author=SimpleNamespace(id=123, bot=False, display_name="alice"), + guild=SimpleNamespace(id=321), + channel=SimpleNamespace(id=456), + attachments=attachments if attachments is not None else [], + add_reaction=AsyncMock(), + ) + + +async def _noop(*_args, **_kwargs): + return None + + +def _ready_channel(bus: MessageBus | None = None) -> DiscordChannel: + channel = DiscordChannel(bus=bus or MessageBus(), config={"bot_token": "discord-bot-token"}) + channel._running = True + channel._client = SimpleNamespace(user=SimpleNamespace(id=999, mention="<@999>")) + channel._discord_module = SimpleNamespace(Thread=type("FakeThread", (), {})) + channel._main_loop = asyncio.get_running_loop() + channel._start_typing = _noop + channel._add_reaction = _noop + return channel + + +@pytest.mark.asyncio +async def test_file_only_message_publishes_inbound_files() -> None: + bus = MessageBus() + channel = _ready_channel(bus) + + await channel._on_message(_make_discord_message("", attachments=[_attachment()])) + + inbound = await asyncio.wait_for(bus.get_inbound(), timeout=5) + bus.inbound_task_done() + assert inbound.text == "" + assert inbound.msg_type == InboundMessageType.CHAT + assert len(inbound.files) == 1 + assert inbound.files[0]["filename"] == "report.pdf" + assert inbound.files[0]["type"] == "file" + assert inbound.files[0]["url"].startswith("https://cdn.discordapp.com/") + + +@pytest.mark.asyncio +async def test_captioned_attachment_publishes_text_and_files() -> None: + bus = MessageBus() + channel = _ready_channel(bus) + + await channel._on_message(_make_discord_message("please review", attachments=[_attachment(filename="photo.png", content_type="image/png")])) + + inbound = await asyncio.wait_for(bus.get_inbound(), timeout=5) + bus.inbound_task_done() + assert inbound.text == "please review" + assert inbound.files[0]["filename"] == "photo.png" + assert inbound.files[0]["type"] == "image" + + +@pytest.mark.asyncio +async def test_empty_message_without_attachments_still_dropped() -> None: + bus = MessageBus() + channel = _ready_channel(bus) + + await channel._on_message(_make_discord_message("")) + + assert bus.inbound_queue.empty() + + +@pytest.mark.asyncio +async def test_mention_only_skips_file_without_mention() -> None: + bus = MessageBus() + channel = _ready_channel(bus) + channel._mention_only = True + + await channel._on_message(_make_discord_message("", attachments=[_attachment()])) + + assert bus.inbound_queue.empty() + + +@pytest.mark.asyncio +async def test_mention_with_file_only_caption_still_publishes() -> None: + bus = MessageBus() + channel = _ready_channel(bus) + channel._mention_only = True + # Avoid discord.py ChannelType / HTTPException stubs: fall back to channel replies. + channel._create_thread = AsyncMock(return_value=None) + + await channel._on_message(_make_discord_message("<@999>", attachments=[_attachment()])) + + inbound = await asyncio.wait_for(bus.get_inbound(), timeout=5) + bus.inbound_task_done() + assert inbound.text == "" + assert inbound.files[0]["filename"] == "report.pdf" + + +class TestDiscordExtractInboundFiles: + def test_extracts_image_and_document_descriptors(self): + message = _make_discord_message( + "x", + attachments=[ + _attachment(id=1, filename="photo.png", content_type="image/png", size=8), + _attachment(id=2, filename="notes.txt", content_type="text/plain", size=2, url="https://cdn.discordapp.com/attachments/1/2/notes.txt"), + _attachment(id=3, filename="missing-url.bin", url=""), + ], + ) + files = DiscordChannel._extract_inbound_files(message) + assert [item["filename"] for item in files] == ["photo.png", "notes.txt"] + assert files[0]["type"] == "image" + assert files[1]["type"] == "file" + + def test_rejects_upload_staging_filename(self): + files = DiscordChannel._extract_inbound_files(_make_discord_message("", attachments=[_attachment(id=7, filename=".upload-hidden.part")])) + assert files[0]["filename"] == "discord-file-7.bin" + + +class TestDiscordReceiveFile: + @pytest.mark.asyncio + async def test_downloads_bytes_with_bot_token_and_strips_url(self, monkeypatch): + from app.channels import discord as discord_mod + + seen: dict[str, str] = {} + + class _FakeStreamResponse: + def __init__(self): + self.headers = {"content-encoding": "identity"} + + def raise_for_status(self) -> None: + return None + + async def aiter_raw(self): + yield b"da" + yield b"ta" + + class _FakeStreamContext: + async def __aenter__(self): + return _FakeStreamResponse() + + async def __aexit__(self, *exc_info): + return False + + class _FakeAsyncClient: + def __init__(self, *args, **kwargs): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc_info): + return False + + def stream(self, method: str, url: str, **kwargs): + seen["method"] = method + seen["url"] = url + seen["authorization"] = kwargs.get("headers", {}).get("Authorization", "") + seen["accept_encoding"] = kwargs.get("headers", {}).get("Accept-Encoding", "") + return _FakeStreamContext() + + monkeypatch.setattr(discord_mod.httpx, "AsyncClient", _FakeAsyncClient) + + channel = DiscordChannel(MessageBus(), {"bot_token": "discord-bot-token"}) + content = await channel._download_attachment( + "https://cdn.discordapp.com/attachments/1/2/report.pdf", + "discord-bot-token", + ) + assert content == b"data" + assert seen["method"] == "GET" + assert seen["authorization"] == "Bot discord-bot-token" + assert seen["accept_encoding"] == "identity" + + channel2 = DiscordChannel(MessageBus(), {"bot_token": "discord-bot-token"}) + + async def fake_download(url: str, token: str | None) -> bytes | None: + assert token == "discord-bot-token" + assert "report.pdf" in url + return b"data" + + channel2._download_attachment = fake_download # type: ignore[method-assign] + msg = InboundMessage( + channel_name="discord", + chat_id="456", + user_id="123", + text="caption", + files=[ + { + "type": "file", + "file_id": "9001", + "filename": "report.pdf", + "mime_type": "application/pdf", + "size": 4, + "url": "https://cdn.discordapp.com/attachments/1/2/report.pdf", + } + ], + ) + result = await channel2.receive_file(msg, "thread-1") + assert result.text == "caption" + assert result.files == [ + { + "type": "file", + "filename": "report.pdf", + "mime_type": "application/pdf", + "size": 4, + INBOUND_FILE_CONTENT_KEY: b"data", + } + ] + + @pytest.mark.asyncio + async def test_missing_url_clears_file_and_notices(self): + channel = DiscordChannel(MessageBus(), {"bot_token": "discord-bot-token"}) + msg = InboundMessage( + channel_name="discord", + chat_id="456", + user_id="123", + text="caption", + files=[ + { + "type": "file", + "filename": "report.pdf", + "mime_type": "application/pdf", + } + ], + ) + result = await channel.receive_file(msg, "thread-1") + assert result.files == [] + assert "download unavailable" in result.text + + @pytest.mark.asyncio + async def test_bot_token_not_sent_to_foreign_host(self, monkeypatch): + from app.channels import discord as discord_mod + + seen: dict[str, str] = {} + + class _FakeStreamResponse: + def __init__(self): + self.headers = {"content-encoding": "identity"} + + def raise_for_status(self) -> None: + return None + + async def aiter_raw(self): + yield b"ok" + + class _FakeStreamContext: + async def __aenter__(self): + return _FakeStreamResponse() + + async def __aexit__(self, *exc_info): + return False + + class _FakeAsyncClient: + def __init__(self, *args, **kwargs): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc_info): + return False + + def stream(self, method: str, url: str, **kwargs): + seen["authorization"] = kwargs.get("headers", {}).get("Authorization", "") + return _FakeStreamContext() + + monkeypatch.setattr(discord_mod.httpx, "AsyncClient", _FakeAsyncClient) + channel = DiscordChannel(MessageBus(), {"bot_token": "discord-bot-token"}) + content = await channel._download_attachment( + "https://evil.example/attachments/1/2/report.pdf", + "discord-bot-token", + ) + assert content == b"ok" + assert seen["authorization"] == "" + + @pytest.mark.asyncio + async def test_content_encoding_refusal_is_download_failed(self, monkeypatch): + from app.channels import discord as discord_mod + + class _FakeStreamResponse: + def __init__(self): + self.headers = {"content-encoding": "gzip"} + + def raise_for_status(self) -> None: + return None + + async def aiter_raw(self): + if False: + yield b"" + + class _FakeStreamContext: + async def __aenter__(self): + return _FakeStreamResponse() + + async def __aexit__(self, *exc_info): + return False + + class _FakeAsyncClient: + def __init__(self, *args, **kwargs): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc_info): + return False + + def stream(self, method: str, url: str, **kwargs): + return _FakeStreamContext() + + monkeypatch.setattr(discord_mod.httpx, "AsyncClient", _FakeAsyncClient) + channel = DiscordChannel(MessageBus(), {"bot_token": "discord-bot-token"}) + msg = InboundMessage( + channel_name="discord", + chat_id="456", + user_id="123", + text="caption", + files=[ + { + "type": "file", + "filename": "report.pdf", + "mime_type": "application/pdf", + "size": 4, + "url": "https://cdn.discordapp.com/attachments/1/2/report.pdf", + } + ], + ) + result = await channel.receive_file(msg, "thread-1") + assert result.files == [] + assert "download failed" in result.text + assert "download limit" not in result.text + + +class TestDiscordCdnHostGate: + def test_allows_discord_cdn_hosts(self): + assert DiscordChannel._is_discord_cdn_url("https://cdn.discordapp.com/attachments/1/2/x") + assert DiscordChannel._is_discord_cdn_url("https://media.discordapp.net/attachments/1/2/x") + assert DiscordChannel._is_discord_cdn_url("https://discord.com/api/attachments/1") + + def test_rejects_lookalike_hosts(self): + assert not DiscordChannel._is_discord_cdn_url("https://evil.example/x") + assert not DiscordChannel._is_discord_cdn_url("https://cdn.discordapp.com.evil.io/x") + assert DiscordChannel._is_discord_cdn_url("http://cdn.discordapp.com/x") # host-only gate + + +@pytest.mark.asyncio +@pytest.mark.parametrize("rejection", ["declared", "stream", "returned"]) +async def test_size_limit_notice_tracks_enforced_cap(monkeypatch, rejection): + from app.channels import discord as discord_mod + + limit = 2 * 1024 * 1024 + monkeypatch.setattr(discord_mod, "DISCORD_MAX_INBOUND_FILE_BYTES", limit) + channel = DiscordChannel(MessageBus(), {"bot_token": "discord-bot-token"}) + download = AsyncMock(return_value=None if rejection == "stream" else b"x" * (limit + 1)) + monkeypatch.setattr(channel, "_download_attachment", download) + msg = InboundMessage( + channel_name="discord", + chat_id="456", + user_id="123", + text="caption", + files=[{"filename": "report.pdf", "size": limit + 1 if rejection == "declared" else 4, "url": "https://cdn.discordapp.com/report.pdf"}], + ) + + result = await channel.receive_file(msg, "thread-1") + + assert result.files == [] + assert result.text == "caption\n\n[Discord attachment unavailable: report.pdf (exceeds the 2 MiB download limit)]" + if rejection == "declared": + download.assert_not_awaited() + else: + download.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_default_size_limit_notice_shows_25_mib(monkeypatch): + channel = DiscordChannel(MessageBus(), {"bot_token": "discord-bot-token"}) + download = AsyncMock() + monkeypatch.setattr(channel, "_download_attachment", download) + msg = InboundMessage( + channel_name="discord", + chat_id="456", + user_id="123", + text="", + files=[{"filename": "large.pdf", "size": 26 * 1024 * 1024, "url": "https://cdn.discordapp.com/large.pdf"}], + ) + + result = await channel.receive_file(msg, "thread-1") + + assert result.text == "[Discord attachment unavailable: large.pdf (exceeds the 25 MiB download limit)]" + assert result.files == [] + download.assert_not_awaited()