Skip to content

Commit bbe3ffc

Browse files
committed
fix: refresh platform commands after command changes
1 parent d5f7371 commit bbe3ffc

9 files changed

Lines changed: 259 additions & 11 deletions

File tree

astrbot/core/platform/platform.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,13 @@ def run(self) -> Coroutine[Any, Any, None]:
126126
async def terminate(self) -> None:
127127
"""终止一个平台的运行实例。"""
128128

129+
async def refresh_registered_commands(self) -> None:
130+
"""Refresh platform-native commands after runtime command metadata changes.
131+
132+
Adapters that expose native application/slash commands can override this hook.
133+
Other adapters intentionally default to a no-op.
134+
"""
135+
129136
@abc.abstractmethod
130137
def meta(self) -> PlatformMetadata:
131138
"""得到一个平台的元数据。"""

astrbot/core/platform/sources/discord/discord_platform_adapter.py

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ def __init__(
5555
self.activity_name = self.config.get("discord_activity_name", None)
5656
self.shutdown_event = asyncio.Event()
5757
self._polling_task = None
58+
self._command_sync_lock = asyncio.Lock()
59+
self._managed_application_commands: list[Any] = []
5860

5961
@override
6062
async def send_by_session(
@@ -115,6 +117,14 @@ def meta(self) -> PlatformMetadata:
115117
support_streaming_message=False,
116118
)
117119

120+
@override
121+
async def refresh_registered_commands(self) -> None:
122+
if not self.enable_command_register:
123+
return
124+
if not getattr(self, "client", None) or self.client.user is None:
125+
return
126+
await self._collect_and_register_commands()
127+
118128
@override
119129
async def run(self) -> None:
120130
"""主要运行逻辑"""
@@ -404,10 +414,22 @@ def register_handler(self, handler_info) -> None:
404414
"""注册处理器信息"""
405415
self.registered_handlers.append(handler_info)
406416

417+
def _replace_managed_application_commands(self, commands: list[Any]) -> None:
418+
for command in self._managed_application_commands:
419+
self.client.remove_application_command(command)
420+
for command in commands:
421+
self.client.add_application_command(command)
422+
self._managed_application_commands = list(commands)
423+
407424
async def _collect_and_register_commands(self) -> None:
425+
async with self._command_sync_lock:
426+
await self._collect_and_register_commands_unlocked()
427+
428+
async def _collect_and_register_commands_unlocked(self) -> None:
408429
"""收集所有指令并注册到Discord"""
409430
logger.info("[Discord] Collecting and registering slash commands...")
410431
registered_commands = []
432+
application_commands = []
411433

412434
for handler_md in star_handlers_registry:
413435
if not star_map[handler_md.handler_module_path].activated:
@@ -442,7 +464,7 @@ async def _collect_and_register_commands(self) -> None:
442464
options=options,
443465
guild_ids=[self.guild_id] if self.guild_id else None,
444466
)
445-
self.client.add_application_command(slash_command)
467+
application_commands.append(slash_command)
446468
registered_commands.append(cmd_name)
447469

448470
if registered_commands:
@@ -452,12 +474,18 @@ async def _collect_and_register_commands(self) -> None:
452474
else:
453475
logger.info("[Discord] No commands found for registration.")
454476

477+
previous_commands = list(self._managed_application_commands)
478+
self._replace_managed_application_commands(application_commands)
479+
455480
# 使用 Pycord 的方法同步指令
456481
# 注意:这可能需要一些时间,并且有频率限制
457482
try:
458-
await self.client.sync_commands()
483+
await self.client.sync_commands(
484+
check_guilds=[self.guild_id] if self.guild_id else [],
485+
)
459486
logger.info("[Discord] Command synchronization completed.")
460487
except discord.HTTPException as e:
488+
self._replace_managed_application_commands(previous_commands)
461489
if self._is_daily_command_quota_error(e):
462490
logger.warning(
463491
"[Discord] Daily application command create quota reached "
@@ -466,6 +494,9 @@ async def _collect_and_register_commands(self) -> None:
466494
)
467495
return
468496
logger.warning(f"[Discord] Sync commands failed: {e}")
497+
except Exception:
498+
self._replace_managed_application_commands(previous_commands)
499+
raise
469500

