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 <Li-john1021@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Li-john1021 2026-10-14 23:01:02 +08:00 committed by GitHub
parent 44a896cc12
commit 32d7dd5fe8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 661 additions and 19 deletions

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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]]:

View File

@ -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()