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
3 changes: 3 additions & 0 deletions astrbot/core/star/star.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ class StarMetadata:
repo: str | None = None
"""插件仓库地址"""

plugin_id: str | None = None
"""插件的唯一标识,格式为 author/name"""

star_cls_type: type[Star] | None = None
"""插件的类对象的类型"""
module_path: str | None = None
Expand Down
24 changes: 21 additions & 3 deletions astrbot/core/star/star_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -739,6 +739,8 @@ def _build_failed_plugin_record(
try:
metadata = self._load_plugin_metadata(plugin_path=plugin_dir_path)
if metadata:
p_name = (metadata.name or "unknown").lower().replace("/", "_")
p_author = (metadata.author or "unknown").lower().replace("/", "_")
record.update(
{
"name": metadata.name,
Expand All @@ -750,6 +752,7 @@ def _build_failed_plugin_record(
"display_name": metadata.display_name,
"support_platforms": metadata.support_platforms,
"astrbot_version": metadata.astrbot_version,
"plugin_id": f"{p_author}/{p_name}",
}
)
except Exception as metadata_error:
Expand Down Expand Up @@ -1016,6 +1019,7 @@ async def load(
p_name = (metadata.name or "unknown").lower().replace("/", "_")
p_author = (metadata.author or "unknown").lower().replace("/", "_")
plugin_id = f"{p_author}/{p_name}"
metadata.plugin_id = plugin_id

# 在实例化前注入类属性,保证插件 __init__ 可读取这些值
if metadata.star_cls_type:
Expand Down Expand Up @@ -1290,11 +1294,12 @@ async def _cleanup_failed_plugin_install(
f"清理安装失败插件配置失败: {plugin_config_path},原因: {e!s}",
)

def _cleanup_plugin_optional_artifacts(
async def _cleanup_plugin_optional_artifacts(
self,
*,
root_dir_name: str,
plugin_label: str,
plugin_id: str | None = None,
delete_config: bool,
delete_data: bool,
) -> None:
Expand Down Expand Up @@ -1329,6 +1334,13 @@ def _cleanup_plugin_optional_artifacts(
f"删除插件持久化数据失败 ({data_dir_name}, {plugin_label}): {e!s}",
)

if plugin_id:
try:
await self.context.get_db().clear_preferences("plugin", plugin_id)
logger.info(f"已清除插件 {plugin_label}({plugin_id}) 的 KV 数据")
except Exception as e:
logger.warning(f"清除插件 KV 数据失败 ({plugin_label}): {e!s}")

def _track_failed_install_dir(
self,
*,
Expand Down Expand Up @@ -1531,9 +1543,12 @@ async def uninstall_plugin(
f"移除插件成功,但是删除插件文件夹失败: {e!s}。您可以手动删除该文件夹,位于 addons/plugins/ 下。",
)

self._cleanup_plugin_optional_artifacts(
plugin_id = plugin.plugin_id

await self._cleanup_plugin_optional_artifacts(
root_dir_name=root_dir_name,
plugin_label=plugin_name,
plugin_id=plugin_id,
delete_config=delete_config,
delete_data=delete_data,
)
Expand Down Expand Up @@ -1577,16 +1592,19 @@ async def uninstall_failed_plugin(
)

plugin_label = dir_name
plugin_id = None
if isinstance(failed_info, dict):
plugin_label = (
failed_info.get("display_name")
or failed_info.get("name")
or dir_name
)
plugin_id = failed_info.get("plugin_id")

self._cleanup_plugin_optional_artifacts(
await self._cleanup_plugin_optional_artifacts(
root_dir_name=dir_name,
plugin_label=plugin_label,
plugin_id=plugin_id,
delete_config=delete_config,
delete_data=delete_data,
)
Expand Down
2 changes: 1 addition & 1 deletion dashboard/src/i18n/locales/en-US/features/extension.json
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@
"deleteConfig": "Also delete plugin configuration file",
"deleteData": "Also delete plugin persistent data",
"configHint": "Configuration file located in data/config directory",
"dataHint": "Deletes data in data/plugin_data and data/plugins_data"
"dataHint": "Deletes data in data/plugin_data and data/plugins_data, as well as KV preference data in the database"
},
"install": {
"title": "Install Extension",
Expand Down
2 changes: 1 addition & 1 deletion dashboard/src/i18n/locales/ru-RU/features/extension.json
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@
"deleteConfig": "Удалить файл конфигурации плагина",
"deleteData": "Удалить сохраненные данные плагина",
"configHint": "Конфиг находится в data/config",
"dataHint": "Данные находятся в data/plugin_data и data/plugins_data"
"dataHint": "Данные находятся в data/plugin_data и data/plugins_data, а также KV-данные в базе данных"
},
"install": {
"title": "Установка плагина",
Expand Down
2 changes: 1 addition & 1 deletion dashboard/src/i18n/locales/zh-CN/features/extension.json
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@
"deleteConfig": "同时删除插件配置文件",
"deleteData": "同时删除插件持久化数据",
"configHint": "配置文件位于 data/config 目录",
"dataHint": "删除 data/plugin_data 和 data/plugins_data 目录下的数据"
"dataHint": "删除 data/plugin_dataplugins_data 目录下的数据,以及数据库中插件的 KV 数据"
},
"install": {
"title": "安装插件",
Expand Down
231 changes: 231 additions & 0 deletions tests/test_plugin_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -1261,3 +1261,234 @@ def flaky_remove(path):
)

assert any("删除临时插件依赖文件失败" in log for log in warning_logs)


# --- Tests for plugin_id KV cleanup logic ---


@pytest.mark.asyncio
async def test_cleanup_plugin_optional_artifacts_clears_kv_when_plugin_id_present(
plugin_manager_pm: PluginManager, monkeypatch
):
cleared = []

class MockDB:
async def clear_preferences(self, scope, scope_id):
cleared.append((scope, scope_id))

monkeypatch.setattr(
plugin_manager_pm.context, "get_db", MockDB, raising=False
)

await plugin_manager_pm._cleanup_plugin_optional_artifacts(
root_dir_name="test_plugin",
plugin_label="TestPlugin",
plugin_id="test_author/test_plugin",
delete_config=False,
delete_data=True,
)

assert cleared == [("plugin", "test_author/test_plugin")]


@pytest.mark.asyncio
async def test_cleanup_plugin_optional_artifacts_skips_kv_when_plugin_id_none(
plugin_manager_pm: PluginManager, monkeypatch
):
cleared = []

class MockDB:
async def clear_preferences(self, scope, scope_id):
cleared.append((scope, scope_id))

monkeypatch.setattr(
plugin_manager_pm.context, "get_db", MockDB, raising=False
)

await plugin_manager_pm._cleanup_plugin_optional_artifacts(
root_dir_name="test_plugin",
plugin_label="TestPlugin",
plugin_id=None,
delete_config=False,
delete_data=True,
)

assert cleared == []


@pytest.mark.asyncio
async def test_uninstall_plugin_reads_plugin_id_from_metadata(
plugin_manager_pm: PluginManager, monkeypatch
):
cleanup_calls = []

mock_star = MockStar()
mock_star.root_dir_name = TEST_PLUGIN_DIR
mock_star.name = TEST_PLUGIN_NAME
mock_star.module_path = "data.plugins.helloworld.main"
mock_star.reserved = False
mock_star.star_cls = None
mock_star.plugin_id = "mock_author/mock_name"

cast(Any, plugin_manager_pm.context).stars.append(mock_star)

monkeypatch.setattr(
plugin_manager_pm, "_terminate_plugin", lambda p: asyncio.sleep(0)
)
monkeypatch.setattr(
plugin_manager_pm, "_unbind_plugin", lambda n, m: asyncio.sleep(0)
)
monkeypatch.setattr(
"astrbot.core.star.star_manager.remove_dir",
lambda p: None,
)

async def mock_cleanup(
*, root_dir_name, plugin_label, plugin_id, delete_config, delete_data
):
cleanup_calls.append(
{
"root_dir_name": root_dir_name,
"plugin_label": plugin_label,
"plugin_id": plugin_id,
}
)

monkeypatch.setattr(
plugin_manager_pm, "_cleanup_plugin_optional_artifacts", mock_cleanup
)

await plugin_manager_pm.uninstall_plugin(
TEST_PLUGIN_NAME, delete_config=False, delete_data=True
)

assert len(cleanup_calls) == 1
assert cleanup_calls[0]["plugin_id"] == "mock_author/mock_name"


@pytest.mark.asyncio
async def test_uninstall_plugin_handles_disabled_plugin_with_plugin_id(
plugin_manager_pm: PluginManager, monkeypatch
):
cleanup_calls = []

mock_star = MockStar()
mock_star.root_dir_name = TEST_PLUGIN_DIR
mock_star.name = TEST_PLUGIN_NAME
mock_star.module_path = "data.plugins.helloworld.main"
mock_star.star_cls = None
mock_star.plugin_id = "mock_author/mock_name"

cast(Any, plugin_manager_pm.context).stars.append(mock_star)

monkeypatch.setattr(
plugin_manager_pm, "_terminate_plugin", lambda p: asyncio.sleep(0)
)
monkeypatch.setattr(
plugin_manager_pm, "_unbind_plugin", lambda n, m: asyncio.sleep(0)
)
monkeypatch.setattr(
"astrbot.core.star.star_manager.remove_dir",
lambda p: None,
)

async def mock_cleanup(
*, root_dir_name, plugin_label, plugin_id, delete_config, delete_data
):
cleanup_calls.append(
{
"root_dir_name": root_dir_name,
"plugin_label": plugin_label,
"plugin_id": plugin_id,
}
)

monkeypatch.setattr(
plugin_manager_pm, "_cleanup_plugin_optional_artifacts", mock_cleanup
)

await plugin_manager_pm.uninstall_plugin(
TEST_PLUGIN_NAME, delete_config=False, delete_data=True
)

assert len(cleanup_calls) == 1
assert cleanup_calls[0]["plugin_id"] == "mock_author/mock_name"


@pytest.mark.asyncio
async def test_uninstall_failed_plugin_passes_plugin_id_from_record(
plugin_manager_pm: PluginManager, monkeypatch
):
cleanup_calls = []

plugin_manager_pm.failed_plugin_dict[TEST_PLUGIN_DIR] = {
"name": TEST_PLUGIN_NAME,
"display_name": "Hello World",
"plugin_id": "astrbot_team/helloworld",
}

monkeypatch.setattr(
"astrbot.core.star.star_manager.remove_dir",
lambda p: None,
)

async def mock_cleanup(
*, root_dir_name, plugin_label, plugin_id, delete_config, delete_data
):
cleanup_calls.append(
{
"root_dir_name": root_dir_name,
"plugin_label": plugin_label,
"plugin_id": plugin_id,
}
)

monkeypatch.setattr(
plugin_manager_pm, "_cleanup_plugin_optional_artifacts", mock_cleanup
)

await plugin_manager_pm.uninstall_failed_plugin(
TEST_PLUGIN_DIR, delete_config=False, delete_data=True
)

assert len(cleanup_calls) == 1
assert cleanup_calls[0]["plugin_id"] == "astrbot_team/helloworld"


@pytest.mark.asyncio
async def test_uninstall_failed_plugin_without_plugin_id_in_record(
plugin_manager_pm: PluginManager, monkeypatch
):
cleanup_calls = []

plugin_manager_pm.failed_plugin_dict[TEST_PLUGIN_DIR] = {
"name": TEST_PLUGIN_NAME,
"display_name": "Hello World",
}

monkeypatch.setattr(
"astrbot.core.star.star_manager.remove_dir",
lambda p: None,
)

async def mock_cleanup(
*, root_dir_name, plugin_label, plugin_id, delete_config, delete_data
):
cleanup_calls.append(
{
"root_dir_name": root_dir_name,
"plugin_label": plugin_label,
"plugin_id": plugin_id,
}
)

monkeypatch.setattr(
plugin_manager_pm, "_cleanup_plugin_optional_artifacts", mock_cleanup
)

await plugin_manager_pm.uninstall_failed_plugin(
TEST_PLUGIN_DIR, delete_config=False, delete_data=True
)

assert len(cleanup_calls) == 1
assert cleanup_calls[0]["plugin_id"] is None