Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions plugins/platforms/discord/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ def __init__(self, id: int) -> None: # noqa: A002 - matches discord API
# every slash command — not just the overflow ones. We keep the desired set
# at or below this limit at registration time.
_DISCORD_MAX_APP_COMMANDS = 100
# The Discord JSON error code returned with that rejection. It arrives as
# HTTP 400 with ``{"code": 30032}`` in the body — the status alone is not
# distinctive (50035 "Invalid Form Body" is also a 400), so detection keys
# on the code.
_DISCORD_COMMAND_CAP_ERROR_CODE = 30032
_DISCORD_SELECT_FIELD_LIMIT = 100
_DISCORD_BUTTON_LABEL_LIMIT = 80
_DISCORD_ELLIPSIS = "\u2026"
Expand Down Expand Up @@ -1880,6 +1885,46 @@ def _is_discord_rate_limit(exc: BaseException) -> bool:
return True
return False

@staticmethod
def _is_discord_command_cap_error(exc: BaseException) -> bool:
"""True only for Discord's global-application-command cap rejection.

Discord refuses the whole batch with **HTTP 400 / error code 30032**
("Maximum number of application commands reached") once an app is at
``_DISCORD_MAX_APP_COMMANDS``. That is the one known cause of an
otherwise-inexplicable sync failure on a loaded install, and it is
worth telling the operator about in plain words rather than a stack
trace.

Deliberately narrow, mirroring ``_is_discord_rate_limit``: the 400
status alone proves nothing (50035 "Invalid Form Body" is also a
400), so a match requires the 30032 code, or — for older discord.py
forks, mocks, and exotic transports that don't expose one — a 400
*plus* the specific cap message. Arbitrary 400s keep raising, so
unrelated bugs still surface with their traceback.
"""
code = getattr(exc, "code", None)
if code is None:
data = getattr(exc, "data", None)
if isinstance(data, dict):
code = data.get("code")
try:
code = int(code)
except (TypeError, ValueError):
code = None
if code == _DISCORD_COMMAND_CAP_ERROR_CODE:
return True

status = getattr(exc, "status", None)
if status is None:
response = getattr(exc, "response", None)
if response is not None:
status = getattr(response, "status", None) or getattr(response, "status_code", None)
if status != 400:
return False
text = getattr(exc, "text", "") or str(exc) or ""
return "Maximum number of application commands reached" in text

@staticmethod
def _is_discord_unknown_interaction(exc: BaseException) -> bool:
"""True for Discord's expired interaction token error."""
Expand Down Expand Up @@ -1949,6 +1994,22 @@ async def _run_post_connect_initialization(self) -> None:
# persist Discord's retry-after when it refuses the batch.
summary = await asyncio.wait_for(self._safe_sync_slash_commands(), timeout=600)
except Exception as e:
# The command cap is checked before the rate-limit branch:
# it is neither a 429 nor a transient condition, so it would
# otherwise fall through to the outer handler and reach the
# operator as a "Slash command sync failed" stack trace with
# no hint that the fix is to free command slots.
if self._is_discord_command_cap_error(e):
logger.warning(
"[%s] Discord slash command sync rejected: app is at the "
"%d global command cap (HTTP 400 / error code %d). "
"Disable unused plugins or trim COMMAND_REGISTRY to free "
"slots; the sync will retry on the next reconnect.",
self.name,
_DISCORD_MAX_APP_COMMANDS,
_DISCORD_COMMAND_CAP_ERROR_CODE,
)
return
if not self._is_discord_rate_limit(e):
raise
retry_after = self._extract_discord_retry_after(e)
Expand Down
124 changes: 124 additions & 0 deletions tests/gateway/test_discord_connect.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import asyncio
import json
import logging
import os
import sys
from types import SimpleNamespace
Expand Down Expand Up @@ -622,3 +623,126 @@ async def test_no_discord_lib_sets_non_retryable_fatal(self, monkeypatch):
assert adapter.fatal_error_retryable is False
assert adapter.fatal_error_code == "missing_dependency"



# ============================================================================
# #48087: the 100-global-command cap (HTTP 400 / error code 30032) is reported
# as itself, not as a generic "Slash command sync failed" stack trace
# ============================================================================

class TestDiscordCommandCapDiagnostic:
"""Discord rejects the whole sync batch with HTTP 400 / code 30032 once an
app holds 100 global commands. Without a dedicated branch it is neither a
429 (so the rate-limit handler skips it) nor recognisable in the outer
handler's traceback, leaving the operator with no hint that the fix is to
free command slots."""

