mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
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:
parent
44a896cc12
commit
32d7dd5fe8
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
41
backend/app/channels/inbound_files.py
Normal file
41
backend/app/channels/inbound_files.py
Normal 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
|
||||
@ -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]]:
|
||||
|
||||
414
backend/tests/test_discord_inbound_attachments.py
Normal file
414
backend/tests/test_discord_inbound_attachments.py
Normal 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()
|
||||
Loading…
x
Reference in New Issue
Block a user