Skip to content
Open
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
45 changes: 43 additions & 2 deletions astrbot/core/platform/sources/discord/discord_platform_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.<plugin>.main``), so a handler defined in a submodule
(e.g. a mixin in ``data.plugins.<plugin>.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
Expand Down
221 changes: 221 additions & 0 deletions tests/test_discord_command_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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()