class _CapError(Exception):
"""HTTPException-shaped 30032, as discord.py raises it."""

code = 30032
status = 400
text = "Maximum number of application commands reached (100)."

def __str__(self):
return self.text

def test_detects_code_30032(self):
assert DiscordAdapter._is_discord_command_cap_error(self._CapError()) is True

def test_detects_code_from_payload_when_attr_absent(self):
"""Some transports expose the JSON body rather than a ``code`` attr."""

class _PayloadCapError(Exception):
data = {"code": 30032, "message": "Maximum number of application commands reached"}
status = 400

assert DiscordAdapter._is_discord_command_cap_error(_PayloadCapError()) is True

def test_detects_via_message_on_legacy_exception(self):
"""Older forks / mocks expose no code — fall back to 400 + the message."""

class _LegacyCapError(Exception):
response = SimpleNamespace(status=400, status_code=400)
text = "Maximum number of application commands reached (100)."

assert DiscordAdapter._is_discord_command_cap_error(_LegacyCapError()) is True

def test_ignores_unrelated_400(self):
"""A 400 alone proves nothing — 50035 must keep raising."""

class _InvalidFormBody(Exception):
code = 50035
status = 400
text = "Invalid Form Body"

assert DiscordAdapter._is_discord_command_cap_error(_InvalidFormBody()) is False

def test_ignores_rate_limit(self):
class _RateLimited(Exception):
status = 429
text = "Too Many Requests"

assert DiscordAdapter._is_discord_command_cap_error(_RateLimited()) is False
assert DiscordAdapter._is_discord_command_cap_error(RuntimeError("boom")) is False

@pytest.mark.asyncio
async def test_cap_error_logs_http_400_and_error_code(self, tmp_path, monkeypatch, caplog):
"""The warning must name both the HTTP status and the Discord error
code — ``30032`` is not an HTTP status, and conflating the two sends
an operator looking in the wrong place."""
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="test-token"))
monkeypatch.setattr("hermes_constants.get_hermes_home", lambda: tmp_path)

class _DesiredCommand:
def to_dict(self, tree):
return {"name": "status", "description": "Show status", "type": 1, "options": []}

adapter._client = SimpleNamespace(
tree=SimpleNamespace(get_commands=lambda: [_DesiredCommand()]),
application_id=4242,
user=SimpleNamespace(id=4242),
)
sync = AsyncMock(side_effect=self._CapError())
monkeypatch.setattr(adapter, "_safe_sync_slash_commands", sync)

with caplog.at_level(logging.WARNING, logger="plugins.platforms.discord.adapter"):
# Must not raise: the cap branch absorbs it after reporting.
await adapter._run_post_connect_initialization()

sync.assert_awaited_once()
messages = [r.getMessage() for r in caplog.records]
cap_lines = [m for m in messages if "global command cap" in m]
assert cap_lines, f"expected a cap-specific warning, got: {messages!r}"
assert "HTTP 400" in cap_lines[0], f"status missing: {cap_lines[0]!r}"
assert "error code 30032" in cap_lines[0], f"error code missing: {cap_lines[0]!r}"
assert "100 global command cap" in cap_lines[0]
assert not any("Slash command sync failed" in m for m in messages), (
f"cap error fell through to the generic handler: {messages!r}"
)

@pytest.mark.asyncio
async def test_unrelated_failure_still_hits_generic_handler(self, tmp_path, monkeypatch, caplog):
"""The narrow branch must not swallow anything else."""
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="test-token"))
monkeypatch.setattr("hermes_constants.get_hermes_home", lambda: tmp_path)

class _DesiredCommand:
def to_dict(self, tree):
return {"name": "status", "description": "Show status", "type": 1, "options": []}

adapter._client = SimpleNamespace(
tree=SimpleNamespace(get_commands=lambda: [_DesiredCommand()]),
application_id=4243,
user=SimpleNamespace(id=4243),
)
monkeypatch.setattr(
adapter, "_safe_sync_slash_commands", AsyncMock(side_effect=RuntimeError("boom"))
)

with caplog.at_level(logging.WARNING, logger="plugins.platforms.discord.adapter"):
await adapter._run_post_connect_initialization()

messages = [r.getMessage() for r in caplog.records]
assert any("Slash command sync failed" in m for m in messages), messages
assert not any("global command cap" in m for m in messages), messages