NousResearch/hermes-agent

Migrate Discord gateway adapter to bundled plugin

Cluster 24325. Data as of

Nothing open on GitHub. Fix merged in #30591 on 22 May 2026. Check it covers your case.

The short answer

0open issues
0open PRs
3items grouped
Known?
Yes. 3 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #30591. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PR #30591. That is separate from the fix event.
Still open
Nothing open on GitHub.

These items all concern refactoring the Discord adapter within the gateway by migrating it from a built-in implementation to a bundled plugin format. Multiple attempts or salvage efforts of the same migration are represented.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #30591 refactor(gateway): migrate Discord adapter to bundled plugin (salvage of #24356)

    merged to main

    files: gateway/config.py, gateway/run.py, hermes_cli/commands.py, hermes_cli/gateway.py, hermes_cli/setup.py, plugins/platforms/discord/__init__.py, plugins/platforms/discord/adapter.py, plugins/platforms/discord/plugin.yaml, tests/e2e/conftest.py, tests/gateway/test_discord_allowed_mentions.py, tests/gateway/test_discord_attachment_download.py, tests/gateway/test_discord_channel_controls.py, tests/gateway/test_discord_channel_prompts.py, tests/gateway/test_discord_channel_skills.py, tests/gateway/test_discord_clarify_buttons.py, tests/gateway/test_discord_component_auth.py, tests/gateway/test_discord_connect.py, tests/gateway/test_discord_document_handling.py, tests/gateway/test_discord_free_response.py, tests/gateway/test_discord_imports.py (+21 more)

    Show the diff
    • gateway/config.py
      @@ -926,73 +926,6 @@ def load_gateway_config() -> GatewayConfig:
                               ac = ",".join(str(v) for v in ac)
                           os.environ["SLACK_ALLOWED_CHANNELS"] = str(ac)
       
      -            # Discord settings → env vars (env vars take precedence)
      -            discord_cfg = yaml_cfg.get("discord", {})
      -            if isinstance(discord_cfg, dict):
      -                if "require_mention" in discord_cfg and not os.getenv("DISCORD_REQUIRE_MENTION"):
      -                    os.environ["DISCORD_REQUIRE_MENTION"] = str(discord_cfg["require_mention"]).lower()
      -                if "thread_require_mention" in discord_cfg and not os.getenv("DISCORD_THREAD_REQUIRE_MENTION"):
      -                    os.environ["DISCORD_THREAD_REQUIRE_MENTION"] = str(discord_cfg["thread_require_mention"]).lower()
      -                frc = discord_cfg.get("free_response_channels")
      -                if frc is not None and not os.getenv("DISCORD_FREE_RESPONSE_CHANNELS"):
      -                    if isinstance(frc, list):
      -                        frc = ",".join(str(v) for v in frc)
      -                    os.environ["DISCORD_FREE_RESPONSE_CHANNELS"] = str(frc)
      -                if "auto_thread" in discord_cfg and not os.getenv("DISCORD_AUTO_THREAD"):
      -                    os.environ["DISCORD_AUTO_THREAD"] = str(discord_cfg["auto_thread"]).lower()
      -                if "reactions" in discord_cfg and not os.getenv("DISCORD_REACTIONS"):
      -                    os.environ["DISCORD_REACTIONS"] = str(discord_cfg["reactions"]).lower()
      -                # ignored_channels: channels where bot never responds (even when mentioned)
      -                ic = discord_cfg.get("ignored_channels")
      -                if ic is not None and not os.getenv("DISCORD_IGNORED_CHANNELS"):
      -                    if isinstance(ic, list):
      -                        ic = ",".join(str(v) for v in ic)
      -                    os.environ["DISCORD_IGNORED_CHANNELS"] = str(ic)
      -                # allowed_channels: if set, bot ONLY responds in these channels (whitelist)
      -                ac = discord_cfg.get("allowed_channels")
      -                if ac is not None and not os.getenv("DISCORD_ALLOWED_CHANNELS"):
      -                    if isinstance(ac, list):
      -                        ac = ",".join(str(v) for v in ac)
      -                    os.environ["DISCORD_ALLOWED_CHANNELS"] = str(ac)
      -                # no_thread_channels: channels where bot responds directly without creating thread
      -                ntc = discord_cfg.get("no_thread_channels")
      -                if ntc is not None and not os.getenv("DISCORD_NO_THREAD_CHANNELS"):
      -                    if isinstance(ntc, list):
      -                        ntc = ",".join(str(v) for v in ntc)
      -                    os.environ["DISCORD_NO_THREAD_CHANNELS"] = str(ntc)
      -                # history_backfill: recover missed channel messages for shared sessions
      -                # when require_mention is active.  Fetches messages between bot turns
      -                # and prepends them to the user message for context.
      -                if "history_backfill" in discord_cfg and not os.getenv("DISCORD_HISTORY_BACKFILL"):
      -                    os.environ["DISCORD_HISTORY_BACKFILL"] = str(discord_cfg["history_backfill"]).lower()
      -                hbl = discord_cfg.get("history_backfill_limit")
      -                if hbl is not None and not os.getenv("DISCORD_HISTORY_BACKFILL_LIMIT"):
      -                    os.environ["DISCORD_HISTORY_BACKFILL_LIMIT"] = str(hbl)
      -                # allow_mentions: granular control over what the bot can ping.
      -                # Safe defaults (no @everyone/roles) are applied in the adapter;
      -                # these YAML keys only override when set and let users opt back
      -                # into unsafe modes (e.g. roles=true) if they actually want it.
      -                allow_mentions_cfg = discord_cfg.get("allow_mentions")
      -                if isinstance(allow_mentions_cfg, dict):
      -                    for yaml_key, env_key in (
      -                        ("everyone", "DISCORD_ALLOW_MENTION_EVERYONE"),
      -                        ("roles", "DISCORD_ALLOW_MENTION_ROLES"),
      -                        ("users", "DISCORD_ALLOW_MENTION_USERS"),
      -                        ("replied_user", "DISCORD_ALLOW_MENTION_REPLIED_USER"),
      -                    ):
      -                        if yaml_key in allow_mentions_cfg and not os.getenv(env_key):
      -                            os.environ[env_key] = str(allow_mentions_cfg[yaml_key]).lower()
      -                # reply_to_mode: top-level preferred, falls back to extra.reply_to_mode
      -                # YAML 1.1 parses bare 'off' as boolean False — coerce to string "off".
      -                _discord_extra = discord_cfg.get("extra") if isinstance(discord_cfg.get("extra"), dict) else {}
      -                _discord_rtm = (
      -                    discord_cfg["reply_to_mode"] if "reply_to_mode" in discord_cfg
      -                    else _discord_extra.get("reply_to_mode")
      -                )
      -                if _discord_rtm is not None and not os.getenv("DISCORD_REPLY_TO_MODE"):
      -                    _rtm_str = "off" if _discord_rtm is False else str(_discord_rtm).lower()
      -                    os.environ["DISCORD_REPLY_TO_MODE"] = _rtm_str
      -
                   # Bridge top-level require_mention to Telegram when the telegram: section
                   # does not already provide one.  Users often write "require_mention: true"
                   # at the top level alongside group_sessions_per_user, expecting it to work
    • gateway/run.py
      @@ -5897,6 +5897,12 @@ def _create_adapter(
                   if platform_registry.is_registered(platform.value):
                       adapter = platform_registry.create_adapter(platform.value, config)
                       if adapter is not None:
      +                    # Adapters that need a back-reference to the gateway runner
      +                    # (e.g. for cross-platform admin alerts) declare a
      +                    # ``gateway_runner`` attribute. Inject it after creation so
      +                    # plugin adapters don't need a custom factory signature.
      +                    if hasattr(adapter, "gateway_runner"):
      +                        adapter.gateway_runner = self
                           return adapter
                       # Registered but failed to instantiate — don't silently fall
                       # through to built-ins (there are none for plugin platforms).
      @@ -5939,15 +5945,6 @@ def _create_adapter(
                   adapter._notifications_mode = _notify_mode
                   return adapter
               
      -        elif platform == Platform.DISCORD:
      -            from gateway.platforms.discord import DiscordAdapter, check_discord_requirements
      -            if not check_discord_requirements():
      -                logger.warning("Discord: discord.py not installed")
      -                return None
      -            adapter = DiscordAdapter(config)
      -            adapter.gateway_runner = self  # For cross-platform admin alerts on unauthorized slash
      -            return adapter
      -        
               elif platform == Platform.WHATSAPP:
                   from gateway.platforms.whatsapp import WhatsAppAdapter, check_whatsapp_requirements
                   if not check_whatsapp_requirements():
    • hermes_cli/commands.py
      @@ -449,7 +449,7 @@ def _iter_plugin_command_entries() -> list[tuple[str, str, str]]:
           :func:`hermes_cli.plugins.PluginContext.register_command`. They behave
           like ``CommandDef`` entries for gateway surfacing: they appear in the
           Telegram command menu, in Slack's ``/hermes`` subcommand mapping, and
      -    (via :func:`gateway.platforms.discord._register_slash_commands`) in
      +    (via :func:`plugins.platforms.discord.adapter._register_slash_commands`) in
           Discord's native slash command picker.
       
           Lookup is lazy so importing this module never forces plugin discovery
    • hermes_cli/gateway.py
      @@ -3327,34 +3327,9 @@ def _atexit_hook() -> None:
                    "help": "For DMs, this is your user ID. You can set it later by typing /set-home in chat."},
               ],
           },
      -    {
      -        "key": "discord",
      -        "label": "Discord",
      -        "emoji": "💬",
      -        "token_var": "DISCORD_BOT_TOKEN",
      -        "setup_instructions": [
      -            "1. Go to https://discord.com/developers/applications → New Application",
      -            "2. Go to Bot → Reset Token → copy the bot token",
      -            "3. Enable: Bot → Privileged Gateway Intents → Message Content Intent",
      -            "4. Invite the bot to your server:",
      -            "   OAuth2 → URL Generator → check BOTH scopes:",
      -            "     - bot",
      -            "     - applications.commands  (required for slash commands!)",
      -            "   Bot Permissions: Send Messages, Read Message History, Attach Files",
      -            "   Copy the URL and open it in your browser to invite.",
      -            "5. Get your user ID: enable Developer Mode in Discord settings,",
      -            "   then right-click your name → Copy ID",
      -        ],
      -        "vars": [
      -            {"name": "DISCORD_BOT_TOKEN", "prompt": "Bot token", "password": True,
      -             "help": "Paste the token from step 2 above."},
      -            {"name": "DISCORD_ALLOWED_USERS", "prompt": "Allowed user IDs or usernames (comma-separated)", "password": [redacted:secret-assignment]
      -             "is_allowlist": True,
      -             "help": "Paste your user ID from step 5 above."},
      -            {"name": "DISCORD_HOME_CHANNEL", "prompt": "Home channel ID (for cron/notification delivery, or empty to set later with /set-home)", "password": [redacted:secret-assignment]
      -             "help": "Right-click a channel → Copy Channel ID (requires Developer Mode)."},
      -        ],
      -    },
      +    # Discord moved to plugins/platforms/discord/ — its setup metadata is
      +    # discovered dynamically via _all_platforms() from the platform registry
      +    # entry registered by plugins/platforms/discord/adapter.py::register().
           {
               "key": "slack",
               "label": "Slack",
      @@ -3762,7 +3737,12 @@ def _platform_status(platform: dict) -> str:
                       configured = bool(entry.is_connected(synthetic))
                   except Exception:
                       configured = False
      -        if not configured:
      +        else:
      +            # No is_connected hook — fall back to check_fn as a coarse
      +            # "are deps present" gate. Don't fall back when is_connected
      +            # is defined and returned False; that would let "SDK is
      +            # installed" override "no token configured" and incorrectly
      +            # report the platform as ready.
                   try:
                       configured = bool(entry.check_fn())
                   except Exception:
      @@ -4747,7 +4727,9 @@ def _builtin_setup_fn(key: str):
           from hermes_cli import setup as _s
           return {
               "telegram": _s._setup_telegram,
      -        "discord": _s._setup_discord,
      +        # discord moved into the plugin: setup_fn is registered by
      +        # plugins/platforms/discord/adapter.py::register() and dispatched
      +        # via the plugin path in _configure_platform().
               "slack": _s._setup_slack,
               "matrix": _s._setup_matrix,
               "mattermost": _s._setup_mattermost,
    • hermes_cli/setup.py
      @@ -2034,74 +2034,6 @@ def _setup_telegram():
                   save_env_value("TELEGRAM_HOME_CHANNEL", home_channel)
       
       
      -def _setup_discord():
      -    """Configure Discord bot credentials and allowlist."""
      -    print_header("Discord")
      -    existing = get_env_value("DISCORD_BOT_TOKEN")
      -    if existing:
      -        print_info("Discord: already configured")
      -        if not prompt_yes_no("Reconfigure Discord?", False):
      -            if not get_env_value("DISCORD_ALLOWED_USERS"):
      -                print_info("⚠️  Discord has no user allowlist - anyone can use your bot!")
      -                if prompt_yes_no("Add allowed users now?", True):
      -                    print_info("   To find Discord ID: Enable Developer Mode, right-click name → Copy ID")
      -                    allowed_users = prompt("Allowed user IDs (comma-separated)")
      -                    if allowed_users:
      -                        cleaned_ids = _clean_discord_user_ids(allowed_users)
      -                        save_env_value("DISCORD_ALLOWED_USERS", ",".join(cleaned_ids))
      -                        print_success("Discord allowlist configured")
      -            return
      -
      -    print_info("Create a bot at https://discord.com/developers/applications")
      -    token = [redacted:secret-assignment]"Discord bot token", password=True)
      -    if not token:
      -        return
      -    save_env_value("DISCORD_BOT_TOKEN", token)
      -    print_success("Discord token saved")
      -
      -    print()
      -    print_info("🔒 Security: Restrict who can use your bot")
      -    print_info("   To find your Discord user ID:")
      -    print_info("   1. Enable Developer Mode in Discord settings")
      -    print_info("   2. Right-click your name → Copy ID")
      -    print()
      -    print_info("   You can also use Discord usernames (resolved on gateway start).")
      -    print()
      -    allowed_users = prompt(
      -        "Allowed user IDs or usernames (comma-separated, leave empty for open access)"
      -    )
      -    if allowed_users:
      -        cleaned_ids = _clean_discord_user_ids(allowed_users)
      -        save_env_value("DISCORD_ALLOWED_USERS", ",".join(cleaned_ids))
      -        print_success("Discord allowlist configured")
      -    else:
      -        print_info("⚠️  No allowlist set - anyone in servers with your bot can use it!")
      -
      -    print()
      -    print_info("📬 Home Channel: where Hermes delivers cron job results,")
      -    print_info("   cross-platform messages, and notifications.")
      -    print_info("   To get a channel ID: right-click a channel → Copy Channel ID")
      -    print_info("   (requires Developer Mode in Discord settings)")
      -    print_info("   You can also set this later by typing /set-home in a Discord channel.")
      -    home_channel = prompt("Home channel ID (leave empty to set later with /set-home)")
      -    if home_channel:
      -        save_env_value("DISCORD_HOME_CHANNEL", home_channel)
      -
      -
      -def _clean_discord_user_ids(raw: str) -> list:
      -    """Strip common Discord mention prefixes from a comma-separated ID string."""
      -    cleaned = []
      -    for uid in raw.replace(" ", "").split(","):
      -        uid = uid.strip()
      -        if uid.startswith("<@") and uid.endswith(">"):
      -            uid = uid.lstrip("<@!").rstrip(">")
      -        if uid.lower().startswith("user:"):
      -            uid = uid[5:]
      -        if uid:
      -            cleaned.append(uid)
      -    return cleaned
      -
      -
       def _setup_slack():
           """Configure Slack bot credentials."""
           print_header("Slack")
    • plugins/platforms/discord/__init__.py
      @@ -0,0 +1,3 @@
      +from .adapter import register
      +
      +__all__ = ["register"]
    • plugins/platforms/discord/adapter.py
      @@ -1489,7 +1489,8 @@ async def _send_to_forum(self, forum_channel: Any, content: str) -> SendResult:
               reported in ``raw_response['warnings']`` so the caller can surface
               partial-send issues.
               """
      -        from tools.send_message_tool import _derive_forum_thread_name
      +        # _derive_forum_thread_name is defined further down in this same
      +        # module — no cross-module import needed.
       
               formatted = self.format_message(content)
               chunks = self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH)
      @@ -1551,7 +1552,8 @@ async def _forum_post_file(
               ForumChannel accepts the same file/files/content kwargs as
               ``channel.send``, creating the thread and starter message atomically.
               """
      -        from tools.send_message_tool import _derive_forum_thread_name
      +        # _derive_forum_thread_name is defined further down in this same
      +        # module — no cross-module import needed.
       
               if not thread_name:
                   # Prefer the text content, fall back to the first attached
      @@ -5699,7 +5701,492 @@ async def on_timeout(self):
                   self.resolved = True
                   for child in self.children:
                       child.disabled = True
      -
      -
       if DISCORD_AVAILABLE:
           _define_discord_view_classes()
      +
      +
      +# ── Standalone (out-of-process) sender ────────────────────────────────────────
      +# Used by ``tools/send_message_tool._send_via_adapter`` when the gateway runner
      +# is not in this process (e.g. ``hermes cron`` running standalone) and no live
      +# DiscordAdapter instance is available.  Implements the same forum/thread/
      +# multipart logic the live adapter would use, via Discord's REST API directly.
      +#
      +# This block was previously hosted in ``tools/send_message_tool.py`` as
      +# ``_send_discord``.  It moved into the plugin so all Discord-specific HTTP
      +# logic lives next to the adapter — same shape as Teams' ``_standalone_send``.
      +
      +# Process-local cache for Discord channel-type probes.  Avoids re-probing the
      +# same channel on every send when the directory cache has no entry (e.g. fresh
      +# install, or channel created after the last directory build).
      +_DISCORD_CHANNEL_TYPE_PROBE_CACHE: Dict[str, bool] = {}
      +
      +
      +def _remember_channel_is_forum(chat_id: str, is_forum: bool) -> None:
      +    _DISCORD_CHANNEL_TYPE_PROBE_CACHE[str(chat_id)] = bool(is_forum)
      +
      +
      +def _probe_is_forum_cached(chat_id: str) -> Optional[bool]:
      +    return _DISCORD_CHANNEL_TYPE_PROBE_CACHE.get(str(chat_id))
      +
      +
      +def _derive_forum_thread_name(message: str) -> str:
      +    """Derive a thread name from the first line of the message, capped at 100 chars."""
      +    first_line = message.strip().split("\n", 1)[0].strip()
      +    # Strip common markdown heading prefixes
      +    first_line = first_line.lstrip("#").strip()
      +    if not first_line:
      +        first_line = "New Post"
      +    return first_line[:100]
      +
      +
      +def _standalone_sanitize_error(text) -> str:
      +    """Local copy of tools.send_message_tool._sanitize_error_text — strips bot
      +    tokens from any error payload before bubbling it up.  Inlined so the
      +    plugin doesn't introduce a hard dependency on send_message_tool internals.
      +    """
      +    s = str(text)
      +    # Mask anything that looks like a Bot token in an Authorization header.
      +    import re as _re_san
      +    return _re_san.sub(
      +        r"(Authorization:\s*Bot\s+)\S+",
      +        r"\1***",
      +        s,
      +        flags=_re_san.IGNORECASE,
      +    )
      +
      +
      +async def _standalone_send(
      +    pconfig,
      +    chat_id: str,
      +    message: str,
      +    *,
      +    thread_id: Optional[str] = None,
      +    media_files: Optional[list] = None,
      +    force_document: bool = False,
      +) -> Dict[str, Any]:
      +    """Send via Discord REST API without a live gateway adapter.
      +
      +    Used by ``tools/send_message_tool._send_via_adapter`` when the gateway
      +    runner is not in this process.  Reads ``DISCORD_BOT_TOKEN`` from
      +    ``pconfig.token`` (set by the gateway config loader from env) and falls
      +    back to the ``DISCORD_BOT_TOKEN`` env var.
      +
      +    Forum channels (type 15) reject ``POST /messages`` — a thread post is
      +    created automatically via ``POST /channels/{id}/threads``.  Media files
      +    are uploaded as multipart attachments on the starter message of the new
      +    thread.  Channel type is resolved from the channel directory first, then
      +    a process-local probe cache, and only as a last resort with a live
      +    ``GET /channels/{id}`` probe (whose result is memoized).
      +
      +    ``force_document`` is accepted for signature parity but unused — Discord
      +    treats every uploaded file as a generic attachment.
      +    """
      +    try:
      +        import aiohttp
      +    except ImportError:
      +        return {"error": "aiohttp not installed. Run: pip install aiohttp"}
      +
      +    token = [redacted:secret-assignment] "token", None) or os.getenv("DISCORD_BOT_TOKEN", "")).strip()
      +    if not token:
      +        return {"error": "Discord standalone send: DISCORD_BOT_TOKEN is not set"}
      +
      +    try:
      +        from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp
      +        _proxy = resolve_proxy_url(platform_env_var="DISCORD_PROXY")
      +        _sess_kw, _req_kw = proxy_kwargs_for_aiohttp(_proxy)
      +        auth_headers = {"Authorization": f"Bot {token}"}
      +        json_headers = {**auth_headers, "Content-Type": "application/json"}
      +        media_files = media_files or []
      +        last_data = None
      +        warnings = []
      +
      +        # Thread endpoint: Discord threads are channels; send directly to the thread ID.
      +        if thread_id:
      +            url = f"https://discord.com/api/v10/channels/{thread_id}/messages"
      +        else:
      +            # Check if the target channel is a forum channel (type 15).
      +            # Forum channels reject POST /messages — create a thread post instead.
      +            # Three-layer detection: directory cache → process-local probe
      +            # cache → GET /channels/{id} probe (with result memoized).
      +            _channel_type = None
      +            try:
      +                from gateway.channel_directory import lookup_channel_type
      +                _channel_type = lookup_channel_type("discord", chat_id)
      +            except Exception:
      +                pass
      +
      +            if _channel_type == "forum":
      +                is_forum = True
      +            elif _channel_type is not None:
      +                is_forum = False
      +            else:
      +                cached = _probe_is_forum_cached(chat_id)
      +                if cached is not None:
      +                    is_forum = cached
      +                else:
      +                    is_forum = False
      +                    try:
      +                        info_url = f"https://discord.com/api/v10/channels/{chat_id}"
      +                        async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=15), **_sess_kw) as info_sess:
      +                            async with info_sess.get(info_url, headers=json_headers, **_req_kw) as info_resp:
      +                                if info_resp.status == 200:
      +                                    info = await info_resp.json()
      +                                    is_forum = info.get("type") == 15
      +                                    _remember_channel_is_forum(chat_id, is_forum)
      +                    except Exception:
      +                        logger.debug("Failed to probe channel type for %s", chat_id, exc_info=True)
      +
      +            if is_forum:
      +                thread_name = _derive_forum_thread_name(message)
      +                thread_url = f"https://discord.com/api/v10/channels/{chat_id}/threads"
      +
      +                # Filter to readable media files up front so we can pick the
      +                # right code path (JSON vs multipart) before opening a session.
      +                valid_media = []
      +                for media_path, _is_voice in media_files:
      +                    if not os.path.exists(media_path):
      +                        warning = f"Media file not found, skipping: {media_path}"
      +                        logger.warning(warning)
      +                        warnings.append(warning)
      +                        continue
      +                    valid_media.append(media_path)
      +
      +                async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=60), **_sess_kw) as session:
      +                    if valid_media:
      +                        # Multipart: payload_json + files[N] creates a forum
      +                        # thread with the starter message plus attachments in
      +                        # a single API call.
      +                        attachments_meta = [
      +                            {"id": str(idx), "filename": os.path.basename(path)}
      +                            for idx, path in enumerate(valid_media)
      +                        ]
      +                        starter_message = {"content": message, "attachments": attachments_meta}
      +                        payload_json = json.dumps({"name": thread_name, "message": starter_message})
      +
      +                        form = aiohttp.FormData()
      +                        form.add_field("payload_json", payload_json, content_type="application/json")
      +
      +                        try:
      +                            for idx, media_path in enumerate(valid_media):
      +                                with open(media_path, "rb") as fh:
      +                                    form.add_field(
      +                                        f"files[{idx}]",
      +                                        fh.read(),
      +                                        filename=os.path.basename(media_path),
      +                                    )
      +                            async with session.post(thread_url, headers=auth_headers, data=form, **_req_kw) as resp:
      +                                if resp.status not in {200, 201}:
      +                                    body = await resp.text()
      +                                    return {"error": f"Discord forum thread creation error ({resp.status}): {body}"}
      +                                data = await resp.json()
      +                        except Exception as e:
      +                            return {"error": _standalone_sanitize_error(f"Discord forum thread upload failed: {e}")}
      +                    else:
      +                        # No media — simple JSON POST creates the thread with
      +                        # just the text starter.
      +                        async with session.post(
      +                            thread_url,
      +                            headers=json_headers,
      +                            json={
      +                                "name": thread_name,
      +                                "message": {"content": message},
      +                            },
      +                            **_req_kw,
      +                        ) as resp:
      +                            if resp.status not in {200, 201}:
      +                                body = await resp.text()
      +                                return {"error": f"Discord forum thread creation error ({resp.status}): {body}"}
      +                            data = await resp.json()
      +
      +                thread_id_created = data.get("id")
      +                starter_msg_id = (data.get("message") or {}).get("id", thread_id_created)
      +                result = {
      +                    "success": True,
      +                    "platform": "discord",
      +                    "chat_id": chat_id,
      +                    "thread_id": thread_id_created,
      +                    "message_id": starter_msg_id,
      +                }
      +                if warnings:
      +                    result["warnings"] = warnings
      +                return result
      +
      +            url = f"https://discord.com/api/v10/channels/{chat_id}/messages"
      +
      +        async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30), **_sess_kw) as session:
      +            # Send text message (skip if empty and media is present)
      +            if message.strip() or not media_files:
      +                async with session.post(url, headers=json_headers, json={"content": message}, **_req_kw) as resp:
      +                    if resp.status not in {200, 201}:
      +                        body = await resp.text()
      +                        return {"error": f"Discord API error ({resp.status}): {body}"}
      +                    last_data = await resp.json()
      +
      +            # Send each media file as a separate multipart upload
      +            for media_path, _is_voice in media_files:
      +                if not os.path.exists(media_path):
      +                    warning = f"Media file not found, skipping: {media_path}"
      +                    logger.warning(warning)
      +                    warnings.append(warning)
      +                    continue
      +                try:
      +                    form = aiohttp.FormData()
      +                    filename = os.path.basename(media_path)
      +                    with open(media_path, "rb") as f:
      +                        form.add_field("files[0]", f, filename=filename)
      +                        async with session.post(url, headers=auth_headers, data=form, **_req_kw) as resp:
      +                            if resp.status not in {200, 201}:
      +                                body = await resp.text()
      +                                warning = _standalone_sanitize_error(f"Failed to send media {media_path}: Discord API error ({resp.status}): {body}")
      +                                logger.error(warning)
      +                                warnings.append(warning)
      +                                continue
      +                            last_data = await resp.json()
      +                except Exception as e:
      +                    warning = _standalone_sanitize_error(f"Failed to send media {media_path}: {e}")
      +                    logger.error(warning)
      +                    warnings.append(warning)
      +
      +        if last_data is None:
      +            error = "No deliverable text or media remained after processing"
      +            if warnings:
      +                return {"error": error, "warnings": warnings}
      +            return {"error": error}
      +
      +        result = {"success": True, "platform": "discord", "chat_id": chat_id, "message_id": last_data.get("id")}
      +        if warnings:
      +            result["warnings"] = warnings
      +        return result
      +    except Exception as e:
      +        return {"error": _standalone_sanitize_error(f"Discord send failed: {e}")}
      +
      +
      +# ── Plugin entry point ────────────────────────────────────────────────────────
      +
      +
      +def _clean_discord_user_ids(raw: str) -> list:
      +    """Strip common Discord mention prefixes from a comma-separated ID string."""
      +    cleaned = []
      +    for uid in raw.replace(" ", "").split(","):
      +        uid = uid.strip()
      +        if uid.startswith("<@") and uid.endswith(">"):
      +            uid = uid.lstrip("<@!").rstrip(">")
      +        if uid.lower().startswith("user:"):
      +            uid = uid[5:]
      +        if uid:
      +            cleaned.append(uid)
      +    return cleaned
      +
      +
      +def interactive_setup() -> None:
      +    """Guide the user through Discord bot setup.
      +
      +    Mirrors Teams' ``interactive_setup`` shape: lazy-imports CLI helpers so
      +    the plugin's import surface stays small, prompts for the bot token,
      +    captures an allowlist, and offers to set a home channel.
      +    """
      +    from hermes_cli.config import get_env_value, save_env_value
      +    from hermes_cli.cli_output import (
      +        prompt,
      +        prompt_yes_no,
      +        print_header,
      +        print_info,
      +        print_success,
      +    )
      +
      +    print_header("Discord")
      +    existing = get_env_value("DISCORD_BOT_TOKEN")
      +    if existing:
      +        print_info("Discord: already configured")
      +        if not prompt_yes_no("Reconfigure Discord?", False):
      +            if not get_env_value("DISCORD_ALLOWED_USERS"):
      +                print_info("⚠️  Discord has no user allowlist - anyone can use your bot!")
      +                if prompt_yes_no("Add allowed users now?", True):
      +                    print_info("   To find Discord ID: Enable Developer Mode, right-click name → Copy ID")
      +                    allowed_users = prompt("Allowed user IDs (comma-separated)")
      +                    if allowed_users:
      +                        cleaned_ids = _clean_discord_user_ids(allowed_users)
      +                        save_env_value("DISCORD_ALLOWED_USERS", ",".join(cleaned_ids))
      +                        print_success("Discord allowlist configured")
      +            return
      +
      +    print_info("Create a bot at https://discord.com/developers/applications")
      +    token = [redacted:secret-assignment]"Discord bot token", password=True)
      +    if not token:
      +        return
      +    save_env_value("DISCORD_BOT_TOKEN", token)
      +    print_success("Discord token saved")
      +
      +    print()
      +    print_info("🔒 Security: Restrict who can use your bot")
      +    print_info("   To find your Discord user ID:")
      +    print_info("   1. Enable Developer Mode in Discord settings")
      +    print_info("   2. Right-click your name → Copy ID")
      +    print()
      +    print_info("   You can also use Discord usernames (resolved on gateway start).")
      +    print()
      +    allowed_users = prompt(
      +        "Allowed user IDs or usernames (comma-separated, leave empty for open access)"
      +    )
      +    if allowed_users:
      +        cleaned_ids = _clean_discord_user_ids(allowed_users)
      +        save_env_value("DISCORD_ALLOWED_USERS", ",".join(cleaned_ids))
      +        print_success("Discord allowlist configured")
      +    else:
      +        print_info("⚠️  No allowlist set - anyone in servers with your bot can use it!")
      +
      +    print()
      +    print_info("📬 Home Channel: where Hermes delivers cron job results,")
      +    print_info("   cross-platform messages, and notifications.")
      +    print_info("   To get a channel ID: right-click a channel → Copy Channel ID")
      +    print_info("   (requires Developer Mode in Discord settings)")
      +    print_info("   You can also set this later by typing /set-home in a Discord channel.")
      +    home_channel = prompt("Home channel ID (leave empty to set later with /set-home)")
      +    if home_channel:
      +        save_env_value("DISCORD_HOME_CHANNEL", home_channel)
      +
      +
      +def _apply_yaml_config(yaml_cfg: dict, discord_cfg: dict) -> dict | None:
      +    """Translate ``config.yaml`` ``discord:`` keys into env vars.
      +
      +    Implements the ``apply_yaml_config_fn`` contract (#24836).  Mirrors the
      +    legacy ``discord_cfg`` block that used to live in
      +    ``gateway/config.py::load_gateway_config()`` before this migration.
      +
      +    The DiscordAdapter reads its runtime configuration via ``os.getenv()``
      +    throughout the connect / handle code paths (``DISCORD_REQUIRE_MENTION``,
      +    ``DISCORD_FREE_RESPONSE_CHANNELS``, ``DISCORD_AUTO_THREAD``,
      +    ``DISCORD_REACTIONS``, ``DISCORD_IGNORED_CHANNELS``,
      +    ``DISCORD_ALLOWED_CHANNELS``, ``DISCORD_NO_THREAD_CHANNELS``,
      +    ``DISCORD_HISTORY_BACKFILL``, ``DISCORD_HISTORY_BACKFILL_LIMIT``,
      +    ``DISCORD_ALLOW_MENTION_*``, ``DISCORD_REPLY_TO_MODE``,
      +    ``DISCORD_THREAD_REQUIRE_MENTION``).  Rather than rewrite ~50 call sites
      +    inside the adapter to read from ``PlatformConfig.extra`` instead, this
      +    hook keeps the existing env-driven model and merely owns the
      +    YAML→env translation here, next to the adapter that consumes it.
      +
      +    Env vars take precedence over YAML — every assignment is guarded by
      +    ``not os.getenv(...)`` so explicit env vars survive a config.yaml
      +    update.  Returns ``None`` because no extras are seeded into
      +    ``PlatformConfig.extra`` directly (everything flows through env).
      +    """
      +    if "require_mention" in discord_cfg and not os.getenv("DISCORD_REQUIRE_MENTION"):
      +        os.environ["DISCORD_REQUIRE_MENTION"] = str(discord_cfg["require_mention"]).lower()
      +    if "thread_require_mention" in discord_cfg and not os.getenv("DISCORD_THREAD_REQUIRE_MENTION"):
      +        os.environ["DISCORD_THREAD_REQUIRE_MENTION"] = str(discord_cfg["thread_require_mention"]).lower()
      +    frc = discord_cfg.get("free_response_channels")
      +    if frc is not None and not os.getenv("DISCORD_FREE_RESPONSE_CHANNELS"):
      +        if isinstance(frc, list):
      +            frc = ",".join(str(v) for v in frc)
      +        os.environ["DISCORD_FREE_RESPONSE_CHANNELS"] = str(frc)
      +    if "auto_thread" in discord_cfg and not os.getenv("DISCORD_AUTO_THREAD"):
      +        os.environ["DISCORD_AUTO_THREAD"] = str(discord_cfg["auto_thread"]).lower()
      +    if "reactions" in discord_cfg and not os.getenv("DISCORD_REACTIONS"):
      +        os.environ["DISCORD_REACTIONS"] = str(discord_cfg["reactions"]).lower()
      +    # ignored_channels: channels where bot never responds (even when mentioned)
      +    ic = discord_cfg.get("ignored_channels")
      +    if ic is not None and not os.getenv("DISCORD_IGNORED_CHANNELS"):
      +        if isinstance(ic, list):
      +            ic = ",".join(str(v) for v in ic)
      +        os.environ["DISCORD_IGNORED_CHANNELS"] = str(ic)
      +    # allowed_channels: if set, bot ONLY responds in these channels (whitelist)
      +    ac = discord_cfg.get("allowed_channels")
      +    if ac is not None and not os.getenv("DISCORD_ALLOWED_CHANNELS"):
      +        if isinstance(ac, list):
      +            ac = ",".join(str(v) for v in ac)
      +        os.environ["DISCORD_ALLOWED_CHANNELS"] = str(ac)
      +    # no_thread_channels: channels where bot responds directly without creating thread
      +    ntc = discord_cfg.get("no_thread_channels")
      +    if ntc is not None and not os.getenv("DISCORD_NO_THREAD_CHANNELS"):
      +        if isinstance(ntc, list):
      +            ntc = ",".join(str(v) for v in ntc)
      +        os.environ["DISCORD_NO_THREAD_CHANNELS"] = str(ntc)
      +    # history_backfill: recover missed channel messages for shared sessions
      +    # when require_mention is active.  Fetches messages between bot turns
      +    # and prepends them to the user message for context.
      +    if "history_backfill" in discord_cfg and not os.getenv("DISCORD_HISTORY_BACKFILL"):
      +        os.environ["DISCORD_HISTORY_BACKFILL"] = str(discord_cfg["history_backfill"]).lower()
      +    hbl = discord_cfg.get("history_backfill_limit")
      +    if hbl is not None and not os.getenv("DISCORD_HISTORY_BACKFILL_LIMIT"):
      +        os.environ["DISCORD_HISTORY_BACKFILL_LIMIT"] = str(hbl)
      +    # allow_mentions: granular control over what the bot can ping.
      +    # Safe defaults (no @everyone/roles) are applied in the adapter;
      +    # these YAML keys only override when set and let users opt back
      +    # into unsafe modes (e.g. roles=true) if they actually want it.
      +    allow_mentions_cfg = discord_cfg.get("allow_mentions")
      +    if isinstance(allow_mentions_cfg, dict):
      +        for yaml_key, env_key in (
      +            ("everyone", "DISCORD_ALLOW_MENTION_EVERYONE"),
      +            ("roles", "DISCORD_ALLOW_MENTION_ROLES"),
      +            ("users", "DISCORD_ALLOW_MENTION_USERS"),
      +            ("replied_user", "DISCORD_ALLOW_MENTION_REPLIED_USER"),
      +        ):
      +            if yaml_key in allow_mentions_cfg and not os.getenv(env_key):
      +                os.environ[env_key] = str(allow_mentions_cfg[yaml_key]).lower()
      +    # reply_to_mode: top-level preferred, falls back to extra.reply_to_mode.
      +    # YAML 1.1 parses bare 'off' as boolean False — coerce to string "off".
      +    _discord_extra = discord_cfg.get("extra") if isinstance(discord_cfg.get("extra"), dict) else {}
      +    _discord_rtm = (
      +        discord_cfg["reply_to_mode"] if "reply_to_mode" in discord_cfg
      +        else _discord_extra.get("reply_to_mode")
      +    )
      +    if _discord_rtm is not None and not os.getenv("DISCORD_REPLY_TO_MODE"):
      +        _rtm_str = "off" if _discord_rtm is False else str(_discord_rtm).lower()
      +        os.environ["DISCORD_REPLY_TO_MODE"] = _rtm_str
      +    return None  # all settings flow through env; nothing to merge into extras
      +
      +
      +def _is_connected(config) -> bool:
      +    """Discord is considered connected when DISCORD_BOT_TOKEN is set.
      +
      +    Looks up via ``hermes_cli.gateway.get_env_value`` at call time (not via
      +    the plugin's own bound import) so tests that patch ``gateway_mod.get_env_value``
      +    — including ``test_setup_openclaw_migration`` — can suppress ambient
      +    ``DISCORD_BOT_TOKEN`` env vars. Matches what the legacy
      +    ``_PLATFORMS["discord"]`` dispatch did before this migration.
      +    """
      +    import hermes_cli.gateway as gateway_mod
      +    return bool((gateway_mod.get_env_value("DISCORD_BOT_TOKEN") or "").strip())
      +
      +
      +def _build_adapter(config):
      +    """Factory wrapper that constructs DiscordAdapter from a PlatformConfig."""
      +    return DiscordAdapter(config)
      +
      +
      +def register(ctx) -> None:
      +    """Plugin entry point — called by the Hermes plugin system."""
      +    ctx.register_platform(
      +        name="discord",
      +        label="Discord",
      +        adapter_factory=_build_adapter,
      +        check_fn=check_discord_requirements,
      +        is_connected=_is_connected,
      +        required_env=["DISCORD_BOT_TOKEN"],
      +        install_hint="pip install 'hermes-agent[discord]'",
      +        # Interactive setup wizard — replaces the central
      +        # hermes_cli/setup.py::_setup_discord function.  Same shape as Teams.
      +        setup_fn=interactive_setup,
      +        # YAML→env config bridge — owns the translation of ``config.yaml``
      +        # ``discord:`` keys (require_mention, free_response_channels,
      +        # auto_thread, reactions, ignored_channels, allowed_channels,
      +        # no_thread_channels, allow_mentions.*, reply_to_mode,
      +        # thread_require_mention) into ``DISCORD_*`` env vars that the
      +        # adapter reads via ``os.getenv()``.  Replaces the hardcoded block
      +        # that used to live in ``gateway/config.py``.  Hook contract: #24836.
      +        apply_yaml_config_fn=_apply_yaml_config,
      +        # Auth env vars for _is_user_authorized() integration
      +        allowed_users_env="DISCORD_ALLOWED_USERS",
      +        allow_all_env="DISCORD_ALLOW_ALL_USERS",
      +        # Cron home-channel delivery
      +        cron_deliver_env_var="DISCORD_HOME_CHANNEL",
      +        # Out-of-process cron delivery via Discord REST API.  Without this
      +        # hook, ``deliver=discord`` cron jobs fail with "No live adapter"
      +        # when cron runs separately from the gateway.  Mirrors Teams pattern.
      +        standalone_sender_fn=_standalone_send,
      +        # Discord hard limit per message
      +        max_message_length=2000,
      +        # Display
      +        emoji="🎮",
      +        allow_update_command=True,
      +    )
    • plugins/platforms/discord/plugin.yaml
      @@ -0,0 +1,34 @@
      +name: discord-platform
      +label: Discord
      +kind: platform
      +version: 1.0.0
      +description: >
      +  Discord gateway adapter for Hermes Agent.
      +  Connects to Discord via the discord.py library and relays messages
      +  between Discord guilds/DMs and the Hermes agent. Supports voice mode,
      +  slash commands, free-response channels, role-based DM auth, threads,
      +  reactions, and channel skill bindings.
      +author: NousResearch
      +requires_env:
      +  - name: DISCORD_BOT_TOKEN
      +    description: "Discord bot token"
      +    prompt: "Discord bot token"
      +    url: "https://discord.com/developers/applications"
      +    password: true
      +optional_env:
      +  - name: DISCORD_ALLOWED_USERS
      +    description: "Comma-separated Discord user IDs allowed to talk to the bot"
      +    prompt: "Allowed users (comma-separated)"
      +    password: false
      +  - name: DISCORD_ALLOW_ALL_USERS
      +    description: "Allow any Discord user to trigger the bot (dev only)"
      +    prompt: "Allow all users? (true/false)"
      +    password: false
      +  - name: DISCORD_HOME_CHANNEL
      +    description: "Default channel ID for cron / notification delivery"
      +    prompt: "Home channel ID"
      +    password: false
      +  - name: DISCORD_HOME_CHANNEL_NAME
      +    description: "Display name for the Discord home channel"
      +    prompt: "Home channel display name"
      +    password: false
    • tests/e2e/conftest.py
      @@ -119,7 +119,7 @@ def _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
    • tests/gateway/test_discord_allowed_mentions.py
      @@ -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.
    • tests/gateway/test_discord_attachment_download.py
      @@ -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 @@ async def test_prefers_att_read_over_url(self):
               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 @@ async def test_falls_back_to_url_when_no_read(self):
               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 @@ async def test_falls_back_to_url_when_bytes_validator_rejects(self):
               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 @@ async def test_prefers_att_read_over_url(self):
               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 @@ async def test_falls_back_to_url_when_no_read(self):
               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 @@ async def test_fallback_blocked_by_ssrf_guard(self):
               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 @@ async def test_fallback_aiohttp_when_safe_url(self):
               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 @@ async def test_image_downloads_via_att_read_not_url(self, monkeypatch):
               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 _FakeDMChannel:
       
                   # 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 @@ async def test_native_voice_note_is_classified_as_voice(self, monkeypatch):
               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 _FakeDMChannel:
                       name = "dm"
       
                   monkeypatch.setattr(
      -                "gateway.platforms.discord.discord.DMChannel",
      +                "plugins.platforms.discord.adapter.discord.DMChannel",
                       _FakeDMChannel,
                   )
                   chan = _FakeDMChannel()
      @@ -412,7 +412,7 @@ async def test_plain_audio_attachment_stays_audio(self, monkeypatch):
               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 _FakeDMChannel:
                       name = "dm"
       
                   monkeypatch.setattr(
      -                "gateway.platforms.discord.discord.DMChannel",
      +                "plugins.platforms.discord.adapter.discord.DMChannel",
                       _FakeDMChannel,
                   )
                   chan = _FakeDMChannel()
    • tests/gateway/test_discord_channel_controls.py
      @@ -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:
    • tests/gateway/test_discord_channel_prompts.py
      @@ -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()
    • tests/gateway/test_discord_channel_skills.py
      @@ -5,7 +5,7 @@
       
       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 = {}
    • tests/gateway/test_discord_clarify_buttons.py
      @@ -26,7 +26,7 @@
       
       # 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,
       )
    • tests/gateway/test_discord_component_auth.py
      @@ -18,7 +18,7 @@
       
       # 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,
    • tests/gateway/test_discord_connect.py
      @@ -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)
    • tests/gateway/test_discord_document_handling.py
      @@ -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 @@ def get(self, url, **kwargs):
           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",
               ):
    • tests/gateway/test_discord_free_response.py
      @@ -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:
    • tests/gateway/test_discord_imports.py
      @@ -14,10 +14,13 @@ def fake_import(name, globals=None, locals=None, fromlist=(), level=0):
                       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
    • tests/gateway/test_discord_lazy_install_views.py
      @@ -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 @@ def test_registers_all_five_view_classes(self, monkeypatch):
           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)
    • tests/gateway/test_discord_media_metadata.py
      @@ -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():
    • tests/gateway/test_discord_model_picker.py
      @@ -11,7 +11,7 @@
       
       import pytest
       
      -from gateway.platforms.discord import ModelPickerView
      +from plugins.platforms.discord.adapter import ModelPickerView
       
       
       @pytest.mark.asyncio
    • tests/gateway/test_discord_opus.py
      @@ -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 @@ def test_homebrew_fallback_is_conditional(self):
       
           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"
    • tests/gateway/test_discord_race_polish.py
      @@ -10,7 +10,7 @@
       
       
       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 slow_connect(self):
           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",
    • tests/gateway/test_discord_reactions.py
      @@ -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:
    • tests/gateway/test_discord_reply_mode.py
      @@ -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()
    • tests/gateway/test_discord_roles_dm_scope.py
      @@ -20,7 +20,7 @@
       
       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):
    • tests/gateway/test_discord_send.py
      @@ -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
    • tests/gateway/test_discord_slash_auth.py
      @@ -85,7 +85,7 @@ def __init__(self, *, name, description, callback, parent=None):
       
       _ensure_discord_mock()
       
      -from gateway.platforms.discord import DiscordAdapter  # noqa: E402
      +from plugins.platforms.discord.adapter import DiscordAdapter  # noqa: E402
       
       
       @pytest.fixture(autouse=True)
    • tests/gateway/test_discord_slash_commands.py
      @@ -75,7 +75,7 @@ def __init__(self, *, name, description, callback, parent=None):
       
       _ensure_discord_mock()
       
      -from gateway.platforms.discord import DiscordAdapter  # noqa: E402
      +from plugins.platforms.discord.adapter import DiscordAdapter  # noqa: E402
       
       
       class FakeTree:
    • tests/gateway/test_discord_thread_persistence.py
      @@ -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="[redacted:secret-assignment]")
               with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
    • tests/gateway/test_reload_skills_discord_resync.py
      @@ -27,7 +27,7 @@
       
       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()
    • tests/gateway/test_send_image_file.py
      @@ -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:
    • tests/gateway/test_send_multiple_images.py
      @@ -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:
    • tests/gateway/test_stream_consumer.py
      @@ -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"),
    • tests/gateway/test_text_batching.py
      @@ -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="[redacted:secret-assignment]")
           adapter = object.__new__(DiscordAdapter)
    • tests/gateway/test_voice_command.py
      @@ -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 = "[redacted:secret-assignment]"
      @@ -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 = "[redacted:secret-assignment]"
      @@ -1208,7 +1208,7 @@ async def test_process_voice_input_success(self):
       
               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 @@ async def test_process_voice_input_hallucination_filtered(self):
               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 @@ async def test_process_voice_input_stt_failure(self):
               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 @@ async def test_process_voice_input_exception_caught(self):
               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 @@ def _make_receiver(self):
           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 @@ def test_check_silence_holds_lock(self):
           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 = "[redacted:secret-assignment]"
      @@ -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 = "[redacted:secret-assignment]"
      @@ -1964,7 +1964,7 @@ def _make_discord_adapter():
           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 @@ def test_source_has_wait_for_timeout(self):
       
           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 @@ async def test_playback_timeout_fires(self):
           @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 = "[redacted:secret-assignment]"
      @@ -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 @@ def test_resumed_receiver_accepts_packets(self):
       
           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 = "[redacted:secret-assignment]"
      @@ -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 @@ async def test_keepalive_sends_silence_frame(self):
               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
    • tests/integration/test_voice_channel_flow.py
      @@ -38,7 +38,7 @@
       
       from types import SimpleNamespace
       from unittest.mock import MagicMock
      -from gateway.platforms.discord import VoiceReceiver
      +from plugins.platforms.discord.adapter import VoiceReceiver
       
       
       # ---------------------------------------------------------------------------
    • tests/tools/test_send_message_tool.py
      @@ -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=[redacted:secret-assignment] 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 = [redacted:secret-assignment] "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 @@ def test_long_message_is_chunked(self):
               """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 @@ def test_discord_thread_id_passed_to_send_discord(self):
               """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 @@ def test_discord_no_thread_id_when_not_provided(self):
               """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 @@ async def mock_send_discord(token, chat_id, message, thread_id=None, media_files
               # 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 @@ def test_single_chunk_gets_media(self):
               """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 @@ def test_send_to_platform_discord_delegates_to_send_discord(self):
               """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 @@ def test_send_to_platform_discord_with_thread_id(self):
               """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 @@ def test_probe_result_is_memoized(self, monkeypatch):
               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 @@ def session_factory(**kwargs):
               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.
    • tools/send_message_tool.py
      @@ -563,7 +563,6 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None,
           """
           from gateway.config import Platform
           from gateway.platforms.base import BasePlatformAdapter, utf16_len
      -    from gateway.platforms.discord import DiscordAdapter
           from gateway.platforms.slack import SlackAdapter
       
           # Telegram adapter import is optional (requires python-telegram-bot)
      @@ -589,10 +588,10 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None,
               except Exception:
                   logger.debug("Failed to apply Slack mrkdwn formatting in _send_to_platform", exc_info=True)
       
      -    # Platform message length limits (from adapter class attributes)
      +    # Platform message length limits (from adapter class attributes for
      +    # built-in platforms; from PlatformEntry.max_message_length for plugins).
           _MAX_LENGTHS = {
               Platform.TELEGRAM: TelegramAdapter.MAX_MESSAGE_LENGTH if _telegram_available else 4096,
      -        Platform.DISCORD: DiscordAdapter.MAX_MESSAGE_LENGTH,
               Platform.SLACK: SlackAdapter.MAX_MESSAGE_LENGTH,
           }
           if _feishu_available:
      @@ -642,17 +641,27 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None,
           if platform == Platform.WEIXIN:
               return await _send_weixin(pconfig, chat_id, message, media_files=media_files)
       
      -    # --- Discord: special handling for media attachments ---
      +    # --- Discord: chunked delivery via the registry's standalone_sender_fn.
      +    # The plugin's ``_standalone_send`` (registered in
      +    # plugins/platforms/discord/adapter.py) handles forum channels, threads,
      +    # and multipart media uploads.  ``_send_via_adapter`` tries the live
      +    # in-process adapter first via ``adapter.send()``, but Discord's elif
      +    # historically went straight to the HTTP path; we preserve that by
      +    # explicitly invoking the registry hook here so behavior is unchanged.
           if platform == Platform.DISCORD:
      +        from gateway.platform_registry import platform_registry
      +        entry = platform_registry.get("discord")
      +        if entry is None or entry.standalone_sender_fn is None:
      +            return {"error": "Discord plugin not registered or missing standalone_sender_fn"}
               last_result = None
               for i, chunk in enumerate(chunks):
                   is_last = (i == len(chunks) - 1)
      -            result = await _send_discord(
      -                pconfig.token,
      +            result = await entry.standalone_sender_fn(
      +                pconfig,
                       chat_id,
                       chunk,
      -                media_files=media_files if is_last else [],
                       thread_id=thread_id,
      +                media_files=media_files if is_last else [],
                   )
                   if isinstance(result, dict) and result.get("error"):
                       return result
      @@ -1026,227 +1035,6 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No
               return _error(f"Telegram send failed: {e}")
       
       
      -def _derive_forum_thread_name(message: str) -> str:
      -    """Derive a thread name from the first line of the message, capped at 100 chars."""
      -    first_line = message.strip().split("\n", 1)[0].strip()
      -    # Strip common markdown heading prefixes
      -    first_line = first_line.lstrip("#").strip()
      -    if not first_line:
      -        first_line = "New Post"
      -    return first_line[:100]
      -
      -
      -# Process-local cache for Discord channel-type probes.  Avoids re-probing the
      -# same channel on every send when the directory cache has no entry (e.g. fresh
      -# install, or channel created after the last directory build).
      -_DISCORD_CHANNEL_TYPE_PROBE_CACHE: Dict[str, bool] = {}
      -
      -
      -def _remember_channel_is_forum(chat_id: str, is_forum: bool) -> None:
      -    _DISCORD_CHANNEL_TYPE_PROBE_CACHE[str(chat_id)] = bool(is_forum)
      -
      -
      -def _probe_is_forum_cached(chat_id: str) -> Optional[bool]:
      -    return _DISCORD_CHANNEL_TYPE_PROBE_CACHE.get(str(chat_id))
      -
      -
      -async def _send_discord(token, chat_id, message, thread_id=None, media_files=None):
      -    """Send a single message via Discord REST API (no websocket client needed).
      -
      -    Chunking is handled by _send_to_platform() before this is called.
      -
      -    When thread_id is provided, the message is sent directly to that thread
      -    via the /channels/{thread_id}/messages endpoint.
      -
      -    Media files are uploaded one-by-one via multipart/form-data after the
      -    text message is sent (same pattern as Telegram).
      -
      -    Forum channels (type 15) reject POST /messages — a thread post is created
      -    automatically via POST /channels/{id}/threads.  Media files are uploaded
      -    as multipart attachments on the starter message of the new thread.
      -
      -    Channel type is resolved from the channel directory first, then a
      -    process-local probe cache, and only as a last resort with a live
      -    GET /channels/{id} probe (whose result is memoized).
      -    """
      -    try:
      -        import aiohttp
      -    except ImportError:
      -        return {"error": "aiohttp not installed. Run: pip install aiohttp"}
      -    try:
      -        from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp
      -        _proxy = resolve_proxy_url(platform_env_var="DISCORD_PROXY")
      -        _sess_kw, _req_kw = proxy_kwargs_for_aiohttp(_proxy)
      -        auth_headers = {"Authorization": f"Bot {token}"}
      -        json_headers = {**auth_headers, "Content-Type": "application/json"}
      -        media_files = media_files or []
      -        last_data = None
      -        warnings = []
      -
      -        # Thread endpoint: Discord threads are channels; send directly to the thread ID.
      -        if thread_id:
      -            url = f"https://discord.com/api/v10/channels/{thread_id}/messages"
      -        else:
      -            # Check if the target channel is a forum channel (type 15).
      -            # Forum channels reject POST /messages — create a thread post instead.
      -            # Three-layer detection: directory cache → process-local probe
      -            # cache → GET /channels/{id} probe (with result memoized).
      -            _channel_type = None
      -            try:
      -                from gateway.channel_directory import lookup_channel_type
      -                _channel_type = lookup_channel_type("discord", chat_id)
      -            except Exception:
      -                pass
      -
      -            if _channel_type == "forum":
      -                is_forum = True
      -            elif _channel_type is not None:
      -                is_forum = False
      -            else:
      -                cached = _probe_is_forum_cached(chat_id)
      -                if cached is not None:
      -                    is_forum = cached
      -                else:
      -                    is_forum = False
      -                    try:
      -                        info_url = f"https://discord.com/api/v10/channels/{chat_id}"
      -                        async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=15), **_sess_kw) as info_sess:
      -                            async with info_sess.get(info_url, headers=json_headers, **_req_kw) as info_resp:
      -                                if info_resp.status == 200:
      -                                    info = await info_resp.json()
      -                                    is_forum = info.get("type") == 15
      -                                    _remember_channel_is_forum(chat_id, is_forum)
      -                    except Exception:
      -                        logger.debug("Failed to probe channel type for %s", chat_id, exc_info=True)
      -
      -            if is_forum:
      -                thread_name = _derive_forum_thread_name(message)
      -                thread_url = f"https://discord.com/api/v10/channels/{chat_id}/threads"
      -
      -                # Filter to readable media files up front so we can pick the
      -                # right code path (JSON vs multipart) before opening a session.
      -                valid_media = []
      -                for media_path, _is_voice in media_files:
      -                    if not os.path.exists(media_path):
      -                        warning = f"Media file not found, skipping: {media_path}"
      -                        logger.warning(warning)
      -                        warnings.append(warning)
      -                        continue
      -                    valid_media.append(media_path)
      -
      -                async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=60), **_sess_kw) as session:
      -                    if valid_media:
      -                        # Multipart: payload_json + files[N] creates a forum
      -                        # thread with the starter message plus attachments in
      -                        # a single API call.
      -                        attachments_meta = [
      -                            {"id": str(idx), "filename": os.path.basename(path)}
      -                            for idx, path in enumerate(valid_media)
      -                        ]
      -                        starter_message = {"content": message, "attachments": attachments_meta}
      -                        payload_json = json.dumps({"name": thread_name, "message": starter_message})
      -
      -                        form = aiohttp.FormData()
      -                        form.add_field("payload_json", payload_json, content_type="application/json")
      -
      -                        # Buffer file bytes up front — aiohttp's FormData can
      -                        # read lazily and we don't want handles closing under
      -                        # it on retry.
      -                        try:
      -                            for idx, media_path in enumerate(valid_media):
      -                                with open(media_path, "rb") as fh:
      -                                    form.add_field(
      -                                        f"files[{idx}]",
      -                                        fh.read(),
      -                                        filename=os.path.basename(media_path),
      -                                    )
      -                            async with session.post(thread_url, headers=auth_headers, data=form, **_req_kw) as resp:
      -                                if resp.status not in {200, 201}:
      -                                    body = await resp.text()
      -                                    return _error(f"Discord forum thread creation error ({resp.status}): {body}")
      -                                data = await resp.json()
      -                        except Exception as e:
      -                            return _error(_sanitize_error_text(f"Discord forum thread upload failed: {e}"))
      -                    else:
      -                        # No media — simple JSON POST creates the thread with
      -                        # just the text starter.
      -                        async with session.post(
      -                            thread_url,
      -                            headers=json_headers,
      -                            json={
      -                                "name": thread_name,
      -                                "message": {"content": message},
      -                            },
      -                            **_req_kw,
      -                        ) as resp:
      -                            if resp.status not in {200, 201}:
      -                                body = await resp.text()
      -                                return _error(f"Discord forum thread creation error ({resp.status}): {body}")
      -                            data = await resp.json()
      -
      -                thread_id_created = data.get("id")
      -                starter_msg_id = (data.get("message") or {}).get("id", thread_id_created)
      -                result = {
      -                    "success": True,
      -                    "platform": "discord",
      -                    "chat_id": chat_id,
      -                    "thread_id": thread_id_created,
      -                    "message_id": starter_msg_id,
      -                }
      -                if warnings:
      -                    result["warnings"] = warnings
      -                return result
      -
      -            url = f"https://discord.com/api/v10/channels/{chat_id}/messages"
      -
      -        async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30), **_sess_kw) as session:
      -            # Send text message (skip if empty and media is present)
      -            if message.strip() or not media_files:
      -                async with session.post(url, headers=json_headers, json={"content": message}, **_req_kw) as resp:
      -                    if resp.status not in {200, 201}:
      -                        body = await resp.text()
      -                        return _error(f"Discord API error ({resp.status}): {body}")
      -                    last_data = await resp.json()
      -
      -            # Send each media file as a separate multipart upload
      -            for media_path, _is_voice in media_files:
      -                if not os.path.exists(media_path):
      -                    warning = f"Media file not found, skipping: {media_path}"
      -                    logger.warning(warning)
      -                    warnings.append(warning)
      -                    continue
      -                try:
      -                    form = aiohttp.FormData()
      -                    filename = os.path.basename(media_path)
      -                    with open(media_path, "rb") as f:
      -                        form.add_field("files[0]", f, filename=filename)
      -                        async with session.post(url, headers=auth_headers, data=form, **_req_kw) as resp:
      -                            if resp.status not in {200, 201}:
      -                                body = await resp.text()
      -                                warning = _sanitize_error_text(f"Failed to send media {media_path}: Discord API error ({resp.status}): {body}")
      -                                logger.error(warning)
      -                                warnings.append(warning)
      -                                continue
      -                            last_data = await resp.json()
      -                except Exception as e:
      -                    warning = _sanitize_error_text(f"Failed to send media {media_path}: {e}")
      -                    logger.error(warning)
      -                    warnings.append(warning)
      -
      -        if last_data is None:
      -            error = "No deliverable text or media remained after processing"
      -            if warnings:
      -                return {"error": error, "warnings": warnings}
      -            return {"error": error}
      -
      -        result = {"success": True, "platform": "discord", "chat_id": chat_id, "message_id": last_data.get("id")}
      -        if warnings:
      -            result["warnings"] = warnings
      -        return result
      -    except Exception as e:
      -        return _error(f"Discord send failed: {e}")
      -
      -
       async def _send_slack(token, chat_id, message):
           """Send via Slack Web API."""
           try:

1 PR closed without merging

From the close comments: merged via another change 1

  • #24356 refactor(gateway): migrate Discord adapter to bundled plugin · merged via another change → #30591
Closed issues · 1
  • Closed#24325refactor(gateway): migrate Discord adapter to bundled plugin (first migration of an existing built-in)
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.67: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Merged PRs
#30591
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
22 May 2026

For your agent

The same facts, typed.