mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-01 01:10:21 +08:00
Fix: KV storage not cleared on plugin uninstall. Improved cleanup logic and updated i18n strings to indicate database KV data removal. (#8291)
* fix: 完善插件卸载时的清理逻辑,新增KV数据清理,更新了多语言文案以说明会清理数据库KV数据 * fix: 修复插件关闭时不清理KV的问题,更新单元测试 * refactor: 统一插件ID生成逻辑 将插件ID生成逻辑抽离到StarMetadata类中,移除重复的代码实现, 同时在__post_init__中自动补全plugin_id字段。 * refactor: 将plugin_id属性从方法转换为属性,确保在属性赋值后正确计算
This commit is contained in:
@@ -75,6 +75,12 @@ class StarMetadata:
|
||||
pages: list[dict] = field(default_factory=list)
|
||||
"""插件注册的 Pages 元数据。"""
|
||||
|
||||
@property
|
||||
def plugin_id(self) -> str:
|
||||
p_name = (self.name or "unknown").lower().replace("/", "_")
|
||||
p_author = (self.author or "unknown").lower().replace("/", "_")
|
||||
return f"{p_author}/{p_name}"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Plugin {self.name} ({self.version}) by {self.author}: {self.desc}"
|
||||
|
||||
|
||||
@@ -777,6 +777,7 @@ class PluginManager:
|
||||
"display_name": metadata.display_name,
|
||||
"support_platforms": metadata.support_platforms,
|
||||
"astrbot_version": metadata.astrbot_version,
|
||||
"plugin_id": metadata.plugin_id,
|
||||
}
|
||||
)
|
||||
except Exception as metadata_error:
|
||||
@@ -1036,12 +1037,11 @@ class PluginManager:
|
||||
|
||||
logger.info(metadata)
|
||||
metadata.config = plugin_config
|
||||
p_name = (metadata.name or "unknown").lower().replace("/", "_")
|
||||
p_author = (metadata.author or "unknown").lower().replace("/", "_")
|
||||
plugin_id = f"{p_author}/{p_name}"
|
||||
plugin_id = metadata.plugin_id
|
||||
|
||||
# 在实例化前注入类属性,保证插件 __init__ 可读取这些值
|
||||
# inject class attributes before instantiation so __init__ can read them
|
||||
if metadata.star_cls_type:
|
||||
p_author, p_name = plugin_id.split("/")
|
||||
setattr(metadata.star_cls_type, "name", p_name)
|
||||
setattr(metadata.star_cls_type, "author", p_author)
|
||||
setattr(metadata.star_cls_type, "plugin_id", plugin_id)
|
||||
@@ -1316,11 +1316,12 @@ class PluginManager:
|
||||
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:
|
||||
@@ -1355,6 +1356,13 @@ class PluginManager:
|
||||
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,
|
||||
*,
|
||||
@@ -1557,9 +1565,12 @@ class PluginManager:
|
||||
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,
|
||||
)
|
||||
@@ -1603,16 +1614,19 @@ class PluginManager:
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -208,7 +208,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",
|
||||
|
||||
@@ -207,7 +207,7 @@
|
||||
"deleteConfig": "Удалить файл конфигурации плагина",
|
||||
"deleteData": "Удалить сохраненные данные плагина",
|
||||
"configHint": "Конфиг находится в data/config",
|
||||
"dataHint": "Данные находятся в data/plugin_data и data/plugins_data"
|
||||
"dataHint": "Данные находятся в data/plugin_data и data/plugins_data, а также KV-данные в базе данных"
|
||||
},
|
||||
"install": {
|
||||
"title": "Установка плагина",
|
||||
|
||||
@@ -208,7 +208,7 @@
|
||||
"deleteConfig": "同时删除插件配置文件",
|
||||
"deleteData": "同时删除插件持久化数据",
|
||||
"configHint": "配置文件位于 data/config 目录",
|
||||
"dataHint": "删除 data/plugin_data 和 data/plugins_data 目录下的数据"
|
||||
"dataHint": "删除 data/plugin_data、plugins_data 目录下的数据,以及数据库中插件的 KV 数据"
|
||||
},
|
||||
"install": {
|
||||
"title": "安装插件",
|
||||
|
||||
@@ -1439,3 +1439,234 @@ async def test_ensure_plugin_requirements_does_not_mask_install_error_when_clean
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
@@ -162,6 +162,60 @@ class TestStarBase:
|
||||
assert len(star_registry) >= initial_count
|
||||
|
||||
|
||||
class TestStarMetadataPluginId:
|
||||
"""Tests for StarMetadata.plugin_id derived view.
|
||||
|
||||
Regression: previously `plugin_id` was set in `__post_init__`, which only
|
||||
fires at dataclass construction. The plugin load flow constructs
|
||||
StarMetadata empty (no name/author) and fills them via attribute
|
||||
assignment later, so `plugin_id` stayed None and crashed the downstream
|
||||
`plugin_id.split("/")`. Now it's a property recomputed on every access.
|
||||
"""
|
||||
|
||||
def test_plugin_id_defaults_to_unknown_when_empty(self):
|
||||
from astrbot.core.star.star import StarMetadata
|
||||
|
||||
assert StarMetadata().plugin_id == "unknown/unknown"
|
||||
|
||||
def test_plugin_id_uses_name_and_author(self):
|
||||
from astrbot.core.star.star import StarMetadata
|
||||
|
||||
metadata = StarMetadata(name="Hello", author="AstrBot")
|
||||
assert metadata.plugin_id == "astrbot/hello"
|
||||
|
||||
def test_plugin_id_recomputes_after_attribute_assignment(self):
|
||||
from astrbot.core.star.star import StarMetadata
|
||||
|
||||
metadata = StarMetadata()
|
||||
metadata.name = "A"
|
||||
metadata.author = "B"
|
||||
assert metadata.plugin_id == "b/a"
|
||||
|
||||
def test_plugin_id_lowercases_and_escapes_slash(self):
|
||||
from astrbot.core.star.star import StarMetadata
|
||||
|
||||
metadata = StarMetadata(name="A/B", author="C")
|
||||
assert metadata.plugin_id == "c/a_b"
|
||||
|
||||
def test_plugin_id_reflects_latest_name_after_change(self):
|
||||
from astrbot.core.star.star import StarMetadata
|
||||
|
||||
metadata = StarMetadata(name="old", author="author")
|
||||
assert metadata.plugin_id == "author/old"
|
||||
metadata.name = "new"
|
||||
assert metadata.plugin_id == "author/new"
|
||||
|
||||
def test_plugin_id_only_name_set(self):
|
||||
from astrbot.core.star.star import StarMetadata
|
||||
|
||||
assert StarMetadata(name="OnlyName").plugin_id == "unknown/onlyname"
|
||||
|
||||
def test_plugin_id_only_author_set(self):
|
||||
from astrbot.core.star.star import StarMetadata
|
||||
|
||||
assert StarMetadata(author="OnlyAuthor").plugin_id == "onlyauthor/unknown"
|
||||
|
||||
|
||||
class TestNoCircularImports:
|
||||
"""Test that there are no circular import issues."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user