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