diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index 2d2ea93f99..091b15f61c 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 c1c3c1df10..c2f2866eb9 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 # ------------------------------------------------------------------