Files
hermes/tests/gateway/test_discord_attachment_download.py
kshitijk4poor cc8e5ec2af refactor(gateway): migrate Discord adapter to bundled plugin (full Teams parity)
First migration of an existing built-in platform adapter to the plugin
system established by IRC / Teams / LINE / Google Chat. Closes #24325;
advances the umbrella refactor in #3823.

Matches Teams' shape exactly — adapter under ``plugins/platforms/discord/``
with the standard ``__init__.py`` / ``adapter.py`` / ``plugin.yaml``
shell, ``register(ctx)`` entry point, **no back-compat shim** at the old
import path, and full parity for the four hooks Teams uses plus the
``apply_yaml_config_fn`` hook that landed in #25443 (the Discord plugin
is the first consumer of that hook):

* ``standalone_sender_fn`` — out-of-process cron delivery via REST API
* ``setup_fn`` — interactive ``hermes setup gateway`` wizard
* ``apply_yaml_config_fn`` — translate ``config.yaml`` ``discord:`` keys
  into ``DISCORD_*`` env vars (replaces the hardcoded block in
  ``gateway/config.py``)
* ``is_connected`` — declares connection state from ``DISCORD_BOT_TOKEN``
* ``check_fn`` — lazy-installs ``discord.py`` on demand
* plus ``allowed_users_env``, ``allow_all_env``, ``cron_deliver_env_var``,
  ``max_message_length``, ``emoji``, ``required_env``, ``install_hint``

* ``gateway/platforms/discord.py`` (5,101 LOC) →
  ``plugins/platforms/discord/adapter.py`` (git rename, R090).
* New ``plugins/platforms/discord/{__init__.py, plugin.yaml}`` with
  ``requires_env`` / ``optional_env`` declarations.
* Append ``register(ctx)`` block + new hook implementations
  (``_standalone_send``, ``interactive_setup``, ``_apply_yaml_config``,
  ``_clean_discord_user_ids``, ``_is_connected``, ``_build_adapter``,
  plus helpers ``_DISCORD_CHANNEL_TYPE_PROBE_CACHE`` etc.) to the
  adapter.

