From 22d22cd75c656bf90f2a179e7df73d06654ed57f Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:25:27 -0700 Subject: [PATCH] fix: auto-register all gateway commands as Discord slash commands (#10528) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discord's _register_slash_commands() had a hardcoded list of ~27 commands while COMMAND_REGISTRY defines 34+ gateway-available commands. Missing commands (debug, branch, rollback, snapshot, profile, yolo, fast, reload, commands) were invisible in Discord's / autocomplete — users couldn't discover them. Add a dynamic catch-all loop after the explicit registrations that iterates COMMAND_REGISTRY, skips already-registered commands, and auto-registers the rest using discord.app_commands.Command(). Commands with args_hint get an optional string parameter; parameterless commands get a simple callback. This ensures any future commands added to COMMAND_REGISTRY automatically appear on Discord without needing a manual entry in discord.py. Telegram and Slack already derive dynamically from COMMAND_REGISTRY via telegram_bot_commands() and slack_subcommand_map() — no changes needed there. --- gateway/platforms/discord.py | 70 ++++++++++++++++++++ tests/gateway/test_discord_slash_commands.py | 51 ++++++++++++++ 2 files changed, 121 insertions(+) diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index 2d2ea93f..091b15f6 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -1802,6 +1802,76 @@ class DiscordAdapter(BasePlatformAdapter): async def slash_btw(interaction: discord.Interaction, question: str): await self._run_simple_slash(interaction, f"/btw {question}") + # ── Auto-register any gateway-available commands not yet on the tree ── + # This ensures new commands added to COMMAND_REGISTRY in + # hermes_cli/commands.py automatically appear as Discord slash + # commands without needing a manual entry here. + try: + from hermes_cli.commands import COMMAND_REGISTRY, _is_gateway_available, _resolve_config_gates + + already_registered = set() + try: + already_registered = {cmd.name for cmd in tree.get_commands()} + except Exception: + pass + + config_overrides = _resolve_config_gates() + + for cmd_def in COMMAND_REGISTRY: + if not _is_gateway_available(cmd_def, config_overrides): + continue + # Discord command names: lowercase, hyphens OK, max 32 chars. + discord_name = cmd_def.name.lower()[:32] + if discord_name in already_registered: + continue + # Skip aliases that overlap with already-registered names + # (aliases for explicitly registered commands are handled above). + desc = (cmd_def.description or f"Run /{cmd_def.name}")[:100] + has_args = bool(cmd_def.args_hint) + + if has_args: + # Command takes optional arguments — create handler with + # an optional ``args`` string parameter. + def _make_args_handler(_name: str, _hint: str): + @discord.app_commands.describe(args=f"Arguments: {_hint}"[:100]) + async def _handler(interaction: discord.Interaction, args: str = ""): + await self._run_simple_slash( + interaction, f"/{_name} {args}".strip() + ) + _handler.__name__ = f"auto_slash_{_name.replace('-', '_')}" + return _handler + + handler = _make_args_handler(cmd_def.name, cmd_def.args_hint) + else: + # Parameterless command. + def _make_simple_handler(_name: str): + async def _handler(interaction: discord.Interaction): + await self._run_simple_slash(interaction, f"/{_name}") + _handler.__name__ = f"auto_slash_{_name.replace('-', '_')}" + return _handler + + handler = _make_simple_handler(cmd_def.name) + + auto_cmd = discord.app_commands.Command( + name=discord_name, + description=desc, + callback=handler, + ) + try: + tree.add_command(auto_cmd) + already_registered.add(discord_name) + except Exception: + # Silently skip commands that fail registration (e.g. + # name conflict with a subcommand group). + pass + + logger.debug( + "Discord auto-registered %d commands from COMMAND_REGISTRY", + len(already_registered), + ) + except Exception as e: + logger.warning("Discord auto-register from COMMAND_REGISTRY failed: %s", e) + # Register skills under a single /skill command group with category # subcommand groups. This uses 1 top-level slot instead of N, # supporting up to 25 categories × 25 skills = 625 skills. diff --git a/tests/gateway/test_discord_slash_commands.py b/tests/gateway/test_discord_slash_commands.py index c1c3c1df..c2f2866e 100644 --- a/tests/gateway/test_discord_slash_commands.py +++ b/tests/gateway/test_discord_slash_commands.py @@ -134,6 +134,57 @@ async def test_registers_native_restart_slash_command(adapter): ) +# ------------------------------------------------------------------ +# Auto-registration from COMMAND_REGISTRY +# ------------------------------------------------------------------ + + +@pytest.mark.asyncio +async def test_auto_registers_missing_gateway_commands(adapter): + """Commands in COMMAND_REGISTRY that aren't explicitly registered should + be auto-registered by the dynamic catch-all block.""" + adapter._run_simple_slash = AsyncMock() + adapter._register_slash_commands() + + tree_names = set(adapter._client.tree.commands.keys()) + + # These commands are gateway-available but were not in the original + # hardcoded registration list — they should be auto-registered. + expected_auto = {"debug", "yolo", "reload", "profile"} + for name in expected_auto: + assert name in tree_names, f"/{name} should be auto-registered on Discord" + + +@pytest.mark.asyncio +async def test_auto_registered_command_dispatches_correctly(adapter): + """Auto-registered commands should dispatch via _run_simple_slash.""" + adapter._run_simple_slash = AsyncMock() + adapter._register_slash_commands() + + # /debug has no args — test parameterless dispatch + debug_cmd = adapter._client.tree.commands["debug"] + interaction = SimpleNamespace() + adapter._run_simple_slash.reset_mock() + await debug_cmd.callback(interaction) + adapter._run_simple_slash.assert_awaited_once_with(interaction, "/debug") + + +@pytest.mark.asyncio +async def test_auto_registered_command_with_args(adapter): + """Auto-registered commands with args_hint should accept an optional args param.""" + adapter._run_simple_slash = AsyncMock() + adapter._register_slash_commands() + + # /branch has args_hint="[name]" — test dispatch with args + branch_cmd = adapter._client.tree.commands["branch"] + interaction = SimpleNamespace() + adapter._run_simple_slash.reset_mock() + await branch_cmd.callback(interaction, args="my-branch") + adapter._run_simple_slash.assert_awaited_once_with( + interaction, "/branch my-branch" + ) + + # ------------------------------------------------------------------ # _handle_thread_create_slash — success, session dispatch, failure # ------------------------------------------------------------------