diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index 69b1bf4d228..8146ca9de10 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -31,6 +31,12 @@ _DISCORD_COMMAND_SYNC_STATE_SUBDIR = "gateway" _DISCORD_COMMAND_SYNC_STATE_FILENAME = "discord_command_sync_state.json" _DISCORD_COMMAND_SYNC_MUTATION_INTERVAL_SECONDS = 4.5 _DISCORD_COMMAND_SYNC_MAX_RATE_LIMIT_SLEEP_SECONDS = 30.0 +# Discord enforces a hard cap of 100 global application (slash) commands per +# app. Registering more makes the ENTIRE sync fail with error 30032 +# ("Maximum number of application commands reached"), which silently breaks +# 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 try: import discord @@ -3518,6 +3524,11 @@ class DiscordAdapter(BasePlatformAdapter): ) already_registered: set[str] = set() + # Native commands above are registered first and are the highest + # priority, so they always survive the 100-command cap. Reserve one + # slot for the consolidated ``/skill`` group registered further below. + slot_cap = _DISCORD_MAX_APP_COMMANDS - 1 + dropped_over_cap = 0 try: from hermes_cli.commands import COMMAND_REGISTRY, _is_gateway_available, _resolve_config_gates @@ -3535,6 +3546,9 @@ class DiscordAdapter(BasePlatformAdapter): discord_name = cmd_def.name.lower()[:32] if discord_name in already_registered: continue + if len(already_registered) >= slot_cap: + dropped_over_cap += 1 + continue auto_cmd = _build_auto_slash_command( cmd_def.name, cmd_def.description, @@ -3567,6 +3581,9 @@ class DiscordAdapter(BasePlatformAdapter): discord_name = plugin_name.lower()[:32] if discord_name in already_registered: continue + if len(already_registered) >= slot_cap: + dropped_over_cap += 1 + continue auto_cmd = _build_auto_slash_command( plugin_name, plugin_desc, @@ -3589,6 +3606,20 @@ class DiscordAdapter(BasePlatformAdapter): # supporting up to 25 categories × 25 skills = 625 skills. self._register_skill_group(tree) + if dropped_over_cap: + # Staying under the cap keeps the whole sync succeeding; without + # this guard a single over-limit command makes Discord reject the + # entire batch (error 30032), breaking every slash command. + logger.warning( + "[%s] Reached Discord's limit of %d slash commands; skipped %d " + "lower-priority command(s) to keep the command sync working. " + "Disable slash commands you don't need or trim installed plugins " + "to surface them all.", + self.name, + _DISCORD_MAX_APP_COMMANDS, + dropped_over_cap, + ) + # Optional defense-in-depth: hide every slash command from non-admin # guild members in Discord's slash picker. Server-side authorization # (``_check_slash_authorization``) is the actual gate; this is purely diff --git a/tests/gateway/test_discord_slash_commands.py b/tests/gateway/test_discord_slash_commands.py index 8d44f77302e..5ef6812f537 100644 --- a/tests/gateway/test_discord_slash_commands.py +++ b/tests/gateway/test_discord_slash_commands.py @@ -292,6 +292,58 @@ async def test_plugin_command_name_conflict_skipped(adapter): ) +# ------------------------------------------------------------------ +# 100-command cap (Discord error 30032 guard) +# ------------------------------------------------------------------ + + +@pytest.mark.asyncio +async def test_slash_command_registration_stays_under_discord_limit(adapter): + """Registering far more commands than Discord allows must NOT push the + tree over the 100-command hard cap. + + Discord rejects the ENTIRE command sync with error 30032 once the + desired set exceeds 100 global application commands, silently breaking + every slash command. The adapter must bound the desired set instead. + Regression guard for samuraiheart's recurring + "Maximum number of application commands reached (100)" sync failures. + """ + from plugins.platforms.discord.adapter import _DISCORD_MAX_APP_COMMANDS + + adapter._run_simple_slash = AsyncMock() + + # 200 plugin commands — way past Discord's limit on their own. + many_plugins = { + f"plug{i:03d}": { + "handler": lambda _a: "ok", + "description": f"Plugin command {i}", + "args_hint": "", + "plugin": "stress-plugin", + } + for i in range(200) + } + + with patch("hermes_cli.plugins.get_plugin_commands", return_value=many_plugins): + adapter._register_slash_commands() + + tree_names = set(adapter._client.tree.commands.keys()) + + # Contract: never exceed Discord's hard cap. + assert len(tree_names) <= _DISCORD_MAX_APP_COMMANDS, ( + f"registered {len(tree_names)} commands — exceeds Discord's " + f"{_DISCORD_MAX_APP_COMMANDS} limit and would fail sync with 30032" + ) + + # Native, high-priority commands are registered first and must survive + # the cap — they are the core UX, not droppable overflow. + for native in ("status", "stop", "new", "model", "help"): + assert native in tree_names, f"/{native} (native) was dropped by the cap" + + # The cap must actually have dropped overflow — not every plugin fit. + registered_plugins = [n for n in tree_names if n.startswith("plug")] + assert len(registered_plugins) < 200, "cap did not drop any overflow commands" + + # ------------------------------------------------------------------ # _handle_thread_create_slash — success, session dispatch, failure # ------------------------------------------------------------------