* Replace the ``Platform.DISCORD elif`` branch in
  ``GatewayRunner._create_adapter()`` (−9 LOC) with a generic post-creation
  hook (+6 LOC) in the registry path: any plugin adapter that declares a
  ``gateway_runner`` attribute now gets it auto-injected. Webhook's
  built-in branch is unchanged (it doesn't go through the registry path).

* Move ``_send_discord`` (190 LOC) and helpers
  (``_DISCORD_CHANNEL_TYPE_PROBE_CACHE``, ``_remember_channel_is_forum``,
  ``_probe_is_forum_cached``, ``_derive_forum_thread_name``) from
  ``tools/send_message_tool.py`` into the plugin as ``_standalone_send``.
* Wire via ``standalone_sender_fn=_standalone_send`` (Teams pattern; same
  gap fixed in #21804 for other plugin platforms).
* Replace the Discord ``elif`` in ``tools/send_message_tool.py``
  ``_send_to_platform`` with a 10-line registry-hook dispatch.
* Drop the ``DiscordAdapter`` import and the
  ``Platform.DISCORD: DiscordAdapter.MAX_MESSAGE_LENGTH`` ``_MAX_LENGTHS``
  entry — the registry's ``max_message_length=2000`` covers it.

* Move ``_setup_discord`` and ``_clean_discord_user_ids`` (68 LOC) from
  ``hermes_cli/setup.py`` into the plugin as ``interactive_setup``.
* Wire via ``setup_fn=interactive_setup``.  CLI helpers (``prompt``,
  ``print_info``, etc.) are lazy-imported so the plugin's module-load
  surface stays minimal.
* Remove ``"discord": _s._setup_discord`` from
  ``hermes_cli/gateway.py::_builtin_setup_fn``.
* Remove the entire 32-line ``_PLATFORMS["discord"]`` static dict entry —
  Discord's setup metadata is now discovered dynamically via
  ``_all_platforms()`` from the registry entry.

* Move the 59-line ``discord_cfg`` YAML→env bridge from
  ``gateway/config.py::load_gateway_config()`` into the plugin as
  ``_apply_yaml_config``.  Covers ``require_mention``,
  ``thread_require_mention``, ``free_response_channels``, ``auto_thread``,
  ``reactions``, ``ignored_channels``, ``allowed_channels``,
  ``no_thread_channels``, ``allow_mentions.{everyone,roles,users,
  replied_user}``, and ``reply_to_mode`` (including the YAML 1.1
  ``off``-as-False coercion and the ``extra.reply_to_mode`` fallback).
* Wire via ``apply_yaml_config_fn=_apply_yaml_config``.
* The hook runs BEFORE ``_apply_env_overrides`` and after the generic
  shared-key loop, exactly as documented in
  ``website/docs/developer-guide/adding-platform-adapters.md``.
* Behavior is preserved exactly — every assignment still uses
  ``not os.getenv(...)`` guards so env vars take precedence over YAML.

All 78 references to the old import path are rewritten — no back-compat
shim:

* 51 ``from gateway.platforms.discord import X`` →
  ``from plugins.platforms.discord.adapter import X``
* 5 ``import gateway.platforms.discord as discord_platform`` →
  ``import plugins.platforms.discord.adapter as discord_platform``
* 1 ``from gateway.platforms import discord as discord_mod`` →
  ``from plugins.platforms.discord import adapter as discord_mod``
* 21 ``mock.patch("gateway.platforms.discord.X")`` strings →
  ``mock.patch("plugins.platforms.discord.adapter.X")``
* 1 docstring reference in ``hermes_cli/commands.py``
* 1 import in ``tools/send_message_tool.py`` (now removed entirely)

The import-safety test in ``tests/gateway/test_discord_imports.py`` is
updated to purge the new canonical module name from ``sys.modules``.

**38 files changed, +621 / −473** — net positive due to the YAML hook
implementation (89 new LOC in the plugin trading for 59 deleted in core),
but every line moved has a clear plugin home now.  The git rename is
detected at R090 because the adapter gained ~340 LOC of moved-in hook
implementations (``_standalone_send`` + ``interactive_setup`` +
``_apply_yaml_config`` + helpers).

* All 568 Discord-specific tests pass across 25 ``test_discord_*.py``
  files plus voice/send/text-batching/reload-skills/stream-consumer/
  integration tests.
* All 147 tests in the YAML-touching subset
  (``test_discord_reply_mode``, ``test_discord_free_response``,
  ``test_discord_allowed_channels``, ``test_discord_allowed_mentions``,
  ``test_discord_channel_controls``, ``test_discord_reactions``,
  ``test_discord_thread_persistence``, ``test_runtime_footer``) pass —
  this is the strongest signal that the YAML→env hook behaves
  identically to the legacy block.
* Broader gateway/cron/integration sweep (1297 tests) introduces zero
  new failures vs ``main``.  Pre-existing failures in
  ``tests/gateway/test_tts_media_routing.py`` and
  ``tests/e2e/test_platform_commands.py`` reproduce identically on the
  unchanged ``main`` revision.
* Plugin discovery sanity check confirms Discord registers alongside the
  other four platform plugins:

    Registered platforms: ['discord', 'google_chat', 'irc', 'line', 'teams']

These Discord-shaped tendrils in core were **deliberately not moved** —
they are generic platform-registry concerns affecting every platform,
not Discord-specific:

* ``gateway/config.py:1205`` ``DISCORD_BOT_TOKEN → config.token`` env
  enablement — same shape Telegram has.  The existing
  ``env_enablement_fn`` registry hook only seeds ``extra``, not
  ``.token``, so it can't replace this without an adapter refactor to
  read from ``extra["bot_token"]``.
* ``gateway/run.py`` voice-mode hooks
  (``self.adapters.get(Platform.DISCORD)`` for
  ``start_voice_mode``/``stop_voice_mode``), role-based auth,
  ``DISCORD_ALLOW_BOTS`` branch in ``_is_user_authorized``,
  ``_UPDATE_ALLOWED_PLATFORMS`` frozenset, and the per-platform
  allowlist maps — generic platform-registry concerns.
* ``Platform.DISCORD`` enum literal — stable identifier used as dict
  keys throughout the codebase; removing it is a separate refactor with
  no real benefit.
* ``tools/discord_tool.py`` and ``tools/environments/local.py`` —
  first-class agent tools and env-passthrough config, neither is the
  gateway adapter.

Each of these is worth its own scoping issue when the time comes.
2026-05-22 14:21:41 -07:00

450 lines
17 KiB
Python

"""Tests for Discord attachment downloads via the authenticated bot session.
Covers the three download paths (image / audio / document) in
``DiscordAdapter._handle_message()`` and the shared ``_cache_discord_*``
helpers. Verifies that:
- ``att.read()`` is preferred over the legacy URL-based downloaders so
that Discord's CDN auth (and user-environment DNS quirks) can't block
media caching. (issues #8242 image 403s, #6587 CDN SSRF false-positives)
- Falls back cleanly to the SSRF-gated ``cache_*_from_url`` helpers
(image/audio) or SSRF-gated aiohttp (documents) when ``att.read()``
isn't available or fails.
- The document fallback path now runs through the SSRF gate for
defense-in-depth. (issue #11345)
"""
import sys
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from gateway.config import PlatformConfig
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()
from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402
from gateway.platforms.base import MessageType # noqa: E402
# Minimal valid image / audio / PDF bytes so the cache_*_from_bytes
# validators accept them. cache_image_from_bytes runs _looks_like_image()
# which checks for magic bytes; PNG's magic is sufficient.
_PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00" * 64
_OGG_BYTES = b"OggS" + b"\x00" * 60
_PDF_BYTES = b"%PDF-1.4\n" + b"fake pdf body" + b"\n%%EOF"
def _make_adapter() -> DiscordAdapter:
return DiscordAdapter(PlatformConfig(enabled=True, token="***"))
def _make_attachment_with_read(payload: bytes) -> SimpleNamespace:
"""Attachment stub that exposes .read() — the happy-path primary."""
return SimpleNamespace(
url="https://cdn.discordapp.com/attachments/fake/file.png",
filename="file.png",
size=len(payload),
read=AsyncMock(return_value=payload),
)
def _make_attachment_without_read() -> SimpleNamespace:
"""Attachment stub that has no .read() — exercises the URL fallback."""
return SimpleNamespace(
url="https://cdn.discordapp.com/attachments/fake/file.png",
filename="file.png",
size=1024,
)
# ---------------------------------------------------------------------------
# _read_attachment_bytes
# ---------------------------------------------------------------------------
class TestReadAttachmentBytes:
"""Unit tests for the low-level att.read() wrapper."""
@pytest.mark.asyncio
async def test_returns_bytes_on_successful_read(self):
adapter = _make_adapter()
att = _make_attachment_with_read(b"hello world")
result = await adapter._read_attachment_bytes(att)
assert result == b"hello world"
att.read.assert_awaited_once()
@pytest.mark.asyncio
async def test_returns_none_when_read_missing(self):
adapter = _make_adapter()
att = _make_attachment_without_read()
result = await adapter._read_attachment_bytes(att)
assert result is None
@pytest.mark.asyncio
async def test_returns_none_when_read_raises(self):
"""Bot-session fetch failures are swallowed so callers fall back."""
adapter = _make_adapter()
att = SimpleNamespace(
url="https://cdn.discordapp.com/attachments/fake/file.png",
filename="file.png",
read=AsyncMock(side_effect=RuntimeError("403 Forbidden")),
)
result = await adapter._read_attachment_bytes(att)
assert result is None
# ---------------------------------------------------------------------------
# _cache_discord_image
# ---------------------------------------------------------------------------
class TestCacheDiscordImage:
@pytest.mark.asyncio
async def test_prefers_att_read_over_url(self):
"""Primary path: att.read() bytes → cache_image_from_bytes, no URL fetch."""
adapter = _make_adapter()
att = _make_attachment_with_read(_PNG_BYTES)
with patch(
"plugins.platforms.discord.adapter.cache_image_from_bytes",
return_value="/tmp/cached.png",
) as mock_bytes, patch(
"plugins.platforms.discord.adapter.cache_image_from_url",
new_callable=AsyncMock,
) as mock_url:
result = await adapter._cache_discord_image(att, ".png")
assert result == "/tmp/cached.png"
mock_bytes.assert_called_once_with(_PNG_BYTES, ext=".png")
mock_url.assert_not_called()
@pytest.mark.asyncio
async def test_falls_back_to_url_when_no_read(self):
"""No .read() → URL path is used (existing SSRF-gated behavior)."""
adapter = _make_adapter()
att = _make_attachment_without_read()
with patch(
"plugins.platforms.discord.adapter.cache_image_from_bytes",
) as mock_bytes, patch(
"plugins.platforms.discord.adapter.cache_image_from_url",
new_callable=AsyncMock,
return_value="/tmp/from_url.png",
) as mock_url:
result = await adapter._cache_discord_image(att, ".png")
assert result == "/tmp/from_url.png"
mock_bytes.assert_not_called()
mock_url.assert_awaited_once_with(att.url, ext=".png")
@pytest.mark.asyncio
async def test_falls_back_to_url_when_bytes_validator_rejects(self):
"""If att.read() returns garbage that cache_image_from_bytes rejects
(e.g. an HTML error page), fall back to the URL downloader instead
of surfacing the validation error to the caller."""
adapter = _make_adapter()
att = _make_attachment_with_read(b"<html>forbidden</html>")
with patch(
"plugins.platforms.discord.adapter.cache_image_from_bytes",
side_effect=ValueError("not a valid image"),
), patch(
"plugins.platforms.discord.adapter.cache_image_from_url",
new_callable=AsyncMock,
return_value="/tmp/fallback.png",
) as mock_url:
result = await adapter._cache_discord_image(att, ".png")
assert result == "/tmp/fallback.png"
mock_url.assert_awaited_once()
# ---------------------------------------------------------------------------
# _cache_discord_audio
# ---------------------------------------------------------------------------
class TestCacheDiscordAudio:
@pytest.mark.asyncio
async def test_prefers_att_read_over_url(self):
adapter = _make_adapter()
att = _make_attachment_with_read(_OGG_BYTES)
with patch(
"plugins.platforms.discord.adapter.cache_audio_from_bytes",
return_value="/tmp/voice.ogg",
) as mock_bytes, patch(
"plugins.platforms.discord.adapter.cache_audio_from_url",
new_callable=AsyncMock,
) as mock_url:
result = await adapter._cache_discord_audio(att, ".ogg")
assert result == "/tmp/voice.ogg"
mock_bytes.assert_called_once_with(_OGG_BYTES, ext=".ogg")
mock_url.assert_not_called()
@pytest.mark.asyncio
async def test_falls_back_to_url_when_no_read(self):
adapter = _make_adapter()
att = _make_attachment_without_read()
with patch(
"plugins.platforms.discord.adapter.cache_audio_from_url",
new_callable=AsyncMock,
return_value="/tmp/from_url.ogg",
) as mock_url:
result = await adapter._cache_discord_audio(att, ".ogg")
assert result == "/tmp/from_url.ogg"
mock_url.assert_awaited_once_with(att.url, ext=".ogg")
# ---------------------------------------------------------------------------
# _cache_discord_document
# ---------------------------------------------------------------------------
class TestCacheDiscordDocument:
@pytest.mark.asyncio
async def test_prefers_att_read_returns_bytes_directly(self):
"""Primary path: att.read() → raw bytes, no aiohttp involvement."""
adapter = _make_adapter()
att = _make_attachment_with_read(_PDF_BYTES)
with patch("aiohttp.ClientSession") as mock_session:
result = await adapter._cache_discord_document(att, ".pdf")
assert result == _PDF_BYTES
mock_session.assert_not_called()
@pytest.mark.asyncio
async def test_fallback_blocked_by_ssrf_guard(self):
"""Document fallback path now honors is_safe_url — was missing before.
Regression guard for #11345: the old aiohttp block skipped the
SSRF check entirely; a non-CDN ``att.url`` could have reached
internal-looking hosts. The fallback must now refuse unsafe URLs.
"""
adapter = _make_adapter()
att = _make_attachment_without_read() # no .read → forces fallback
with patch(
"plugins.platforms.discord.adapter.is_safe_url", return_value=False
) as mock_safe, patch("aiohttp.ClientSession") as mock_session:
with pytest.raises(ValueError, match="SSRF"):
await adapter._cache_discord_document(att, ".pdf")
mock_safe.assert_called_once_with(att.url)
# aiohttp must NOT be contacted when the URL is blocked.
mock_session.assert_not_called()
@pytest.mark.asyncio
async def test_fallback_aiohttp_when_safe_url(self):
"""Safe URL + no att.read() → aiohttp fallback executes."""
adapter = _make_adapter()
att = _make_attachment_without_read()
# Build an aiohttp session mock that returns 200 + payload.
resp = AsyncMock()
resp.status = 200
resp.read = AsyncMock(return_value=_PDF_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)
with patch(
"plugins.platforms.discord.adapter.is_safe_url", return_value=True
), patch("aiohttp.ClientSession", return_value=session):
result = await adapter._cache_discord_document(att, ".pdf")
assert result == _PDF_BYTES
# ---------------------------------------------------------------------------
# Integration: end-to-end via _handle_message
# ---------------------------------------------------------------------------
class TestHandleMessageUsesAuthenticatedRead:
"""E2E: verify _handle_message routes image/audio downloads through
att.read() so cdn.discordapp.com 403s (#8242) and SSRF false-positives
on mangled DNS (#6587) no longer block media caching.
"""
@pytest.mark.asyncio
async def test_image_downloads_via_att_read_not_url(self, monkeypatch):
"""Image attachments with .read() never call cache_image_from_url."""
adapter = _make_adapter()
adapter._client = SimpleNamespace(user=SimpleNamespace(id=999))
adapter.handle_message = AsyncMock()
with patch(
"plugins.platforms.discord.adapter.cache_image_from_bytes",
return_value="/tmp/img_from_read.png",
), patch(
"plugins.platforms.discord.adapter.cache_image_from_url",
new_callable=AsyncMock,
) as mock_url_download:
att = SimpleNamespace(
url="https://cdn.discordapp.com/attachments/fake/file.png",
filename="file.png",
content_type="image/png",
size=len(_PNG_BYTES),
read=AsyncMock(return_value=_PNG_BYTES),
)
# Minimal Discord message stub for _handle_message.
from datetime import datetime, timezone
class _FakeDMChannel:
id = 100
name = "dm"
# Patch the DMChannel isinstance check so our fake counts as DM.
monkeypatch.setattr(
"plugins.platforms.discord.adapter.discord.DMChannel",
_FakeDMChannel,
)
chan = _FakeDMChannel()
msg = SimpleNamespace(
id=1, content="", attachments=[att], mentions=[],
reference=None,
created_at=datetime.now(timezone.utc),
channel=chan,
author=SimpleNamespace(id=42, display_name="U", name="U"),
)
await adapter._handle_message(msg)
mock_url_download.assert_not_called()
event = adapter.handle_message.call_args[0][0]
assert event.media_urls == ["/tmp/img_from_read.png"]
assert event.media_types == ["image/png"]
@pytest.mark.asyncio
async def test_native_voice_note_is_classified_as_voice(self, monkeypatch):
"""Discord native voice notes must enter the auto-STT voice path."""
adapter = _make_adapter()
adapter._client = SimpleNamespace(user=SimpleNamespace(id=999))
adapter.handle_message = AsyncMock()
with patch(
"plugins.platforms.discord.adapter.cache_audio_from_bytes",
return_value="/tmp/voice_from_read.ogg",
):
att = SimpleNamespace(
url="https://cdn.discordapp.com/attachments/fake/voice.ogg",
filename="voice.ogg",
content_type="audio/ogg",
size=len(_OGG_BYTES),
read=AsyncMock(return_value=_OGG_BYTES),
is_voice_message=lambda: True,
)
from datetime import datetime, timezone
class _FakeDMChannel:
id = 100
name = "dm"
monkeypatch.setattr(
"plugins.platforms.discord.adapter.discord.DMChannel",
_FakeDMChannel,
)
chan = _FakeDMChannel()
msg = SimpleNamespace(
id=1, content="", attachments=[att], mentions=[],
reference=None,
created_at=datetime.now(timezone.utc),
channel=chan,
author=SimpleNamespace(id=42, display_name="U", name="U"),
)
await adapter._handle_message(msg)
event = adapter.handle_message.call_args[0][0]
assert event.message_type == MessageType.VOICE
assert event.media_urls == ["/tmp/voice_from_read.ogg"]
assert event.media_types == ["audio/ogg"]
@pytest.mark.asyncio
async def test_plain_audio_attachment_stays_audio(self, monkeypatch):
"""Plain audio uploads should stay out of automatic voice-note STT."""
adapter = _make_adapter()
adapter._client = SimpleNamespace(user=SimpleNamespace(id=999))
adapter.handle_message = AsyncMock()
with patch(
"plugins.platforms.discord.adapter.cache_audio_from_bytes",
return_value="/tmp/audio_from_read.ogg",
):
att = SimpleNamespace(
url="https://cdn.discordapp.com/attachments/fake/audio.ogg",
filename="audio.ogg",
content_type="audio/ogg",
size=len(_OGG_BYTES),
read=AsyncMock(return_value=_OGG_BYTES),
is_voice_message=lambda: False,
)
from datetime import datetime, timezone
class _FakeDMChannel:
id = 100
name = "dm"
monkeypatch.setattr(
"plugins.platforms.discord.adapter.discord.DMChannel",
_FakeDMChannel,
)
chan = _FakeDMChannel()
msg = SimpleNamespace(
id=1, content="", attachments=[att], mentions=[],
reference=None,
created_at=datetime.now(timezone.utc),
channel=chan,
author=SimpleNamespace(id=42, display_name="U", name="U"),
)
await adapter._handle_message(msg)
event = adapter.handle_message.call_args[0][0]
assert event.message_type == MessageType.AUDIO
assert event.media_urls == ["/tmp/audio_from_read.ogg"]
assert event.media_types == ["audio/ogg"]