{"repo":"NousResearch/hermes-agent","public_id":"24325","display_label":"Migrate Discord gateway adapter to bundled plugin","summary":"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.","subsystem":null,"counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":30591,"merged_at":"2026-05-22T21:21:42+00:00"},"resolution":{"state":"resolved","by":"pr-30591"},"freshness":{"state":"verified","last_verified_at":"2026-05-22T21:21:42+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":24325,"pr_number":30591,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-22T21:21:42+00:00"}],"members":[{"kind":"issue","number":24325,"title":"refactor(gateway): migrate Discord adapter to bundled plugin (first migration of an existing built-in)","url":null,"state":"closed"},{"kind":"pr","number":24356,"title":"refactor(gateway): migrate Discord adapter to bundled plugin","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#30591","number":30591}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":30591,"title":"refactor(gateway): migrate Discord adapter to bundled plugin (salvage of #24356)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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","tests/gateway/test_discord_lazy_install_views.py","tests/gateway/test_discord_media_metadata.py","tests/gateway/test_discord_model_picker.py","tests/gateway/test_discord_opus.py","tests/gateway/test_discord_race_polish.py","tests/gateway/test_discord_reactions.py","tests/gateway/test_discord_reply_mode.py","tests/gateway/test_discord_roles_dm_scope.py","tests/gateway/test_discord_send.py","tests/gateway/test_discord_slash_auth.py","tests/gateway/test_discord_slash_commands.py","tests/gateway/test_discord_thread_persistence.py","tests/gateway/test_reload_skills_discord_resync.py","tests/gateway/test_send_image_file.py","tests/gateway/test_send_multiple_images.py","tests/gateway/test_stream_consumer.py","tests/gateway/test_text_batching.py","tests/gateway/test_voice_command.py","tests/integration/test_voice_channel_flow.py","tests/tools/test_send_message_tool.py","tools/send_message_tool.py"],"fix_hunks":[{"file":"gateway/config.py","hunks":["@@ -926,73 +926,6 @@ def load_gateway_config() -> GatewayConfig:\n                         ac = \",\".join(str(v) for v in ac)\n                     os.environ[\"SLACK_ALLOWED_CHANNELS\"] = str(ac)\n \n-            # Discord settings → env vars (env vars take precedence)\n-            discord_cfg = yaml_cfg.get(\"discord\", {})\n-            if isinstance(discord_cfg, dict):\n-                if \"require_mention\" in discord_cfg and not os.getenv(\"DISCORD_REQUIRE_MENTION\"):\n-                    os.environ[\"DISCORD_REQUIRE_MENTION\"] = str(discord_cfg[\"require_mention\"]).lower()\n-                if \"thread_require_mention\" in discord_cfg and not os.getenv(\"DISCORD_THREAD_REQUIRE_MENTION\"):\n-                    os.environ[\"DISCORD_THREAD_REQUIRE_MENTION\"] = str(discord_cfg[\"thread_require_mention\"]).lower()\n-                frc = discord_cfg.get(\"free_response_channels\")\n-                if frc is not None and not os.getenv(\"DISCORD_FREE_RESPONSE_CHANNELS\"):\n-                    if isinstance(frc, list):\n-                        frc = \",\".join(str(v) for v in frc)\n-                    os.environ[\"DISCORD_FREE_RESPONSE_CHANNELS\"] = str(frc)\n-                if \"auto_thread\" in discord_cfg and not os.getenv(\"DISCORD_AUTO_THREAD\"):\n-                    os.environ[\"DISCORD_AUTO_THREAD\"] = str(discord_cfg[\"auto_thread\"]).lower()\n-                if \"reactions\" in discord_cfg and not os.getenv(\"DISCORD_REACTIONS\"):\n-                    os.environ[\"DISCORD_REACTIONS\"] = str(discord_cfg[\"reactions\"]).lower()\n-                # ignored_channels: channels where bot never responds (even when mentioned)\n-                ic = discord_cfg.get(\"ignored_channels\")\n-                if ic is not None and not os.getenv(\"DISCORD_IGNORED_CHANNELS\"):\n-                    if isinstance(ic, list):\n-                        ic = \",\".join(str(v) for v in ic)\n-                    os.environ[\"DISCORD_IGNORED_CHANNELS\"] = str(ic)\n-                # allowed_channels: if set, bot ONLY responds in these channels (whitelist)\n-                ac = discord_cfg.get(\"allowed_channels\")\n-                if ac is not None and not os.getenv(\"DISCORD_ALLOWED_CHANNELS\"):\n-                    if isinstance(ac, list):\n-                        ac = \",\".join(str(v) for v in ac)\n-                    os.environ[\"DISCORD_ALLOWED_CHANNELS\"] = str(ac)\n-                # no_thread_channels: channels where bot responds directly without creating thread\n-                ntc = discord_cfg.get(\"no_thread_channels\")\n-                if ntc is not None and not os.getenv(\"DISCORD_NO_THREAD_CHANNELS\"):\n-                    if isinstance(ntc, list):\n-                        ntc = \",\".join(str(v) for v in ntc)\n-                    os.environ[\"DISCORD_NO_THREAD_CHANNELS\"] = str(ntc)\n-                # history_backfill: recover missed channel messages for shared sessions\n-                # when require_mention is active.  Fetches messages between bot turns\n-                # and prepends them to the user message for context.\n-                if \"history_backfill\" in discord_cfg and not os.getenv(\"DISCORD_HISTORY_BACKFILL\"):\n-                    os.environ[\"DISCORD_HISTORY_BACKFILL\"] = str(discord_cfg[\"history_backfill\"]).lower()\n-                hbl = discord_cfg.get(\"history_backfill_limit\")\n-                if hbl is not None and not os.getenv(\"DISCORD_HISTORY_BACKFILL_LIMIT\"):\n-                    os.environ[\"DISCORD_HISTORY_BACKFILL_LIMIT\"] = str(hbl)\n-                # allow_mentions: granular control over what the bot can ping.\n-                # Safe defaults (no @everyone/roles) are applied in the adapter;\n-                # these YAML keys only override when set and let users opt back\n-                # into unsafe modes (e.g. roles=true) if they actually want it.\n-                allow_mentions_cfg = discord_cfg.get(\"allow_mentions\")\n-                if isinstance(allow_mentions_cfg, dict):\n-                    for yaml_key, env_key in (\n-                        (\"everyone\", \"DISCORD_ALLOW_MENTION_EVERYONE\"),\n-                        (\"roles\", \"DISCORD_ALLOW_MENTION_ROLES\"),\n-                        (\"users\", \"DISCORD_ALLOW_MENTION_USERS\"),\n-                        (\"replied_user\", \"DISCORD_ALLOW_MENTION_REPLIED_USER\"),\n-                    ):\n-                        if yaml_key in allow_mentions_cfg and not os.getenv(env_key):\n-                            os.environ[env_key] = str(allow_mentions_cfg[yaml_key]).lower()\n-                # reply_to_mode: top-level preferred, falls back to extra.reply_to_mode\n-                # YAML 1.1 parses bare 'off' as boolean False — coerce to string \"off\".\n-                _discord_extra = discord_cfg.get(\"extra\") if isinstance(discord_cfg.get(\"extra\"), dict) else {}\n-                _discord_rtm = (\n-                    discord_cfg[\"reply_to_mode\"] if \"reply_to_mode\" in discord_cfg\n-                    else _discord_extra.get(\"reply_to_mode\")\n-                )\n-                if _discord_rtm is not None and not os.getenv(\"DISCORD_REPLY_TO_MODE\"):\n-                    _rtm_str = \"off\" if _discord_rtm is False else str(_discord_rtm).lower()\n-                    os.environ[\"DISCORD_REPLY_TO_MODE\"] = _rtm_str\n-\n             # Bridge top-level require_mention to Telegram when the telegram: section\n             # does not already provide one.  Users often write \"require_mention: true\"\n             # at the top level alongside group_sessions_per_user, expecting it to work"]},{"file":"gateway/run.py","hunks":["@@ -5897,6 +5897,12 @@ def _create_adapter(\n             if platform_registry.is_registered(platform.value):\n                 adapter = platform_registry.create_adapter(platform.value, config)\n                 if adapter is not None:\n+                    # Adapters that need a back-reference to the gateway runner\n+                    # (e.g. for cross-platform admin alerts) declare a\n+                    # ``gateway_runner`` attribute. Inject it after creation so\n+                    # plugin adapters don't need a custom factory signature.\n+                    if hasattr(adapter, \"gateway_runner\"):\n+                        adapter.gateway_runner = self\n                     return adapter\n                 # Registered but failed to instantiate — don't silently fall\n                 # through to built-ins (there are none for plugin platforms).","@@ -5939,15 +5945,6 @@ def _create_adapter(\n             adapter._notifications_mode = _notify_mode\n             return adapter\n         \n-        elif platform == Platform.DISCORD:\n-            from gateway.platforms.discord import DiscordAdapter, check_discord_requirements\n-            if not check_discord_requirements():\n-                logger.warning(\"Discord: discord.py not installed\")\n-                return None\n-            adapter = DiscordAdapter(config)\n-            adapter.gateway_runner = self  # For cross-platform admin alerts on unauthorized slash\n-            return adapter\n-        \n         elif platform == Platform.WHATSAPP:\n             from gateway.platforms.whatsapp import WhatsAppAdapter, check_whatsapp_requirements\n             if not check_whatsapp_requirements():"]},{"file":"hermes_cli/commands.py","hunks":["@@ -449,7 +449,7 @@ def _iter_plugin_command_entries() -> list[tuple[str, str, str]]:\n     :func:`hermes_cli.plugins.PluginContext.register_command`. They behave\n     like ``CommandDef`` entries for gateway surfacing: they appear in the\n     Telegram command menu, in Slack's ``/hermes`` subcommand mapping, and\n-    (via :func:`gateway.platforms.discord._register_slash_commands`) in\n+    (via :func:`plugins.platforms.discord.adapter._register_slash_commands`) in\n     Discord's native slash command picker.\n \n     Lookup is lazy so importing this module never forces plugin discovery"]},{"file":"hermes_cli/gateway.py","hunks":["@@ -3327,34 +3327,9 @@ def _atexit_hook() -> None:\n              \"help\": \"For DMs, this is your user ID. You can set it later by typing /set-home in chat.\"},\n         ],\n     },\n-    {\n-        \"key\": \"discord\",\n-        \"label\": \"Discord\",\n-        \"emoji\": \"💬\",\n-        \"token_var\": \"DISCORD_BOT_TOKEN\",\n-        \"setup_instructions\": [\n-            \"1. Go to https://discord.com/developers/applications → New Application\",\n-            \"2. Go to Bot → Reset Token → copy the bot token\",\n-            \"3. Enable: Bot → Privileged Gateway Intents → Message Content Intent\",\n-            \"4. Invite the bot to your server:\",\n-            \"   OAuth2 → URL Generator → check BOTH scopes:\",\n-            \"     - bot\",\n-            \"     - applications.commands  (required for slash commands!)\",\n-            \"   Bot Permissions: Send Messages, Read Message History, Attach Files\",\n-            \"   Copy the URL and open it in your browser to invite.\",\n-            \"5. Get your user ID: enable Developer Mode in Discord settings,\",\n-            \"   then right-click your name → Copy ID\",\n-        ],\n-        \"vars\": [\n-            {\"name\": \"DISCORD_BOT_TOKEN\", \"prompt\": \"Bot token\", \"password\": True,\n-             \"help\": \"Paste the token from step 2 above.\"},\n-            {\"name\": \"DISCORD_ALLOWED_USERS\", \"prompt\": \"Allowed user IDs or usernames (comma-separated)\", \"password\": [redacted:secret-assignment]\n-             \"is_allowlist\": True,\n-             \"help\": \"Paste your user ID from step 5 above.\"},\n-            {\"name\": \"DISCORD_HOME_CHANNEL\", \"prompt\": \"Home channel ID (for cron/notification delivery, or empty to set later with /set-home)\", \"password\": [redacted:secret-assignment]\n-             \"help\": \"Right-click a channel → Copy Channel ID (requires Developer Mode).\"},\n-        ],\n-    },\n+    # Discord moved to plugins/platforms/discord/ — its setup metadata is\n+    # discovered dynamically via _all_platforms() from the platform registry\n+    # entry registered by plugins/platforms/discord/adapter.py::register().\n     {\n         \"key\": \"slack\",\n         \"label\": \"Slack\",","@@ -3762,7 +3737,12 @@ def _platform_status(platform: dict) -> str:\n                 configured = bool(entry.is_connected(synthetic))\n             except Exception:\n                 configured = False\n-        if not configured:\n+        else:\n+            # No is_connected hook — fall back to check_fn as a coarse\n+            # \"are deps present\" gate. Don't fall back when is_connected\n+            # is defined and returned False; that would let \"SDK is\n+            # installed\" override \"no token configured\" and incorrectly\n+            # report the platform as ready.\n             try:\n                 configured = bool(entry.check_fn())\n             except Exception:","@@ -4747,7 +4727,9 @@ def _builtin_setup_fn(key: str):\n     from hermes_cli import setup as _s\n     return {\n         \"telegram\": _s._setup_telegram,\n-        \"discord\": _s._setup_discord,\n+        # discord moved into the plugin: setup_fn is registered by\n+        # plugins/platforms/discord/adapter.py::register() and dispatched\n+        # via the plugin path in _configure_platform().\n         \"slack\": _s._setup_slack,\n         \"matrix\": _s._setup_matrix,\n         \"mattermost\": _s._setup_mattermost,"]},{"file":"hermes_cli/setup.py","hunks":["@@ -2034,74 +2034,6 @@ def _setup_telegram():\n             save_env_value(\"TELEGRAM_HOME_CHANNEL\", home_channel)\n \n \n-def _setup_discord():\n-    \"\"\"Configure Discord bot credentials and allowlist.\"\"\"\n-    print_header(\"Discord\")\n-    existing = get_env_value(\"DISCORD_BOT_TOKEN\")\n-    if existing:\n-        print_info(\"Discord: already configured\")\n-        if not prompt_yes_no(\"Reconfigure Discord?\", False):\n-            if not get_env_value(\"DISCORD_ALLOWED_USERS\"):\n-                print_info(\"⚠️  Discord has no user allowlist - anyone can use your bot!\")\n-                if prompt_yes_no(\"Add allowed users now?\", True):\n-                    print_info(\"   To find Discord ID: Enable Developer Mode, right-click name → Copy ID\")\n-                    allowed_users = prompt(\"Allowed user IDs (comma-separated)\")\n-                    if allowed_users:\n-                        cleaned_ids = _clean_discord_user_ids(allowed_users)\n-                        save_env_value(\"DISCORD_ALLOWED_USERS\", \",\".join(cleaned_ids))\n-                        print_success(\"Discord allowlist configured\")\n-            return\n-\n-    print_info(\"Create a bot at https://discord.com/developers/applications\")\n-    token = [redacted:secret-assignment]\"Discord bot token\", password=True)\n-    if not token:\n-        return\n-    save_env_value(\"DISCORD_BOT_TOKEN\", token)\n-    print_success(\"Discord token saved\")\n-\n-    print()\n-    print_info(\"🔒 Security: Restrict who can use your bot\")\n-    print_info(\"   To find your Discord user ID:\")\n-    print_info(\"   1. Enable Developer Mode in Discord settings\")\n-    print_info(\"   2. Right-click your name → Copy ID\")\n-    print()\n-    print_info(\"   You can also use Discord usernames (resolved on gateway start).\")\n-    print()\n-    allowed_users = prompt(\n-        \"Allowed user IDs or usernames (comma-separated, leave empty for open access)\"\n-    )\n-    if allowed_users:\n-        cleaned_ids = _clean_discord_user_ids(allowed_users)\n-        save_env_value(\"DISCORD_ALLOWED_USERS\", \",\".join(cleaned_ids))\n-        print_success(\"Discord allowlist configured\")\n-    else:\n-        print_info(\"⚠️  No allowlist set - anyone in servers with your bot can use it!\")\n-\n-    print()\n-    print_info(\"📬 Home Channel: where Hermes delivers cron job results,\")\n-    print_info(\"   cross-platform messages, and notifications.\")\n-    print_info(\"   To get a channel ID: right-click a channel → Copy Channel ID\")\n-    print_info(\"   (requires Developer Mode in Discord settings)\")\n-    print_info(\"   You can also set this later by typing /set-home in a Discord channel.\")\n-    home_channel = prompt(\"Home channel ID (leave empty to set later with /set-home)\")\n-    if home_channel:\n-        save_env_value(\"DISCORD_HOME_CHANNEL\", home_channel)\n-\n-\n-def _clean_discord_user_ids(raw: str) -> list:\n-    \"\"\"Strip common Discord mention prefixes from a comma-separated ID string.\"\"\"\n-    cleaned = []\n-    for uid in raw.replace(\" \", \"\").split(\",\"):\n-        uid = uid.strip()\n-        if uid.startswith(\"<@\") and uid.endswith(\">\"):\n-            uid = uid.lstrip(\"<@!\").rstrip(\">\")\n-        if uid.lower().startswith(\"user:\"):\n-            uid = uid[5:]\n-        if uid:\n-            cleaned.append(uid)\n-    return cleaned\n-\n-\n def _setup_slack():\n     \"\"\"Configure Slack bot credentials.\"\"\"\n     print_header(\"Slack\")"]},{"file":"plugins/platforms/discord/__init__.py","hunks":["@@ -0,0 +1,3 @@\n+from .adapter import register\n+\n+__all__ = [\"register\"]"]},{"file":"plugins/platforms/discord/adapter.py","hunks":["@@ -1489,7 +1489,8 @@ async def _send_to_forum(self, forum_channel: Any, content: str) -> SendResult:\n         reported in ``raw_response['warnings']`` so the caller can surface\n         partial-send issues.\n         \"\"\"\n-        from tools.send_message_tool import _derive_forum_thread_name\n+        # _derive_forum_thread_name is defined further down in this same\n+        # module — no cross-module import needed.\n \n         formatted = self.format_message(content)\n         chunks = self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH)","@@ -1551,7 +1552,8 @@ async def _forum_post_file(\n         ForumChannel accepts the same file/files/content kwargs as\n         ``channel.send``, creating the thread and starter message atomically.\n         \"\"\"\n-        from tools.send_message_tool import _derive_forum_thread_name\n+        # _derive_forum_thread_name is defined further down in this same\n+        # module — no cross-module import needed.\n \n         if not thread_name:\n             # Prefer the text content, fall back to the first attached","@@ -5699,7 +5701,492 @@ async def on_timeout(self):\n             self.resolved = True\n             for child in self.children:\n                 child.disabled = True\n-\n-\n if DISCORD_AVAILABLE:\n     _define_discord_view_classes()\n+\n+\n+# ── Standalone (out-of-process) sender ────────────────────────────────────────\n+# Used by ``tools/send_message_tool._send_via_adapter`` when the gateway runner\n+# is not in this process (e.g. ``hermes cron`` running standalone) and no live\n+# DiscordAdapter instance is available.  Implements the same forum/thread/\n+# multipart logic the live adapter would use, via Discord's REST API directly.\n+#\n+# This block was previously hosted in ``tools/send_message_tool.py`` as\n+# ``_send_discord``.  It moved into the plugin so all Discord-specific HTTP\n+# logic lives next to the adapter — same shape as Teams' ``_standalone_send``.\n+\n+# Process-local cache for Discord channel-type probes.  Avoids re-probing the\n+# same channel on every send when the directory cache has no entry (e.g. fresh\n+# install, or channel created after the last directory build).\n+_DISCORD_CHANNEL_TYPE_PROBE_CACHE: Dict[str, bool] = {}\n+\n+\n+def _remember_channel_is_forum(chat_id: str, is_forum: bool) -> None:\n+    _DISCORD_CHANNEL_TYPE_PROBE_CACHE[str(chat_id)] = bool(is_forum)\n+\n+\n+def _probe_is_forum_cached(chat_id: str) -> Optional[bool]:\n+    return _DISCORD_CHANNEL_TYPE_PROBE_CACHE.get(str(chat_id))\n+\n+\n+def _derive_forum_thread_name(message: str) -> str:\n+    \"\"\"Derive a thread name from the first line of the message, capped at 100 chars.\"\"\"\n+    first_line = message.strip().split(\"\\n\", 1)[0].strip()\n+    # Strip common markdown heading prefixes\n+    first_line = first_line.lstrip(\"#\").strip()\n+    if not first_line:\n+        first_line = \"New Post\"\n+    return first_line[:100]\n+\n+\n+def _standalone_sanitize_error(text) -> str:\n+    \"\"\"Local copy of tools.send_message_tool._sanitize_error_text — strips bot\n+    tokens from any error payload before bubbling it up.  Inlined so the\n+    plugin doesn't introduce a hard dependency on send_message_tool internals.\n+    \"\"\"\n+    s = str(text)\n+    # Mask anything that looks like a Bot token in an Authorization header.\n+    import re as _re_san\n+    return _re_san.sub(\n+        r\"(Authorization:\\s*Bot\\s+)\\S+\",\n+        r\"\\1***\",\n+        s,\n+        flags=_re_san.IGNORECASE,\n+    )\n+\n+\n+async def _standalone_send(\n+    pconfig,\n+    chat_id: str,\n+    message: str,\n+    *,\n+    thread_id: Optional[str] = None,\n+    media_files: Optional[list] = None,\n+    force_document: bool = False,\n+) -> Dict[str, Any]:\n+    \"\"\"Send via Discord REST API without a live gateway adapter.\n+\n+    Used by ``tools/send_message_tool._send_via_adapter`` when the gateway\n+    runner is not in this process.  Reads ``DISCORD_BOT_TOKEN`` from\n+    ``pconfig.token`` (set by the gateway config loader from env) and falls\n+    back to the ``DISCORD_BOT_TOKEN`` env var.\n+\n+    Forum channels (type 15) reject ``POST /messages`` — a thread post is\n+    created automatically via ``POST /channels/{id}/threads``.  Media files\n+    are uploaded as multipart attachments on the starter message of the new\n+    thread.  Channel type is resolved from the channel directory first, then\n+    a process-local probe cache, and only as a last resort with a live\n+    ``GET /channels/{id}`` probe (whose result is memoized).\n+\n+    ``force_document`` is accepted for signature parity but unused — Discord\n+    treats every uploaded file as a generic attachment.\n+    \"\"\"\n+    try:\n+        import aiohttp\n+    except ImportError:\n+        return {\"error\": \"aiohttp not installed. Run: pip install aiohttp\"}\n+\n+    token = [redacted:secret-assignment] \"token\", None) or os.getenv(\"DISCORD_BOT_TOKEN\", \"\")).strip()\n+    if not token:\n+        return {\"error\": \"Discord standalone send: DISCORD_BOT_TOKEN is not set\"}\n+\n+    try:\n+        from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp\n+        _proxy = resolve_proxy_url(platform_env_var=\"DISCORD_PROXY\")\n+        _sess_kw, _req_kw = proxy_kwargs_for_aiohttp(_proxy)\n+        auth_headers = {\"Authorization\": f\"Bot {token}\"}\n+        json_headers = {**auth_headers, \"Content-Type\": \"application/json\"}\n+        media_files = media_files or []\n+        last_data = None\n+        warnings = []\n+\n+        # Thread endpoint: Discord threads are channels; send directly to the thread ID.\n+        if thread_id:\n+            url = f\"https://discord.com/api/v10/channels/{thread_id}/messages\"\n+        else:\n+            # Check if the target channel is a forum channel (type 15).\n+            # Forum channels reject POST /messages — create a thread post instead.\n+            # Three-layer detection: directory cache → process-local probe\n+            # cache → GET /channels/{id} probe (with result memoized).\n+            _channel_type = None\n+            try:\n+                from gateway.channel_directory import lookup_channel_type\n+                _channel_type = lookup_channel_type(\"discord\", chat_id)\n+            except Exception:\n+                pass\n+\n+            if _channel_type == \"forum\":\n+                is_forum = True\n+            elif _channel_type is not None:\n+                is_forum = False\n+            else:\n+                cached = _probe_is_forum_cached(chat_id)\n+                if cached is not None:\n+                    is_forum = cached\n+                else:\n+                    is_forum = False\n+                    try:\n+                        info_url = f\"https://discord.com/api/v10/channels/{chat_id}\"\n+                        async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=15), **_sess_kw) as info_sess:\n+                            async with info_sess.get(info_url, headers=json_headers, **_req_kw) as info_resp:\n+                                if info_resp.status == 200:\n+                                    info = await info_resp.json()\n+                                    is_forum = info.get(\"type\") == 15\n+                                    _remember_channel_is_forum(chat_id, is_forum)\n+                    except Exception:\n+                        logger.debug(\"Failed to probe channel type for %s\", chat_id, exc_info=True)\n+\n+            if is_forum:\n+                thread_name = _derive_forum_thread_name(message)\n+                thread_url = f\"https://discord.com/api/v10/channels/{chat_id}/threads\"\n+\n+                # Filter to readable media files up front so we can pick the\n+                # right code path (JSON vs multipart) before opening a session.\n+                valid_media = []\n+                for media_path, _is_voice in media_files:\n+                    if not os.path.exists(media_path):\n+                        warning = f\"Media file not found, skipping: {media_path}\"\n+                        logger.warning(warning)\n+                        warnings.append(warning)\n+                        continue\n+                    valid_media.append(media_path)\n+\n+                async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=60), **_sess_kw) as session:\n+                    if valid_media:\n+                        # Multipart: payload_json + files[N] creates a forum\n+                        # thread with the starter message plus attachments in\n+                        # a single API call.\n+                        attachments_meta = [\n+                            {\"id\": str(idx), \"filename\": os.path.basename(path)}\n+                            for idx, path in enumerate(valid_media)\n+                        ]\n+                        starter_message = {\"content\": message, \"attachments\": attachments_meta}\n+                        payload_json = json.dumps({\"name\": thread_name, \"message\": starter_message})\n+\n+                        form = aiohttp.FormData()\n+                        form.add_field(\"payload_json\", payload_json, content_type=\"application/json\")\n+\n+                        try:\n+                            for idx, media_path in enumerate(valid_media):\n+                                with open(media_path, \"rb\") as fh:\n+                                    form.add_field(\n+                                        f\"files[{idx}]\",\n+                                        fh.read(),\n+                                        filename=os.path.basename(media_path),\n+                                    )\n+                            async with session.post(thread_url, headers=auth_headers, data=form, **_req_kw) as resp:\n+                                if resp.status not in {200, 201}:\n+                                    body = await resp.text()\n+                                    return {\"error\": f\"Discord forum thread creation error ({resp.status}): {body}\"}\n+                                data = await resp.json()\n+                        except Exception as e:\n+                            return {\"error\": _standalone_sanitize_error(f\"Discord forum thread upload failed: {e}\")}\n+                    else:\n+                        # No media — simple JSON POST creates the thread with\n+                        # just the text starter.\n+                        async with session.post(\n+                            thread_url,\n+                            headers=json_headers,\n+                            json={\n+                                \"name\": thread_name,\n+                                \"message\": {\"content\": message},\n+                            },\n+                            **_req_kw,\n+                        ) as resp:\n+                            if resp.status not in {200, 201}:\n+                                body = await resp.text()\n+                                return {\"error\": f\"Discord forum thread creation error ({resp.status}): {body}\"}\n+                            data = await resp.json()\n+\n+                thread_id_created = data.get(\"id\")\n+                starter_msg_id = (data.get(\"message\") or {}).get(\"id\", thread_id_created)\n+                result = {\n+                    \"success\": True,\n+                    \"platform\": \"discord\",\n+                    \"chat_id\": chat_id,\n+                    \"thread_id\": thread_id_created,\n+                    \"message_id\": starter_msg_id,\n+                }\n+                if warnings:\n+                    result[\"warnings\"] = warnings\n+                return result\n+\n+            url = f\"https://discord.com/api/v10/channels/{chat_id}/messages\"\n+\n+        async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30), **_sess_kw) as session:\n+            # Send text message (skip if empty and media is present)\n+            if message.strip() or not media_files:\n+                async with session.post(url, headers=json_headers, json={\"content\": message}, **_req_kw) as resp:\n+                    if resp.status not in {200, 201}:\n+                        body = await resp.text()\n+                        return {\"error\": f\"Discord API error ({resp.status}): {body}\"}\n+                    last_data = await resp.json()\n+\n+            # Send each media file as a separate multipart upload\n+            for media_path, _is_voice in media_files:\n+                if not os.path.exists(media_path):\n+                    warning = f\"Media file not found, skipping: {media_path}\"\n+                    logger.warning(warning)\n+                    warnings.append(warning)\n+                    continue\n+                try:\n+                    form = aiohttp.FormData()\n+                    filename = os.path.basename(media_path)\n+                    with open(media_path, \"rb\") as f:\n+                        form.add_field(\"files[0]\", f, filename=filename)\n+                        async with session.post(url, headers=auth_headers, data=form, **_req_kw) as resp:\n+                            if resp.status not in {200, 201}:\n+                                body = await resp.text()\n+                                warning = _standalone_sanitize_error(f\"Failed to send media {media_path}: Discord API error ({resp.status}): {body}\")\n+                                logger.error(warning)\n+                                warnings.append(warning)\n+                                continue\n+                            last_data = await resp.json()\n+                except Exception as e:\n+                    warning = _standalone_sanitize_error(f\"Failed to send media {media_path}: {e}\")\n+                    logger.error(warning)\n+                    warnings.append(warning)\n+\n+        if last_data is None:\n+            error = \"No deliverable text or media remained after processing\"\n+            if warnings:\n+                return {\"error\": error, \"warnings\": warnings}\n+            return {\"error\": error}\n+\n+        result = {\"success\": True, \"platform\": \"discord\", \"chat_id\": chat_id, \"message_id\": last_data.get(\"id\")}\n+        if warnings:\n+            result[\"warnings\"] = warnings\n+        return result\n+    except Exception as e:\n+        return {\"error\": _standalone_sanitize_error(f\"Discord send failed: {e}\")}\n+\n+\n+# ── Plugin entry point ────────────────────────────────────────────────────────\n+\n+\n+def _clean_discord_user_ids(raw: str) -> list:\n+    \"\"\"Strip common Discord mention prefixes from a comma-separated ID string.\"\"\"\n+    cleaned = []\n+    for uid in raw.replace(\" \", \"\").split(\",\"):\n+        uid = uid.strip()\n+        if uid.startswith(\"<@\") and uid.endswith(\">\"):\n+            uid = uid.lstrip(\"<@!\").rstrip(\">\")\n+        if uid.lower().startswith(\"user:\"):\n+            uid = uid[5:]\n+        if uid:\n+            cleaned.append(uid)\n+    return cleaned\n+\n+\n+def interactive_setup() -> None:\n+    \"\"\"Guide the user through Discord bot setup.\n+\n+    Mirrors Teams' ``interactive_setup`` shape: lazy-imports CLI helpers so\n+    the plugin's import surface stays small, prompts for the bot token,\n+    captures an allowlist, and offers to set a home channel.\n+    \"\"\"\n+    from hermes_cli.config import get_env_value, save_env_value\n+    from hermes_cli.cli_output import (\n+        prompt,\n+        prompt_yes_no,\n+        print_header,\n+        print_info,\n+        print_success,\n+    )\n+\n+    print_header(\"Discord\")\n+    existing = get_env_value(\"DISCORD_BOT_TOKEN\")\n+    if existing:\n+        print_info(\"Discord: already configured\")\n+        if not prompt_yes_no(\"Reconfigure Discord?\", False):\n+            if not get_env_value(\"DISCORD_ALLOWED_USERS\"):\n+                print_info(\"⚠️  Discord has no user allowlist - anyone can use your bot!\")\n+                if prompt_yes_no(\"Add allowed users now?\", True):\n+                    print_info(\"   To find Discord ID: Enable Developer Mode, right-click name → Copy ID\")\n+                    allowed_users = prompt(\"Allowed user IDs (comma-separated)\")\n+                    if allowed_users:\n+                        cleaned_ids = _clean_discord_user_ids(allowed_users)\n+                        save_env_value(\"DISCORD_ALLOWED_USERS\", \",\".join(cleaned_ids))\n+                        print_success(\"Discord allowlist configured\")\n+            return\n+\n+    print_info(\"Create a bot at https://discord.com/developers/applications\")\n+    token = [redacted:secret-assignment]\"Discord bot token\", password=True)\n+    if not token:\n+        return\n+    save_env_value(\"DISCORD_BOT_TOKEN\", token)\n+    print_success(\"Discord token saved\")\n+\n+    print()\n+    print_info(\"🔒 Security: Restrict who can use your bot\")\n+    print_info(\"   To find your Discord user ID:\")\n+    print_info(\"   1. Enable Developer Mode in Discord settings\")\n+    print_info(\"   2. Right-click your name → Copy ID\")\n+    print()\n+    print_info(\"   You can also use Discord usernames (resolved on gateway start).\")\n+    print()\n+    allowed_users = prompt(\n+        \"Allowed user IDs or usernames (comma-separated, leave empty for open access)\"\n+    )\n+    if allowed_users:\n+        cleaned_ids = _clean_discord_user_ids(allowed_users)\n+        save_env_value(\"DISCORD_ALLOWED_USERS\", \",\".join(cleaned_ids))\n+        print_success(\"Discord allowlist configured\")\n+    else:\n+        print_info(\"⚠️  No allowlist set - anyone in servers with your bot can use it!\")\n+\n+    print()\n+    print_info(\"📬 Home Channel: where Hermes delivers cron job results,\")\n+    print_info(\"   cross-platform messages, and notifications.\")\n+    print_info(\"   To get a channel ID: right-click a channel → Copy Channel ID\")\n+    print_info(\"   (requires Developer Mode in Discord settings)\")\n+    print_info(\"   You can also set this later by typing /set-home in a Discord channel.\")\n+    home_channel = prompt(\"Home channel ID (leave empty to set later with /set-home)\")\n+    if home_channel:\n+        save_env_value(\"DISCORD_HOME_CHANNEL\", home_channel)\n+\n+\n+def _apply_yaml_config(yaml_cfg: dict, discord_cfg: dict) -> dict | None:\n+    \"\"\"Translate ``config.yaml`` ``discord:`` keys into env vars.\n+\n+    Implements the ``apply_yaml_config_fn`` contract (#24836).  Mirrors the\n+    legacy ``discord_cfg`` block that used to live in\n+    ``gateway/config.py::load_gateway_config()`` before this migration.\n+\n+    The DiscordAdapter reads its runtime configuration via ``os.getenv()``\n+    throughout the connect / handle code paths (``DISCORD_REQUIRE_MENTION``,\n+    ``DISCORD_FREE_RESPONSE_CHANNELS``, ``DISCORD_AUTO_THREAD``,\n+    ``DISCORD_REACTIONS``, ``DISCORD_IGNORED_CHANNELS``,\n+    ``DISCORD_ALLOWED_CHANNELS``, ``DISCORD_NO_THREAD_CHANNELS``,\n+    ``DISCORD_HISTORY_BACKFILL``, ``DISCORD_HISTORY_BACKFILL_LIMIT``,\n+    ``DISCORD_ALLOW_MENTION_*``, ``DISCORD_REPLY_TO_MODE``,\n+    ``DISCORD_THREAD_REQUIRE_MENTION``).  Rather than rewrite ~50 call sites\n+    inside the adapter to read from ``PlatformConfig.extra`` instead, this\n+    hook keeps the existing env-driven model and merely owns the\n+    YAML→env translation here, next to the adapter that consumes it.\n+\n+    Env vars take precedence over YAML — every assignment is guarded by\n+    ``not os.getenv(...)`` so explicit env vars survive a config.yaml\n+    update.  Returns ``None`` because no extras are seeded into\n+    ``PlatformConfig.extra`` directly (everything flows through env).\n+    \"\"\"\n+    if \"require_mention\" in discord_cfg and not os.getenv(\"DISCORD_REQUIRE_MENTION\"):\n+        os.environ[\"DISCORD_REQUIRE_MENTION\"] = str(discord_cfg[\"require_mention\"]).lower()\n+    if \"thread_require_mention\" in discord_cfg and not os.getenv(\"DISCORD_THREAD_REQUIRE_MENTION\"):\n+        os.environ[\"DISCORD_THREAD_REQUIRE_MENTION\"] = str(discord_cfg[\"thread_require_mention\"]).lower()\n+    frc = discord_cfg.get(\"free_response_channels\")\n+    if frc is not None and not os.getenv(\"DISCORD_FREE_RESPONSE_CHANNELS\"):\n+        if isinstance(frc, list):\n+            frc = \",\".join(str(v) for v in frc)\n+        os.environ[\"DISCORD_FREE_RESPONSE_CHANNELS\"] = str(frc)\n+    if \"auto_thread\" in discord_cfg and not os.getenv(\"DISCORD_AUTO_THREAD\"):\n+        os.environ[\"DISCORD_AUTO_THREAD\"] = str(discord_cfg[\"auto_thread\"]).lower()\n+    if \"reactions\" in discord_cfg and not os.getenv(\"DISCORD_REACTIONS\"):\n+        os.environ[\"DISCORD_REACTIONS\"] = str(discord_cfg[\"reactions\"]).lower()\n+    # ignored_channels: channels where bot never responds (even when mentioned)\n+    ic = discord_cfg.get(\"ignored_channels\")\n+    if ic is not None and not os.getenv(\"DISCORD_IGNORED_CHANNELS\"):\n+        if isinstance(ic, list):\n+            ic = \",\".join(str(v) for v in ic)\n+        os.environ[\"DISCORD_IGNORED_CHANNELS\"] = str(ic)\n+    # allowed_channels: if set, bot ONLY responds in these channels (whitelist)\n+    ac = discord_cfg.get(\"allowed_channels\")\n+    if ac is not None and not os.getenv(\"DISCORD_ALLOWED_CHANNELS\"):\n+        if isinstance(ac, list):\n+            ac = \",\".join(str(v) for v in ac)\n+        os.environ[\"DISCORD_ALLOWED_CHANNELS\"] = str(ac)\n+    # no_thread_channels: channels where bot responds directly without creating thread\n+    ntc = discord_cfg.get(\"no_thread_channels\")\n+    if ntc is not None and not os.getenv(\"DISCORD_NO_THREAD_CHANNELS\"):\n+        if isinstance(ntc, list):\n+            ntc = \",\".join(str(v) for v in ntc)\n+        os.environ[\"DISCORD_NO_THREAD_CHANNELS\"] = str(ntc)\n+    # history_backfill: recover missed channel messages for shared sessions\n+    # when require_mention is active.  Fetches messages between bot turns\n+    # and prepends them to the user message for context.\n+    if \"history_backfill\" in discord_cfg and not os.getenv(\"DISCORD_HISTORY_BACKFILL\"):\n+        os.environ[\"DISCORD_HISTORY_BACKFILL\"] = str(discord_cfg[\"history_backfill\"]).lower()\n+    hbl = discord_cfg.get(\"history_backfill_limit\")\n+    if hbl is not None and not os.getenv(\"DISCORD_HISTORY_BACKFILL_LIMIT\"):\n+        os.environ[\"DISCORD_HISTORY_BACKFILL_LIMIT\"] = str(hbl)\n+    # allow_mentions: granular control over what the bot can ping.\n+    # Safe defaults (no @everyone/roles) are applied in the adapter;\n+    # these YAML keys only override when set and let users opt back\n+    # into unsafe modes (e.g. roles=true) if they actually want it.\n+    allow_mentions_cfg = discord_cfg.get(\"allow_mentions\")\n+    if isinstance(allow_mentions_cfg, dict):\n+        for yaml_key, env_key in (\n+            (\"everyone\", \"DISCORD_ALLOW_MENTION_EVERYONE\"),\n+            (\"roles\", \"DISCORD_ALLOW_MENTION_ROLES\"),\n+            (\"users\", \"DISCORD_ALLOW_MENTION_USERS\"),\n+            (\"replied_user\", \"DISCORD_ALLOW_MENTION_REPLIED_USER\"),\n+        ):\n+            if yaml_key in allow_mentions_cfg and not os.getenv(env_key):\n+                os.environ[env_key] = str(allow_mentions_cfg[yaml_key]).lower()\n+    # reply_to_mode: top-level preferred, falls back to extra.reply_to_mode.\n+    # YAML 1.1 parses bare 'off' as boolean False — coerce to string \"off\".\n+    _discord_extra = discord_cfg.get(\"extra\") if isinstance(discord_cfg.get(\"extra\"), dict) else {}\n+    _discord_rtm = (\n+        discord_cfg[\"reply_to_mode\"] if \"reply_to_mode\" in discord_cfg\n+        else _discord_extra.get(\"reply_to_mode\")\n+    )\n+    if _discord_rtm is not None and not os.getenv(\"DISCORD_REPLY_TO_MODE\"):\n+        _rtm_str = \"off\" if _discord_rtm is False else str(_discord_rtm).lower()\n+        os.environ[\"DISCORD_REPLY_TO_MODE\"] = _rtm_str\n+    return None  # all settings flow through env; nothing to merge into extras\n+\n+\n+def _is_connected(config) -> bool:\n+    \"\"\"Discord is considered connected when DISCORD_BOT_TOKEN is set.\n+\n+    Looks up via ``hermes_cli.gateway.get_env_value`` at call time (not via\n+    the plugin's own bound import) so tests that patch ``gateway_mod.get_env_value``\n+    — including ``test_setup_openclaw_migration`` — can suppress ambient\n+    ``DISCORD_BOT_TOKEN`` env vars. Matches what the legacy\n+    ``_PLATFORMS[\"discord\"]`` dispatch did before this migration.\n+    \"\"\"\n+    import hermes_cli.gateway as gateway_mod\n+    return bool((gateway_mod.get_env_value(\"DISCORD_BOT_TOKEN\") or \"\").strip())\n+\n+\n+def _build_adapter(config):\n+    \"\"\"Factory wrapper that constructs DiscordAdapter from a PlatformConfig.\"\"\"\n+    return DiscordAdapter(config)\n+\n+\n+def register(ctx) -> None:\n+    \"\"\"Plugin entry point — called by the Hermes plugin system.\"\"\"\n+    ctx.register_platform(\n+        name=\"discord\",\n+        label=\"Discord\",\n+        adapter_factory=_build_adapter,\n+        check_fn=check_discord_requirements,\n+        is_connected=_is_connected,\n+        required_env=[\"DISCORD_BOT_TOKEN\"],\n+        install_hint=\"pip install 'hermes-agent[discord]'\",\n+        # Interactive setup wizard — replaces the central\n+        # hermes_cli/setup.py::_setup_discord function.  Same shape as Teams.\n+        setup_fn=interactive_setup,\n+        # YAML→env config bridge — owns the translation of ``config.yaml``\n+        # ``discord:`` keys (require_mention, free_response_channels,\n+        # auto_thread, reactions, ignored_channels, allowed_channels,\n+        # no_thread_channels, allow_mentions.*, reply_to_mode,\n+        # thread_require_mention) into ``DISCORD_*`` env vars that the\n+        # adapter reads via ``os.getenv()``.  Replaces the hardcoded block\n+        # that used to live in ``gateway/config.py``.  Hook contract: #24836.\n+        apply_yaml_config_fn=_apply_yaml_config,\n+        # Auth env vars for _is_user_authorized() integration\n+        allowed_users_env=\"DISCORD_ALLOWED_USERS\",\n+        allow_all_env=\"DISCORD_ALLOW_ALL_USERS\",\n+        # Cron home-channel delivery\n+        cron_deliver_env_var=\"DISCORD_HOME_CHANNEL\",\n+        # Out-of-process cron delivery via Discord REST API.  Without this\n+        # hook, ``deliver=discord`` cron jobs fail with \"No live adapter\"\n+        # when cron runs separately from the gateway.  Mirrors Teams pattern.\n+        standalone_sender_fn=_standalone_send,\n+        # Discord hard limit per message\n+        max_message_length=2000,\n+        # Display\n+        emoji=\"🎮\",\n+        allow_update_command=True,\n+    )"]},{"file":"plugins/platforms/discord/plugin.yaml","hunks":["@@ -0,0 +1,34 @@\n+name: discord-platform\n+label: Discord\n+kind: platform\n+version: 1.0.0\n+description: >\n+  Discord gateway adapter for Hermes Agent.\n+  Connects to Discord via the discord.py library and relays messages\n+  between Discord guilds/DMs and the Hermes agent. Supports voice mode,\n+  slash commands, free-response channels, role-based DM auth, threads,\n+  reactions, and channel skill bindings.\n+author: NousResearch\n+requires_env:\n+  - name: DISCORD_BOT_TOKEN\n+    description: \"Discord bot token\"\n+    prompt: \"Discord bot token\"\n+    url: \"https://discord.com/developers/applications\"\n+    password: true\n+optional_env:\n+  - name: DISCORD_ALLOWED_USERS\n+    description: \"Comma-separated Discord user IDs allowed to talk to the bot\"\n+    prompt: \"Allowed users (comma-separated)\"\n+    password: false\n+  - name: DISCORD_ALLOW_ALL_USERS\n+    description: \"Allow any Discord user to trigger the bot (dev only)\"\n+    prompt: \"Allow all users? (true/false)\"\n+    password: false\n+  - name: DISCORD_HOME_CHANNEL\n+    description: \"Default channel ID for cron / notification delivery\"\n+    prompt: \"Home channel ID\"\n+    password: false\n+  - name: DISCORD_HOME_CHANNEL_NAME\n+    description: \"Display name for the Discord home channel\"\n+    prompt: \"Home channel display name\"\n+    password: false"]},{"file":"tests/e2e/conftest.py","hunks":["@@ -119,7 +119,7 @@ def _ensure_slack_mock():\n \n import discord  # noqa: E402 — mocked above\n from gateway.platforms.telegram import TelegramAdapter  # noqa: E402\n-from gateway.platforms.discord import DiscordAdapter  # noqa: E402\n+from plugins.platforms.discord.adapter import DiscordAdapter  # noqa: E402\n \n import gateway.platforms.slack as _slack_mod  # noqa: E402\n _slack_mod.SLACK_AVAILABLE = True"]},{"file":"tests/gateway/test_discord_allowed_mentions.py","hunks":["@@ -81,7 +81,7 @@ def _ensure_discord_mock():\n \n _ensure_discord_mock()\n \n-from gateway.platforms.discord import _build_allowed_mentions  # noqa: E402\n+from plugins.platforms.discord.adapter import _build_allowed_mentions  # noqa: E402\n \n \n # The four DISCORD_ALLOW_MENTION_* env vars that _build_allowed_mentions reads."]},{"file":"tests/gateway/test_discord_attachment_download.py","hunks":["@@ -58,7 +58,7 @@ def _ensure_discord_mock():\n \n _ensure_discord_mock()\n \n-from gateway.platforms.discord import DiscordAdapter  # noqa: E402\n+from plugins.platforms.discord.adapter import DiscordAdapter  # noqa: E402\n from gateway.platforms.base import MessageType  # noqa: E402\n \n ","@@ -146,10 +146,10 @@ async def test_prefers_att_read_over_url(self):\n         att = _make_attachment_with_read(_PNG_BYTES)\n \n         with patch(\n-            \"gateway.platforms.discord.cache_image_from_bytes\",\n+            \"plugins.platforms.discord.adapter.cache_image_from_bytes\",\n             return_value=\"/tmp/cached.png\",\n         ) as mock_bytes, patch(\n-            \"gateway.platforms.discord.cache_image_from_url\",\n+            \"plugins.platforms.discord.adapter.cache_image_from_url\",\n             new_callable=AsyncMock,\n         ) as mock_url:\n             result = await adapter._cache_discord_image(att, \".png\")","@@ -165,9 +165,9 @@ async def test_falls_back_to_url_when_no_read(self):\n         att = _make_attachment_without_read()\n \n         with patch(\n-            \"gateway.platforms.discord.cache_image_from_bytes\",\n+            \"plugins.platforms.discord.adapter.cache_image_from_bytes\",\n         ) as mock_bytes, patch(\n-            \"gateway.platforms.discord.cache_image_from_url\",\n+            \"plugins.platforms.discord.adapter.cache_image_from_url\",\n             new_callable=AsyncMock,\n             return_value=\"/tmp/from_url.png\",\n         ) as mock_url:","@@ -186,10 +186,10 @@ async def test_falls_back_to_url_when_bytes_validator_rejects(self):\n         att = _make_attachment_with_read(b\"<html>forbidden</html>\")\n \n         with patch(\n-            \"gateway.platforms.discord.cache_image_from_bytes\",\n+            \"plugins.platforms.discord.adapter.cache_image_from_bytes\",\n             side_effect=ValueError(\"not a valid image\"),\n         ), patch(\n-            \"gateway.platforms.discord.cache_image_from_url\",\n+            \"plugins.platforms.discord.adapter.cache_image_from_url\",\n             new_callable=AsyncMock,\n             return_value=\"/tmp/fallback.png\",\n         ) as mock_url:","@@ -210,10 +210,10 @@ async def test_prefers_att_read_over_url(self):\n         att = _make_attachment_with_read(_OGG_BYTES)\n \n         with patch(\n-            \"gateway.platforms.discord.cache_audio_from_bytes\",\n+            \"plugins.platforms.discord.adapter.cache_audio_from_bytes\",\n             return_value=\"/tmp/voice.ogg\",\n         ) as mock_bytes, patch(\n-            \"gateway.platforms.discord.cache_audio_from_url\",\n+            \"plugins.platforms.discord.adapter.cache_audio_from_url\",\n             new_callable=AsyncMock,\n         ) as mock_url:\n             result = await adapter._cache_discord_audio(att, \".ogg\")","@@ -228,7 +228,7 @@ async def test_falls_back_to_url_when_no_read(self):\n         att = _make_attachment_without_read()\n \n         with patch(\n-            \"gateway.platforms.discord.cache_audio_from_url\",\n+            \"plugins.platforms.discord.adapter.cache_audio_from_url\",\n             new_callable=AsyncMock,\n             return_value=\"/tmp/from_url.ogg\",\n         ) as mock_url:","@@ -267,7 +267,7 @@ async def test_fallback_blocked_by_ssrf_guard(self):\n         att = _make_attachment_without_read()  # no .read → forces fallback\n \n         with patch(\n-            \"gateway.platforms.discord.is_safe_url\", return_value=False\n+            \"plugins.platforms.discord.adapter.is_safe_url\", return_value=False\n         ) as mock_safe, patch(\"aiohttp.ClientSession\") as mock_session:\n             with pytest.raises(ValueError, match=\"SSRF\"):\n                 await adapter._cache_discord_document(att, \".pdf\")","@@ -295,7 +295,7 @@ async def test_fallback_aiohttp_when_safe_url(self):\n         session.__aexit__ = AsyncMock(return_value=False)\n \n         with patch(\n-            \"gateway.platforms.discord.is_safe_url\", return_value=True\n+            \"plugins.platforms.discord.adapter.is_safe_url\", return_value=True\n         ), patch(\"aiohttp.ClientSession\", return_value=session):\n             result = await adapter._cache_discord_document(att, \".pdf\")\n ","@@ -320,10 +320,10 @@ async def test_image_downloads_via_att_read_not_url(self, monkeypatch):\n         adapter.handle_message = AsyncMock()\n \n         with patch(\n-            \"gateway.platforms.discord.cache_image_from_bytes\",\n+            \"plugins.platforms.discord.adapter.cache_image_from_bytes\",\n             return_value=\"/tmp/img_from_read.png\",\n         ), patch(\n-            \"gateway.platforms.discord.cache_image_from_url\",\n+            \"plugins.platforms.discord.adapter.cache_image_from_url\",\n             new_callable=AsyncMock,\n         ) as mock_url_download:\n             att = SimpleNamespace(","@@ -342,7 +342,7 @@ class _FakeDMChannel:\n \n             # Patch the DMChannel isinstance check so our fake counts as DM.\n             monkeypatch.setattr(\n-                \"gateway.platforms.discord.discord.DMChannel\",\n+                \"plugins.platforms.discord.adapter.discord.DMChannel\",\n                 _FakeDMChannel,\n             )\n             chan = _FakeDMChannel()","@@ -368,7 +368,7 @@ async def test_native_voice_note_is_classified_as_voice(self, monkeypatch):\n         adapter.handle_message = AsyncMock()\n \n         with patch(\n-            \"gateway.platforms.discord.cache_audio_from_bytes\",\n+            \"plugins.platforms.discord.adapter.cache_audio_from_bytes\",\n             return_value=\"/tmp/voice_from_read.ogg\",\n         ):\n             att = SimpleNamespace(","@@ -386,7 +386,7 @@ class _FakeDMChannel:\n                 name = \"dm\"\n \n             monkeypatch.setattr(\n-                \"gateway.platforms.discord.discord.DMChannel\",\n+                \"plugins.platforms.discord.adapter.discord.DMChannel\",\n                 _FakeDMChannel,\n             )\n             chan = _FakeDMChannel()","@@ -412,7 +412,7 @@ async def test_plain_audio_attachment_stays_audio(self, monkeypatch):\n         adapter.handle_message = AsyncMock()\n \n         with patch(\n-            \"gateway.platforms.discord.cache_audio_from_bytes\",\n+            \"plugins.platforms.discord.adapter.cache_audio_from_bytes\",\n             return_value=\"/tmp/audio_from_read.ogg\",\n         ):\n             att = SimpleNamespace(","@@ -430,7 +430,7 @@ class _FakeDMChannel:\n                 name = \"dm\"\n \n             monkeypatch.setattr(\n-                \"gateway.platforms.discord.discord.DMChannel\",\n+                \"plugins.platforms.discord.adapter.discord.DMChannel\",\n                 _FakeDMChannel,\n             )\n             chan = _FakeDMChannel()"]},{"file":"tests/gateway/test_discord_channel_controls.py","hunks":["@@ -45,8 +45,8 @@ def _ensure_discord_mock():\n \n _ensure_discord_mock()\n \n-import gateway.platforms.discord as discord_platform  # noqa: E402\n-from gateway.platforms.discord import DiscordAdapter  # noqa: E402\n+import plugins.platforms.discord.adapter as discord_platform  # noqa: E402\n+from plugins.platforms.discord.adapter import DiscordAdapter  # noqa: E402\n \n \n class FakeDMChannel:"]},{"file":"tests/gateway/test_discord_channel_prompts.py","hunks":["@@ -58,7 +58,7 @@ def _install_fake_agent(monkeypatch):\n \n def _make_adapter():\n     _ensure_discord_mock()\n-    from gateway.platforms.discord import DiscordAdapter\n+    from plugins.platforms.discord.adapter import DiscordAdapter\n \n     adapter = object.__new__(DiscordAdapter)\n     adapter.config = MagicMock()"]},{"file":"tests/gateway/test_discord_channel_skills.py","hunks":["@@ -5,7 +5,7 @@\n \n def _make_adapter():\n     \"\"\"Create a minimal DiscordAdapter with mocked config.\"\"\"\n-    from gateway.platforms.discord import DiscordAdapter\n+    from plugins.platforms.discord.adapter import DiscordAdapter\n     adapter = object.__new__(DiscordAdapter)\n     adapter.config = MagicMock()\n     adapter.config.extra = {}"]},{"file":"tests/gateway/test_discord_clarify_buttons.py","hunks":["@@ -26,7 +26,7 @@\n \n # Triggers the shared discord mock from tests/gateway/conftest.py before\n # importing the production module.\n-from gateway.platforms.discord import (  # noqa: E402\n+from plugins.platforms.discord.adapter import (  # noqa: E402\n     ClarifyChoiceView,\n     DiscordAdapter,\n )"]},{"file":"tests/gateway/test_discord_component_auth.py","hunks":["@@ -18,7 +18,7 @@\n \n # Trigger the shared discord mock from tests/gateway/conftest.py before\n # importing the production module.\n-from gateway.platforms.discord import (  # noqa: E402\n+from plugins.platforms.discord.adapter import (  # noqa: E402\n     ExecApprovalView,\n     ModelPickerView,\n     SlashConfirmView,"]},{"file":"tests/gateway/test_discord_connect.py","hunks":["@@ -67,8 +67,8 @@ def _ensure_discord_mock():\n \n _ensure_discord_mock()\n \n-import gateway.platforms.discord as discord_platform  # noqa: E402\n-from gateway.platforms.discord import DiscordAdapter  # noqa: E402\n+import plugins.platforms.discord.adapter as discord_platform  # noqa: E402\n+from plugins.platforms.discord.adapter import DiscordAdapter  # noqa: E402\n \n \n @pytest.fixture(autouse=True)"]},{"file":"tests/gateway/test_discord_document_handling.py","hunks":["@@ -57,8 +57,8 @@ def _ensure_discord_mock():\n \n _ensure_discord_mock()\n \n-import gateway.platforms.discord as discord_platform  # noqa: E402\n-from gateway.platforms.discord import DiscordAdapter  # noqa: E402\n+import plugins.platforms.discord.adapter as discord_platform  # noqa: E402\n+from plugins.platforms.discord.adapter import DiscordAdapter  # noqa: E402\n \n \n # ---------------------------------------------------------------------------","@@ -371,7 +371,7 @@ def get(self, url, **kwargs):\n     async def test_image_attachment_unaffected(self, adapter):\n         \"\"\"Image attachments should still go through the image path, not the document path.\"\"\"\n         with patch(\n-            \"gateway.platforms.discord.cache_image_from_url\",\n+            \"plugins.platforms.discord.adapter.cache_image_from_url\",\n             new_callable=AsyncMock,\n             return_value=\"/tmp/cached_image.png\",\n         ):"]},{"file":"tests/gateway/test_discord_free_response.py","hunks":["@@ -45,8 +45,8 @@ def _ensure_discord_mock():\n \n _ensure_discord_mock()\n \n-import gateway.platforms.discord as discord_platform  # noqa: E402\n-from gateway.platforms.discord import DiscordAdapter  # noqa: E402\n+import plugins.platforms.discord.adapter as discord_platform  # noqa: E402\n+from plugins.platforms.discord.adapter import DiscordAdapter  # noqa: E402\n \n \n class FakeDMChannel:"]},{"file":"tests/gateway/test_discord_imports.py","hunks":["@@ -14,10 +14,13 @@ def fake_import(name, globals=None, locals=None, fromlist=(), level=0):\n                 raise ImportError(\"discord unavailable for test\")\n             return original_import(name, globals, locals, fromlist, level)\n \n-        monkeypatch.delitem(sys.modules, \"gateway.platforms.discord\", raising=False)\n+        # Purge the cached module so the import below actually re-runs the\n+        # module body with discord.py simulated-missing.\n+        monkeypatch.delitem(sys.modules, \"plugins.platforms.discord.adapter\", raising=False)\n+        monkeypatch.delitem(sys.modules, \"plugins.platforms.discord\", raising=False)\n         monkeypatch.setattr(builtins, \"__import__\", fake_import)\n \n-        module = importlib.import_module(\"gateway.platforms.discord\")\n+        module = importlib.import_module(\"plugins.platforms.discord.adapter\")\n \n         assert module.DISCORD_AVAILABLE is False\n         assert module.discord is None"]},{"file":"tests/gateway/test_discord_lazy_install_views.py","hunks":["@@ -34,7 +34,7 @@ class TestDefineDiscordViewClasses:\n \n     def test_registers_all_five_view_classes(self, monkeypatch):\n         \"\"\"Calling _define_discord_view_classes() must (re)define all 5 view classes.\"\"\"\n-        dp = importlib.import_module(\"gateway.platforms.discord\")\n+        dp = importlib.import_module(\"plugins.platforms.discord.adapter\")\n \n         # Remove the classes to simulate the state where the module was loaded\n         # with DISCORD_AVAILABLE=False (the lazy-install scenario).","@@ -54,7 +54,7 @@ def test_registers_all_five_view_classes(self, monkeypatch):\n     def test_check_discord_requirements_calls_define_on_lazy_install(self, monkeypatch):\n         \"\"\"check_discord_requirements() must call _define_discord_view_classes() on\n         a successful lazy install so view classes exist when DISCORD_AVAILABLE=True.\"\"\"\n-        dp = importlib.import_module(\"gateway.platforms.discord\")\n+        dp = importlib.import_module(\"plugins.platforms.discord.adapter\")\n \n         # Simulate discord not yet available at module load.\n         monkeypatch.setattr(dp, \"DISCORD_AVAILABLE\", False)"]},{"file":"tests/gateway/test_discord_media_metadata.py","hunks":["@@ -1,6 +1,6 @@\n import inspect\n \n-from gateway.platforms.discord import DiscordAdapter\n+from plugins.platforms.discord.adapter import DiscordAdapter\n \n \n def test_discord_media_methods_accept_metadata_kwarg():"]},{"file":"tests/gateway/test_discord_model_picker.py","hunks":["@@ -11,7 +11,7 @@\n \n import pytest\n \n-from gateway.platforms.discord import ModelPickerView\n+from plugins.platforms.discord.adapter import ModelPickerView\n \n \n @pytest.mark.asyncio"]},{"file":"tests/gateway/test_discord_opus.py","hunks":["@@ -8,14 +8,14 @@ class TestOpusFindLibrary:\n \n     def test_uses_find_library_first(self):\n         \"\"\"find_library must be the primary lookup strategy.\"\"\"\n-        from gateway.platforms.discord import DiscordAdapter\n+        from plugins.platforms.discord.adapter import DiscordAdapter\n         source = inspect.getsource(DiscordAdapter.connect)\n         assert \"find_library\" in source, \\\n             \"Opus loading must use ctypes.util.find_library\"\n \n     def test_homebrew_fallback_is_conditional(self):\n         \"\"\"Homebrew paths must only be tried when find_library returns None.\"\"\"\n-        from gateway.platforms.discord import DiscordAdapter\n+        from plugins.platforms.discord.adapter import DiscordAdapter\n         source = inspect.getsource(DiscordAdapter.connect)\n         # Homebrew fallback must exist\n         assert \"/opt/homebrew\" in source or \"homebrew\" in source, \\","@@ -31,7 +31,7 @@ def test_homebrew_fallback_is_conditional(self):\n \n     def test_opus_decode_error_logged(self):\n         \"\"\"Opus decode failure must log the error, not silently return.\"\"\"\n-        from gateway.platforms.discord import VoiceReceiver\n+        from plugins.platforms.discord.adapter import VoiceReceiver\n         source = inspect.getsource(VoiceReceiver._on_packet)\n         assert \"logger\" in source, \\\n             \"_on_packet must log Opus decode errors\""]},{"file":"tests/gateway/test_discord_race_polish.py","hunks":["@@ -10,7 +10,7 @@\n \n \n def _make_adapter():\n-    from gateway.platforms.discord import DiscordAdapter\n+    from plugins.platforms.discord.adapter import DiscordAdapter\n \n     adapter = object.__new__(DiscordAdapter)\n     adapter._platform = Platform.DISCORD","@@ -60,7 +60,7 @@ async def slow_connect(self):\n     channel.guild.id = 42\n     channel.connect = lambda: slow_connect(channel)\n \n-    from gateway.platforms import discord as discord_mod\n+    from plugins.platforms.discord import adapter as discord_mod\n     with patch.object(discord_mod, \"VoiceReceiver\",\n                       MagicMock(return_value=MagicMock(start=lambda: None))):\n         with patch.object(discord_mod.asyncio, \"ensure_future\","]},{"file":"tests/gateway/test_discord_reactions.py","hunks":["@@ -40,7 +40,7 @@ def _ensure_discord_mock():\n \n _ensure_discord_mock()\n \n-from gateway.platforms.discord import DiscordAdapter  # noqa: E402\n+from plugins.platforms.discord.adapter import DiscordAdapter  # noqa: E402\n \n \n class FakeTree:"]},{"file":"tests/gateway/test_discord_reply_mode.py","hunks":["@@ -53,7 +53,7 @@ def _ensure_discord_mock():\n \n _ensure_discord_mock()\n \n-from gateway.platforms.discord import DiscordAdapter  # noqa: E402\n+from plugins.platforms.discord.adapter import DiscordAdapter  # noqa: E402\n \n \n @pytest.fixture()"]},{"file":"tests/gateway/test_discord_roles_dm_scope.py","hunks":["@@ -20,7 +20,7 @@\n \n import pytest\n \n-from gateway.platforms.discord import DiscordAdapter\n+from plugins.platforms.discord.adapter import DiscordAdapter\n \n \n def _set_dm_role_auth_guild(monkeypatch, guild_id=None):"]},{"file":"tests/gateway/test_discord_send.py","hunks":["@@ -42,7 +42,7 @@ def _ensure_discord_mock():\n \n _ensure_discord_mock()\n \n-from gateway.platforms.discord import DiscordAdapter  # noqa: E402\n+from plugins.platforms.discord.adapter import DiscordAdapter  # noqa: E402\n \n \n @pytest.mark.asyncio"]},{"file":"tests/gateway/test_discord_slash_auth.py","hunks":["@@ -85,7 +85,7 @@ def __init__(self, *, name, description, callback, parent=None):\n \n _ensure_discord_mock()\n \n-from gateway.platforms.discord import DiscordAdapter  # noqa: E402\n+from plugins.platforms.discord.adapter import DiscordAdapter  # noqa: E402\n \n \n @pytest.fixture(autouse=True)"]},{"file":"tests/gateway/test_discord_slash_commands.py","hunks":["@@ -75,7 +75,7 @@ def __init__(self, *, name, description, callback, parent=None):\n \n _ensure_discord_mock()\n \n-from gateway.platforms.discord import DiscordAdapter  # noqa: E402\n+from plugins.platforms.discord.adapter import DiscordAdapter  # noqa: E402\n \n \n class FakeTree:"]},{"file":"tests/gateway/test_discord_thread_persistence.py","hunks":["@@ -17,7 +17,7 @@ class TestDiscordThreadPersistence:\n     def _make_adapter(self, tmp_path):\n         \"\"\"Build a minimal DiscordAdapter with HERMES_HOME pointed at tmp_path.\"\"\"\n         from gateway.config import PlatformConfig\n-        from gateway.platforms.discord import DiscordAdapter\n+        from plugins.platforms.discord.adapter import DiscordAdapter\n \n         config = PlatformConfig(enabled=True, token=\"[redacted:secret-assignment]\")\n         with patch.dict(os.environ, {\"HERMES_HOME\": str(tmp_path)}):"]},{"file":"tests/gateway/test_reload_skills_discord_resync.py","hunks":["@@ -27,7 +27,7 @@\n \n def _make_adapter():\n     \"\"\"Construct a DiscordAdapter without going through __init__ / token checks.\"\"\"\n-    from gateway.platforms.discord import DiscordAdapter\n+    from plugins.platforms.discord.adapter import DiscordAdapter\n     from gateway.platforms.base import Platform\n     adapter = object.__new__(DiscordAdapter)\n     adapter.config = MagicMock()"]},{"file":"tests/gateway/test_send_image_file.py","hunks":["@@ -190,7 +190,7 @@ def _ensure_discord_mock():\n _ensure_discord_mock()\n \n import discord as discord_mod_ref  # noqa: E402\n-from gateway.platforms.discord import DiscordAdapter  # noqa: E402\n+from plugins.platforms.discord.adapter import DiscordAdapter  # noqa: E402\n \n \n class TestDiscordSendImageFile:"]},{"file":"tests/gateway/test_send_multiple_images.py","hunks":["@@ -210,7 +210,7 @@ def _ensure_discord_mock():\n \n _ensure_discord_mock()\n \n-from gateway.platforms.discord import DiscordAdapter  # noqa: E402\n+from plugins.platforms.discord.adapter import DiscordAdapter  # noqa: E402\n \n \n class TestDiscordMultiImage:"]},{"file":"tests/gateway/test_stream_consumer.py","hunks":["@@ -149,7 +149,7 @@ class TestEditMessageFinalizeSignature:\n         \"module_path,class_name\",\n         [\n             (\"gateway.platforms.telegram\", \"TelegramAdapter\"),\n-            (\"gateway.platforms.discord\", \"DiscordAdapter\"),\n+            (\"plugins.platforms.discord.adapter\", \"DiscordAdapter\"),\n             (\"gateway.platforms.slack\", \"SlackAdapter\"),\n             (\"gateway.platforms.matrix\", \"MatrixAdapter\"),\n             (\"gateway.platforms.mattermost\", \"MattermostAdapter\"),"]},{"file":"tests/gateway/test_text_batching.py","hunks":["@@ -41,7 +41,7 @@ def _make_event(\n \n def _make_discord_adapter():\n     \"\"\"Create a minimal DiscordAdapter for testing text batching.\"\"\"\n-    from gateway.platforms.discord import DiscordAdapter\n+    from plugins.platforms.discord.adapter import DiscordAdapter\n \n     config = PlatformConfig(enabled=True, token=\"[redacted:secret-assignment]\")\n     adapter = object.__new__(DiscordAdapter)"]},{"file":"tests/gateway/test_voice_command.py","hunks":["@@ -511,7 +511,7 @@ class TestDiscordPlayTtsSkip:\n     \"\"\"Discord adapter skips play_tts when bot is in a voice channel.\"\"\"\n \n     def _make_discord_adapter(self):\n-        from gateway.platforms.discord import DiscordAdapter\n+        from plugins.platforms.discord.adapter import DiscordAdapter\n         from gateway.config import Platform, PlatformConfig\n         config = PlatformConfig(enabled=True, extra={})\n         config.token = \"[redacted:secret-assignment]\"","@@ -599,7 +599,7 @@ class TestVoiceReceiver:\n     \"\"\"Test VoiceReceiver silence detection, SSRC mapping, and lifecycle.\"\"\"\n \n     def _make_receiver(self):\n-        from gateway.platforms.discord import VoiceReceiver\n+        from plugins.platforms.discord.adapter import VoiceReceiver\n         mock_vc = MagicMock()\n         mock_vc._connection.secret_key = [0] * 32\n         mock_vc._connection.dave_session = None","@@ -1066,7 +1066,7 @@ class TestDiscordVoiceChannelMethods:\n     \"\"\"Test DiscordAdapter voice channel methods (join, leave, play, etc.).\"\"\"\n \n     def _make_adapter(self):\n-        from gateway.platforms.discord import DiscordAdapter\n+        from plugins.platforms.discord.adapter import DiscordAdapter\n         from gateway.config import Platform, PlatformConfig\n         config = PlatformConfig(enabled=True, extra={})\n         config.token = \"[redacted:secret-assignment]\"","@@ -1208,7 +1208,7 @@ async def test_process_voice_input_success(self):\n \n         pcm_data = b\"\\x00\" * 96000\n \n-        with patch(\"gateway.platforms.discord.VoiceReceiver.pcm_to_wav\"), \\\n+        with patch(\"plugins.platforms.discord.adapter.VoiceReceiver.pcm_to_wav\"), \\\n              patch(\"tools.transcription_tools.transcribe_audio\",\n                    return_value={\"success\": True, \"transcript\": \"Hello\"}), \\\n              patch(\"tools.voice_mode.is_whisper_hallucination\", return_value=False):","@@ -1223,7 +1223,7 @@ async def test_process_voice_input_hallucination_filtered(self):\n         callback = AsyncMock()\n         adapter._voice_input_callback = callback\n \n-        with patch(\"gateway.platforms.discord.VoiceReceiver.pcm_to_wav\"), \\\n+        with patch(\"plugins.platforms.discord.adapter.VoiceReceiver.pcm_to_wav\"), \\\n              patch(\"tools.transcription_tools.transcribe_audio\",\n                    return_value={\"success\": True, \"transcript\": \"Thank you.\"}), \\\n              patch(\"tools.voice_mode.is_whisper_hallucination\", return_value=True):","@@ -1238,7 +1238,7 @@ async def test_process_voice_input_stt_failure(self):\n         callback = AsyncMock()\n         adapter._voice_input_callback = callback\n \n-        with patch(\"gateway.platforms.discord.VoiceReceiver.pcm_to_wav\"), \\\n+        with patch(\"plugins.platforms.discord.adapter.VoiceReceiver.pcm_to_wav\"), \\\n              patch(\"tools.transcription_tools.transcribe_audio\",\n                    return_value={\"success\": False, \"error\": \"API error\"}):\n             await adapter._process_voice_input(111, 42, b\"\\x00\" * 96000)","@@ -1251,7 +1251,7 @@ async def test_process_voice_input_exception_caught(self):\n         adapter = self._make_adapter()\n         adapter._voice_input_callback = AsyncMock()\n \n-        with patch(\"gateway.platforms.discord.VoiceReceiver.pcm_to_wav\",\n+        with patch(\"plugins.platforms.discord.adapter.VoiceReceiver.pcm_to_wav\",\n                    side_effect=RuntimeError(\"ffmpeg not found\")):\n             await adapter._process_voice_input(111, 42, b\"\\x00\" * 96000)\n         # Should not raise","@@ -1269,7 +1269,7 @@ class TestVoiceReceiverThreadSafety:\n     \"\"\"Verify that VoiceReceiver buffer access is protected by lock.\"\"\"\n \n     def _make_receiver(self):\n-        from gateway.platforms.discord import VoiceReceiver\n+        from plugins.platforms.discord.adapter import VoiceReceiver\n         mock_vc = MagicMock()\n         mock_vc._connection.secret_key = [0] * 32\n         mock_vc._connection.dave_session = None","@@ -1282,7 +1282,7 @@ def _make_receiver(self):\n     def test_check_silence_holds_lock(self):\n         \"\"\"check_silence must hold lock while iterating buffers.\"\"\"\n         import ast, inspect, textwrap\n-        from gateway.platforms.discord import VoiceReceiver\n+        from plugins.platforms.discord.adapter import VoiceReceiver\n         source = textwrap.dedent(inspect.getsource(VoiceReceiver.check_silence))\n         tree = ast.parse(source)\n         # Find 'with self._lock:' that contains buffer iteration","@@ -1303,7 +1303,7 @@ def test_check_silence_holds_lock(self):\n     def test_on_packet_buffer_write_holds_lock(self):\n         \"\"\"_on_packet must hold lock when writing to buffers.\"\"\"\n         import ast, inspect, textwrap\n-        from gateway.platforms.discord import VoiceReceiver\n+        from plugins.platforms.discord.adapter import VoiceReceiver\n         source = textwrap.dedent(inspect.getsource(VoiceReceiver._on_packet))\n         tree = ast.parse(source)\n         # Find 'with self._lock:' that contains buffer extend","@@ -1670,7 +1670,7 @@ class TestStopAcquiresLock:\n \n     @staticmethod\n     def _make_receiver():\n-        from gateway.platforms.discord import VoiceReceiver\n+        from plugins.platforms.discord.adapter import VoiceReceiver\n         vc = MagicMock()\n         vc._connection.secret_key = [0] * 32\n         vc._connection.dave_session = None","@@ -1772,7 +1772,7 @@ class TestPacketDebugCounterIsInstanceLevel:\n \n     @staticmethod\n     def _make_receiver():\n-        from gateway.platforms.discord import VoiceReceiver\n+        from plugins.platforms.discord.adapter import VoiceReceiver\n         vc = MagicMock()\n         vc._connection.secret_key = [0] * 32\n         vc._connection.dave_session = None","@@ -1805,7 +1805,7 @@ class TestPlayInVoiceChannelUsesRunningLoop:\n     def test_source_uses_get_running_loop(self):\n         \"\"\"The method source code calls get_running_loop, not get_event_loop.\"\"\"\n         import inspect\n-        from gateway.platforms.discord import DiscordAdapter\n+        from plugins.platforms.discord.adapter import DiscordAdapter\n         source = inspect.getsource(DiscordAdapter.play_in_voice_channel)\n         assert \"get_running_loop\" in source, \\\n             \"play_in_voice_channel should use asyncio.get_running_loop()\"","@@ -1849,7 +1849,7 @@ class TestVoiceTimeoutCleansRunnerState:\n \n     @staticmethod\n     def _make_discord_adapter():\n-        from gateway.platforms.discord import DiscordAdapter\n+        from plugins.platforms.discord.adapter import DiscordAdapter\n         from gateway.config import PlatformConfig, Platform\n         config = PlatformConfig(enabled=True, extra={})\n         config.token = \"[redacted:secret-assignment]\"","@@ -1940,7 +1940,7 @@ class TestPlaybackTimeout:\n \n     @staticmethod\n     def _make_discord_adapter():\n-        from gateway.platforms.discord import DiscordAdapter\n+        from plugins.platforms.discord.adapter import DiscordAdapter\n         from gateway.config import PlatformConfig, Platform\n         config = PlatformConfig(enabled=True, extra={})\n         config.token = \"[redacted:secret-assignment]\"","@@ -1964,7 +1964,7 @@ def _make_discord_adapter():\n     def test_source_has_wait_for_timeout(self):\n         \"\"\"The method uses asyncio.wait_for with timeout.\"\"\"\n         import inspect\n-        from gateway.platforms.discord import DiscordAdapter\n+        from plugins.platforms.discord.adapter import DiscordAdapter\n         source = inspect.getsource(DiscordAdapter.play_in_voice_channel)\n         assert \"wait_for\" in source, \\\n             \"play_in_voice_channel must use asyncio.wait_for for timeout\"","@@ -1973,14 +1973,14 @@ def test_source_has_wait_for_timeout(self):\n \n     def test_playback_timeout_constant_exists(self):\n         \"\"\"PLAYBACK_TIMEOUT constant is defined on DiscordAdapter.\"\"\"\n-        from gateway.platforms.discord import DiscordAdapter\n+        from plugins.platforms.discord.adapter import DiscordAdapter\n         assert hasattr(DiscordAdapter, \"PLAYBACK_TIMEOUT\")\n         assert DiscordAdapter.PLAYBACK_TIMEOUT > 0\n \n     @pytest.mark.asyncio\n     async def test_playback_timeout_fires(self):\n         \"\"\"When done event is never set, playback times out gracefully.\"\"\"\n-        from gateway.platforms.discord import DiscordAdapter\n+        from plugins.platforms.discord.adapter import DiscordAdapter\n         adapter = self._make_discord_adapter()\n \n         mock_vc = MagicMock()","@@ -2008,7 +2008,7 @@ async def test_playback_timeout_fires(self):\n     @pytest.mark.asyncio\n     async def test_is_playing_wait_has_timeout(self):\n         \"\"\"While loop waiting for previous playback has a timeout.\"\"\"\n-        from gateway.platforms.discord import DiscordAdapter\n+        from plugins.platforms.discord.adapter import DiscordAdapter\n         adapter = self._make_discord_adapter()\n \n         mock_vc = MagicMock()","@@ -2124,7 +2124,7 @@ class TestVoiceChannelAwareness:\n     \"\"\"Tests for get_voice_channel_info() and get_voice_channel_context().\"\"\"\n \n     def _make_adapter(self):\n-        from gateway.platforms.discord import DiscordAdapter\n+        from plugins.platforms.discord.adapter import DiscordAdapter\n         from gateway.config import PlatformConfig\n         config = PlatformConfig(enabled=True, extra={})\n         config.token = \"[redacted:secret-assignment]\"","@@ -2267,7 +2267,7 @@ class TestVoiceReception:\n \n     @staticmethod\n     def _make_receiver(allowed_ids=None, members=None, dave=False, bot_id=9999):\n-        from gateway.platforms.discord import VoiceReceiver\n+        from plugins.platforms.discord.adapter import VoiceReceiver\n         vc = MagicMock()\n         vc._connection.secret_key = [0] * 32\n         vc._connection.dave_session = MagicMock() if dave else None","@@ -2451,7 +2451,7 @@ def test_resumed_receiver_accepts_packets(self):\n \n     def _make_receiver_with_nacl(self, dave_session=None, mapped_ssrcs=None):\n         \"\"\"Create a receiver that can process _on_packet with mocked NaCl + Opus.\"\"\"\n-        from gateway.platforms.discord import VoiceReceiver\n+        from plugins.platforms.discord.adapter import VoiceReceiver\n         vc = MagicMock()\n         vc._connection.secret_key = [0] * 32\n         vc._connection.dave_session = dave_session","@@ -2593,7 +2593,7 @@ class TestVoiceTTSPlayback:\n \n     @staticmethod\n     def _make_discord_adapter():\n-        from gateway.platforms.discord import DiscordAdapter\n+        from plugins.platforms.discord.adapter import DiscordAdapter\n         from gateway.config import PlatformConfig, Platform\n         config = PlatformConfig(enabled=True, extra={})\n         config.token = \"[redacted:secret-assignment]\"","@@ -2766,14 +2766,14 @@ class TestUDPKeepalive:\n     \"\"\"UDP keepalive prevents Discord from dropping the voice session.\"\"\"\n \n     def test_keepalive_interval_is_reasonable(self):\n-        from gateway.platforms.discord import DiscordAdapter\n+        from plugins.platforms.discord.adapter import DiscordAdapter\n         interval = DiscordAdapter._KEEPALIVE_INTERVAL\n         assert 5 <= interval <= 30, f\"Keepalive interval {interval}s should be between 5-30s\"\n \n     @pytest.mark.asyncio\n     async def test_keepalive_sends_silence_frame(self):\n         \"\"\"Listen loop sends silence frame via send_packet after interval.\"\"\"\n-        from gateway.platforms.discord import DiscordAdapter\n+        from plugins.platforms.discord.adapter import DiscordAdapter\n         from gateway.config import PlatformConfig, Platform\n \n         config = PlatformConfig(enabled=True, extra={})","@@ -2795,7 +2795,7 @@ async def test_keepalive_sends_silence_frame(self):\n         adapter._voice_clients[111] = mock_vc\n         mock_vc._connection = mock_conn\n \n-        from gateway.platforms.discord import VoiceReceiver\n+        from plugins.platforms.discord.adapter import VoiceReceiver\n         mock_receiver_vc = MagicMock()\n         mock_receiver_vc._connection.secret_key = [0] * 32\n         mock_receiver_vc._connection.dave_session = None"]},{"file":"tests/integration/test_voice_channel_flow.py","hunks":["@@ -38,7 +38,7 @@\n \n from types import SimpleNamespace\n from unittest.mock import MagicMock\n-from gateway.platforms.discord import VoiceReceiver\n+from plugins.platforms.discord.adapter import VoiceReceiver\n \n \n # ---------------------------------------------------------------------------"]},{"file":"tests/tools/test_send_message_tool.py","hunks":["@@ -28,16 +28,93 @@ def _reset_signal_scheduler():\n \n from gateway.config import Platform\n from tools.send_message_tool import (\n-    _derive_forum_thread_name,\n     _is_telegram_thread_not_found,\n     _parse_target_ref,\n-    _send_discord,\n     _send_matrix_via_adapter,\n     _send_signal,\n     _send_telegram,\n     _send_to_platform,\n     send_message_tool,\n )\n+# Discord helpers moved to the plugin in #24325.  Import from the new path\n+# and provide a thin ``_send_discord(token, ...)`` shim that mirrors the\n+# pre-migration signature so the existing test bodies keep working.\n+from plugins.platforms.discord.adapter import (\n+    _DISCORD_CHANNEL_TYPE_PROBE_CACHE,\n+    _derive_forum_thread_name,\n+    _probe_is_forum_cached,\n+    _remember_channel_is_forum,\n+    _standalone_send,\n+)\n+\n+\n+async def _send_discord(\n+    token,\n+    chat_id,\n+    message,\n+    *,\n+    thread_id=None,\n+    media_files=None,\n+):\n+    \"\"\"Pre-migration ``(token, chat_id, message, …)`` adapter around the\n+    plugin's ``_standalone_send(pconfig, …)``.  Lets test bodies continue\n+    to call ``_send_discord(\"tok\", ...)`` without rewriting every signature.\n+    \"\"\"\n+    pconfig = SimpleNamespace(token=[redacted:secret-assignment] extra={})\n+    return await _standalone_send(\n+        pconfig,\n+        chat_id,\n+        message,\n+        thread_id=thread_id,\n+        media_files=media_files,\n+    )\n+\n+\n+def _discord_entry():\n+    \"\"\"Return the live Discord PlatformEntry, importing lazily so plugin\n+    discovery is forced exactly once and patches survive across tests.\"\"\"\n+    from hermes_cli.plugins import discover_plugins\n+    from gateway.platform_registry import platform_registry\n+    discover_plugins()\n+    return platform_registry.get(\"discord\")\n+\n+\n+class _patch_discord_sender:\n+    \"\"\"Patch the Discord registry entry's ``standalone_sender_fn`` with the\n+    given mock and translate the production ``(pconfig, ...)`` call shape\n+    back to the pre-migration ``(token, ...)`` shape the test mocks expect.\n+\n+    Use as a context manager:\n+\n+        send_mock = AsyncMock(return_value={...})\n+        with _patch_discord_sender(send_mock):\n+            asyncio.run(_send_to_platform(Platform.DISCORD, ...))\n+        send_mock.assert_awaited_once_with(\"tok\", \"chat\", \"msg\",\n+                                           thread_id=None, media_files=[])\n+    \"\"\"\n+\n+    def __init__(self, mock):\n+        self._mock = mock\n+        self._entry = None\n+        self._original = None\n+\n+    async def _adapter(self, pconfig, chat_id, message, *, thread_id=None, media_files=None):\n+        token = [redacted:secret-assignment] \"token\", None)\n+        return await self._mock(\n+            token, chat_id, message,\n+            thread_id=thread_id, media_files=media_files,\n+        )\n+\n+    def __enter__(self):\n+        self._entry = _discord_entry()\n+        self._original = self._entry.standalone_sender_fn\n+        self._entry.standalone_sender_fn = self._adapter\n+        return self._mock\n+\n+    def __exit__(self, exc_type, exc, tb):\n+        if self._entry is not None:\n+            self._entry.standalone_sender_fn = self._original\n+        return False\n \n \n def _run_async_immediately(coro):","@@ -446,7 +523,7 @@ def test_long_message_is_chunked(self):\n         \"\"\"Messages exceeding the platform limit are split into multiple sends.\"\"\"\n         send = AsyncMock(return_value={\"success\": True, \"message_id\": \"1\"})\n         long_msg = \"word \" * 1000  # ~5000 chars, well over Discord's 2000 limit\n-        with patch(\"tools.send_message_tool._send_discord\", send):\n+        with _patch_discord_sender(send):\n             result = asyncio.run(\n                 _send_to_platform(\n                     Platform.DISCORD,","@@ -1176,7 +1253,7 @@ def test_discord_thread_id_passed_to_send_discord(self):\n         \"\"\"Discord platform with thread_id passes it to _send_discord.\"\"\"\n         send_mock = AsyncMock(return_value={\"success\": True, \"message_id\": \"1\"})\n \n-        with patch(\"tools.send_message_tool._send_discord\", send_mock):\n+        with _patch_discord_sender(send_mock):\n             result = asyncio.run(\n                 _send_to_platform(\n                     Platform.DISCORD,","@@ -1196,7 +1273,7 @@ def test_discord_no_thread_id_when_not_provided(self):\n         \"\"\"Discord platform without thread_id passes None.\"\"\"\n         send_mock = AsyncMock(return_value={\"success\": True, \"message_id\": \"1\"})\n \n-        with patch(\"tools.send_message_tool._send_discord\", send_mock):\n+        with _patch_discord_sender(send_mock):\n             result = asyncio.run(\n                 _send_to_platform(\n                     Platform.DISCORD,","@@ -1360,7 +1437,7 @@ async def mock_send_discord(token, chat_id, message, thread_id=None, media_files\n         # A message long enough to get chunked (Discord limit is 2000)\n         long_msg = \"A\" * 1900 + \" \" + \"B\" * 1900\n \n-        with patch(\"tools.send_message_tool._send_discord\", side_effect=mock_send_discord):\n+        with _patch_discord_sender(AsyncMock(side_effect=mock_send_discord)):\n             result = asyncio.run(\n                 _send_to_platform(\n                     Platform.DISCORD,","@@ -1380,7 +1457,7 @@ def test_single_chunk_gets_media(self):\n         \"\"\"Short message (single chunk) gets media_files directly.\"\"\"\n         send_mock = AsyncMock(return_value={\"success\": True, \"message_id\": \"1\"})\n \n-        with patch(\"tools.send_message_tool._send_discord\", send_mock):\n+        with _patch_discord_sender(send_mock):\n             result = asyncio.run(\n                 _send_to_platform(\n                     Platform.DISCORD,","@@ -1618,7 +1695,7 @@ def test_send_to_platform_discord_delegates_to_send_discord(self):\n         \"\"\"Discord messages are routed through _send_discord, which handles forum detection.\"\"\"\n         send_mock = AsyncMock(return_value={\"success\": True, \"message_id\": \"1\"})\n \n-        with patch(\"tools.send_message_tool._send_discord\", send_mock):\n+        with _patch_discord_sender(send_mock):\n             result = asyncio.run(\n                 _send_to_platform(\n                     Platform.DISCORD,","@@ -1637,7 +1714,7 @@ def test_send_to_platform_discord_with_thread_id(self):\n         \"\"\"Thread ID is still passed through when sending to Discord.\"\"\"\n         send_mock = AsyncMock(return_value={\"success\": True, \"message_id\": \"1\"})\n \n-        with patch(\"tools.send_message_tool._send_discord\", send_mock):\n+        with _patch_discord_sender(send_mock):\n             result = asyncio.run(\n                 _send_to_platform(\n                     Platform.DISCORD,","@@ -1775,11 +1852,11 @@ class TestForumProbeCache:\n     \"\"\"_DISCORD_CHANNEL_TYPE_PROBE_CACHE memoizes forum detection results.\"\"\"\n \n     def setup_method(self):\n-        from tools import send_message_tool as smt\n-        smt._DISCORD_CHANNEL_TYPE_PROBE_CACHE.clear()\n+        from plugins.platforms.discord import adapter as discord_adapter\n+        discord_adapter._DISCORD_CHANNEL_TYPE_PROBE_CACHE.clear()\n \n     def test_cache_round_trip(self):\n-        from tools.send_message_tool import (\n+        from plugins.platforms.discord.adapter import (\n             _probe_is_forum_cached,\n             _remember_channel_is_forum,\n         )","@@ -1819,7 +1896,7 @@ def test_probe_result_is_memoized(self, monkeypatch):\n         thread_session.post = MagicMock(return_value=thread_resp)\n \n         # Two _send_discord calls: first does probe + thread-create; second should skip probe\n-        from tools import send_message_tool as smt\n+        from plugins.platforms.discord import adapter as discord_adapter\n \n         sessions_created = []\n ","@@ -1837,7 +1914,7 @@ def session_factory(**kwargs):\n         with patch(\"aiohttp.ClientSession\", side_effect=session_factory):\n             result1 = asyncio.run(_send_discord(\"tok\", \"ch1\", \"first\"))\n         assert result1[\"success\"] is True\n-        assert smt._probe_is_forum_cached(\"ch1\") is True\n+        assert discord_adapter._probe_is_forum_cached(\"ch1\") is True\n \n         # Second call: cache hits, no new probe session needed. We need to only\n         # return thread_session now since probe is skipped."]},{"file":"tools/send_message_tool.py","hunks":["@@ -563,7 +563,6 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None,\n     \"\"\"\n     from gateway.config import Platform\n     from gateway.platforms.base import BasePlatformAdapter, utf16_len\n-    from gateway.platforms.discord import DiscordAdapter\n     from gateway.platforms.slack import SlackAdapter\n \n     # Telegram adapter import is optional (requires python-telegram-bot)","@@ -589,10 +588,10 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None,\n         except Exception:\n             logger.debug(\"Failed to apply Slack mrkdwn formatting in _send_to_platform\", exc_info=True)\n \n-    # Platform message length limits (from adapter class attributes)\n+    # Platform message length limits (from adapter class attributes for\n+    # built-in platforms; from PlatformEntry.max_message_length for plugins).\n     _MAX_LENGTHS = {\n         Platform.TELEGRAM: TelegramAdapter.MAX_MESSAGE_LENGTH if _telegram_available else 4096,\n-        Platform.DISCORD: DiscordAdapter.MAX_MESSAGE_LENGTH,\n         Platform.SLACK: SlackAdapter.MAX_MESSAGE_LENGTH,\n     }\n     if _feishu_available:","@@ -642,17 +641,27 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None,\n     if platform == Platform.WEIXIN:\n         return await _send_weixin(pconfig, chat_id, message, media_files=media_files)\n \n-    # --- Discord: special handling for media attachments ---\n+    # --- Discord: chunked delivery via the registry's standalone_sender_fn.\n+    # The plugin's ``_standalone_send`` (registered in\n+    # plugins/platforms/discord/adapter.py) handles forum channels, threads,\n+    # and multipart media uploads.  ``_send_via_adapter`` tries the live\n+    # in-process adapter first via ``adapter.send()``, but Discord's elif\n+    # historically went straight to the HTTP path; we preserve that by\n+    # explicitly invoking the registry hook here so behavior is unchanged.\n     if platform == Platform.DISCORD:\n+        from gateway.platform_registry import platform_registry\n+        entry = platform_registry.get(\"discord\")\n+        if entry is None or entry.standalone_sender_fn is None:\n+            return {\"error\": \"Discord plugin not registered or missing standalone_sender_fn\"}\n         last_result = None\n         for i, chunk in enumerate(chunks):\n             is_last = (i == len(chunks) - 1)\n-            result = await _send_discord(\n-                pconfig.token,\n+            result = await entry.standalone_sender_fn(\n+                pconfig,\n                 chat_id,\n                 chunk,\n-                media_files=media_files if is_last else [],\n                 thread_id=thread_id,\n+                media_files=media_files if is_last else [],\n             )\n             if isinstance(result, dict) and result.get(\"error\"):\n                 return result","@@ -1026,227 +1035,6 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No\n         return _error(f\"Telegram send failed: {e}\")\n \n \n-def _derive_forum_thread_name(message: str) -> str:\n-    \"\"\"Derive a thread name from the first line of the message, capped at 100 chars.\"\"\"\n-    first_line = message.strip().split(\"\\n\", 1)[0].strip()\n-    # Strip common markdown heading prefixes\n-    first_line = first_line.lstrip(\"#\").strip()\n-    if not first_line:\n-        first_line = \"New Post\"\n-    return first_line[:100]\n-\n-\n-# Process-local cache for Discord channel-type probes.  Avoids re-probing the\n-# same channel on every send when the directory cache has no entry (e.g. fresh\n-# install, or channel created after the last directory build).\n-_DISCORD_CHANNEL_TYPE_PROBE_CACHE: Dict[str, bool] = {}\n-\n-\n-def _remember_channel_is_forum(chat_id: str, is_forum: bool) -> None:\n-    _DISCORD_CHANNEL_TYPE_PROBE_CACHE[str(chat_id)] = bool(is_forum)\n-\n-\n-def _probe_is_forum_cached(chat_id: str) -> Optional[bool]:\n-    return _DISCORD_CHANNEL_TYPE_PROBE_CACHE.get(str(chat_id))\n-\n-\n-async def _send_discord(token, chat_id, message, thread_id=None, media_files=None):\n-    \"\"\"Send a single message via Discord REST API (no websocket client needed).\n-\n-    Chunking is handled by _send_to_platform() before this is called.\n-\n-    When thread_id is provided, the message is sent directly to that thread\n-    via the /channels/{thread_id}/messages endpoint.\n-\n-    Media files are uploaded one-by-one via multipart/form-data after the\n-    text message is sent (same pattern as Telegram).\n-\n-    Forum channels (type 15) reject POST /messages — a thread post is created\n-    automatically via POST /channels/{id}/threads.  Media files are uploaded\n-    as multipart attachments on the starter message of the new thread.\n-\n-    Channel type is resolved from the channel directory first, then a\n-    process-local probe cache, and only as a last resort with a live\n-    GET /channels/{id} probe (whose result is memoized).\n-    \"\"\"\n-    try:\n-        import aiohttp\n-    except ImportError:\n-        return {\"error\": \"aiohttp not installed. Run: pip install aiohttp\"}\n-    try:\n-        from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp\n-        _proxy = resolve_proxy_url(platform_env_var=\"DISCORD_PROXY\")\n-        _sess_kw, _req_kw = proxy_kwargs_for_aiohttp(_proxy)\n-        auth_headers = {\"Authorization\": f\"Bot {token}\"}\n-        json_headers = {**auth_headers, \"Content-Type\": \"application/json\"}\n-        media_files = media_files or []\n-        last_data = None\n-        warnings = []\n-\n-        # Thread endpoint: Discord threads are channels; send directly to the thread ID.\n-        if thread_id:\n-            url = f\"https://discord.com/api/v10/channels/{thread_id}/messages\"\n-        else:\n-            # Check if the target channel is a forum channel (type 15).\n-            # Forum channels reject POST /messages — create a thread post instead.\n-            # Three-layer detection: directory cache → process-local probe\n-            # cache → GET /channels/{id} probe (with result memoized).\n-            _channel_type = None\n-            try:\n-                from gateway.channel_directory import lookup_channel_type\n-                _channel_type = lookup_channel_type(\"discord\", chat_id)\n-            except Exception:\n-                pass\n-\n-            if _channel_type == \"forum\":\n-                is_forum = True\n-            elif _channel_type is not None:\n-                is_forum = False\n-            else:\n-                cached = _probe_is_forum_cached(chat_id)\n-                if cached is not None:\n-                    is_forum = cached\n-                else:\n-                    is_forum = False\n-                    try:\n-                        info_url = f\"https://discord.com/api/v10/channels/{chat_id}\"\n-                        async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=15), **_sess_kw) as info_sess:\n-                            async with info_sess.get(info_url, headers=json_headers, **_req_kw) as info_resp:\n-                                if info_resp.status == 200:\n-                                    info = await info_resp.json()\n-                                    is_forum = info.get(\"type\") == 15\n-                                    _remember_channel_is_forum(chat_id, is_forum)\n-                    except Exception:\n-                        logger.debug(\"Failed to probe channel type for %s\", chat_id, exc_info=True)\n-\n-            if is_forum:\n-                thread_name = _derive_forum_thread_name(message)\n-                thread_url = f\"https://discord.com/api/v10/channels/{chat_id}/threads\"\n-\n-                # Filter to readable media files up front so we can pick the\n-                # right code path (JSON vs multipart) before opening a session.\n-                valid_media = []\n-                for media_path, _is_voice in media_files:\n-                    if not os.path.exists(media_path):\n-                        warning = f\"Media file not found, skipping: {media_path}\"\n-                        logger.warning(warning)\n-                        warnings.append(warning)\n-                        continue\n-                    valid_media.append(media_path)\n-\n-                async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=60), **_sess_kw) as session:\n-                    if valid_media:\n-                        # Multipart: payload_json + files[N] creates a forum\n-                        # thread with the starter message plus attachments in\n-                        # a single API call.\n-                        attachments_meta = [\n-                            {\"id\": str(idx), \"filename\": os.path.basename(path)}\n-                            for idx, path in enumerate(valid_media)\n-                        ]\n-                        starter_message = {\"content\": message, \"attachments\": attachments_meta}\n-                        payload_json = json.dumps({\"name\": thread_name, \"message\": starter_message})\n-\n-                        form = aiohttp.FormData()\n-                        form.add_field(\"payload_json\", payload_json, content_type=\"application/json\")\n-\n-                        # Buffer file bytes up front — aiohttp's FormData can\n-                        # read lazily and we don't want handles closing under\n-                        # it on retry.\n-                        try:\n-                            for idx, media_path in enumerate(valid_media):\n-                                with open(media_path, \"rb\") as fh:\n-                                    form.add_field(\n-                                        f\"files[{idx}]\",\n-                                        fh.read(),\n-                                        filename=os.path.basename(media_path),\n-                                    )\n-                            async with session.post(thread_url, headers=auth_headers, data=form, **_req_kw) as resp:\n-                                if resp.status not in {200, 201}:\n-                                    body = await resp.text()\n-                                    return _error(f\"Discord forum thread creation error ({resp.status}): {body}\")\n-                                data = await resp.json()\n-                        except Exception as e:\n-                            return _error(_sanitize_error_text(f\"Discord forum thread upload failed: {e}\"))\n-                    else:\n-                        # No media — simple JSON POST creates the thread with\n-                        # just the text starter.\n-                        async with session.post(\n-                            thread_url,\n-                            headers=json_headers,\n-                            json={\n-                                \"name\": thread_name,\n-                                \"message\": {\"content\": message},\n-                            },\n-                            **_req_kw,\n-                        ) as resp:\n-                            if resp.status not in {200, 201}:\n-                                body = await resp.text()\n-                                return _error(f\"Discord forum thread creation error ({resp.status}): {body}\")\n-                            data = await resp.json()\n-\n-                thread_id_created = data.get(\"id\")\n-                starter_msg_id = (data.get(\"message\") or {}).get(\"id\", thread_id_created)\n-                result = {\n-                    \"success\": True,\n-                    \"platform\": \"discord\",\n-                    \"chat_id\": chat_id,\n-                    \"thread_id\": thread_id_created,\n-                    \"message_id\": starter_msg_id,\n-                }\n-                if warnings:\n-                    result[\"warnings\"] = warnings\n-                return result\n-\n-            url = f\"https://discord.com/api/v10/channels/{chat_id}/messages\"\n-\n-        async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30), **_sess_kw) as session:\n-            # Send text message (skip if empty and media is present)\n-            if message.strip() or not media_files:\n-                async with session.post(url, headers=json_headers, json={\"content\": message}, **_req_kw) as resp:\n-                    if resp.status not in {200, 201}:\n-                        body = await resp.text()\n-                        return _error(f\"Discord API error ({resp.status}): {body}\")\n-                    last_data = await resp.json()\n-\n-            # Send each media file as a separate multipart upload\n-            for media_path, _is_voice in media_files:\n-                if not os.path.exists(media_path):\n-                    warning = f\"Media file not found, skipping: {media_path}\"\n-                    logger.warning(warning)\n-                    warnings.append(warning)\n-                    continue\n-                try:\n-                    form = aiohttp.FormData()\n-                    filename = os.path.basename(media_path)\n-                    with open(media_path, \"rb\") as f:\n-                        form.add_field(\"files[0]\", f, filename=filename)\n-                        async with session.post(url, headers=auth_headers, data=form, **_req_kw) as resp:\n-                            if resp.status not in {200, 201}:\n-                                body = await resp.text()\n-                                warning = _sanitize_error_text(f\"Failed to send media {media_path}: Discord API error ({resp.status}): {body}\")\n-                                logger.error(warning)\n-                                warnings.append(warning)\n-                                continue\n-                            last_data = await resp.json()\n-                except Exception as e:\n-                    warning = _sanitize_error_text(f\"Failed to send media {media_path}: {e}\")\n-                    logger.error(warning)\n-                    warnings.append(warning)\n-\n-        if last_data is None:\n-            error = \"No deliverable text or media remained after processing\"\n-            if warnings:\n-                return {\"error\": error, \"warnings\": warnings}\n-            return {\"error\": error}\n-\n-        result = {\"success\": True, \"platform\": \"discord\", \"chat_id\": chat_id, \"message_id\": last_data.get(\"id\")}\n-        if warnings:\n-            result[\"warnings\"] = warnings\n-        return result\n-    except Exception as e:\n-        return _error(f\"Discord send failed: {e}\")\n-\n-\n async def _send_slack(token, chat_id, message):\n     \"\"\"Send via Slack Web API.\"\"\"\n     try:\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}