mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-05 07:41:39 +00:00
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:
parent
4f988634f8
commit
cc8e5ec2af
41 changed files with 728 additions and 504 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue