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.
This commit is contained in:
kshitijk4poor 2026-05-12 16:59:12 +05:30 committed by Teknium
parent 4f988634f8
commit cc8e5ec2af
41 changed files with 728 additions and 504 deletions

View file

@ -119,7 +119,7 @@ _ensure_slack_mock()
import discord # noqa: E402 — mocked above
from gateway.platforms.telegram import TelegramAdapter # noqa: E402
from gateway.platforms.discord import DiscordAdapter # noqa: E402
from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402
import gateway.platforms.slack as _slack_mod # noqa: E402
_slack_mod.SLACK_AVAILABLE = True

View file

@ -81,7 +81,7 @@ def _ensure_discord_mock():
_ensure_discord_mock()
from gateway.platforms.discord import _build_allowed_mentions # noqa: E402
from plugins.platforms.discord.adapter import _build_allowed_mentions # noqa: E402
# The four DISCORD_ALLOW_MENTION_* env vars that _build_allowed_mentions reads.

View file

@ -58,7 +58,7 @@ def _ensure_discord_mock():
_ensure_discord_mock()
from gateway.platforms.discord import DiscordAdapter # noqa: E402
from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402
from gateway.platforms.base import MessageType # noqa: E402
@ -146,10 +146,10 @@ class TestCacheDiscordImage:
att = _make_attachment_with_read(_PNG_BYTES)
with patch(
"gateway.platforms.discord.cache_image_from_bytes",
"plugins.platforms.discord.adapter.cache_image_from_bytes",
return_value="/tmp/cached.png",
) as mock_bytes, patch(
"gateway.platforms.discord.cache_image_from_url",
"plugins.platforms.discord.adapter.cache_image_from_url",
new_callable=AsyncMock,
) as mock_url:
result = await adapter._cache_discord_image(att, ".png")
@ -165,9 +165,9 @@ class TestCacheDiscordImage:
att = _make_attachment_without_read()
with patch(
"gateway.platforms.discord.cache_image_from_bytes",
"plugins.platforms.discord.adapter.cache_image_from_bytes",
) as mock_bytes, patch(
"gateway.platforms.discord.cache_image_from_url",
"plugins.platforms.discord.adapter.cache_image_from_url",
new_callable=AsyncMock,
return_value="/tmp/from_url.png",
) as mock_url:
@ -186,10 +186,10 @@ class TestCacheDiscordImage:
att = _make_attachment_with_read(b"<html>forbidden</html>")
with patch(
"gateway.platforms.discord.cache_image_from_bytes",
"plugins.platforms.discord.adapter.cache_image_from_bytes",
side_effect=ValueError("not a valid image"),
), patch(
"gateway.platforms.discord.cache_image_from_url",
"plugins.platforms.discord.adapter.cache_image_from_url",
new_callable=AsyncMock,
return_value="/tmp/fallback.png",
) as mock_url:
@ -210,10 +210,10 @@ class TestCacheDiscordAudio:
att = _make_attachment_with_read(_OGG_BYTES)
with patch(
"gateway.platforms.discord.cache_audio_from_bytes",
"plugins.platforms.discord.adapter.cache_audio_from_bytes",
return_value="/tmp/voice.ogg",
) as mock_bytes, patch(
"gateway.platforms.discord.cache_audio_from_url",
"plugins.platforms.discord.adapter.cache_audio_from_url",
new_callable=AsyncMock,
) as mock_url:
result = await adapter._cache_discord_audio(att, ".ogg")
@ -228,7 +228,7 @@ class TestCacheDiscordAudio:
att = _make_attachment_without_read()
with patch(
"gateway.platforms.discord.cache_audio_from_url",
"plugins.platforms.discord.adapter.cache_audio_from_url",
new_callable=AsyncMock,
return_value="/tmp/from_url.ogg",
) as mock_url:
@ -267,7 +267,7 @@ class TestCacheDiscordDocument:
att = _make_attachment_without_read() # no .read → forces fallback
with patch(
"gateway.platforms.discord.is_safe_url", return_value=False
"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")
@ -295,7 +295,7 @@ class TestCacheDiscordDocument:
session.__aexit__ = AsyncMock(return_value=False)
with patch(
"gateway.platforms.discord.is_safe_url", return_value=True
"plugins.platforms.discord.adapter.is_safe_url", return_value=True
), patch("aiohttp.ClientSession", return_value=session):
result = await adapter._cache_discord_document(att, ".pdf")
@ -320,10 +320,10 @@ class TestHandleMessageUsesAuthenticatedRead:
adapter.handle_message = AsyncMock()
with patch(
"gateway.platforms.discord.cache_image_from_bytes",
"plugins.platforms.discord.adapter.cache_image_from_bytes",
return_value="/tmp/img_from_read.png",
), patch(
"gateway.platforms.discord.cache_image_from_url",
"plugins.platforms.discord.adapter.cache_image_from_url",
new_callable=AsyncMock,
) as mock_url_download:
att = SimpleNamespace(
@ -342,7 +342,7 @@ class TestHandleMessageUsesAuthenticatedRead:
# Patch the DMChannel isinstance check so our fake counts as DM.
monkeypatch.setattr(
"gateway.platforms.discord.discord.DMChannel",
"plugins.platforms.discord.adapter.discord.DMChannel",
_FakeDMChannel,
)
chan = _FakeDMChannel()
@ -368,7 +368,7 @@ class TestHandleMessageUsesAuthenticatedRead:
adapter.handle_message = AsyncMock()
with patch(
"gateway.platforms.discord.cache_audio_from_bytes",
"plugins.platforms.discord.adapter.cache_audio_from_bytes",
return_value="/tmp/voice_from_read.ogg",
):
att = SimpleNamespace(
@ -386,7 +386,7 @@ class TestHandleMessageUsesAuthenticatedRead:
name = "dm"
monkeypatch.setattr(
"gateway.platforms.discord.discord.DMChannel",
"plugins.platforms.discord.adapter.discord.DMChannel",
_FakeDMChannel,
)
chan = _FakeDMChannel()
@ -412,7 +412,7 @@ class TestHandleMessageUsesAuthenticatedRead:
adapter.handle_message = AsyncMock()
with patch(
"gateway.platforms.discord.cache_audio_from_bytes",
"plugins.platforms.discord.adapter.cache_audio_from_bytes",
return_value="/tmp/audio_from_read.ogg",
):
att = SimpleNamespace(
@ -430,7 +430,7 @@ class TestHandleMessageUsesAuthenticatedRead:
name = "dm"
monkeypatch.setattr(
"gateway.platforms.discord.discord.DMChannel",
"plugins.platforms.discord.adapter.discord.DMChannel",
_FakeDMChannel,
)
chan = _FakeDMChannel()

View file

@ -45,8 +45,8 @@ def _ensure_discord_mock():
_ensure_discord_mock()
import gateway.platforms.discord as discord_platform # noqa: E402
from gateway.platforms.discord import DiscordAdapter # noqa: E402
import plugins.platforms.discord.adapter as discord_platform # noqa: E402
from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402
class FakeDMChannel:

View file

@ -58,7 +58,7 @@ def _install_fake_agent(monkeypatch):
def _make_adapter():
_ensure_discord_mock()
from gateway.platforms.discord import DiscordAdapter
from plugins.platforms.discord.adapter import DiscordAdapter
adapter = object.__new__(DiscordAdapter)
adapter.config = MagicMock()

View file

@ -5,7 +5,7 @@ import pytest
def _make_adapter():
"""Create a minimal DiscordAdapter with mocked config."""
from gateway.platforms.discord import DiscordAdapter
from plugins.platforms.discord.adapter import DiscordAdapter
adapter = object.__new__(DiscordAdapter)
adapter.config = MagicMock()
adapter.config.extra = {}

View file

@ -26,7 +26,7 @@ if _repo not in sys.path:
# Triggers the shared discord mock from tests/gateway/conftest.py before
# importing the production module.
from gateway.platforms.discord import ( # noqa: E402
from plugins.platforms.discord.adapter import ( # noqa: E402
ClarifyChoiceView,
DiscordAdapter,
)

View file

@ -18,7 +18,7 @@ import pytest
# Trigger the shared discord mock from tests/gateway/conftest.py before
# importing the production module.
from gateway.platforms.discord import ( # noqa: E402
from plugins.platforms.discord.adapter import ( # noqa: E402
ExecApprovalView,
ModelPickerView,
SlashConfirmView,

View file

@ -67,8 +67,8 @@ def _ensure_discord_mock():
_ensure_discord_mock()
import gateway.platforms.discord as discord_platform # noqa: E402
from gateway.platforms.discord import DiscordAdapter # noqa: E402
import plugins.platforms.discord.adapter as discord_platform # noqa: E402
from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402
@pytest.fixture(autouse=True)

View file

@ -57,8 +57,8 @@ def _ensure_discord_mock():
_ensure_discord_mock()
import gateway.platforms.discord as discord_platform # noqa: E402
from gateway.platforms.discord import DiscordAdapter # noqa: E402
import plugins.platforms.discord.adapter as discord_platform # noqa: E402
from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402
# ---------------------------------------------------------------------------
@ -371,7 +371,7 @@ class TestIncomingDocumentHandling:
async def test_image_attachment_unaffected(self, adapter):
"""Image attachments should still go through the image path, not the document path."""
with patch(
"gateway.platforms.discord.cache_image_from_url",
"plugins.platforms.discord.adapter.cache_image_from_url",
new_callable=AsyncMock,
return_value="/tmp/cached_image.png",
):

View file

@ -45,8 +45,8 @@ def _ensure_discord_mock():
_ensure_discord_mock()
import gateway.platforms.discord as discord_platform # noqa: E402
from gateway.platforms.discord import DiscordAdapter # noqa: E402
import plugins.platforms.discord.adapter as discord_platform # noqa: E402
from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402
class FakeDMChannel:

View file

@ -14,10 +14,13 @@ class TestDiscordImportSafety:
raise ImportError("discord unavailable for test")
return original_import(name, globals, locals, fromlist, level)
monkeypatch.delitem(sys.modules, "gateway.platforms.discord", raising=False)
# Purge the cached module so the import below actually re-runs the
# module body with discord.py simulated-missing.
monkeypatch.delitem(sys.modules, "plugins.platforms.discord.adapter", raising=False)
monkeypatch.delitem(sys.modules, "plugins.platforms.discord", raising=False)
monkeypatch.setattr(builtins, "__import__", fake_import)
module = importlib.import_module("gateway.platforms.discord")
module = importlib.import_module("plugins.platforms.discord.adapter")
assert module.DISCORD_AVAILABLE is False
assert module.discord is None

View file

@ -34,7 +34,7 @@ class TestDefineDiscordViewClasses:
def test_registers_all_five_view_classes(self, monkeypatch):
"""Calling _define_discord_view_classes() must (re)define all 5 view classes."""
dp = importlib.import_module("gateway.platforms.discord")
dp = importlib.import_module("plugins.platforms.discord.adapter")
# Remove the classes to simulate the state where the module was loaded
# with DISCORD_AVAILABLE=False (the lazy-install scenario).
@ -54,7 +54,7 @@ class TestDefineDiscordViewClasses:
def test_check_discord_requirements_calls_define_on_lazy_install(self, monkeypatch):
"""check_discord_requirements() must call _define_discord_view_classes() on
a successful lazy install so view classes exist when DISCORD_AVAILABLE=True."""
dp = importlib.import_module("gateway.platforms.discord")
dp = importlib.import_module("plugins.platforms.discord.adapter")
# Simulate discord not yet available at module load.
monkeypatch.setattr(dp, "DISCORD_AVAILABLE", False)

View file

@ -1,6 +1,6 @@
import inspect
from gateway.platforms.discord import DiscordAdapter
from plugins.platforms.discord.adapter import DiscordAdapter
def test_discord_media_methods_accept_metadata_kwarg():

View file

@ -11,7 +11,7 @@ from unittest.mock import AsyncMock
import pytest
from gateway.platforms.discord import ModelPickerView
from plugins.platforms.discord.adapter import ModelPickerView
@pytest.mark.asyncio

View file

@ -8,14 +8,14 @@ class TestOpusFindLibrary:
def test_uses_find_library_first(self):
"""find_library must be the primary lookup strategy."""
from gateway.platforms.discord import DiscordAdapter
from plugins.platforms.discord.adapter import DiscordAdapter
source = inspect.getsource(DiscordAdapter.connect)
assert "find_library" in source, \
"Opus loading must use ctypes.util.find_library"
def test_homebrew_fallback_is_conditional(self):
"""Homebrew paths must only be tried when find_library returns None."""
from gateway.platforms.discord import DiscordAdapter
from plugins.platforms.discord.adapter import DiscordAdapter
source = inspect.getsource(DiscordAdapter.connect)
# Homebrew fallback must exist
assert "/opt/homebrew" in source or "homebrew" in source, \
@ -31,7 +31,7 @@ class TestOpusFindLibrary:
def test_opus_decode_error_logged(self):
"""Opus decode failure must log the error, not silently return."""
from gateway.platforms.discord import VoiceReceiver
from plugins.platforms.discord.adapter import VoiceReceiver
source = inspect.getsource(VoiceReceiver._on_packet)
assert "logger" in source, \
"_on_packet must log Opus decode errors"

View file

@ -10,7 +10,7 @@ from gateway.config import Platform, PlatformConfig
def _make_adapter():
from gateway.platforms.discord import DiscordAdapter
from plugins.platforms.discord.adapter import DiscordAdapter
adapter = object.__new__(DiscordAdapter)
adapter._platform = Platform.DISCORD
@ -60,7 +60,7 @@ async def test_concurrent_joins_do_not_double_connect():
channel.guild.id = 42
channel.connect = lambda: slow_connect(channel)
from gateway.platforms import discord as discord_mod
from plugins.platforms.discord import adapter as discord_mod
with patch.object(discord_mod, "VoiceReceiver",
MagicMock(return_value=MagicMock(start=lambda: None))):
with patch.object(discord_mod.asyncio, "ensure_future",

View file

@ -40,7 +40,7 @@ def _ensure_discord_mock():
_ensure_discord_mock()
from gateway.platforms.discord import DiscordAdapter # noqa: E402
from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402
class FakeTree:

View file

@ -53,7 +53,7 @@ def _ensure_discord_mock():
_ensure_discord_mock()
from gateway.platforms.discord import DiscordAdapter # noqa: E402
from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402
@pytest.fixture()

View file

@ -20,7 +20,7 @@ from unittest.mock import MagicMock
import pytest
from gateway.platforms.discord import DiscordAdapter
from plugins.platforms.discord.adapter import DiscordAdapter
def _set_dm_role_auth_guild(monkeypatch, guild_id=None):

View file

@ -42,7 +42,7 @@ def _ensure_discord_mock():
_ensure_discord_mock()
from gateway.platforms.discord import DiscordAdapter # noqa: E402
from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402
@pytest.mark.asyncio

View file

@ -85,7 +85,7 @@ def _ensure_discord_mock():
_ensure_discord_mock()
from gateway.platforms.discord import DiscordAdapter # noqa: E402
from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402
@pytest.fixture(autouse=True)

View file

@ -75,7 +75,7 @@ def _ensure_discord_mock():
_ensure_discord_mock()
from gateway.platforms.discord import DiscordAdapter # noqa: E402
from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402
class FakeTree:

View file

@ -17,7 +17,7 @@ class TestDiscordThreadPersistence:
def _make_adapter(self, tmp_path):
"""Build a minimal DiscordAdapter with HERMES_HOME pointed at tmp_path."""
from gateway.config import PlatformConfig
from gateway.platforms.discord import DiscordAdapter
from plugins.platforms.discord.adapter import DiscordAdapter
config = PlatformConfig(enabled=True, token="test-token")
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):

View file

@ -27,7 +27,7 @@ from unittest.mock import MagicMock
def _make_adapter():
"""Construct a DiscordAdapter without going through __init__ / token checks."""
from gateway.platforms.discord import DiscordAdapter
from plugins.platforms.discord.adapter import DiscordAdapter
from gateway.platforms.base import Platform
adapter = object.__new__(DiscordAdapter)
adapter.config = MagicMock()

View file

@ -190,7 +190,7 @@ def _ensure_discord_mock():
_ensure_discord_mock()
import discord as discord_mod_ref # noqa: E402
from gateway.platforms.discord import DiscordAdapter # noqa: E402
from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402
class TestDiscordSendImageFile:

View file

@ -210,7 +210,7 @@ def _ensure_discord_mock():
_ensure_discord_mock()
from gateway.platforms.discord import DiscordAdapter # noqa: E402
from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402
class TestDiscordMultiImage:

View file

@ -149,7 +149,7 @@ class TestEditMessageFinalizeSignature:
"module_path,class_name",
[
("gateway.platforms.telegram", "TelegramAdapter"),
("gateway.platforms.discord", "DiscordAdapter"),
("plugins.platforms.discord.adapter", "DiscordAdapter"),
("gateway.platforms.slack", "SlackAdapter"),
("gateway.platforms.matrix", "MatrixAdapter"),
("gateway.platforms.mattermost", "MattermostAdapter"),

View file

@ -41,7 +41,7 @@ def _make_event(
def _make_discord_adapter():
"""Create a minimal DiscordAdapter for testing text batching."""
from gateway.platforms.discord import DiscordAdapter
from plugins.platforms.discord.adapter import DiscordAdapter
config = PlatformConfig(enabled=True, token="test-token")
adapter = object.__new__(DiscordAdapter)

View file

@ -511,7 +511,7 @@ class TestDiscordPlayTtsSkip:
"""Discord adapter skips play_tts when bot is in a voice channel."""
def _make_discord_adapter(self):
from gateway.platforms.discord import DiscordAdapter
from plugins.platforms.discord.adapter import DiscordAdapter
from gateway.config import Platform, PlatformConfig
config = PlatformConfig(enabled=True, extra={})
config.token = "fake-token"
@ -599,7 +599,7 @@ class TestVoiceReceiver:
"""Test VoiceReceiver silence detection, SSRC mapping, and lifecycle."""
def _make_receiver(self):
from gateway.platforms.discord import VoiceReceiver
from plugins.platforms.discord.adapter import VoiceReceiver
mock_vc = MagicMock()
mock_vc._connection.secret_key = [0] * 32
mock_vc._connection.dave_session = None
@ -1066,7 +1066,7 @@ class TestDiscordVoiceChannelMethods:
"""Test DiscordAdapter voice channel methods (join, leave, play, etc.)."""
def _make_adapter(self):
from gateway.platforms.discord import DiscordAdapter
from plugins.platforms.discord.adapter import DiscordAdapter
from gateway.config import Platform, PlatformConfig
config = PlatformConfig(enabled=True, extra={})
config.token = "fake-token"
@ -1208,7 +1208,7 @@ class TestDiscordVoiceChannelMethods:
pcm_data = b"\x00" * 96000
with patch("gateway.platforms.discord.VoiceReceiver.pcm_to_wav"), \
with patch("plugins.platforms.discord.adapter.VoiceReceiver.pcm_to_wav"), \
patch("tools.transcription_tools.transcribe_audio",
return_value={"success": True, "transcript": "Hello"}), \
patch("tools.voice_mode.is_whisper_hallucination", return_value=False):
@ -1223,7 +1223,7 @@ class TestDiscordVoiceChannelMethods:
callback = AsyncMock()
adapter._voice_input_callback = callback
with patch("gateway.platforms.discord.VoiceReceiver.pcm_to_wav"), \
with patch("plugins.platforms.discord.adapter.VoiceReceiver.pcm_to_wav"), \
patch("tools.transcription_tools.transcribe_audio",
return_value={"success": True, "transcript": "Thank you."}), \
patch("tools.voice_mode.is_whisper_hallucination", return_value=True):
@ -1238,7 +1238,7 @@ class TestDiscordVoiceChannelMethods:
callback = AsyncMock()
adapter._voice_input_callback = callback
with patch("gateway.platforms.discord.VoiceReceiver.pcm_to_wav"), \
with patch("plugins.platforms.discord.adapter.VoiceReceiver.pcm_to_wav"), \
patch("tools.transcription_tools.transcribe_audio",
return_value={"success": False, "error": "API error"}):
await adapter._process_voice_input(111, 42, b"\x00" * 96000)
@ -1251,7 +1251,7 @@ class TestDiscordVoiceChannelMethods:
adapter = self._make_adapter()
adapter._voice_input_callback = AsyncMock()
with patch("gateway.platforms.discord.VoiceReceiver.pcm_to_wav",
with patch("plugins.platforms.discord.adapter.VoiceReceiver.pcm_to_wav",
side_effect=RuntimeError("ffmpeg not found")):
await adapter._process_voice_input(111, 42, b"\x00" * 96000)
# Should not raise
@ -1269,7 +1269,7 @@ class TestVoiceReceiverThreadSafety:
"""Verify that VoiceReceiver buffer access is protected by lock."""
def _make_receiver(self):
from gateway.platforms.discord import VoiceReceiver
from plugins.platforms.discord.adapter import VoiceReceiver
mock_vc = MagicMock()
mock_vc._connection.secret_key = [0] * 32
mock_vc._connection.dave_session = None
@ -1282,7 +1282,7 @@ class TestVoiceReceiverThreadSafety:
def test_check_silence_holds_lock(self):
"""check_silence must hold lock while iterating buffers."""
import ast, inspect, textwrap
from gateway.platforms.discord import VoiceReceiver
from plugins.platforms.discord.adapter import VoiceReceiver
source = textwrap.dedent(inspect.getsource(VoiceReceiver.check_silence))
tree = ast.parse(source)
# Find 'with self._lock:' that contains buffer iteration
@ -1303,7 +1303,7 @@ class TestVoiceReceiverThreadSafety:
def test_on_packet_buffer_write_holds_lock(self):
"""_on_packet must hold lock when writing to buffers."""
import ast, inspect, textwrap
from gateway.platforms.discord import VoiceReceiver
from plugins.platforms.discord.adapter import VoiceReceiver
source = textwrap.dedent(inspect.getsource(VoiceReceiver._on_packet))
tree = ast.parse(source)
# Find 'with self._lock:' that contains buffer extend
@ -1670,7 +1670,7 @@ class TestStopAcquiresLock:
@staticmethod
def _make_receiver():
from gateway.platforms.discord import VoiceReceiver
from plugins.platforms.discord.adapter import VoiceReceiver
vc = MagicMock()
vc._connection.secret_key = [0] * 32
vc._connection.dave_session = None
@ -1772,7 +1772,7 @@ class TestPacketDebugCounterIsInstanceLevel:
@staticmethod
def _make_receiver():
from gateway.platforms.discord import VoiceReceiver
from plugins.platforms.discord.adapter import VoiceReceiver
vc = MagicMock()
vc._connection.secret_key = [0] * 32
vc._connection.dave_session = None
@ -1805,7 +1805,7 @@ class TestPlayInVoiceChannelUsesRunningLoop:
def test_source_uses_get_running_loop(self):
"""The method source code calls get_running_loop, not get_event_loop."""
import inspect
from gateway.platforms.discord import DiscordAdapter
from plugins.platforms.discord.adapter import DiscordAdapter
source = inspect.getsource(DiscordAdapter.play_in_voice_channel)
assert "get_running_loop" in source, \
"play_in_voice_channel should use asyncio.get_running_loop()"
@ -1849,7 +1849,7 @@ class TestVoiceTimeoutCleansRunnerState:
@staticmethod
def _make_discord_adapter():
from gateway.platforms.discord import DiscordAdapter
from plugins.platforms.discord.adapter import DiscordAdapter
from gateway.config import PlatformConfig, Platform
config = PlatformConfig(enabled=True, extra={})
config.token = "fake-token"
@ -1940,7 +1940,7 @@ class TestPlaybackTimeout:
@staticmethod
def _make_discord_adapter():
from gateway.platforms.discord import DiscordAdapter
from plugins.platforms.discord.adapter import DiscordAdapter
from gateway.config import PlatformConfig, Platform
config = PlatformConfig(enabled=True, extra={})
config.token = "fake-token"
@ -1964,7 +1964,7 @@ class TestPlaybackTimeout:
def test_source_has_wait_for_timeout(self):
"""The method uses asyncio.wait_for with timeout."""
import inspect
from gateway.platforms.discord import DiscordAdapter
from plugins.platforms.discord.adapter import DiscordAdapter
source = inspect.getsource(DiscordAdapter.play_in_voice_channel)
assert "wait_for" in source, \
"play_in_voice_channel must use asyncio.wait_for for timeout"
@ -1973,14 +1973,14 @@ class TestPlaybackTimeout:
def test_playback_timeout_constant_exists(self):
"""PLAYBACK_TIMEOUT constant is defined on DiscordAdapter."""
from gateway.platforms.discord import DiscordAdapter
from plugins.platforms.discord.adapter import DiscordAdapter
assert hasattr(DiscordAdapter, "PLAYBACK_TIMEOUT")
assert DiscordAdapter.PLAYBACK_TIMEOUT > 0
@pytest.mark.asyncio
async def test_playback_timeout_fires(self):
"""When done event is never set, playback times out gracefully."""
from gateway.platforms.discord import DiscordAdapter
from plugins.platforms.discord.adapter import DiscordAdapter
adapter = self._make_discord_adapter()
mock_vc = MagicMock()
@ -2008,7 +2008,7 @@ class TestPlaybackTimeout:
@pytest.mark.asyncio
async def test_is_playing_wait_has_timeout(self):
"""While loop waiting for previous playback has a timeout."""
from gateway.platforms.discord import DiscordAdapter
from plugins.platforms.discord.adapter import DiscordAdapter
adapter = self._make_discord_adapter()
mock_vc = MagicMock()
@ -2124,7 +2124,7 @@ class TestVoiceChannelAwareness:
"""Tests for get_voice_channel_info() and get_voice_channel_context()."""
def _make_adapter(self):
from gateway.platforms.discord import DiscordAdapter
from plugins.platforms.discord.adapter import DiscordAdapter
from gateway.config import PlatformConfig
config = PlatformConfig(enabled=True, extra={})
config.token = "fake-token"
@ -2267,7 +2267,7 @@ class TestVoiceReception:
@staticmethod
def _make_receiver(allowed_ids=None, members=None, dave=False, bot_id=9999):
from gateway.platforms.discord import VoiceReceiver
from plugins.platforms.discord.adapter import VoiceReceiver
vc = MagicMock()
vc._connection.secret_key = [0] * 32
vc._connection.dave_session = MagicMock() if dave else None
@ -2451,7 +2451,7 @@ class TestVoiceReception:
def _make_receiver_with_nacl(self, dave_session=None, mapped_ssrcs=None):
"""Create a receiver that can process _on_packet with mocked NaCl + Opus."""
from gateway.platforms.discord import VoiceReceiver
from plugins.platforms.discord.adapter import VoiceReceiver
vc = MagicMock()
vc._connection.secret_key = [0] * 32
vc._connection.dave_session = dave_session
@ -2593,7 +2593,7 @@ class TestVoiceTTSPlayback:
@staticmethod
def _make_discord_adapter():
from gateway.platforms.discord import DiscordAdapter
from plugins.platforms.discord.adapter import DiscordAdapter
from gateway.config import PlatformConfig, Platform
config = PlatformConfig(enabled=True, extra={})
config.token = "fake-token"
@ -2766,14 +2766,14 @@ class TestUDPKeepalive:
"""UDP keepalive prevents Discord from dropping the voice session."""
def test_keepalive_interval_is_reasonable(self):
from gateway.platforms.discord import DiscordAdapter
from plugins.platforms.discord.adapter import DiscordAdapter
interval = DiscordAdapter._KEEPALIVE_INTERVAL
assert 5 <= interval <= 30, f"Keepalive interval {interval}s should be between 5-30s"
@pytest.mark.asyncio
async def test_keepalive_sends_silence_frame(self):
"""Listen loop sends silence frame via send_packet after interval."""
from gateway.platforms.discord import DiscordAdapter
from plugins.platforms.discord.adapter import DiscordAdapter
from gateway.config import PlatformConfig, Platform
config = PlatformConfig(enabled=True, extra={})
@ -2795,7 +2795,7 @@ class TestUDPKeepalive:
adapter._voice_clients[111] = mock_vc
mock_vc._connection = mock_conn
from gateway.platforms.discord import VoiceReceiver
from plugins.platforms.discord.adapter import VoiceReceiver
mock_receiver_vc = MagicMock()
mock_receiver_vc._connection.secret_key = [0] * 32
mock_receiver_vc._connection.dave_session = None

View file

@ -38,7 +38,7 @@ except Exception:
from types import SimpleNamespace
from unittest.mock import MagicMock
from gateway.platforms.discord import VoiceReceiver
from plugins.platforms.discord.adapter import VoiceReceiver
# ---------------------------------------------------------------------------

View file

@ -28,16 +28,93 @@ def _reset_signal_scheduler():
from gateway.config import Platform
from tools.send_message_tool import (
_derive_forum_thread_name,
_is_telegram_thread_not_found,
_parse_target_ref,
_send_discord,
_send_matrix_via_adapter,
_send_signal,
_send_telegram,
_send_to_platform,
send_message_tool,
)
# Discord helpers moved to the plugin in #24325. Import from the new path
# and provide a thin ``_send_discord(token, ...)`` shim that mirrors the
# pre-migration signature so the existing test bodies keep working.
from plugins.platforms.discord.adapter import (
_DISCORD_CHANNEL_TYPE_PROBE_CACHE,
_derive_forum_thread_name,
_probe_is_forum_cached,
_remember_channel_is_forum,
_standalone_send,
)
async def _send_discord(
token,
chat_id,
message,
*,
thread_id=None,
media_files=None,
):
"""Pre-migration ``(token, chat_id, message, …)`` adapter around the
plugin's ``_standalone_send(pconfig, …)``. Lets test bodies continue
to call ``_send_discord("tok", ...)`` without rewriting every signature.
"""
pconfig = SimpleNamespace(token=token, extra={})
return await _standalone_send(
pconfig,
chat_id,
message,
thread_id=thread_id,
media_files=media_files,
)
def _discord_entry():
"""Return the live Discord PlatformEntry, importing lazily so plugin
discovery is forced exactly once and patches survive across tests."""
from hermes_cli.plugins import discover_plugins
from gateway.platform_registry import platform_registry
discover_plugins()
return platform_registry.get("discord")
class _patch_discord_sender:
"""Patch the Discord registry entry's ``standalone_sender_fn`` with the
given mock and translate the production ``(pconfig, ...)`` call shape
back to the pre-migration ``(token, ...)`` shape the test mocks expect.
Use as a context manager:
send_mock = AsyncMock(return_value={...})
with _patch_discord_sender(send_mock):
asyncio.run(_send_to_platform(Platform.DISCORD, ...))
send_mock.assert_awaited_once_with("tok", "chat", "msg",
thread_id=None, media_files=[])
"""
def __init__(self, mock):
self._mock = mock
self._entry = None
self._original = None
async def _adapter(self, pconfig, chat_id, message, *, thread_id=None, media_files=None):
token = getattr(pconfig, "token", None)
return await self._mock(
token, chat_id, message,
thread_id=thread_id, media_files=media_files,
)
def __enter__(self):
self._entry = _discord_entry()
self._original = self._entry.standalone_sender_fn
self._entry.standalone_sender_fn = self._adapter
return self._mock
def __exit__(self, exc_type, exc, tb):
if self._entry is not None:
self._entry.standalone_sender_fn = self._original
return False
def _run_async_immediately(coro):
@ -446,7 +523,7 @@ class TestSendToPlatformChunking:
"""Messages exceeding the platform limit are split into multiple sends."""
send = AsyncMock(return_value={"success": True, "message_id": "1"})
long_msg = "word " * 1000 # ~5000 chars, well over Discord's 2000 limit
with patch("tools.send_message_tool._send_discord", send):
with _patch_discord_sender(send):
result = asyncio.run(
_send_to_platform(
Platform.DISCORD,
@ -1176,7 +1253,7 @@ class TestSendToPlatformDiscordThread:
"""Discord platform with thread_id passes it to _send_discord."""
send_mock = AsyncMock(return_value={"success": True, "message_id": "1"})
with patch("tools.send_message_tool._send_discord", send_mock):
with _patch_discord_sender(send_mock):
result = asyncio.run(
_send_to_platform(
Platform.DISCORD,
@ -1196,7 +1273,7 @@ class TestSendToPlatformDiscordThread:
"""Discord platform without thread_id passes None."""
send_mock = AsyncMock(return_value={"success": True, "message_id": "1"})
with patch("tools.send_message_tool._send_discord", send_mock):
with _patch_discord_sender(send_mock):
result = asyncio.run(
_send_to_platform(
Platform.DISCORD,
@ -1360,7 +1437,7 @@ class TestSendToPlatformDiscordMedia:
# A message long enough to get chunked (Discord limit is 2000)
long_msg = "A" * 1900 + " " + "B" * 1900
with patch("tools.send_message_tool._send_discord", side_effect=mock_send_discord):
with _patch_discord_sender(AsyncMock(side_effect=mock_send_discord)):
result = asyncio.run(
_send_to_platform(
Platform.DISCORD,
@ -1380,7 +1457,7 @@ class TestSendToPlatformDiscordMedia:
"""Short message (single chunk) gets media_files directly."""
send_mock = AsyncMock(return_value={"success": True, "message_id": "1"})
with patch("tools.send_message_tool._send_discord", send_mock):
with _patch_discord_sender(send_mock):
result = asyncio.run(
_send_to_platform(
Platform.DISCORD,
@ -1618,7 +1695,7 @@ class TestSendToPlatformDiscordForum:
"""Discord messages are routed through _send_discord, which handles forum detection."""
send_mock = AsyncMock(return_value={"success": True, "message_id": "1"})
with patch("tools.send_message_tool._send_discord", send_mock):
with _patch_discord_sender(send_mock):
result = asyncio.run(
_send_to_platform(
Platform.DISCORD,
@ -1637,7 +1714,7 @@ class TestSendToPlatformDiscordForum:
"""Thread ID is still passed through when sending to Discord."""
send_mock = AsyncMock(return_value={"success": True, "message_id": "1"})
with patch("tools.send_message_tool._send_discord", send_mock):
with _patch_discord_sender(send_mock):
result = asyncio.run(
_send_to_platform(
Platform.DISCORD,
@ -1775,11 +1852,11 @@ class TestForumProbeCache:
"""_DISCORD_CHANNEL_TYPE_PROBE_CACHE memoizes forum detection results."""
def setup_method(self):
from tools import send_message_tool as smt
smt._DISCORD_CHANNEL_TYPE_PROBE_CACHE.clear()
from plugins.platforms.discord import adapter as discord_adapter
discord_adapter._DISCORD_CHANNEL_TYPE_PROBE_CACHE.clear()
def test_cache_round_trip(self):
from tools.send_message_tool import (
from plugins.platforms.discord.adapter import (
_probe_is_forum_cached,
_remember_channel_is_forum,
)
@ -1819,7 +1896,7 @@ class TestForumProbeCache:
thread_session.post = MagicMock(return_value=thread_resp)
# Two _send_discord calls: first does probe + thread-create; second should skip probe
from tools import send_message_tool as smt
from plugins.platforms.discord import adapter as discord_adapter
sessions_created = []
@ -1837,7 +1914,7 @@ class TestForumProbeCache:
with patch("aiohttp.ClientSession", side_effect=session_factory):
result1 = asyncio.run(_send_discord("tok", "ch1", "first"))
assert result1["success"] is True
assert smt._probe_is_forum_cached("ch1") is True
assert discord_adapter._probe_is_forum_cached("ch1") is True
# Second call: cache hits, no new probe session needed. We need to only
# return thread_session now since probe is skipped.