From 6474be9b711b68c4435b6af0abb9031c672f1ac2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Ccary-server=E2=80=9D?= Date: Thu, 30 Jul 2026 04:32:21 +0800 Subject: [PATCH] feat: register Discord command groups --- .../discord/discord_platform_adapter.py | 219 +++++++++++++++++- tests/test_discord_command_sync.py | 184 ++++++++++++++- 2 files changed, 396 insertions(+), 7 deletions(-) diff --git a/astrbot/core/platform/sources/discord/discord_platform_adapter.py b/astrbot/core/platform/sources/discord/discord_platform_adapter.py index 7c6e009254..06a98f31a1 100644 --- a/astrbot/core/platform/sources/discord/discord_platform_adapter.py +++ b/astrbot/core/platform/sources/discord/discord_platform_adapter.py @@ -28,6 +28,8 @@ from .client import DiscordBotClient from .discord_platform_event import DiscordPlatformEvent +_DISCORD_MAX_OPTIONS = 25 + if sys.version_info >= (3, 12): from typing import override else: @@ -415,6 +417,19 @@ async def _collect_and_register_commands(self) -> None: if not handler_md.enabled: continue for event_filter in handler_md.event_filters: + if isinstance(event_filter, CommandGroupFilter): + if event_filter.parent_group is not None: + continue + slash_group = self._create_slash_command_group( + event_filter, + handler_md, + ) + if slash_group is None: + continue + self.client.add_application_command(slash_group) + registered_commands.append(event_filter.group_name) + continue + cmd_info = self._extract_command_info(event_filter, handler_md) if not cmd_info: continue @@ -537,6 +552,197 @@ async def dynamic_callback( return dynamic_callback + @staticmethod + def _is_valid_slash_command_name(name: str) -> bool: + """Check whether a command name follows Discord slash command rules. + + Args: + name: Command, subcommand, or group name to validate. + + Returns: + Whether the name can be registered with Discord. + """ + return name == name.lower() and bool(re.match(r"^[-_'\w]{1,32}$", name)) + + @staticmethod + def _normalize_slash_description(description: str, fallback: str) -> str: + """Return a non-empty Discord description within the 100-character limit. + + Args: + description: Preferred command description. + fallback: Description used when the preferred value is empty. + + Returns: + A valid Discord slash command description. + """ + normalized = description or fallback + return normalized if len(normalized) <= 100 else f"{normalized[:97]}..." + + def _create_slash_subcommand( + self, + command_filter: CommandFilter, + full_command_name: str, + parent: discord.SlashCommandGroup, + ) -> discord.SlashCommand | None: + """Build one Discord subcommand from an AstrBot command filter. + + Args: + command_filter: AstrBot leaf command to convert. + full_command_name: Complete command path used by the callback. + parent: Discord group that owns the subcommand. + + Returns: + The Discord subcommand, or None when the AstrBot command is unavailable. + """ + command_name = command_filter.command_name + command_metadata = getattr(command_filter, "handler_md", None) + if ( + not self._is_valid_slash_command_name(command_name) + or command_metadata is None + or not command_metadata.enabled + ): + logger.warning( + f"[Discord] Skipping invalid or disabled entry '{full_command_name}'." + ) + return None + + return discord.SlashCommand( + name=command_name, + description=self._normalize_slash_description( + command_metadata.desc, + f"Command: {full_command_name}", + ), + func=self._create_dynamic_callback(full_command_name), + options=[ + discord.Option( + name="params", + description="All command parameters", + type=discord.SlashCommandOptionType.string, + required=False, + ), + ], + parent=parent, + ) + + def _create_slash_command_group( + self, + group_filter: CommandGroupFilter, + handler_metadata: StarHandlerMetadata, + ) -> discord.SlashCommandGroup | None: + """Build a Discord slash command tree from an AstrBot command group. + + Discord supports direct subcommands and one level of subcommand groups. + Deeper AstrBot command groups are skipped with a warning. + + Args: + group_filter: Root AstrBot command group to convert. + handler_metadata: Metadata registered for the root group. + + Returns: + The Discord slash command group, or None when no valid leaves exist. + """ + root_name = group_filter.group_name + if not self._is_valid_slash_command_name(root_name): + logger.debug(f"[Discord] Skipping invalid slash command group: {root_name}") + return None + + root_group = discord.SlashCommandGroup( + name=root_name, + description=self._normalize_slash_description( + handler_metadata.desc, + f"Command group: {root_name}", + ), + guild_ids=[self.guild_id] if self.guild_id else None, + ) + root_names: set[str] = set() + + for child_filter in group_filter.sub_command_filters: + if len(root_group.subcommands) >= _DISCORD_MAX_OPTIONS: + logger.warning( + f"[Discord] Command group '{root_name}' exceeds " + f"{_DISCORD_MAX_OPTIONS} options; remaining entries were skipped." + ) + break + + child_name = ( + child_filter.command_name + if isinstance(child_filter, CommandFilter) + else child_filter.group_name + ) + if ( + not self._is_valid_slash_command_name(child_name) + or child_name in root_names + ): + logger.warning( + f"[Discord] Skipping invalid or duplicate entry " + f"'{root_name} {child_name}'." + ) + continue + + if isinstance(child_filter, CommandFilter): + slash_command = self._create_slash_subcommand( + child_filter, + f"{root_name} {child_name}", + root_group, + ) + if slash_command is None: + continue + root_group.add_command(slash_command) + root_names.add(child_name) + continue + + subgroup = discord.SlashCommandGroup( + name=child_name, + description=self._normalize_slash_description( + "", + f"Command group: {root_name} {child_name}", + ), + parent=root_group, + ) + subgroup_names: set[str] = set() + + for leaf_filter in child_filter.sub_command_filters: + if isinstance(leaf_filter, CommandGroupFilter): + logger.warning( + f"[Discord] Skipping command group deeper than one level: " + f"'{root_name} {child_name} {leaf_filter.group_name}'." + ) + continue + if len(subgroup.subcommands) >= _DISCORD_MAX_OPTIONS: + logger.warning( + f"[Discord] Command subgroup '{root_name} {child_name}' " + f"exceeds {_DISCORD_MAX_OPTIONS} options; remaining entries " + "were skipped." + ) + break + + leaf_name = leaf_filter.command_name + if ( + not self._is_valid_slash_command_name(leaf_name) + or leaf_name in subgroup_names + ): + logger.warning( + f"[Discord] Skipping invalid or duplicate entry " + f"'{root_name} {child_name} {leaf_name}'." + ) + continue + + slash_command = self._create_slash_subcommand( + leaf_filter, + f"{root_name} {child_name} {leaf_name}", + subgroup, + ) + if slash_command is None: + continue + subgroup.add_command(slash_command) + subgroup_names.add(leaf_name) + + if subgroup.subcommands: + root_group.add_command(subgroup) + root_names.add(child_name) + + return root_group if root_group.subcommands else None + @staticmethod def _extract_command_info( event_filter: Any, @@ -548,7 +754,7 @@ def _extract_command_info( cmd_filter_instance = None if isinstance(event_filter, CommandFilter): - # 暂不支持子指令注册为斜杠指令 + # Child commands are registered through their root command group. if ( event_filter.parent_command_names and event_filter.parent_command_names != [""] @@ -558,19 +764,20 @@ def _extract_command_info( cmd_filter_instance = event_filter elif isinstance(event_filter, CommandGroupFilter): - # 暂不支持指令组直接注册为斜杠指令,因为它们没有 handle 方法 + # Root groups are handled directly by the command collector. return None if not cmd_name: return None # Discord 斜杠指令名称规范 - if cmd_name != cmd_name.lower() or not re.match(r"^[-_'\w]{1,32}$", cmd_name): + if not DiscordPlatformAdapter._is_valid_slash_command_name(cmd_name): logger.debug(f"[Discord] Skipping invalid slash command format: {cmd_name}") return None - description = handler_metadata.desc or f"Command: {cmd_name}" - if len(description) > 100: - description = f"{description[:97]}..." + description = DiscordPlatformAdapter._normalize_slash_description( + handler_metadata.desc, + f"Command: {cmd_name}", + ) return cmd_name, description, cmd_filter_instance diff --git a/tests/test_discord_command_sync.py b/tests/test_discord_command_sync.py index 2dee1cadb1..631aa00669 100644 --- a/tests/test_discord_command_sync.py +++ b/tests/test_discord_command_sync.py @@ -1,8 +1,11 @@ import asyncio -from unittest.mock import Mock +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock import pytest +from astrbot.core.star.filter.command import CommandFilter +from astrbot.core.star.filter.command_group import CommandGroupFilter from tests.fixtures.mocks.discord import ( MockDiscordBuilder, mock_discord_modules, # noqa: F401 @@ -15,6 +18,34 @@ def __init__(self, message: str, code: int | None = None) -> None: self.code = code +class FakeSlashCommand: + def __init__(self, *, name, description, func, options, parent=None): + self.name = name + self.description = description + self.callback = func + self.options = options + self.parent = parent + + +class FakeSlashCommandGroup: + def __init__(self, *, name, description, guild_ids=None, parent=None): + self.name = name + self.description = description + self.guild_ids = guild_ids + self.parent = parent + self.subcommands = [] + + def add_command(self, command): + self.subcommands.append(command) + + +def _command_filter(name, description, parent_names): + command_filter = CommandFilter(name, parent_command_names=parent_names) + command_filter.handler_md = SimpleNamespace(desc=description, enabled=True) + command_filter.handler_params = {} + return command_filter + + def _build_adapter(monkeypatch: pytest.MonkeyPatch): from astrbot.core.platform.sources.discord import discord_platform_adapter from astrbot.core.platform.sources.discord.discord_platform_adapter import ( @@ -38,6 +69,26 @@ def _build_adapter(monkeypatch: pytest.MonkeyPatch): return adapter +def _patch_slash_command_types(monkeypatch): + from astrbot.core.platform.sources.discord import discord_platform_adapter + + monkeypatch.setattr( + discord_platform_adapter.discord, + "SlashCommand", + FakeSlashCommand, + ) + monkeypatch.setattr( + discord_platform_adapter.discord, + "SlashCommandGroup", + FakeSlashCommandGroup, + ) + monkeypatch.setattr( + discord_platform_adapter.discord, + "Option", + lambda **kwargs: SimpleNamespace(**kwargs), + ) + + @pytest.mark.asyncio async def test_discord_command_sync_ignores_daily_quota(monkeypatch): from astrbot.core.platform.sources.discord import discord_platform_adapter @@ -55,3 +106,134 @@ async def test_discord_command_sync_ignores_daily_quota(monkeypatch): adapter.client.sync_commands.assert_awaited_once() warning.assert_called_once() assert "30034" in warning.call_args.args[0] + + +@pytest.mark.asyncio +async def test_discord_registers_command_group_as_one_slash_command(monkeypatch): + from astrbot.core.platform.sources.discord import discord_platform_adapter + + adapter = _build_adapter(monkeypatch) + _patch_slash_command_types(monkeypatch) + + root = CommandGroupFilter("pixiv") + search = _command_filter("search", "Search illustrations", ["pixiv"]) + user_group = CommandGroupFilter("user", parent_group=root) + detail = _command_filter("detail", "Show user details", ["pixiv user"]) + user_group.add_sub_command_filter(detail) + root.add_sub_command_filter(search) + root.add_sub_command_filter(user_group) + + root_metadata = SimpleNamespace( + desc="Pixiv commands", + enabled=True, + handler_module_path="pixiv_plugin", + event_filters=[root], + ) + search_metadata = SimpleNamespace( + desc="Search illustrations", + enabled=True, + handler_module_path="pixiv_plugin", + event_filters=[search], + ) + user_group_metadata = SimpleNamespace( + desc="User commands", + enabled=True, + handler_module_path="pixiv_plugin", + event_filters=[user_group], + ) + detail_metadata = SimpleNamespace( + desc="Show user details", + enabled=True, + handler_module_path="pixiv_plugin", + event_filters=[detail], + ) + search.handler_md = search_metadata + detail.handler_md = detail_metadata + monkeypatch.setattr( + discord_platform_adapter, + "star_handlers_registry", + [root_metadata, search_metadata, user_group_metadata, detail_metadata], + ) + monkeypatch.setattr( + discord_platform_adapter, + "star_map", + {"pixiv_plugin": SimpleNamespace(activated=True)}, + ) + + await adapter._collect_and_register_commands() + + adapter.client.add_application_command.assert_called_once() + slash_root = adapter.client.add_application_command.call_args.args[0] + assert slash_root.name == "pixiv" + assert [command.name for command in slash_root.subcommands] == ["search", "user"] + assert [command.name for command in slash_root.subcommands[1].subcommands] == [ + "detail" + ] + + +@pytest.mark.asyncio +async def test_discord_group_callback_rebuilds_full_command_path(monkeypatch): + adapter = _build_adapter(monkeypatch) + _patch_slash_command_types(monkeypatch) + adapter.bot_self_id = "bot-id" + adapter.handle_msg = AsyncMock() + + root = CommandGroupFilter("pixiv") + user_group = CommandGroupFilter("user", parent_group=root) + detail = _command_filter("detail", "Show user details", ["pixiv user"]) + user_group.add_sub_command_filter(detail) + root.add_sub_command_filter(user_group) + root_metadata = SimpleNamespace(desc="Pixiv commands") + + slash_root = adapter._create_slash_command_group(root, root_metadata) + detail_command = slash_root.subcommands[0].subcommands[0] + context = SimpleNamespace( + defer=AsyncMock(), + followup=object(), + channel=SimpleNamespace(id=123), + channel_id=123, + guild_id=456, + author=SimpleNamespace(id=789, display_name="tester"), + interaction=SimpleNamespace(id=999), + ) + + await detail_command.callback(context, "42") + + message = adapter.handle_msg.await_args.args[0] + assert message.message_str == "pixiv user detail 42" + + +def test_discord_skips_command_groups_deeper_than_one_level(monkeypatch): + from astrbot.core.platform.sources.discord import discord_platform_adapter + + adapter = _build_adapter(monkeypatch) + _patch_slash_command_types(monkeypatch) + warning = Mock() + monkeypatch.setattr(discord_platform_adapter.logger, "warning", warning) + + root = CommandGroupFilter("pixiv") + random_group = CommandGroupFilter("random", parent_group=root) + status = _command_filter("status", "Show queue status", ["pixiv random"]) + ranking_group = CommandGroupFilter("ranking", parent_group=random_group) + ranking_add = _command_filter( + "add", + "Add ranking source", + ["pixiv random ranking"], + ) + ranking_group.add_sub_command_filter(ranking_add) + random_group.add_sub_command_filter(status) + random_group.add_sub_command_filter(ranking_group) + root.add_sub_command_filter(random_group) + + slash_root = adapter._create_slash_command_group( + root, + SimpleNamespace(desc="Pixiv commands"), + ) + + assert [command.name for command in slash_root.subcommands] == ["random"] + assert [command.name for command in slash_root.subcommands[0].subcommands] == [ + "status" + ] + assert any( + "deeper than one level" in call.args[0] for call in warning.call_args_list + )