hermes-agent/tests/gateway/test_discord_send.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

447 lines
16 KiB
Python

import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import sys
import pytest
from gateway.config import PlatformConfig
def _ensure_discord_mock():
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
@pytest.mark.asyncio
async def test_send_retries_without_reference_when_reply_target_is_system_message():
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
reference_obj = object()
ref_msg = SimpleNamespace(id=99, to_reference=MagicMock(return_value=reference_obj))
sent_msg = SimpleNamespace(id=1234)
send_calls = []
async def fake_send(*, content, reference=None):
send_calls.append({"content": content, "reference": reference})
if len(send_calls) == 1:
raise RuntimeError(
"400 Bad Request (error code: 50035): Invalid Form Body\n"
"In message_reference: Cannot reply to a system message"
)
return sent_msg
channel = SimpleNamespace(
fetch_message=AsyncMock(return_value=ref_msg),
send=AsyncMock(side_effect=fake_send),
)
adapter._client = SimpleNamespace(
get_channel=lambda _chat_id: channel,
fetch_channel=AsyncMock(),
)
result = await adapter.send("555", "hello", reply_to="99")
assert result.success is True
assert result.message_id == "1234"
assert channel.fetch_message.await_count == 1
assert channel.send.await_count == 2
ref_msg.to_reference.assert_called_once_with(fail_if_not_exists=False)
assert send_calls[0]["reference"] is reference_obj
assert send_calls[1]["reference"] is None
@pytest.mark.asyncio
async def test_send_retries_without_reference_when_reply_target_is_deleted():
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
reference_obj = object()
ref_msg = SimpleNamespace(id=99, to_reference=MagicMock(return_value=reference_obj))
sent_msgs = [SimpleNamespace(id=1001), SimpleNamespace(id=1002)]
send_calls = []
async def fake_send(*, content, reference=None):
send_calls.append({"content": content, "reference": reference})
if len(send_calls) == 1:
raise RuntimeError(
"400 Bad Request (error code: 10008): Unknown Message"
)
return sent_msgs[len(send_calls) - 2]
channel = SimpleNamespace(
fetch_message=AsyncMock(return_value=ref_msg),
send=AsyncMock(side_effect=fake_send),
)
adapter._client = SimpleNamespace(
get_channel=lambda _chat_id: channel,
fetch_channel=AsyncMock(),
)
long_text = "A" * (adapter.MAX_MESSAGE_LENGTH + 50)
result = await adapter.send("555", long_text, reply_to="99")
assert result.success is True
assert result.message_id == "1001"
assert channel.fetch_message.await_count == 1
assert channel.send.await_count == 3
ref_msg.to_reference.assert_called_once_with(fail_if_not_exists=False)
assert send_calls[0]["reference"] is reference_obj
assert send_calls[1]["reference"] is None
assert send_calls[2]["reference"] is None
@pytest.mark.asyncio
async def test_send_does_not_retry_on_unrelated_errors():
"""Regression guard: errors unrelated to the reply reference (e.g. 50013
Missing Permissions) must NOT trigger the no-reference retry path — they
should propagate out of the per-chunk loop and surface as a failed
SendResult so the caller sees the real problem instead of a silent retry.
"""
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
reference_obj = object()
ref_msg = SimpleNamespace(id=99, to_reference=MagicMock(return_value=reference_obj))
send_calls = []
async def fake_send(*, content, reference=None):
send_calls.append({"content": content, "reference": reference})
raise RuntimeError(
"403 Forbidden (error code: 50013): Missing Permissions"
)
channel = SimpleNamespace(
fetch_message=AsyncMock(return_value=ref_msg),
send=AsyncMock(side_effect=fake_send),
)
adapter._client = SimpleNamespace(
get_channel=lambda _chat_id: channel,
fetch_channel=AsyncMock(),
)
result = await adapter.send("555", "hello", reply_to="99")
# Outer except in adapter.send() wraps propagated errors as SendResult.
assert result.success is False
assert "50013" in (result.error or "")
# Only the first attempt happens — no reference-retry replay.
assert channel.send.await_count == 1
assert send_calls[0]["reference"] is reference_obj
# ---------------------------------------------------------------------------
# Forum channel tests
# ---------------------------------------------------------------------------
import discord as _discord_mod # noqa: E402 — imported after _ensure_discord_mock
class TestIsForumParent:
def test_none_returns_false(self):
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
assert adapter._is_forum_parent(None) is False
def test_forum_channel_class_instance(self):
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
forum_cls = getattr(_discord_mod, "ForumChannel", None)
if forum_cls is None:
# Re-create a type for the mock
forum_cls = type("ForumChannel", (), {})
_discord_mod.ForumChannel = forum_cls
ch = forum_cls()
assert adapter._is_forum_parent(ch) is True
def test_type_value_15(self):
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
ch = SimpleNamespace(type=15)
assert adapter._is_forum_parent(ch) is True
def test_regular_channel_returns_false(self):
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
ch = SimpleNamespace(type=0)
assert adapter._is_forum_parent(ch) is False
def test_thread_returns_false(self):
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
ch = SimpleNamespace(type=11) # public thread
assert adapter._is_forum_parent(ch) is False
@pytest.mark.asyncio
async def test_send_to_forum_creates_thread_post():
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
# thread object has no 'send' so _send_to_forum uses thread.thread
thread_ch = SimpleNamespace(id=555, send=AsyncMock(return_value=SimpleNamespace(id=600)))
thread = SimpleNamespace(
id=555,
message=SimpleNamespace(id=500),
thread=thread_ch,
)
forum_channel = _discord_mod.ForumChannel()
forum_channel.id = 999
forum_channel.name = "ideas"
forum_channel.create_thread = AsyncMock(return_value=thread)
adapter._client = SimpleNamespace(
get_channel=lambda _chat_id: forum_channel,
fetch_channel=AsyncMock(),
)
result = await adapter.send("999", "Hello forum!")
assert result.success is True
assert result.message_id == "500"
forum_channel.create_thread.assert_awaited_once()
@pytest.mark.asyncio
async def test_send_to_forum_sends_remaining_chunks():
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
# Force a small max message length so the message splits
adapter.MAX_MESSAGE_LENGTH = 20
chunk_msg_1 = SimpleNamespace(id=500)
chunk_msg_2 = SimpleNamespace(id=501)
thread_ch = SimpleNamespace(
id=555,
send=AsyncMock(return_value=chunk_msg_2),
)
# thread object has no 'send' so _send_to_forum uses thread.thread
thread = SimpleNamespace(
id=555,
message=chunk_msg_1,
thread=thread_ch,
)
forum_channel = _discord_mod.ForumChannel()
forum_channel.id = 999
forum_channel.name = "ideas"
forum_channel.create_thread = AsyncMock(return_value=thread)
adapter._client = SimpleNamespace(
get_channel=lambda _chat_id: forum_channel,
fetch_channel=AsyncMock(),
)
result = await adapter.send("999", "A" * 50)
assert result.success is True
assert result.message_id == "500"
# Should have sent at least one follow-up chunk
assert thread_ch.send.await_count >= 1
@pytest.mark.asyncio
async def test_send_to_forum_create_thread_failure():
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
forum_channel = _discord_mod.ForumChannel()
forum_channel.id = 999
forum_channel.name = "ideas"
forum_channel.create_thread = AsyncMock(side_effect=Exception("rate limited"))
adapter._client = SimpleNamespace(
get_channel=lambda _chat_id: forum_channel,
fetch_channel=AsyncMock(),
)
result = await adapter.send("999", "Hello forum!")
assert result.success is False
assert "rate limited" in result.error
# ---------------------------------------------------------------------------
# Forum follow-up chunk failure reporting + media on forum paths
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_send_to_forum_follow_up_chunk_failures_collected_as_warnings():
"""Partial-send chunk failures surface in raw_response['warnings']."""
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
adapter.MAX_MESSAGE_LENGTH = 20
chunk_msg_1 = SimpleNamespace(id=500)
# Every follow-up chunk fails — we should collect a warning per failure
thread_ch = SimpleNamespace(
id=555,
send=AsyncMock(side_effect=Exception("rate limited")),
)
thread = SimpleNamespace(id=555, message=chunk_msg_1, thread=thread_ch)
forum_channel = _discord_mod.ForumChannel()
forum_channel.id = 999
forum_channel.name = "ideas"
forum_channel.create_thread = AsyncMock(return_value=thread)
adapter._client = SimpleNamespace(
get_channel=lambda _chat_id: forum_channel,
fetch_channel=AsyncMock(),
)
# Long enough to produce multiple chunks
result = await adapter.send("999", "A" * 60)
# Starter message (first chunk) was delivered via create_thread, so send is
# successful overall — but follow-up chunks all failed and are reported.
assert result.success is True
assert result.message_id == "500"
warnings = (result.raw_response or {}).get("warnings") or []
assert len(warnings) >= 1
assert all("rate limited" in w for w in warnings)
@pytest.mark.asyncio
async def test_forum_post_file_creates_thread_with_attachment():
"""_forum_post_file routes file-bearing sends to create_thread with file kwarg."""
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
thread_ch = SimpleNamespace(id=777, send=AsyncMock())
thread = SimpleNamespace(id=777, message=SimpleNamespace(id=800), thread=thread_ch)
forum_channel = _discord_mod.ForumChannel()
forum_channel.id = 999
forum_channel.name = "ideas"
forum_channel.create_thread = AsyncMock(return_value=thread)
# discord.File is a real class; build a MagicMock that looks like one
fake_file = SimpleNamespace(filename="photo.png")
result = await adapter._forum_post_file(
forum_channel,
content="here is a photo",
file=fake_file,
)
assert result.success is True
assert result.message_id == "800"
forum_channel.create_thread.assert_awaited_once()
call_kwargs = forum_channel.create_thread.await_args.kwargs
assert call_kwargs["file"] is fake_file
assert call_kwargs["content"] == "here is a photo"
# Thread name derived from content's first line
assert call_kwargs["name"] == "here is a photo"
@pytest.mark.asyncio
async def test_forum_post_file_uses_filename_when_no_content():
"""Thread name falls back to file.filename when no content is provided."""
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
thread = SimpleNamespace(id=1, message=SimpleNamespace(id=2), thread=SimpleNamespace(id=1, send=AsyncMock()))
forum_channel = _discord_mod.ForumChannel()
forum_channel.id = 10
forum_channel.name = "forum"
forum_channel.create_thread = AsyncMock(return_value=thread)
fake_file = SimpleNamespace(filename="voice-message.ogg")
result = await adapter._forum_post_file(forum_channel, content="", file=fake_file)
assert result.success is True
call_kwargs = forum_channel.create_thread.await_args.kwargs
# Content was empty → thread name derived from filename
assert call_kwargs["name"] == "voice-message.ogg"
@pytest.mark.asyncio
async def test_forum_post_file_creation_failure():
"""_forum_post_file returns a failed SendResult when create_thread raises."""
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
forum_channel = _discord_mod.ForumChannel()
forum_channel.id = 999
forum_channel.create_thread = AsyncMock(side_effect=Exception("missing perms"))
result = await adapter._forum_post_file(
forum_channel,
content="hi",
file=SimpleNamespace(filename="x.png"),
)
assert result.success is False
assert "missing perms" in (result.error or "")
# ---------------------------------------------------------------------------
# Typing indicator task lifecycle
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_typing_task_removed_after_api_error():
"""When typing API call fails, stale task must be removed so typing can restart."""
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
adapter._client = MagicMock()
adapter._client.http = MagicMock()
adapter._client.http.request = AsyncMock(side_effect=Exception("rate limited"))
adapter._typing_tasks = {}
await adapter.send_typing("12345")
await asyncio.sleep(0.1)
assert "12345" not in adapter._typing_tasks, \
"Stale task should be removed after API error"
@pytest.mark.asyncio
async def test_typing_restartable_after_error():
"""After a typing error, send_typing should start a new task (not blocked by stale entry)."""
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
adapter._client = MagicMock()
adapter._client.http = MagicMock()
adapter._typing_tasks = {}
# First call fails
adapter._client.http.request = AsyncMock(side_effect=Exception("503"))
await adapter.send_typing("12345")
await asyncio.sleep(0.1)
# Second call should work
adapter._client.http.request = AsyncMock()
await adapter.send_typing("12345")
assert "12345" in adapter._typing_tasks, \
"Should restart typing after previous failure"
@pytest.mark.asyncio
async def test_typing_stop_cleans_up():
"""stop_typing should remove the task from _typing_tasks."""
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
adapter._client = MagicMock()
adapter._client.http = MagicMock()
adapter._client.http.request = AsyncMock()
adapter._typing_tasks = {}
await adapter.send_typing("12345")
assert "12345" in adapter._typing_tasks
await adapter.stop_typing("12345")
assert "12345" not in adapter._typing_tasks