470501
@staticmethod
471502
def _is_daily_command_quota_error(error: discord.HTTPException) -> bool:

astrbot/core/platform/sources/telegram/tg_adapter.py

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,12 @@ def meta(self) -> PlatformMetadata:
227227
id_ = self.config.get("id") or "telegram"
228228
return PlatformMetadata(name="telegram", description="telegram 适配器", id=id_)
229229

230+
@override
231+
async def refresh_registered_commands(self) -> None:
232+
if not self.enable_command_register or not self._application_started:
233+
return
234+
await self.register_commands()
235+
230236
@override
231237
async def run(self) -> None:
232238
self._loop = asyncio.get_running_loop()
@@ -322,16 +328,16 @@ async def register_commands(self) -> None:
322328
"""收集所有注册的指令并注册到 Telegram"""
323329
try:
324330
commands = self.collect_commands()
331+
current_hash = hash(
332+
tuple((cmd.command, cmd.description) for cmd in commands),
333+
)
334+
if current_hash == self.last_command_hash:
335+
return
325336

337+
await self.client.delete_my_commands()
326338
if commands:
327-
current_hash = hash(
328-
tuple((cmd.command, cmd.description) for cmd in commands),
329-
)
330-
if current_hash == self.last_command_hash:
331-
return
332-
self.last_command_hash = current_hash
333-
await self.client.delete_my_commands()
334339
await self.client.set_my_commands(commands)
340+
self.last_command_hash = current_hash
335341

336342
except Exception as e:
337343
logger.error(f"向 Telegram 注册指令时发生错误: {e!s}")

