# NousResearch/hermes-agent · Migrate Discord gateway adapter to bundled plugin

## Open now

- 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.

## TL;DR

- 3 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #30591. Verify it addresses your case.
- **Recommended:** read PR #30591 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-30591`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.67`: the share of member 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.

## Closed / duplicate issues

- #24325: refactor(gateway): migrate Discord adapter to bundled plugin (first migration of an existing built-in)

## Prior & competing PRs

- **#30591: refactor(gateway): migrate Discord adapter to bundled plugin (salvage of #24356)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - 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)
  - fix hunks:
  - `gateway/config.py`

    ```diff
    @@ -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`

    ```diff
    @@ -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).
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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",
    ```

    ```diff
    @@ -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:
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -0,0 +1,3 @@
    +from .adapter import register
    +
    +__all__ = ["register"]
    ```
  - `plugins/platforms/discord/adapter.py`

    ```diff
    @@ -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)
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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
     
     
    ```

    ```diff
    @@ -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")
    ```

    ```diff
    @@ -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:
    ```

    ```diff
    @@ -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:
    ```

    ```diff
    @@ -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")
    ```

    ```diff
    @@ -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:
    ```

    ```diff
    @@ -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")
    ```

    ```diff
    @@ -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")
     
    ```

    ```diff
    @@ -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(
    ```

    ```diff
    @@ -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()
    ```

    ```diff
    @@ -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(
    ```

    ```diff
    @@ -386,7 +386,7 @@ class _FakeDMChannel:
                     name = "dm"
     
                 monkeypatch.setattr(
    -                "gateway.platforms.discord.discord.DMChannel",
    +                "plugins.platforms.discord.adapter.discord.DMChannel",
                     _FakeDMChannel,
                 )
                 chan = _FakeDMChannel()
    ```

    ```diff
    @@ -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(
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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
     
     
     # ---------------------------------------------------------------------------
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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).
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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, \
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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]"
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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]"
    ```

    ```diff
    @@ -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):
    ```

    ```diff
    @@ -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):
    ```

    ```diff
    @@ -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)
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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()"
    ```

    ```diff
    @@ -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]"
    ```

    ```diff
    @@ -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]"
    ```

    ```diff
    @@ -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"
    ```

    ```diff
    @@ -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()
    ```

    ```diff
    @@ -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()
    ```

    ```diff
    @@ -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]"
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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]"
    ```

    ```diff
    @@ -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={})
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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):
    ```

    ```diff
    @@ -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,
    ```

    ```diff
    @@ -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,
    ```

    ```diff
    @@ -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,
    ```

    ```diff
    @@ -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,
    ```

    ```diff
    @@ -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,
    ```

    ```diff
    @@ -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,
    ```

    ```diff
    @@ -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,
    ```

    ```diff
    @@ -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,
             )
    ```

    ```diff
    @@ -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 = []
     
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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)
    ```

    ```diff
    @@ -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:
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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:
    ```
- **#24356: refactor(gateway): migrate Discord adapter to bundled plugin**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#30591`

## Fix status

- Merged to main in PR #30591 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-05-22`.
