Skip to content
Merged
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
31 changes: 31 additions & 0 deletions plugins/platforms/discord/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@
_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
Expand Down Expand Up @@ -3518,6 +3524,11 @@ async def _handler(interaction: discord.Interaction):
)

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

Expand All @@ -3535,6 +3546,9 @@ async def _handler(interaction: discord.Interaction):
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,
Expand Down Expand Up @@ -3567,6 +3581,9 @@ async def _handler(interaction: discord.Interaction):
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,
Expand All @@ -3589,6 +3606,20 @@ async def _handler(interaction: discord.Interaction):
# 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
Expand Down
52 changes: 52 additions & 0 deletions tests/gateway/test_discord_slash_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ------------------------------------------------------------------
Expand Down
Loading