Skip to content

Commit 9a7a594

Browse files
authored
feat: add support for plugin astrbot-version and platform requirement checks (#5235)
* feat: add support for plugin astrbot-version and platform requirement checks * fix: remove unsupported platform and version constraints from metadata.yaml * fix: remove restriction on 'v' in astrbot_version specification format * ruff format
1 parent e469178 commit 9a7a594

File tree

13 files changed

+585
-28
lines changed

13 files changed

+585
-28
lines changed

astrbot/core/star/filter/platform_adapter_type.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ class PlatformAdapterType(enum.Flag):
1111
QQOFFICIAL = enum.auto()
1212
TELEGRAM = enum.auto()
1313
WECOM = enum.auto()
14+
WECOM_AI_BOT = enum.auto()
1415
LARK = enum.auto()
1516
DINGTALK = enum.auto()
1617
DISCORD = enum.auto()
@@ -26,6 +27,7 @@ class PlatformAdapterType(enum.Flag):
2627
| QQOFFICIAL
2728
| TELEGRAM
2829
| WECOM
30+
| WECOM_AI_BOT
2931
| LARK
3032
| DINGTALK
3133
| DISCORD
@@ -44,6 +46,7 @@ class PlatformAdapterType(enum.Flag):
4446
"qq_official": PlatformAdapterType.QQOFFICIAL,
4547
"telegram": PlatformAdapterType.TELEGRAM,
4648
"wecom": PlatformAdapterType.WECOM,
49+
"wecom_ai_bot": PlatformAdapterType.WECOM_AI_BOT,
4750
"lark": PlatformAdapterType.LARK,
4851
"dingtalk": PlatformAdapterType.DINGTALK,
4952
"discord": PlatformAdapterType.DISCORD,

astrbot/core/star/star.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,12 @@ class StarMetadata:
6161
logo_path: str | None = None
6262
"""插件 Logo 的路径"""
6363

64+
support_platforms: list[str] = field(default_factory=list)
65+
"""插件声明支持的平台适配器 ID 列表(对应 ADAPTER_NAME_2_TYPE 的 key)"""
66+
67+
astrbot_version: str | None = None
68+
"""插件要求的 AstrBot 版本范围(PEP 440 specifier,如 >=4.13.0,<4.17.0)"""
69+
6470
def __str__(self) -> str:
6571
return f"Plugin {self.name} ({self.version}) by {self.author}: {self.desc}"
6672

astrbot/core/star/star_manager.py

Lines changed: 103 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,13 @@
1111
from types import ModuleType
1212

1313
import yaml
14+
from packaging.specifiers import InvalidSpecifier, SpecifierSet
15+
from packaging.version import InvalidVersion, Version
1416

1517
from astrbot.core import logger, pip_installer, sp
1618
from astrbot.core.agent.handoff import FunctionTool, HandoffTool
1719
from astrbot.core.config.astrbot_config import AstrBotConfig
20+
from astrbot.core.config.default import VERSION
1821
from astrbot.core.platform.register import unregister_platform_adapters_by_module
1922
from astrbot.core.provider.register import llm_tools
2023
from astrbot.core.utils.astrbot_path import (
@@ -40,6 +43,10 @@
4043
logger.warning("未安装 watchfiles,无法实现插件的热重载。")
4144

4245

46+
class PluginVersionIncompatibleError(Exception):
47+
"""Raised when plugin astrbot_version is incompatible with current AstrBot."""
48+
49+
4350
class PluginManager:
4451
def __init__(self, context: Context, config: AstrBotConfig) -> None:
4552
self.updator = PluginUpdator()
@@ -268,10 +275,58 @@ def _load_plugin_metadata(plugin_path: str, plugin_obj=None) -> StarMetadata | N
268275
version=metadata["version"],
269276
repo=metadata["repo"] if "repo" in metadata else None,
270277
display_name=metadata.get("display_name", None),
278+
support_platforms=(
279+
[
280+
platform_id
281+
for platform_id in metadata["support_platforms"]
282+
if isinstance(platform_id, str)
283+
]
284+
if isinstance(metadata.get("support_platforms"), list)
285+
else []
286+
),
287+
astrbot_version=(
288+
metadata["astrbot_version"]
289+
if isinstance(metadata.get("astrbot_version"), str)
290+
else None
291+
),
271292
)
272293

273294
return metadata
274295

296+
@staticmethod
297+
def _validate_astrbot_version_specifier(
298+
version_spec: str | None,
299+
) -> tuple[bool, str | None]:
300+
if not version_spec:
301+
return True, None
302+
303+
normalized_spec = version_spec.strip()
304+
if not normalized_spec:
305+
return True, None
306+
307+
try:
308+
specifier = SpecifierSet(normalized_spec)
309+
except InvalidSpecifier:
310+
return (
311+
False,
312+
"astrbot_version 格式无效,请使用 PEP 440 版本范围格式,例如 >=4.16,<5。",
313+
)
314+
315+
try:
316+
current_version = Version(VERSION)
317+
except InvalidVersion:
318+
return (
319+
False,
320+
f"AstrBot 当前版本 {VERSION} 无法被解析,无法校验插件版本范围。",
321+
)
322+
323+
if current_version not in specifier:
324+
return (
325+
False,
326+
f"当前 AstrBot 版本为 {VERSION},不满足插件要求的 astrbot_version: {normalized_spec}",
327+
)
328+
return True, None
329+
275330
@staticmethod
276331
def _get_plugin_related_modules(
277332
plugin_root_dir: str,
@@ -408,7 +463,12 @@ async def reload(self, specified_plugin_name=None):
408463

409464
return result
410465

411-
async def load(self, specified_module_path=None, specified_dir_name=None):
466+
async def load(
467+
self,
468+
specified_module_path=None,
469+
specified_dir_name=None,
470+
ignore_version_check: bool = False,
471+
):
412472
"""载入插件。
413473
当 specified_module_path 或者 specified_dir_name 不为 None 时,只载入指定的插件。
414474
@@ -507,10 +567,25 @@ async def load(self, specified_module_path=None, specified_dir_name=None):
507567
metadata.version = metadata_yaml.version
508568
metadata.repo = metadata_yaml.repo
509569
metadata.display_name = metadata_yaml.display_name
570+
metadata.support_platforms = metadata_yaml.support_platforms
571+
metadata.astrbot_version = metadata_yaml.astrbot_version
510572
except Exception as e:
511573
logger.warning(
512574
f"插件 {root_dir_name} 元数据载入失败: {e!s}。使用默认元数据。",
513575
)
576+
577+
if not ignore_version_check:
578+
is_valid, error_message = (
579+
self._validate_astrbot_version_specifier(
580+
metadata.astrbot_version,
581+
)
582+
)
583+
if not is_valid:
584+
raise PluginVersionIncompatibleError(
585+
error_message
586+
or "The plugin is not compatible with the current AstrBot version."
587+
)
588+
514589
logger.info(metadata)
515590
metadata.config = plugin_config
516591
p_name = (metadata.name or "unknown").lower().replace("/", "_")
@@ -621,6 +696,19 @@ async def load(self, specified_module_path=None, specified_dir_name=None):
621696
)
622697
if not metadata:
623698
raise Exception(f"无法找到插件 {plugin_dir_path} 的元数据。")
699+
700+
if not ignore_version_check:
701+
is_valid, error_message = (
702+
self._validate_astrbot_version_specifier(
703+
metadata.astrbot_version,
704+
)
705+
)
706+
if not is_valid:
707+
raise PluginVersionIncompatibleError(
708+
error_message
709+
or "The plugin is not compatible with the current AstrBot version."
710+
)
711+
624712
metadata.star_cls = obj
625713
metadata.config = plugin_config
626714
metadata.module = module
@@ -754,7 +842,9 @@ async def _cleanup_failed_plugin_install(
754842
f"清理安装失败插件配置失败: {plugin_config_path},原因: {e!s}",
755843
)
756844

757-
async def install_plugin(self, repo_url: str, proxy=""):
845+
async def install_plugin(
846+
self, repo_url: str, proxy: str = "", ignore_version_check: bool = False
847+
):
758848
"""从仓库 URL 安装插件
759849
760850
从指定的仓库 URL 下载并安装插件,然后加载该插件到系统中
@@ -788,7 +878,10 @@ async def install_plugin(self, repo_url: str, proxy=""):
788878

789879
# reload the plugin
790880
dir_name = os.path.basename(plugin_path)
791-
success, error_message = await self.load(specified_dir_name=dir_name)
881+
success, error_message = await self.load(
882+
specified_dir_name=dir_name,
883+
ignore_version_check=ignore_version_check,
884+
)
792885
if not success:
793886
raise Exception(
794887
error_message
@@ -1092,7 +1185,9 @@ async def turn_on_plugin(self, plugin_name: str) -> None:
10921185

10931186
await self.reload(plugin_name)
10941187

1095-
async def install_plugin_from_file(self, zip_file_path: str):
1188+
async def install_plugin_from_file(
1189+
self, zip_file_path: str, ignore_version_check: bool = False
1190+
):
10961191
dir_name = os.path.basename(zip_file_path).replace(".zip", "")
10971192
dir_name = dir_name.removesuffix("-master").removesuffix("-main").lower()
10981193
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):
11481243
except BaseException as e:
11491244
logger.warning(f"删除插件压缩包失败: {e!s}")
11501245
# await self.reload()
1151-
success, error_message = await self.load(specified_dir_name=dir_name)
1246+
success, error_message = await self.load(
1247+
specified_dir_name=dir_name,
1248+
ignore_version_check=ignore_version_check,
1249+
)
11521250
if not success:
11531251
raise Exception(
11541252
error_message

astrbot/dashboard/routes/plugin.py

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,10 @@
1919
from astrbot.core.star.filter.permission import PermissionTypeFilter
2020
from astrbot.core.star.filter.regex import RegexFilter
2121
from astrbot.core.star.star_handler import EventType, star_handlers_registry
22-
from astrbot.core.star.star_manager import PluginManager
22+
from astrbot.core.star.star_manager import (
23+
PluginManager,
24+
PluginVersionIncompatibleError,
25+
)
2326
from astrbot.core.utils.astrbot_path import (
2427
get_astrbot_data_path,
2528
get_astrbot_temp_path,
@@ -49,6 +52,7 @@ def __init__(
4952
super().__init__(context)
5053
self.routes = {
5154
"/plugin/get": ("GET", self.get_plugins),
55+
"/plugin/check-compat": ("POST", self.check_plugin_compatibility),
5256
"/plugin/install": ("POST", self.install_plugin),
5357
"/plugin/install-upload": ("POST", self.install_plugin_upload),
5458
"/plugin/update": ("POST", self.update_plugin),
@@ -81,6 +85,27 @@ def __init__(
8185

8286
self._logo_cache = {}
8387

88+
async def check_plugin_compatibility(self):
89+
try:
90+
data = await request.get_json()
91+
version_spec = data.get("astrbot_version", "")
92+
is_valid, message = self.plugin_manager._validate_astrbot_version_specifier(
93+
version_spec
94+
)
95+
return (
96+
Response()
97+
.ok(
98+
{
99+
"compatible": is_valid,
100+
"message": message,
101+
"astrbot_version": version_spec,
102+
}
103+
)
104+
.__dict__
105+
)
106+
except Exception as e:
107+
return Response().error(str(e)).__dict__
108+
84109
async def reload_failed_plugins(self):
85110
if DEMO_MODE:
86111
return (
@@ -121,7 +146,7 @@ async def reload_plugins(self):
121146
try:
122147
success, message = await self.plugin_manager.reload(plugin_name)
123148
if not success:
124-
return Response().error(message).__dict__
149+
return Response().error(message or "插件重载失败").__dict__
125150
return Response().ok(None, "重载成功。").__dict__
126151
except Exception as e:
127152
logger.error(f"/api/plugin/reload: {traceback.format_exc()}")
@@ -349,6 +374,8 @@ async def get_plugins(self):
349374
),
350375
"display_name": plugin.display_name,
351376
"logo": f"/api/file/{logo_url}" if logo_url else None,
377+
"support_platforms": plugin.support_platforms,
378+
"astrbot_version": plugin.astrbot_version,
352379
}
353380
# 检查是否为全空的幽灵插件
354381
if not any(
@@ -443,17 +470,31 @@ async def install_plugin(self):
443470

444471
post_data = await request.get_json()
445472
repo_url = post_data["url"]
473+
ignore_version_check = bool(post_data.get("ignore_version_check", False))
446474

447475
proxy: str = post_data.get("proxy", None)
448476
if proxy:
449477
proxy = proxy.removesuffix("/")
450478

451479
try:
452480
logger.info(f"正在安装插件 {repo_url}")
453-
plugin_info = await self.plugin_manager.install_plugin(repo_url, proxy)
481+
plugin_info = await self.plugin_manager.install_plugin(
482+
repo_url,
483+
proxy,
484+
ignore_version_check=ignore_version_check,
485+
)
454486
# self.core_lifecycle.restart()
455487
logger.info(f"安装插件 {repo_url} 成功。")
456488
return Response().ok(plugin_info, "安装成功。").__dict__
489+
except PluginVersionIncompatibleError as e:
490+
return {
491+
"status": "warning",
492+
"message": str(e),
493+
"data": {
494+
"warning_type": "astrbot_version_incompatible",
495+
"can_ignore": True,
496+
},
497+
}
457498
except Exception as e:
458499
logger.error(traceback.format_exc())
459500
return Response().error(str(e)).__dict__
@@ -469,16 +510,32 @@ async def install_plugin_upload(self):
469510
try:
470511
file = await request.files
471512
file = file["file"]
513+
form_data = await request.form
514+
ignore_version_check = (
515+
str(form_data.get("ignore_version_check", "false")).lower() == "true"
516+
)
472517
logger.info(f"正在安装用户上传的插件 {file.filename}")
473518
file_path = os.path.join(
474519
get_astrbot_temp_path(),
475520
f"plugin_upload_{file.filename}",
476521
)
477522
await file.save(file_path)
478-
plugin_info = await self.plugin_manager.install_plugin_from_file(file_path)
523+
plugin_info = await self.plugin_manager.install_plugin_from_file(
524+
file_path,
525+
ignore_version_check=ignore_version_check,
526+
)
479527
# self.core_lifecycle.restart()
480528
logger.info(f"安装插件 {file.filename} 成功")
481529
return Response().ok(plugin_info, "安装成功。").__dict__
530+
except PluginVersionIncompatibleError as e:
531+
return {
532+
"status": "warning",
533+
"message": str(e),
534+
"data": {
535+
"warning_type": "astrbot_version_incompatible",
536+
"can_ignore": True,
537+
},
538+
}
482539
except Exception as e:
483540
logger.error(traceback.format_exc())
484541
return Response().error(str(e)).__dict__

0 commit comments

Comments
 (0)