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
7 changes: 7 additions & 0 deletions astrbot/core/platform/platform.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
"""得到一个平台的元数据。"""
Expand Down
35 changes: 33 additions & 2 deletions astrbot/core/platform/sources/discord/discord_platform_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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:
"""主要运行逻辑"""
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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 "
Expand All @@ -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:
Expand Down
20 changes: 13 additions & 7 deletions astrbot/core/platform/sources/telegram/tg_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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}")
Expand Down
29 changes: 29 additions & 0 deletions astrbot/dashboard/services/command_service.py
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand Down
1 change: 1 addition & 0 deletions tests/fixtures/mocks/discord.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
64 changes: 64 additions & 0 deletions tests/test_command_platform_refresh.py
Original file line number Diff line number Diff line change
@@ -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"
70 changes: 69 additions & 1 deletion tests/test_discord_command_sync.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import asyncio
from types import SimpleNamespace
from unittest.mock import Mock

import pytest
Expand Down Expand Up @@ -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},
Expand All @@ -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)
2 changes: 1 addition & 1 deletion tests/test_telegram_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
42 changes: 42 additions & 0 deletions tests/test_telegram_command_sync.py
Original file line number Diff line number Diff line change
@@ -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()