diff --git a/astrbot/core/star/filter/platform_adapter_type.py b/astrbot/core/star/filter/platform_adapter_type.py index 1630650a94..3ac8019ef8 100644 --- a/astrbot/core/star/filter/platform_adapter_type.py +++ b/astrbot/core/star/filter/platform_adapter_type.py @@ -11,6 +11,7 @@ class PlatformAdapterType(enum.Flag): QQOFFICIAL = enum.auto() TELEGRAM = enum.auto() WECOM = enum.auto() + WECOM_AI_BOT = enum.auto() LARK = enum.auto() DINGTALK = enum.auto() DISCORD = enum.auto() @@ -26,6 +27,7 @@ class PlatformAdapterType(enum.Flag): | QQOFFICIAL | TELEGRAM | WECOM + | WECOM_AI_BOT | LARK | DINGTALK | DISCORD @@ -44,6 +46,7 @@ class PlatformAdapterType(enum.Flag): "qq_official": PlatformAdapterType.QQOFFICIAL, "telegram": PlatformAdapterType.TELEGRAM, "wecom": PlatformAdapterType.WECOM, + "wecom_ai_bot": PlatformAdapterType.WECOM_AI_BOT, "lark": PlatformAdapterType.LARK, "dingtalk": PlatformAdapterType.DINGTALK, "discord": PlatformAdapterType.DISCORD, diff --git a/astrbot/core/star/star.py b/astrbot/core/star/star.py index c5b7b12430..8cebbd7720 100644 --- a/astrbot/core/star/star.py +++ b/astrbot/core/star/star.py @@ -61,6 +61,12 @@ class StarMetadata: logo_path: str | None = None """插件 Logo 的路径""" + support_platforms: list[str] = field(default_factory=list) + """插件声明支持的平台适配器 ID 列表(对应 ADAPTER_NAME_2_TYPE 的 key)""" + + astrbot_version: str | None = None + """插件要求的 AstrBot 版本范围(PEP 440 specifier,如 >=4.13.0,<4.17.0)""" + def __str__(self) -> str: return f"Plugin {self.name} ({self.version}) by {self.author}: {self.desc}" diff --git a/astrbot/core/star/star_manager.py b/astrbot/core/star/star_manager.py index 51f50aedfb..93512bde21 100644 --- a/astrbot/core/star/star_manager.py +++ b/astrbot/core/star/star_manager.py @@ -11,10 +11,13 @@ from types import ModuleType import yaml +from packaging.specifiers import InvalidSpecifier, SpecifierSet +from packaging.version import InvalidVersion, Version from astrbot.core import logger, pip_installer, sp from astrbot.core.agent.handoff import FunctionTool, HandoffTool from astrbot.core.config.astrbot_config import AstrBotConfig +from astrbot.core.config.default import VERSION from astrbot.core.platform.register import unregister_platform_adapters_by_module from astrbot.core.provider.register import llm_tools from astrbot.core.utils.astrbot_path import ( @@ -40,6 +43,10 @@ logger.warning("未安装 watchfiles,无法实现插件的热重载。") +class PluginVersionIncompatibleError(Exception): + """Raised when plugin astrbot_version is incompatible with current AstrBot.""" + + class PluginManager: def __init__(self, context: Context, config: AstrBotConfig) -> None: self.updator = PluginUpdator() @@ -268,10 +275,58 @@ def _load_plugin_metadata(plugin_path: str, plugin_obj=None) -> StarMetadata | N version=metadata["version"], repo=metadata["repo"] if "repo" in metadata else None, display_name=metadata.get("display_name", None), + support_platforms=( + [ + platform_id + for platform_id in metadata["support_platforms"] + if isinstance(platform_id, str) + ] + if isinstance(metadata.get("support_platforms"), list) + else [] + ), + astrbot_version=( + metadata["astrbot_version"] + if isinstance(metadata.get("astrbot_version"), str) + else None + ), ) return metadata + @staticmethod + def _validate_astrbot_version_specifier( + version_spec: str | None, + ) -> tuple[bool, str | None]: + if not version_spec: + return True, None + + normalized_spec = version_spec.strip() + if not normalized_spec: + return True, None + + try: + specifier = SpecifierSet(normalized_spec) + except InvalidSpecifier: + return ( + False, + "astrbot_version 格式无效,请使用 PEP 440 版本范围格式,例如 >=4.16,<5。", + ) + + try: + current_version = Version(VERSION) + except InvalidVersion: + return ( + False, + f"AstrBot 当前版本 {VERSION} 无法被解析,无法校验插件版本范围。", + ) + + if current_version not in specifier: + return ( + False, + f"当前 AstrBot 版本为 {VERSION},不满足插件要求的 astrbot_version: {normalized_spec}", + ) + return True, None + @staticmethod def _get_plugin_related_modules( plugin_root_dir: str, @@ -408,7 +463,12 @@ async def reload(self, specified_plugin_name=None): return result - async def load(self, specified_module_path=None, specified_dir_name=None): + async def load( + self, + specified_module_path=None, + specified_dir_name=None, + ignore_version_check: bool = False, + ): """载入插件。 当 specified_module_path 或者 specified_dir_name 不为 None 时,只载入指定的插件。 @@ -507,10 +567,25 @@ async def load(self, specified_module_path=None, specified_dir_name=None): metadata.version = metadata_yaml.version metadata.repo = metadata_yaml.repo metadata.display_name = metadata_yaml.display_name + metadata.support_platforms = metadata_yaml.support_platforms + metadata.astrbot_version = metadata_yaml.astrbot_version except Exception as e: logger.warning( f"插件 {root_dir_name} 元数据载入失败: {e!s}。使用默认元数据。", ) + + if not ignore_version_check: + is_valid, error_message = ( + self._validate_astrbot_version_specifier( + metadata.astrbot_version, + ) + ) + if not is_valid: + raise PluginVersionIncompatibleError( + error_message + or "The plugin is not compatible with the current AstrBot version." + ) + logger.info(metadata) metadata.config = plugin_config p_name = (metadata.name or "unknown").lower().replace("/", "_") @@ -621,6 +696,19 @@ async def load(self, specified_module_path=None, specified_dir_name=None): ) if not metadata: raise Exception(f"无法找到插件 {plugin_dir_path} 的元数据。") + + if not ignore_version_check: + is_valid, error_message = ( + self._validate_astrbot_version_specifier( + metadata.astrbot_version, + ) + ) + if not is_valid: + raise PluginVersionIncompatibleError( + error_message + or "The plugin is not compatible with the current AstrBot version." + ) + metadata.star_cls = obj metadata.config = plugin_config metadata.module = module @@ -754,7 +842,9 @@ async def _cleanup_failed_plugin_install( f"清理安装失败插件配置失败: {plugin_config_path},原因: {e!s}", ) - async def install_plugin(self, repo_url: str, proxy=""): + async def install_plugin( + self, repo_url: str, proxy: str = "", ignore_version_check: bool = False + ): """从仓库 URL 安装插件 从指定的仓库 URL 下载并安装插件,然后加载该插件到系统中 @@ -788,7 +878,10 @@ async def install_plugin(self, repo_url: str, proxy=""): # reload the plugin dir_name = os.path.basename(plugin_path) - success, error_message = await self.load(specified_dir_name=dir_name) + success, error_message = await self.load( + specified_dir_name=dir_name, + ignore_version_check=ignore_version_check, + ) if not success: raise Exception( error_message @@ -1092,7 +1185,9 @@ async def turn_on_plugin(self, plugin_name: str) -> None: await self.reload(plugin_name) - async def install_plugin_from_file(self, zip_file_path: str): + async def install_plugin_from_file( + self, zip_file_path: str, ignore_version_check: bool = False + ): dir_name = os.path.basename(zip_file_path).replace(".zip", "") dir_name = dir_name.removesuffix("-master").removesuffix("-main").lower() desti_dir = os.path.join(self.plugin_store_path, dir_name) @@ -1148,7 +1243,10 @@ async def install_plugin_from_file(self, zip_file_path: str): except BaseException as e: logger.warning(f"删除插件压缩包失败: {e!s}") # await self.reload() - success, error_message = await self.load(specified_dir_name=dir_name) + success, error_message = await self.load( + specified_dir_name=dir_name, + ignore_version_check=ignore_version_check, + ) if not success: raise Exception( error_message diff --git a/astrbot/dashboard/routes/plugin.py b/astrbot/dashboard/routes/plugin.py index 25fed7d27e..e1cfe12cde 100644 --- a/astrbot/dashboard/routes/plugin.py +++ b/astrbot/dashboard/routes/plugin.py @@ -19,7 +19,10 @@ from astrbot.core.star.filter.permission import PermissionTypeFilter from astrbot.core.star.filter.regex import RegexFilter from astrbot.core.star.star_handler import EventType, star_handlers_registry -from astrbot.core.star.star_manager import PluginManager +from astrbot.core.star.star_manager import ( + PluginManager, + PluginVersionIncompatibleError, +) from astrbot.core.utils.astrbot_path import ( get_astrbot_data_path, get_astrbot_temp_path, @@ -49,6 +52,7 @@ def __init__( super().__init__(context) self.routes = { "/plugin/get": ("GET", self.get_plugins), + "/plugin/check-compat": ("POST", self.check_plugin_compatibility), "/plugin/install": ("POST", self.install_plugin), "/plugin/install-upload": ("POST", self.install_plugin_upload), "/plugin/update": ("POST", self.update_plugin), @@ -81,6 +85,27 @@ def __init__( self._logo_cache = {} + async def check_plugin_compatibility(self): + try: + data = await request.get_json() + version_spec = data.get("astrbot_version", "") + is_valid, message = self.plugin_manager._validate_astrbot_version_specifier( + version_spec + ) + return ( + Response() + .ok( + { + "compatible": is_valid, + "message": message, + "astrbot_version": version_spec, + } + ) + .__dict__ + ) + except Exception as e: + return Response().error(str(e)).__dict__ + async def reload_failed_plugins(self): if DEMO_MODE: return ( @@ -121,7 +146,7 @@ async def reload_plugins(self): try: success, message = await self.plugin_manager.reload(plugin_name) if not success: - return Response().error(message).__dict__ + return Response().error(message or "插件重载失败").__dict__ return Response().ok(None, "重载成功。").__dict__ except Exception as e: logger.error(f"/api/plugin/reload: {traceback.format_exc()}") @@ -349,6 +374,8 @@ async def get_plugins(self): ), "display_name": plugin.display_name, "logo": f"/api/file/{logo_url}" if logo_url else None, + "support_platforms": plugin.support_platforms, + "astrbot_version": plugin.astrbot_version, } # 检查是否为全空的幽灵插件 if not any( @@ -443,6 +470,7 @@ async def install_plugin(self): post_data = await request.get_json() repo_url = post_data["url"] + ignore_version_check = bool(post_data.get("ignore_version_check", False)) proxy: str = post_data.get("proxy", None) if proxy: @@ -450,10 +478,23 @@ async def install_plugin(self): try: logger.info(f"正在安装插件 {repo_url}") - plugin_info = await self.plugin_manager.install_plugin(repo_url, proxy) + plugin_info = await self.plugin_manager.install_plugin( + repo_url, + proxy, + ignore_version_check=ignore_version_check, + ) # self.core_lifecycle.restart() logger.info(f"安装插件 {repo_url} 成功。") return Response().ok(plugin_info, "安装成功。").__dict__ + except PluginVersionIncompatibleError as e: + return { + "status": "warning", + "message": str(e), + "data": { + "warning_type": "astrbot_version_incompatible", + "can_ignore": True, + }, + } except Exception as e: logger.error(traceback.format_exc()) return Response().error(str(e)).__dict__ @@ -469,16 +510,32 @@ async def install_plugin_upload(self): try: file = await request.files file = file["file"] + form_data = await request.form + ignore_version_check = ( + str(form_data.get("ignore_version_check", "false")).lower() == "true" + ) logger.info(f"正在安装用户上传的插件 {file.filename}") file_path = os.path.join( get_astrbot_temp_path(), f"plugin_upload_{file.filename}", ) await file.save(file_path) - plugin_info = await self.plugin_manager.install_plugin_from_file(file_path) + plugin_info = await self.plugin_manager.install_plugin_from_file( + file_path, + ignore_version_check=ignore_version_check, + ) # self.core_lifecycle.restart() logger.info(f"安装插件 {file.filename} 成功") return Response().ok(plugin_info, "安装成功。").__dict__ + except PluginVersionIncompatibleError as e: + return { + "status": "warning", + "message": str(e), + "data": { + "warning_type": "astrbot_version_incompatible", + "can_ignore": True, + }, + } except Exception as e: logger.error(traceback.format_exc()) return Response().error(str(e)).__dict__ diff --git a/dashboard/src/components/extension/MarketPluginCard.vue b/dashboard/src/components/extension/MarketPluginCard.vue index 03425553dc..a32bf3b85b 100644 --- a/dashboard/src/components/extension/MarketPluginCard.vue +++ b/dashboard/src/components/extension/MarketPluginCard.vue @@ -1,5 +1,6 @@