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:
parent
590d547b40
commit
b10e7890b6
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue