diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index fdb04dee406a0..1a497b607ab06 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -266,6 +266,57 @@ async def _wait_for_ready_or_bot_exit( await ready_task +def _needs_server_members_intent( + allowed_user_ids: set[str] | list[str] | None, + allowed_role_ids: set[str] | list[str] | None, +) -> bool: + """Return True when Hermes must request Discord's Server Members intent. + + Message Content is always requested. Server Members is only needed when the + allowlist contains usernames (not pure numeric IDs / ``*``) or when role + allowlists require member role lookups. + """ + entries = allowed_user_ids or () + if any(entry != "*" and not str(entry).isdigit() for entry in entries): + return True + return bool(allowed_role_ids) + + +def _is_privileged_intents_required(exc: BaseException) -> bool: + """True when ``exc`` is discord.py's PrivilegedIntentsRequired error.""" + if type(exc).__name__ == "PrivilegedIntentsRequired": + return True + if discord is None: + return False + err_mod = getattr(discord, "errors", None) + cls = getattr(err_mod, "PrivilegedIntentsRequired", None) + return cls is not None and isinstance(exc, cls) + + +def _format_privileged_intents_guidance(*, needs_members: bool) -> str: + """Actionable fix text when Discord rejects privileged Gateway Intents.""" + lines = [ + "Discord rejected the connection because privileged Gateway Intents " + "are not enabled for this bot in the Developer Portal.", + "Hermes is requesting:", + " - Message Content Intent (required to read message text)", + ] + if needs_members: + lines.append( + " - Server Members Intent (required for username allowlists " + "and/or DISCORD_ALLOWED_ROLES)" + ) + lines.extend( + [ + "Fix: https://discord.com/developers/applications → your application " + "→ Bot → Privileged Gateway Intents → enable the intent(s) listed " + "above → Save Changes, then restart the gateway.", + "Docs: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/discord", + ] + ) + return "\n".join(lines) + + def _find_discord_windows_bundled_opus(discord_module: Any = None) -> Optional[str]: """Return discord.py's bundled Windows opus DLL path when present.""" if sys.platform != "win32": @@ -1286,18 +1337,16 @@ class DiscordAdapter(BasePlatformAdapter): # that aren't enabled in the Discord Developer Portal can prevent the # bot from coming online at all, so avoid requesting members intent # unless it is actually necessary. + # ``"*"`` is the open-mode wildcard (honored in _is_allowed_user), not + # a username to resolve — requesting Members for it would silently + # fail bots that never enabled Members Intent in the Developer Portal. intents = Intents.default() intents.message_content = True intents.dm_messages = True intents.guild_messages = True - intents.members = ( - # ``"*"`` is the open-mode wildcard (honored in _is_allowed_user), - # not a username to resolve, so it must not pull in the privileged - # Server Members intent — exactly the migrate-from-OpenClaw path - # the wildcard fix targets would otherwise silently fail to come - # online when Members Intent isn't enabled in the Developer Portal. - any(entry != "*" and not entry.isdigit() for entry in self._allowed_user_ids) - or bool(self._allowed_role_ids) # Need members intent for role lookup + intents.members = _needs_server_members_intent( + self._allowed_user_ids, + self._allowed_role_ids, ) intents.voice_states = True @@ -1444,7 +1493,25 @@ class DiscordAdapter(BasePlatformAdapter): ) return False except Exception as e: # pragma: no cover - defensive logging - logger.error("[%s] Failed to connect to Discord: %s", self.name, e, exc_info=True) + # PrivilegedIntentsRequired is a Developer Portal config error, not a + # transient network blip — name the exact intents Hermes requested and + # mark non-retryable so the gateway does not spin reconnect forever + # (#79430). + if _is_privileged_intents_required(e): + guidance = _format_privileged_intents_guidance( + needs_members=_needs_server_members_intent( + getattr(self, "_allowed_user_ids", None), + getattr(self, "_allowed_role_ids", None), + ) + ) + logger.error("[%s] %s", self.name, guidance) + self._set_fatal_error( + "privileged_intents_required", + guidance, + retryable=False, + ) + else: + logger.error("[%s] Failed to connect to Discord: %s", self.name, e, exc_info=True) # Same zombie-client hazard as the timeout branch: the background # client.start() task may already be running when a later setup # step raises. Cancel it so the discarded adapter cannot connect. @@ -10188,6 +10255,13 @@ def interactive_setup() -> None: return print_info("Create a bot at https://discord.com/developers/applications") + print_info("On Bot → Privileged Gateway Intents, enable:") + print_info(" - Message Content Intent (required — without it Discord rejects the connection)") + print_info(" - Server Members Intent (required if you use usernames or role allowlists)") + print_info("Save Changes in the Developer Portal before starting the gateway.") + print_info( + "Docs: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/discord" + ) token = prompt("Discord bot token", password=True) if not token: return diff --git a/tests/gateway/test_discord_connect.py b/tests/gateway/test_discord_connect.py index 2ef7c877628f9..8fe9e7881b02c 100644 --- a/tests/gateway/test_discord_connect.py +++ b/tests/gateway/test_discord_connect.py @@ -622,3 +622,88 @@ class TestDiscordUnconfiguredNonRetryable: assert adapter.fatal_error_retryable is False assert adapter.fatal_error_code == "missing_dependency" + +# ============================================================================ +# #79430: PrivilegedIntentsRequired → actionable, non-retryable fatal +# ============================================================================ + +class PrivilegedIntentsRequired(Exception): + """Stand-in for discord.errors.PrivilegedIntentsRequired.""" + + def __init__(self, shard_id=None): + self.shard_id = shard_id + super().__init__( + "Shard ID None is requesting privileged intents that have not been " + "explicitly enabled in the developer portal." + ) + + +class TestPrivilegedIntentsRequiredFatal: + """Missing Developer Portal intents must not spin reconnect forever.""" + + def test_guidance_lists_message_content_always(self): + text = discord_platform._format_privileged_intents_guidance(needs_members=False) + assert "Message Content Intent" in text + assert "Server Members Intent" not in text + assert "discord.com/developers/applications" in text + + def test_guidance_lists_members_when_needed(self): + text = discord_platform._format_privileged_intents_guidance(needs_members=True) + assert "Message Content Intent" in text + assert "Server Members Intent" in text + + def test_needs_members_intent_rules(self): + needs = discord_platform._needs_server_members_intent + assert needs(set(), set()) is False + assert needs({"*"}, set()) is False + assert needs({"769524422783664158"}, set()) is False + assert needs({"alice"}, set()) is True + assert needs(set(), {"111"}) is True + + @pytest.mark.asyncio + async def test_connect_sets_non_retryable_fatal(self, monkeypatch): + adapter = DiscordAdapter( + PlatformConfig(enabled=True, token="test-token", extra={"slash_commands": False}) + ) + monkeypatch.setattr( + "gateway.status.acquire_scoped_lock", + lambda scope, identity, metadata=None: (True, None), + ) + monkeypatch.setattr( + "gateway.status.release_scoped_lock", + lambda scope, identity: None, + ) + + intents = SimpleNamespace( + message_content=False, + dm_messages=False, + guild_messages=False, + members=False, + voice_states=False, + ) + monkeypatch.setattr(discord_platform.Intents, "default", lambda: intents) + + class BoomBot(FakeBot): + async def start(self, token): + raise PrivilegedIntentsRequired(None) + + monkeypatch.setattr( + discord_platform.commands, + "Bot", + lambda **kwargs: BoomBot( + intents=kwargs["intents"], + proxy=kwargs.get("proxy"), + allowed_mentions=kwargs.get("allowed_mentions"), + ), + ) + + ok = await adapter.connect() + + assert ok is False + assert adapter.has_fatal_error is True + assert adapter.fatal_error_retryable is False + assert adapter.fatal_error_code == "privileged_intents_required" + assert "Message Content Intent" in (adapter.fatal_error_message or "") + assert "discord.com/developers/applications" in (adapter.fatal_error_message or "") + assert adapter._bot_task is None + diff --git a/tests/gateway/test_discord_plugin_setup.py b/tests/gateway/test_discord_plugin_setup.py index 733d45e7c23e4..203cfe99c061d 100644 --- a/tests/gateway/test_discord_plugin_setup.py +++ b/tests/gateway/test_discord_plugin_setup.py @@ -11,7 +11,7 @@ import hermes_cli.cli_output as cli_output_mod from plugins.platforms.discord.adapter import interactive_setup -def _patch_setup_io(monkeypatch, prompts, saved, removed, existing): +def _patch_setup_io(monkeypatch, prompts, saved, removed, existing, infos=None): prompt_iter = iter(prompts) monkeypatch.setattr(config_mod, "get_env_value", lambda key: existing.get(key, "")) monkeypatch.setattr(config_mod, "save_env_value", lambda k, v: saved.update({k: v})) @@ -23,9 +23,15 @@ def _patch_setup_io(monkeypatch, prompts, saved, removed, existing): monkeypatch.setattr(config_mod, "remove_env_value", _remove) monkeypatch.setattr(cli_output_mod, "prompt", lambda *_a, **_kw: next(prompt_iter)) monkeypatch.setattr(cli_output_mod, "prompt_yes_no", lambda *_a, **_kw: False) - for name in ("print_header", "print_info", "print_success", "print_warning"): + for name in ("print_header", "print_success", "print_warning"): monkeypatch.setattr(cli_output_mod, name, lambda *_a, **_kw: None) + def _info(*args, **_kw): + if infos is not None: + infos.append(" ".join(str(a) for a in args)) + + monkeypatch.setattr(cli_output_mod, "print_info", _info) + # Discord prompts: bot_token (password), allowed_users, home_channel. _PROMPTS_NONEMPTY = ["«redacted:discord-bot-token»", "", "123456789012345678"] @@ -51,3 +57,24 @@ class TestDiscordHomeChannelClear: assert "DISCORD_HOME_CHANNEL" not in saved +class TestDiscordSetupPrivilegedIntentsGuidance: + """Setup must name Privileged Gateway Intents before asking for the token (#79430).""" + + def test_setup_mentions_message_content_intent(self, monkeypatch, tmp_path): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + saved, removed, infos = {}, [], [] + _patch_setup_io( + monkeypatch, + _PROMPTS_BLANK, + saved, + removed, + existing={}, + infos=infos, + ) + interactive_setup() + joined = "\n".join(infos) + assert "Message Content Intent" in joined + assert "Privileged Gateway Intents" in joined + assert "discord.com/developers/applications" in joined + + diff --git a/website/docs/user-guide/messaging/discord.md b/website/docs/user-guide/messaging/discord.md index 31dd0863af86f..f6da7e0c4dfa6 100644 --- a/website/docs/user-guide/messaging/discord.md +++ b/website/docs/user-guide/messaging/discord.md @@ -826,11 +826,17 @@ No Discord access policy configured; inbound Discord messages will be denied by Hermes 0.18 intentionally fails closed on externally reachable adapters. A Discord bot with no `DISCORD_ALLOWED_USERS`, no `DISCORD_ALLOWED_ROLES`, no `DISCORD_ALLOWED_CHANNELS`, and no explicit allow-all flag will connect successfully but deny inbound users before normal message handling. -### "Disallowed Intents" error on startup +### "Privileged intents" / `PrivilegedIntentsRequired` error on startup -**Cause**: Your code requests intents that aren't enabled in the Developer Portal. +**Cause**: Hermes requests privileged Gateway Intents that are not enabled for your bot in the Developer Portal. Discord then rejects the WebSocket connection. Hermes always requests **Message Content Intent**. It also requests **Server Members Intent** when your allowlist uses usernames (not numeric IDs) or when `DISCORD_ALLOWED_ROLES` is set. Presence Intent is not required. -**Fix**: Enable all three Privileged Gateway Intents (Presence, Server Members, Message Content) in the Bot settings, then restart. +**Fix**: + +1. Go to [Developer Portal](https://discord.com/developers/applications) → your app → Bot → Privileged Gateway Intents. +2. Enable **Message Content Intent** (required). Enable **Server Members Intent** if you use usernames or role allowlists. +3. Click **Save Changes**, then restart the gateway (`hermes gateway restart`). + +The gateway log should name the exact intent(s) Hermes requested. Until they are enabled, Discord will keep rejecting the connection — this is a portal configuration error, not a flaky network issue. ### Bot can't see messages in a specific channel