astrbot/dashboard/services/command_service.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
from astrbot.core import logger
34
from astrbot.core.config.astrbot_config import AstrBotConfig
45
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
56
from astrbot.core.star.command_management import (
@@ -53,6 +54,20 @@ async def toggle_command(self, handler_full_name: str | None, enabled) -> dict:
5354
except ValueError as exc:
5455
raise CommandServiceError(str(exc)) from exc
5556

57+
if self.core_lifecycle:
58+
platform_manager = getattr(self.core_lifecycle, "platform_manager", None)
59+
if platform_manager:
60+
for platform in list(platform_manager.get_insts()):
61+
try:
62+
await platform.refresh_registered_commands()
63+
except Exception as exc:
64+
logger.warning(
65+
"Failed to refresh registered commands for platform %s: %s",
66+
type(platform).__name__,
67+
exc,
68+
exc_info=True,
69+
)
70+
5671
return await self._get_command_payload(handler_full_name)
5772

5873
async def rename_command(
@@ -69,6 +84,20 @@ async def rename_command(
6984
except ValueError as exc:
7085
raise CommandServiceError(str(exc)) from exc
7186

87+
if self.core_lifecycle:
88+
platform_manager = getattr(self.core_lifecycle, "platform_manager", None)
89+
if platform_manager:
90+
for platform in list(platform_manager.get_insts()):
91+
try:
92+
await platform.refresh_registered_commands()
93+
except Exception as exc:
94+
logger.warning(
95+
"Failed to refresh registered commands for platform %s: %s",
96+
type(platform).__name__,
97+
exc,
98+
exc_info=True,
99+
)
100+
72101
return await self._get_command_payload(handler_full_name)
73102

74103
async def update_permission(

tests/fixtures/mocks/discord.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ def create_client():
135135
client.close = AsyncMock()
136136
client.is_closed = MagicMock(return_value=False)
137137
client.add_application_command = MagicMock()
138+
client.remove_application_command = MagicMock()
138139
client.sync_commands = AsyncMock()
139140
client.change_presence = AsyncMock()
140141
return client
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
from types import SimpleNamespace
2+
from unittest.mock import AsyncMock
3+
4+
import pytest
5+
6+
from astrbot.dashboard.services import command_service as command_service_module
7+
from astrbot.dashboard.services.command_service import CommandService
8+
9+
10+
@pytest.mark.asyncio
11+
async def test_toggle_command_refreshes_platform_commands(monkeypatch):
12+
platform = SimpleNamespace(refresh_registered_commands=AsyncMock())
13+
platform_manager = SimpleNamespace(get_insts=lambda: [platform])
14+
lifecycle = SimpleNamespace(platform_manager=platform_manager)
15+
service = CommandService({}, lifecycle)
16+
toggle = AsyncMock()
17+
monkeypatch.setattr(command_service_module, "toggle_command", toggle)
18+
monkeypatch.setattr(
19+
command_service_module,
20+
"list_commands",
21+
AsyncMock(
22+
return_value=[
23+
{
24+
"handler_full_name": "plugin.handler",
25+
"enabled": False,
26+
}
27+
]
28+
),
29+
)
30+
31+
payload = await service.toggle_command("plugin.handler", False)
32+
33+
toggle.assert_awaited_once_with("plugin.handler", False)
34+
platform.refresh_registered_commands.assert_awaited_once()
35+
assert payload["enabled"] is False
36+
37+
38+
@pytest.mark.asyncio
39+
async def test_rename_command_refreshes_platform_commands(monkeypatch):
40+
platform = SimpleNamespace(refresh_registered_commands=AsyncMock())
41+
platform_manager = SimpleNamespace(get_insts=lambda: [platform])
42+
lifecycle = SimpleNamespace(platform_manager=platform_manager)
43+
service = CommandService({}, lifecycle)
44+
rename = AsyncMock()
45+
monkeypatch.setattr(command_service_module, "rename_command", rename)
46+
monkeypatch.setattr(
47+
command_service_module,
48+
"list_commands",
49+
AsyncMock(
50+
return_value=[
51+
{
52+
"handler_full_name": "plugin.handler",
53+
"enabled": True,
54+
"effective_command": "renamed",
55+
}
56+
]
57+
),
58+
)
59+
60+
payload = await service.rename_command("plugin.handler", "renamed", aliases=["r"])
61+
62+
rename.assert_awaited_once_with("plugin.handler", "renamed", aliases=["r"])
63+
platform.refresh_registered_commands.assert_awaited_once()
64+
assert payload["effective_command"] == "renamed"

tests/test_discord_command_sync.py

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import asyncio
2+
from types import SimpleNamespace
23
from unittest.mock import Mock
34

45
import pytest
@@ -28,6 +29,16 @@ def _build_adapter(monkeypatch: pytest.MonkeyPatch):
2829
DiscordSyncError,
2930
raising=False,
3031
)
32+
monkeypatch.setattr(
33+
discord_platform_adapter.discord,
34+
"Option",
35+
lambda **kwargs: SimpleNamespace(**kwargs),
36+
)
37+
monkeypatch.setattr(
38+
discord_platform_adapter.discord,
39+
"SlashCommand",
40+
lambda **kwargs: SimpleNamespace(**kwargs),
41+
)
3142

3243
adapter = DiscordPlatformAdapter(
3344
{"discord_command_register": True},
@@ -52,6 +63,63 @@ async def test_discord_command_sync_ignores_daily_quota(monkeypatch):
5263

5364
await adapter._collect_and_register_commands()
5465

55-
adapter.client.sync_commands.assert_awaited_once()
66+
adapter.client.sync_commands.assert_awaited_once_with(check_guilds=[])
5667
warning.assert_called_once()
5768
assert "30034" in warning.call_args.args[0]
69+
70+
71+
@pytest.mark.asyncio
72+
async def test_discord_command_sync_removes_disabled_commands(monkeypatch):
73+
from astrbot.core.platform.sources.discord import discord_platform_adapter
74+
from astrbot.core.star.filter.command import CommandFilter
75+
76+
adapter = _build_adapter(monkeypatch)
77+
handler = SimpleNamespace(
78+
handler_module_path="test_plugin",
79+
enabled=True,
80+
event_filters=[CommandFilter("ping")],
81+
desc="Ping command",
82+
)
83+
monkeypatch.setattr(discord_platform_adapter, "star_handlers_registry", [handler])
84+
monkeypatch.setattr(
85+
discord_platform_adapter,
86+
"star_map",
87+
{"test_plugin": SimpleNamespace(activated=True)},
88+
)
89+
90+
await adapter._collect_and_register_commands()
91+
92+
assert adapter.client.add_application_command.call_count == 1
93+
assert len(adapter._managed_application_commands) == 1
94+
assert adapter.client.sync_commands.await_args_list[0].kwargs["check_guilds"] == []
95+
96+
handler.enabled = False
97+
await adapter._collect_and_register_commands()
98+
99+
assert adapter.client.remove_application_command.call_count == 1
100+
assert adapter.client.sync_commands.await_args_list[1].kwargs["check_guilds"] == []
101+
assert adapter._managed_application_commands == []
102+
103+
104+
@pytest.mark.asyncio
105+
async def test_discord_command_sync_checks_debug_guild_when_empty(monkeypatch):
106+
adapter = _build_adapter(monkeypatch)
107+
adapter.guild_id = 123456
108+
109+
await adapter._collect_and_register_commands()
110+
111+
adapter.client.sync_commands.assert_awaited_once_with(check_guilds=[123456])
112+
113+
114+
@pytest.mark.asyncio
115+
async def test_discord_command_sync_rolls_back_local_registry_on_failure(monkeypatch):
116+
adapter = _build_adapter(monkeypatch)
117+
previous_command = Mock(name="previous_command")
118+
adapter._managed_application_commands = [previous_command]
119+
adapter.client.sync_commands.side_effect = DiscordSyncError("sync failed", code=50000)
120+
121+
await adapter._collect_and_register_commands()
122+
123+
assert adapter._managed_application_commands == [previous_command]
124+
adapter.client.remove_application_command.assert_called_once_with(previous_command)
125+
adapter.client.add_application_command.assert_called_once_with(previous_command)

tests/test_telegram_adapter.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -472,7 +472,7 @@ async def second_start_polling(*args, **kwargs):
472472

473473
assert builder.build.call_count == 2
474474
app_one.updater.stop.assert_awaited()
475-
app_one.bot.delete_my_commands.assert_not_awaited()
475+
app_one.bot.delete_my_commands.assert_awaited_once()
476476
app_one.stop.assert_awaited()
477477
app_one.shutdown.assert_awaited()
478478
app_two.initialize.assert_awaited()
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
from types import SimpleNamespace
2+
from unittest.mock import AsyncMock
3+
4+
import pytest
5+
6+
from astrbot.core.platform.sources.telegram.tg_adapter import TelegramPlatformAdapter
7+
8+
9+
@pytest.mark.asyncio
10+
async def test_telegram_command_sync_deletes_stale_commands_when_empty():
11+
adapter = object.__new__(TelegramPlatformAdapter)
12+
adapter.last_command_hash = None
13+
adapter.collect_commands = lambda: []
14+
adapter.client = SimpleNamespace(
15+
delete_my_commands=AsyncMock(),
16+
set_my_commands=AsyncMock(),
17+
)
18+
19+
await adapter.register_commands()
20+
21+
adapter.client.delete_my_commands.assert_awaited_once()
22+
adapter.client.set_my_commands.assert_not_awaited()
23+
assert adapter.last_command_hash == hash(())
24+
25+
await adapter.register_commands()
26+
adapter.client.delete_my_commands.assert_awaited_once()
27+
28+
29+
@pytest.mark.asyncio
30+
async def test_telegram_refresh_only_runs_for_started_registration():
31+
adapter = object.__new__(TelegramPlatformAdapter)
32+
adapter.enable_command_register = True
33+
adapter._application_started = True
34+
adapter.register_commands = AsyncMock()
35+
36+
await adapter.refresh_registered_commands()
37+
adapter.register_commands.assert_awaited_once()
38+
39+
adapter.register_commands.reset_mock()
40+
adapter._application_started = False
41+
await adapter.refresh_registered_commands()
42+
adapter.register_commands.assert_not_awaited()

0 commit comments

Comments
 (0)