fix(discord): name missing privileged intents and stop reconnect loop

PrivilegedIntentsRequired is a Developer Portal config error; surface which
intents Hermes requested as a non-retryable fatal and teach setup/docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
rainbowgits 2026-08-05 17:55:21 +03:00 committed by Teknium
parent 590d547b40
commit b10e7890b6
4 changed files with 206 additions and 14 deletions

View File

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

View File

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

View File

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

View File

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