mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-16 09:40:30 +08:00
416 lines
14 KiB
Python
416 lines
14 KiB
Python
"""插件加载模块。
|
||
|
||
定义插件发现、环境管理和加载的核心逻辑。
|
||
支持新旧两种 Star 组件的兼容加载。
|
||
|
||
核心概念:
|
||
PluginSpec: 插件规范,描述插件的基本信息
|
||
PluginDiscoveryResult: 插件发现结果,包含成功和跳过的插件
|
||
PluginEnvironmentManager: 插件虚拟环境管理器
|
||
LoadedHandler: 加载后的处理器,包含描述符和可调用对象
|
||
LoadedPlugin: 加载后的插件,包含处理器和实例
|
||
|
||
插件发现流程:
|
||
1. 扫描 plugins_dir 下的子目录
|
||
2. 检查 plugin.yaml 和 requirements.txt
|
||
3. 解析 manifest_data 获取插件信息
|
||
4. 验证必要字段(name, components, runtime.python)
|
||
5. 返回 PluginDiscoveryResult
|
||
|
||
环境管理流程:
|
||
1. 检查 .venv 目录是否存在
|
||
2. 检查 Python 版本是否匹配
|
||
3. 检查指纹是否变化(requirements 内容)
|
||
4. 必要时重建虚拟环境
|
||
5. 使用 uv 安装依赖
|
||
|
||
插件加载流程:
|
||
1. 将插件目录添加到 sys.path
|
||
2. 遍历 components 列表
|
||
3. 动态导入组件类
|
||
4. 判断是否为新版 Star
|
||
5. 创建实例(新版直接实例化,旧版传入 legacy_context)
|
||
6. 扫描处理器方法
|
||
7. 构建 HandlerDescriptor
|
||
|
||
新旧 Star 组件兼容:
|
||
新版 Star:
|
||
- 继承自 Star 基类
|
||
- __astrbot_is_new_star__ 返回 True
|
||
- 无参构造函数
|
||
- 通过 @handler 装饰器注册处理器
|
||
|
||
旧版 Star:
|
||
- 不继承或 __astrbot_is_new_star__ 返回 False
|
||
- 需要 legacy_context 参数
|
||
- 通过 @xxx_handler 装饰器注册处理器
|
||
- 使用 extras_configs 传递配置
|
||
|
||
与旧版对比:
|
||
旧版 StarManager:
|
||
- 通过 plugin.yaml 发现插件
|
||
- 动态导入组件类并实例化
|
||
- 注册到 star_handlers_registry
|
||
- 使用 functools.partial 绑定实例
|
||
- 无环境管理
|
||
- 无指纹缓存
|
||
|
||
新版 loader.py:
|
||
- PluginSpec 描述插件规范
|
||
- PluginEnvironmentManager 管理虚拟环境
|
||
- load_plugin() 加载并解析组件
|
||
- LoadedHandler 封装处理器和描述符
|
||
- 支持新旧 Star 组件兼容
|
||
- 支持环境指纹缓存
|
||
|
||
plugin.yaml 格式:
|
||
name: my_plugin
|
||
author: author_name
|
||
desc: Plugin description
|
||
version: 1.0.0
|
||
runtime:
|
||
python: "3.11"
|
||
components:
|
||
- class: my_plugin.main:MyComponent
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
import re
|
||
import shutil
|
||
import subprocess
|
||
import sys
|
||
from dataclasses import dataclass
|
||
from importlib import import_module
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
import yaml
|
||
|
||
from ..decorators import get_handler_meta
|
||
from ..protocol.descriptors import HandlerDescriptor
|
||
from ..star import Star
|
||
|
||
STATE_FILE_NAME = ".astrbot-worker-state.json"
|
||
|
||
|
||
def _venv_python_path(venv_dir: Path) -> Path:
|
||
if os.name == "nt":
|
||
return venv_dir / "Scripts" / "python.exe"
|
||
return venv_dir / "bin" / "python"
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class PluginSpec:
|
||
name: str
|
||
plugin_dir: Path
|
||
manifest_path: Path
|
||
requirements_path: Path
|
||
python_version: str
|
||
manifest_data: dict[str, Any]
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class PluginDiscoveryResult:
|
||
plugins: list[PluginSpec]
|
||
skipped_plugins: dict[str, str]
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class LoadedHandler:
|
||
descriptor: HandlerDescriptor
|
||
callable: Any
|
||
owner: Any
|
||
legacy_context: Any | None = None
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class LoadedPlugin:
|
||
plugin: PluginSpec
|
||
handlers: list[LoadedHandler]
|
||
instances: list[Any]
|
||
|
||
|
||
def _is_new_star_component(component_cls: Any) -> bool:
|
||
if not isinstance(component_cls, type) or not issubclass(component_cls, Star):
|
||
return False
|
||
marker = getattr(component_cls, "__astrbot_is_new_star__", None)
|
||
if callable(marker):
|
||
return bool(marker())
|
||
return True
|
||
|
||
|
||
def _create_legacy_context(component_cls: Any, plugin_name: str) -> Any:
|
||
factory = getattr(component_cls, "_astrbot_create_legacy_context", None)
|
||
if callable(factory):
|
||
return factory(plugin_name)
|
||
from ..api.star.context import Context as LegacyContext
|
||
|
||
return LegacyContext(plugin_name)
|
||
|
||
|
||
def _iter_handler_names(instance: Any) -> list[str]:
|
||
handler_names = getattr(instance.__class__, "__handlers__", ())
|
||
if handler_names:
|
||
return list(handler_names)
|
||
return list(dir(instance))
|
||
|
||
|
||
def load_plugin_spec(plugin_dir: Path) -> PluginSpec:
|
||
plugin_dir = plugin_dir.resolve()
|
||
manifest_path = plugin_dir / "plugin.yaml"
|
||
requirements_path = plugin_dir / "requirements.txt"
|
||
manifest_data = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) or {}
|
||
runtime = manifest_data.get("runtime") or {}
|
||
python_version = (
|
||
runtime.get("python") or f"{sys.version_info.major}.{sys.version_info.minor}"
|
||
)
|
||
return PluginSpec(
|
||
name=str(manifest_data.get("name") or plugin_dir.name),
|
||
plugin_dir=plugin_dir,
|
||
manifest_path=manifest_path,
|
||
requirements_path=requirements_path,
|
||
python_version=str(python_version),
|
||
manifest_data=manifest_data,
|
||
)
|
||
|
||
|
||
def discover_plugins(plugins_dir: Path) -> PluginDiscoveryResult:
|
||
plugins_root = plugins_dir.resolve()
|
||
skipped_plugins: dict[str, str] = {}
|
||
plugins: list[PluginSpec] = []
|
||
seen_names: set[str] = set()
|
||
|
||
if not plugins_root.exists():
|
||
return PluginDiscoveryResult([], {})
|
||
|
||
for entry in sorted(plugins_root.iterdir()):
|
||
if not entry.is_dir() or entry.name.startswith("."):
|
||
continue
|
||
manifest_path = entry / "plugin.yaml"
|
||
requirements_path = entry / "requirements.txt"
|
||
if not manifest_path.exists():
|
||
continue
|
||
if not requirements_path.exists():
|
||
skipped_plugins[entry.name] = "missing requirements.txt"
|
||
continue
|
||
try:
|
||
manifest_data = (
|
||
yaml.safe_load(manifest_path.read_text(encoding="utf-8")) or {}
|
||
)
|
||
except Exception as exc:
|
||
skipped_plugins[entry.name] = f"failed to parse plugin.yaml: {exc}"
|
||
continue
|
||
plugin_name = manifest_data.get("name")
|
||
runtime = manifest_data.get("runtime") or {}
|
||
python_version = runtime.get("python")
|
||
components = manifest_data.get("components")
|
||
if not isinstance(plugin_name, str) or not plugin_name:
|
||
skipped_plugins[entry.name] = "plugin name is required"
|
||
continue
|
||
if plugin_name in seen_names:
|
||
skipped_plugins[plugin_name] = "duplicate plugin name"
|
||
continue
|
||
if not isinstance(components, list):
|
||
skipped_plugins[plugin_name] = "components must be a list"
|
||
continue
|
||
if not isinstance(python_version, str) or not python_version:
|
||
skipped_plugins[plugin_name] = "runtime.python is required"
|
||
continue
|
||
seen_names.add(plugin_name)
|
||
plugins.append(
|
||
PluginSpec(
|
||
name=plugin_name,
|
||
plugin_dir=entry.resolve(),
|
||
manifest_path=manifest_path.resolve(),
|
||
requirements_path=requirements_path.resolve(),
|
||
python_version=python_version,
|
||
manifest_data=manifest_data,
|
||
)
|
||
)
|
||
return PluginDiscoveryResult(plugins=plugins, skipped_plugins=skipped_plugins)
|
||
|
||
|
||
class PluginEnvironmentManager:
|
||
def __init__(self, repo_root: Path, uv_binary: str | None = None) -> None:
|
||
self.repo_root = repo_root.resolve()
|
||
self.uv_binary = uv_binary or shutil.which("uv")
|
||
self.cache_dir = self.repo_root / ".uv-cache"
|
||
|
||
def prepare_environment(self, plugin: PluginSpec) -> Path:
|
||
if not self.uv_binary:
|
||
raise RuntimeError("uv executable not found")
|
||
state_path = plugin.plugin_dir / STATE_FILE_NAME
|
||
venv_dir = plugin.plugin_dir / ".venv"
|
||
python_path = _venv_python_path(venv_dir)
|
||
fingerprint = self._fingerprint(plugin)
|
||
state = self._load_state(state_path)
|
||
if (
|
||
not python_path.exists()
|
||
or not self._matches_python_version(venv_dir, plugin.python_version)
|
||
or state.get("fingerprint") != fingerprint
|
||
):
|
||
self._rebuild(plugin, venv_dir, python_path)
|
||
self._write_state(state_path, plugin, fingerprint)
|
||
return python_path
|
||
|
||
def _rebuild(self, plugin: PluginSpec, venv_dir: Path, python_path: Path) -> None:
|
||
if venv_dir.exists():
|
||
shutil.rmtree(venv_dir)
|
||
self._run_command(
|
||
[
|
||
self.uv_binary,
|
||
"venv",
|
||
"--python",
|
||
plugin.python_version,
|
||
"--system-site-packages",
|
||
"--no-python-downloads",
|
||
"--no-managed-python",
|
||
str(venv_dir),
|
||
],
|
||
cwd=self.repo_root,
|
||
command_name=f"create venv for {plugin.name}",
|
||
)
|
||
requirements_text = plugin.requirements_path.read_text(encoding="utf-8").strip()
|
||
if not requirements_text:
|
||
return
|
||
self._run_command(
|
||
[
|
||
self.uv_binary,
|
||
"pip",
|
||
"install",
|
||
"--python",
|
||
str(python_path),
|
||
"-r",
|
||
str(plugin.requirements_path),
|
||
],
|
||
cwd=plugin.plugin_dir,
|
||
command_name=f"install requirements for {plugin.name}",
|
||
)
|
||
|
||
def _run_command(
|
||
self,
|
||
command: list[str],
|
||
*,
|
||
cwd: Path,
|
||
command_name: str,
|
||
) -> None:
|
||
process = subprocess.run(
|
||
command,
|
||
cwd=str(cwd),
|
||
env={**os.environ, "UV_CACHE_DIR": str(self.cache_dir)},
|
||
capture_output=True,
|
||
text=True,
|
||
check=False,
|
||
)
|
||
if process.returncode != 0:
|
||
raise RuntimeError(
|
||
f"{command_name} failed with exit code {process.returncode}: "
|
||
f"{process.stderr.strip() or process.stdout.strip()}"
|
||
)
|
||
|
||
@staticmethod
|
||
def _fingerprint(plugin: PluginSpec) -> str:
|
||
requirements = plugin.requirements_path.read_text(encoding="utf-8")
|
||
payload = {
|
||
"python_version": plugin.python_version,
|
||
"requirements": requirements,
|
||
}
|
||
return json.dumps(payload, ensure_ascii=True, sort_keys=True)
|
||
|
||
@staticmethod
|
||
def _load_state(state_path: Path) -> dict[str, Any]:
|
||
if not state_path.exists():
|
||
return {}
|
||
try:
|
||
data = json.loads(state_path.read_text(encoding="utf-8"))
|
||
except Exception:
|
||
return {}
|
||
return data if isinstance(data, dict) else {}
|
||
|
||
@staticmethod
|
||
def _write_state(state_path: Path, plugin: PluginSpec, fingerprint: str) -> None:
|
||
state_path.write_text(
|
||
json.dumps(
|
||
{
|
||
"plugin": plugin.name,
|
||
"python_version": plugin.python_version,
|
||
"fingerprint": fingerprint,
|
||
},
|
||
ensure_ascii=True,
|
||
indent=2,
|
||
sort_keys=True,
|
||
),
|
||
encoding="utf-8",
|
||
)
|
||
|
||
@staticmethod
|
||
def _matches_python_version(venv_dir: Path, version: str) -> bool:
|
||
pyvenv_cfg = venv_dir / "pyvenv.cfg"
|
||
if not pyvenv_cfg.exists():
|
||
return False
|
||
content = pyvenv_cfg.read_text(encoding="utf-8")
|
||
match = re.search(r"version\s*=\s*(\d+\.\d+)\.\d+", content, re.IGNORECASE)
|
||
return match is not None and match.group(1) == version
|
||
|
||
|
||
def load_plugin(plugin: PluginSpec) -> LoadedPlugin:
|
||
plugin_path = str(plugin.plugin_dir)
|
||
if plugin_path not in sys.path:
|
||
sys.path.insert(0, plugin_path)
|
||
|
||
instances: list[Any] = []
|
||
handlers: list[LoadedHandler] = []
|
||
shared_legacy_context = None
|
||
for component in plugin.manifest_data.get("components", []):
|
||
class_path = component.get("class")
|
||
if not isinstance(class_path, str) or ":" not in class_path:
|
||
continue
|
||
component_cls = import_string(class_path)
|
||
legacy_context = None
|
||
if _is_new_star_component(component_cls):
|
||
instance = component_cls()
|
||
else:
|
||
if shared_legacy_context is None:
|
||
# 旧版 StarManager 为同一插件复用一个 Context 实例。
|
||
shared_legacy_context = _create_legacy_context(
|
||
component_cls, plugin.name
|
||
)
|
||
legacy_context = shared_legacy_context
|
||
try:
|
||
instance = component_cls(legacy_context)
|
||
except TypeError:
|
||
instance = component_cls()
|
||
if getattr(instance, "context", None) is None:
|
||
setattr(instance, "context", legacy_context)
|
||
instances.append(instance)
|
||
for name in _iter_handler_names(instance):
|
||
bound = getattr(instance, name)
|
||
func = getattr(bound, "__func__", bound)
|
||
meta = get_handler_meta(func)
|
||
if meta is None or meta.trigger is None:
|
||
continue
|
||
handler_id = f"{plugin.name}:{instance.__class__.__module__}.{instance.__class__.__name__}.{name}"
|
||
handlers.append(
|
||
LoadedHandler(
|
||
descriptor=HandlerDescriptor(
|
||
id=handler_id,
|
||
trigger=meta.trigger,
|
||
priority=meta.priority,
|
||
permissions=meta.permissions.model_copy(deep=True),
|
||
),
|
||
callable=bound,
|
||
owner=instance,
|
||
legacy_context=legacy_context,
|
||
)
|
||
)
|
||
return LoadedPlugin(plugin=plugin, handlers=handlers, instances=instances)
|
||
|
||
|
||
def import_string(path: str) -> Any:
|
||
module_name, attr = path.split(":", 1)
|
||
module = import_module(module_name)
|
||
return getattr(module, attr)
|