diff --git a/astrbot/core/platform/platform.py b/astrbot/core/platform/platform.py index 3a74c3b91a..155e736caa 100644 --- a/astrbot/core/platform/platform.py +++ b/astrbot/core/platform/platform.py @@ -126,6 +126,13 @@ def run(self) -> Coroutine[Any, Any, None]: async def terminate(self) -> None: """终止一个平台的运行实例。""" + async def refresh_registered_commands(self) -> None: + """Refresh platform-native commands after runtime command metadata changes. + + Adapters that expose native application/slash commands can override this hook. + Other adapters intentionally default to a no-op. + """ + @abc.abstractmethod def meta(self) -> PlatformMetadata: """得到一个平台的元数据。""" diff --git a/astrbot/core/platform/sources/discord/discord_platform_adapter.py b/astrbot/core/platform/sources/discord/discord_platform_adapter.py index 7c6e009254..4962b98af2 100644 --- a/astrbot/core/platform/sources/discord/discord_platform_adapter.py +++ b/astrbot/core/platform/sources/discord/discord_platform_adapter.py @@ -55,6 +55,8 @@ def __init__( self.activity_name = self.config.get("discord_activity_name", None) self.shutdown_event = asyncio.Event() self._polling_task = None + self._command_sync_lock = asyncio.Lock() + self._managed_application_commands: list[Any] = [] @override async def send_by_session( @@ -115,6 +117,14 @@ def meta(self) -> PlatformMetadata: support_streaming_message=False, ) + @override + async def refresh_registered_commands(self) -> None: + if not self.enable_command_register: + return + if not getattr(self, "client", None) or self.client.user is None: + return + await self._collect_and_register_commands() + @override async def run(self) -> None: """主要运行逻辑""" @@ -404,10 +414,22 @@ def register_handler(self, handler_info) -> None: """注册处理器信息""" self.registered_handlers.append(handler_info) + def _replace_managed_application_commands(self, commands: list[Any]) -> None: + for command in self._managed_application_commands: + self.client.remove_application_command(command) + for command in commands: + self.client.add_application_command(command) + self._managed_application_commands = list(commands) + async def _collect_and_register_commands(self) -> None: + async with self._command_sync_lock: + await self._collect_and_register_commands_unlocked() + + async def _collect_and_register_commands_unlocked(self) -> None: """收集所有指令并注册到Discord""" logger.info("[Discord] Collecting and registering slash commands...") registered_commands = [] + application_commands = [] for handler_md in star_handlers_registry: if not star_map[handler_md.handler_module_path].activated: @@ -442,7 +464,7 @@ async def _collect_and_register_commands(self) -> None: options=options, guild_ids=[self.guild_id] if self.guild_id else None, ) - self.client.add_application_command(slash_command) + application_commands.append(slash_command) registered_commands.append(cmd_name) if registered_commands: @@ -452,12 +474,18 @@ async def _collect_and_register_commands(self) -> None: else: logger.info("[Discord] No commands found for registration.") + previous_commands = list(self._managed_application_commands) + self._replace_managed_application_commands(application_commands) + # 使用 Pycord 的方法同步指令 # 注意:这可能需要一些时间,并且有频率限制 try: - await self.client.sync_commands() + await self.client.sync_commands( + check_guilds=[self.guild_id] if self.guild_id else [], + ) logger.info("[Discord] Command synchronization completed.") except discord.HTTPException as e: + self._replace_managed_application_commands(previous_commands) if self._is_daily_command_quota_error(e): logger.warning( "[Discord] Daily application command create quota reached " @@ -466,6 +494,9 @@ async def _collect_and_register_commands(self) -> None: ) return logger.warning(f"[Discord] Sync commands failed: {e}") + except Exception: + self._replace_managed_application_commands(previous_commands) + raise @staticmethod def _is_daily_command_quota_error(error: discord.HTTPException) -> bool: diff --git a/astrbot/core/platform/sources/telegram/tg_adapter.py b/astrbot/core/platform/sources/telegram/tg_adapter.py index 82e2a4dfd1..77a645eec6 100644 --- a/astrbot/core/platform/sources/telegram/tg_adapter.py +++ b/astrbot/core/platform/sources/telegram/tg_adapter.py @@ -227,6 +227,12 @@ def meta(self) -> PlatformMetadata: id_ = self.config.get("id") or "telegram" return PlatformMetadata(name="telegram", description="telegram 适配器", id=id_) + @override + async def refresh_registered_commands(self) -> None: + if not self.enable_command_register or not self._application_started: + return + await self.register_commands() + @override async def run(self) -> None: self._loop = asyncio.get_running_loop() @@ -322,16 +328,16 @@ async def register_commands(self) -> None: """收集所有注册的指令并注册到 Telegram""" try: commands = self.collect_commands() + current_hash = hash( + tuple((cmd.command, cmd.description) for cmd in commands), + ) + if current_hash == self.last_command_hash: + return + await self.client.delete_my_commands() if commands: - current_hash = hash( - tuple((cmd.command, cmd.description) for cmd in commands), - ) - if current_hash == self.last_command_hash: - return - self.last_command_hash = current_hash - await self.client.delete_my_commands() await self.client.set_my_commands(commands) + self.last_command_hash = current_hash except Exception as e: logger.error(f"向 Telegram 注册指令时发生错误: {e!s}") diff --git a/astrbot/dashboard/services/command_service.py b/astrbot/dashboard/services/command_service.py index a978d3ad4b..efa43d152a 100644 --- a/astrbot/dashboard/services/command_service.py +++ b/astrbot/dashboard/services/command_service.py @@ -1,5 +1,6 @@ from __future__ import annotations +from astrbot.core import logger from astrbot.core.config.astrbot_config import AstrBotConfig from astrbot.core.core_lifecycle import AstrBotCoreLifecycle from astrbot.core.star.command_management import ( @@ -53,6 +54,20 @@ async def toggle_command(self, handler_full_name: str | None, enabled) -> dict: except ValueError as exc: raise CommandServiceError(str(exc)) from exc + if self.core_lifecycle: + platform_manager = getattr(self.core_lifecycle, "platform_manager", None) + if platform_manager: + for platform in list(platform_manager.get_insts()): + try: + await platform.refresh_registered_commands() + except Exception as exc: + logger.warning( + "Failed to refresh registered commands for platform %s: %s", + type(platform).__name__, + exc, + exc_info=True, + ) + return await self._get_command_payload(handler_full_name) async def rename_command( @@ -69,6 +84,20 @@ async def rename_command( except ValueError as exc: raise CommandServiceError(str(exc)) from exc + if self.core_lifecycle: + platform_manager = getattr(self.core_lifecycle, "platform_manager", None) + if platform_manager: + for platform in list(platform_manager.get_insts()): + try: + await platform.refresh_registered_commands() + except Exception as exc: + logger.warning( + "Failed to refresh registered commands for platform %s: %s", + type(platform).__name__, + exc, + exc_info=True, + ) + return await self._get_command_payload(handler_full_name) async def update_permission( diff --git a/tests/fixtures/mocks/discord.py b/tests/fixtures/mocks/discord.py index e13786af17..6a1693b6ac 100644 --- a/tests/fixtures/mocks/discord.py +++ b/tests/fixtures/mocks/discord.py @@ -135,6 +135,7 @@ def create_client(): client.close = AsyncMock() client.is_closed = MagicMock(return_value=False) client.add_application_command = MagicMock() + client.remove_application_command = MagicMock() client.sync_commands = AsyncMock() client.change_presence = AsyncMock() return client diff --git a/tests/test_command_platform_refresh.py b/tests/test_command_platform_refresh.py new file mode 100644 index 0000000000..2126e36fbe --- /dev/null +++ b/tests/test_command_platform_refresh.py @@ -0,0 +1,64 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from astrbot.dashboard.services import command_service as command_service_module +from astrbot.dashboard.services.command_service import CommandService + + +@pytest.mark.asyncio +async def test_toggle_command_refreshes_platform_commands(monkeypatch): + platform = SimpleNamespace(refresh_registered_commands=AsyncMock()) + platform_manager = SimpleNamespace(get_insts=lambda: [platform]) + lifecycle = SimpleNamespace(platform_manager=platform_manager) + service = CommandService({}, lifecycle) + toggle = AsyncMock() + monkeypatch.setattr(command_service_module, "toggle_command", toggle) + monkeypatch.setattr( + command_service_module, + "list_commands", + AsyncMock( + return_value=[ + { + "handler_full_name": "plugin.handler", + "enabled": False, + } + ] + ), + ) + + payload = await service.toggle_command("plugin.handler", False) + + toggle.assert_awaited_once_with("plugin.handler", False) + platform.refresh_registered_commands.assert_awaited_once() + assert payload["enabled"] is False + + +@pytest.mark.asyncio +async def test_rename_command_refreshes_platform_commands(monkeypatch): + platform = SimpleNamespace(refresh_registered_commands=AsyncMock()) + platform_manager = SimpleNamespace(get_insts=lambda: [platform]) + lifecycle = SimpleNamespace(platform_manager=platform_manager) + service = CommandService({}, lifecycle) + rename = AsyncMock() + monkeypatch.setattr(command_service_module, "rename_command", rename) + monkeypatch.setattr( + command_service_module, + "list_commands", + AsyncMock( + return_value=[ + { + "handler_full_name": "plugin.handler", + "enabled": True, + "effective_command": "renamed", + } + ] + ), + ) + + payload = await service.rename_command("plugin.handler", "renamed", aliases=["r"]) + + rename.assert_awaited_once_with("plugin.handler", "renamed", aliases=["r"]) + platform.refresh_registered_commands.assert_awaited_once() + assert payload["effective_command"] == "renamed" diff --git a/tests/test_discord_command_sync.py b/tests/test_discord_command_sync.py index 2dee1cadb1..b49bf9a5b7 100644 --- a/tests/test_discord_command_sync.py +++ b/tests/test_discord_command_sync.py @@ -1,4 +1,5 @@ import asyncio +from types import SimpleNamespace from unittest.mock import Mock import pytest @@ -28,6 +29,16 @@ def _build_adapter(monkeypatch: pytest.MonkeyPatch): DiscordSyncError, raising=False, ) + monkeypatch.setattr( + discord_platform_adapter.discord, + "Option", + lambda **kwargs: SimpleNamespace(**kwargs), + ) + monkeypatch.setattr( + discord_platform_adapter.discord, + "SlashCommand", + lambda **kwargs: SimpleNamespace(**kwargs), + ) adapter = DiscordPlatformAdapter( {"discord_command_register": True}, @@ -52,6 +63,63 @@ async def test_discord_command_sync_ignores_daily_quota(monkeypatch): await adapter._collect_and_register_commands() - adapter.client.sync_commands.assert_awaited_once() + adapter.client.sync_commands.assert_awaited_once_with(check_guilds=[]) warning.assert_called_once() assert "30034" in warning.call_args.args[0] + + +@pytest.mark.asyncio +async def test_discord_command_sync_removes_disabled_commands(monkeypatch): + from astrbot.core.platform.sources.discord import discord_platform_adapter + from astrbot.core.star.filter.command import CommandFilter + + adapter = _build_adapter(monkeypatch) + handler = SimpleNamespace( + handler_module_path="test_plugin", + enabled=True, + event_filters=[CommandFilter("ping")], + desc="Ping command", + ) + monkeypatch.setattr(discord_platform_adapter, "star_handlers_registry", [handler]) + monkeypatch.setattr( + discord_platform_adapter, + "star_map", + {"test_plugin": SimpleNamespace(activated=True)}, + ) + + await adapter._collect_and_register_commands() + + assert adapter.client.add_application_command.call_count == 1 + assert len(adapter._managed_application_commands) == 1 + assert adapter.client.sync_commands.await_args_list[0].kwargs["check_guilds"] == [] + + handler.enabled = False + await adapter._collect_and_register_commands() + + assert adapter.client.remove_application_command.call_count == 1 + assert adapter.client.sync_commands.await_args_list[1].kwargs["check_guilds"] == [] + assert adapter._managed_application_commands == [] + + +@pytest.mark.asyncio +async def test_discord_command_sync_checks_debug_guild_when_empty(monkeypatch): + adapter = _build_adapter(monkeypatch) + adapter.guild_id = 123456 + + await adapter._collect_and_register_commands() + + adapter.client.sync_commands.assert_awaited_once_with(check_guilds=[123456]) + + +@pytest.mark.asyncio +async def test_discord_command_sync_rolls_back_local_registry_on_failure(monkeypatch): + adapter = _build_adapter(monkeypatch) + previous_command = Mock(name="previous_command") + adapter._managed_application_commands = [previous_command] + adapter.client.sync_commands.side_effect = DiscordSyncError("sync failed", code=50000) + + await adapter._collect_and_register_commands() + + assert adapter._managed_application_commands == [previous_command] + adapter.client.remove_application_command.assert_called_once_with(previous_command) + adapter.client.add_application_command.assert_called_once_with(previous_command) diff --git a/tests/test_telegram_adapter.py b/tests/test_telegram_adapter.py index 07c9808806..f437a8f952 100644 --- a/tests/test_telegram_adapter.py +++ b/tests/test_telegram_adapter.py @@ -472,7 +472,7 @@ async def second_start_polling(*args, **kwargs): assert builder.build.call_count == 2 app_one.updater.stop.assert_awaited() - app_one.bot.delete_my_commands.assert_not_awaited() + app_one.bot.delete_my_commands.assert_awaited_once() app_one.stop.assert_awaited() app_one.shutdown.assert_awaited() app_two.initialize.assert_awaited() diff --git a/tests/test_telegram_command_sync.py b/tests/test_telegram_command_sync.py new file mode 100644 index 0000000000..705fe6d1b8 --- /dev/null +++ b/tests/test_telegram_command_sync.py @@ -0,0 +1,42 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from astrbot.core.platform.sources.telegram.tg_adapter import TelegramPlatformAdapter + + +@pytest.mark.asyncio +async def test_telegram_command_sync_deletes_stale_commands_when_empty(): + adapter = object.__new__(TelegramPlatformAdapter) + adapter.last_command_hash = None + adapter.collect_commands = lambda: [] + adapter.client = SimpleNamespace( + delete_my_commands=AsyncMock(), + set_my_commands=AsyncMock(), + ) + + await adapter.register_commands() + + adapter.client.delete_my_commands.assert_awaited_once() + adapter.client.set_my_commands.assert_not_awaited() + assert adapter.last_command_hash == hash(()) + + await adapter.register_commands() + adapter.client.delete_my_commands.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_telegram_refresh_only_runs_for_started_registration(): + adapter = object.__new__(TelegramPlatformAdapter) + adapter.enable_command_register = True + adapter._application_started = True + adapter.register_commands = AsyncMock() + + await adapter.refresh_registered_commands() + adapter.register_commands.assert_awaited_once() + + adapter.register_commands.reset_mock() + adapter._application_started = False + await adapter.refresh_registered_commands() + adapter.register_commands.assert_not_awaited()