Merge pull request #46078 from xxxigm/fix/discord-slash-command-100-cap

fix(discord): cap slash commands at Discord's 100-command limit
This commit is contained in:
kshitij 2026-06-16 02:05:31 +05:30 committed by GitHub
commit cffd6e3c8d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 83 additions and 0 deletions

View file

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

View file

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