diff --git a/astrbot/core/platform/sources/discord/discord_platform_adapter.py b/astrbot/core/platform/sources/discord/discord_platform_adapter.py index 9c44691538a..de79150f9b6 100644 --- a/astrbot/core/platform/sources/discord/discord_platform_adapter.py +++ b/astrbot/core/platform/sources/discord/discord_platform_adapter.py @@ -22,7 +22,7 @@ from astrbot.core.platform.astr_message_event import MessageSesion from astrbot.core.star.filter.command import CommandFilter from astrbot.core.star.filter.command_group import CommandGroupFilter -from astrbot.core.star.star import star_map +from astrbot.core.star.star import StarMetadata, star_map from astrbot.core.star.star_handler import StarHandlerMetadata, star_handlers_registry from astrbot.core.utils.media_utils import MediaResolver @@ -466,13 +466,54 @@ def register_handler(self, handler_info) -> None: """注册处理器信息""" self.registered_handlers.append(handler_info) + @staticmethod + def _plugin_for_handler_module(handler_module_path: str) -> StarMetadata | None: + """Resolve the plugin that owns a handler module. + + ``star_map`` is keyed by the plugin entry module + (``data.plugins..main``), so a handler defined in a submodule + (e.g. a mixin in ``data.plugins..foo``) never matches exactly. + Fall back to the plugin package root and return ``None`` only for + orphan modules that no plugin owns. + + Args: + handler_module_path: Module path recorded on the handler metadata. + + Returns: + The metadata of the owning plugin, or ``None`` if no plugin owns + the module. + """ + plugin = star_map.get(handler_module_path) + if plugin is not None: + return plugin + for metadata in star_map.values(): + if not metadata.root_dir_name: + continue + package_root = ( + "astrbot.builtin_stars." if metadata.reserved else "data.plugins." + ) + metadata.root_dir_name + # Exact match covers handlers defined in the plugin's __init__.py, + # prefix match covers helper submodules. + if handler_module_path == package_root or handler_module_path.startswith( + package_root + "." + ): + return metadata + return None + async def _collect_and_register_commands(self) -> None: """收集所有指令并注册到Discord""" logger.info("[Discord] Collecting and registering slash commands...") registered_commands = [] for handler_md in star_handlers_registry: - if not star_map[handler_md.handler_module_path].activated: + # A handler may live in a plugin submodule (e.g. a mixin in + # `plugin/foo.py`). Resolve its owning plugin by package root + # instead of requiring an exact star_map key, so that activated + # plugins keep their slash commands. Skip only orphan handlers + # that no plugin owns, otherwise one unbound handler aborts the + # whole sync loop and no slash commands get registered at all. + plugin = self._plugin_for_handler_module(handler_md.handler_module_path) + if plugin is None or not plugin.activated: continue if not handler_md.enabled: continue diff --git a/tests/test_discord_command_sync.py b/tests/test_discord_command_sync.py index 2dee1cadb11..461609a5937 100644 --- a/tests/test_discord_command_sync.py +++ b/tests/test_discord_command_sync.py @@ -22,6 +22,12 @@ def _build_adapter(monkeypatch: pytest.MonkeyPatch): ) monkeypatch.setattr(discord_platform_adapter, "star_handlers_registry", []) + # The real ``discord`` module may already be bound to this adapter when the + # rest of the suite imported it first. Stub the constructors the adapter + # calls so the tests do not depend on ``sys.modules["discord"]`` being mocked + # by ``mock_discord_modules``. + monkeypatch.setattr(discord_platform_adapter.discord, "SlashCommand", Mock()) + monkeypatch.setattr(discord_platform_adapter.discord, "Option", Mock()) monkeypatch.setattr( discord_platform_adapter.discord, "HTTPException", @@ -55,3 +61,218 @@ 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_command_sync_keeps_going_after_unowned_handler( + monkeypatch, +): + """A handler owned by no plugin must not abort the whole sync loop. + + Regression test for https://github.com/AstrBotDevs/AstrBot/issues/10626: + the old code subscripted ``star_map`` directly and raised ``KeyError`` + inside ``on_ready``, so no plugin's slash commands were registered at all. + The unowned handler must be skipped while active plugins still register. + """ + from astrbot.core.platform.sources.discord import discord_platform_adapter + + adapter = _build_adapter(monkeypatch) + + orphan = Mock() + orphan.handler_module_path = "some.unrelated.toolkit.helpers" # owned by no plugin + orphan.enabled = True + orphan.event_filters = [object()] + + owned = Mock() + owned.handler_module_path = "data.plugins.demo.main" + owned.enabled = True + owned.event_filters = [object()] + + plugin_md = Mock() + plugin_md.activated = True + plugin_md.reserved = False + plugin_md.root_dir_name = "demo" + monkeypatch.setattr( + discord_platform_adapter, "star_handlers_registry", [orphan, owned] + ) + monkeypatch.setattr( + discord_platform_adapter, + "star_map", + {"data.plugins.demo.main": plugin_md}, + ) + + cmd_info = ("demo_cmd", "demo description", object()) + extract = Mock(return_value=cmd_info) + monkeypatch.setattr(adapter, "_extract_command_info", extract) + + await adapter._collect_and_register_commands() # must not raise KeyError + + extract.assert_called_once() + adapter.client.add_application_command.assert_called_once() + adapter.client.sync_commands.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_discord_command_sync_registers_submodule_handler_of_active_plugin( + monkeypatch, +): + """A submodule handler whose owning plugin is activated must register. + + ``star_map`` is keyed by the plugin entry module, so a handler living in + ``plugin/foo.py`` never matches exactly. The adapter resolves ownership by + plugin package root; the command must reach Discord instead of being + silently dropped. + """ + from astrbot.core.platform.sources.discord import discord_platform_adapter + + adapter = _build_adapter(monkeypatch) + + submodule_handler = Mock() + submodule_handler.handler_module_path = "data.plugins.demo.mixin" + submodule_handler.enabled = True + submodule_handler.event_filters = [object()] + + active_plugin_md = Mock() + active_plugin_md.activated = True + active_plugin_md.reserved = False + active_plugin_md.root_dir_name = "demo" + monkeypatch.setattr( + discord_platform_adapter, + "star_handlers_registry", + [submodule_handler], + ) + monkeypatch.setattr( + discord_platform_adapter, + "star_map", + {"data.plugins.demo.main": active_plugin_md}, + ) + + cmd_info = ("demo_cmd", "demo description", object()) + extract = Mock(return_value=cmd_info) + monkeypatch.setattr(adapter, "_extract_command_info", extract) + + await adapter._collect_and_register_commands() + + extract.assert_called_once() + adapter.client.add_application_command.assert_called_once() + adapter.client.sync_commands.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_discord_command_sync_registers_package_root_handler_of_active_plugin( + monkeypatch, +): + """A handler defined in the plugin's ``__init__.py`` has a module path that + equals the plugin package root exactly (no trailing submodule). It must + still resolve to the plugin and register. + """ + from astrbot.core.platform.sources.discord import discord_platform_adapter + + adapter = _build_adapter(monkeypatch) + + root_handler = Mock() + root_handler.handler_module_path = "data.plugins.demo" # == package root + root_handler.enabled = True + root_handler.event_filters = [object()] + + active_plugin_md = Mock() + active_plugin_md.activated = True + active_plugin_md.reserved = False + active_plugin_md.root_dir_name = "demo" + monkeypatch.setattr( + discord_platform_adapter, + "star_handlers_registry", + [root_handler], + ) + monkeypatch.setattr( + discord_platform_adapter, + "star_map", + {"data.plugins.demo.main": active_plugin_md}, + ) + + cmd_info = ("demo_cmd", "demo description", object()) + extract = Mock(return_value=cmd_info) + monkeypatch.setattr(adapter, "_extract_command_info", extract) + + await adapter._collect_and_register_commands() + + extract.assert_called_once() + adapter.client.add_application_command.assert_called_once() + + +@pytest.mark.asyncio +async def test_discord_command_sync_skips_true_orphan_handler(monkeypatch): + """A handler owned by no plugin at all is skipped without aborting, + and must not accidentally resolve to an unrelated plugin. + """ + from astrbot.core.platform.sources.discord import discord_platform_adapter + + adapter = _build_adapter(monkeypatch) + + orphan = Mock() + orphan.handler_module_path = "some.unrelated.toolkit.helpers" + orphan.enabled = True + orphan.event_filters = [object()] + + active_plugin_md = Mock() + active_plugin_md.activated = True + active_plugin_md.reserved = False + active_plugin_md.root_dir_name = "demo" + monkeypatch.setattr( + discord_platform_adapter, + "star_handlers_registry", + [orphan], + ) + monkeypatch.setattr( + discord_platform_adapter, + "star_map", + {"data.plugins.demo.main": active_plugin_md}, + ) + + extract = Mock(return_value=None) + monkeypatch.setattr(adapter, "_extract_command_info", extract) + + await adapter._collect_and_register_commands() + + extract.assert_not_called() + adapter.client.add_application_command.assert_not_called() + adapter.client.sync_commands.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_discord_command_sync_skips_submodule_handler_of_inactive_plugin( + monkeypatch, +): + """A submodule handler of a deactivated plugin must not register.""" + from astrbot.core.platform.sources.discord import discord_platform_adapter + + adapter = _build_adapter(monkeypatch) + + submodule_handler = Mock() + submodule_handler.handler_module_path = "data.plugins.demo.mixin" + submodule_handler.enabled = True + submodule_handler.event_filters = [object()] + + inactive_plugin_md = Mock() + inactive_plugin_md.activated = False + inactive_plugin_md.reserved = False + inactive_plugin_md.root_dir_name = "demo" + monkeypatch.setattr( + discord_platform_adapter, + "star_handlers_registry", + [submodule_handler], + ) + monkeypatch.setattr( + discord_platform_adapter, + "star_map", + {"data.plugins.demo.main": inactive_plugin_md}, + ) + + extract = Mock(return_value=None) + monkeypatch.setattr(adapter, "_extract_command_info", extract) + + await adapter._collect_and_register_commands() + + extract.assert_not_called() + adapter.client.add_application_command.assert_not_called() + adapter.client.sync_commands.assert_awaited_once()