1. 移除所有适配器文件中多余的空导入行 2. 调整ValidationError继承,移除不必要的ValueError继承 3. 修正多处ChannelType使用方式,从.value改为直接使用枚举实例 4. 优化飞书插件部分硬编码渠道类型为枚举实例 5. 更新wechat_ilink插件清单与适配器配置 6. 新增飞书目录适配器缓存清理支持判断与iLink生命周期适配器凭据轮换支持判断 7. 优化配置处理器历史查询逻辑,区分键不存在与无历史记录场景
532 lines
21 KiB
Python
532 lines
21 KiB
Python
"""插件加载器。
|
||
|
||
通过 ``importlib`` 动态导入插件模块,调用 ``CHANNEL_ENTRY`` 入口函数,返回
|
||
``PluginManifest``。插件通过 ``CHANNEL_ENTRY`` 契约注册,不隐式全局注入
|
||
(FR-32)。
|
||
|
||
异常处理保留原始错误上下文(INV-7),使用 ``raise ... from e`` 保留 Python
|
||
异常链,同时将原始异常封装为 ``Error`` 作为结构化 ``cause``:
|
||
- 模块导入失败 → ``DependencyError``
|
||
- ``CHANNEL_ENTRY`` 缺失或不可调用 → ``ValidationError``
|
||
- ``CHANNEL_ENTRY`` 执行失败 → ``InternalError``
|
||
- 清单 ID 不一致 → ``ValidationError``
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import importlib
|
||
import json
|
||
import os
|
||
import shutil
|
||
|
||
from yuxi.channels.contract.errors import (
|
||
ConflictError,
|
||
DependencyError,
|
||
InternalError,
|
||
NotFoundError,
|
||
ValidationError,
|
||
)
|
||
from yuxi.channels.contract.errors.base import Error
|
||
from yuxi.channels.contract.plugin.entry import CHANNEL_ENTRY, PluginHost
|
||
from yuxi.channels.contract.plugin.manifest import (
|
||
ChannelManifest,
|
||
PluginManifest,
|
||
)
|
||
from yuxi.channels.contract.ports.driven.logger_port import LoggerPort
|
||
|
||
__all__ = ["PluginLoader"]
|
||
|
||
|
||
class PluginLoader:
|
||
"""插件加载器,``importlib`` 动态导入 + ``CHANNEL_ENTRY`` 调用。
|
||
|
||
FR-32。插件通过 ``CHANNEL_ENTRY`` 注册,不隐式全局注入。
|
||
|
||
加载流程:
|
||
1. ``importlib.import_module`` 导入 ``manifest.entry_module`` 指定的
|
||
插件模块。
|
||
2. 从模块获取 ``CHANNEL_ENTRY`` 入口函数,校验存在性与可调用性。
|
||
3. 调用入口函数,传入 ``PluginHost``,获取 ``PluginManifest``。
|
||
4. 校验返回清单的 ID 与声明清单的 ID 一致。
|
||
|
||
异常处理保留原始错误上下文(INV-7),使用 ``raise ... from e`` 保留
|
||
Python 异常链,同时将原始异常封装为 ``Error`` 作为结构化 ``cause``
|
||
(与 ``adapters`` 层既有模式一致):
|
||
- ``ImportError`` → ``DependencyError``(依赖模块导入失败)
|
||
- ``CHANNEL_ENTRY`` 缺失或不可调用 → ``ValidationError``
|
||
- ``CHANNEL_ENTRY`` 执行失败 → ``InternalError``
|
||
- 清单 ID 不一致 → ``ValidationError``
|
||
"""
|
||
|
||
def __init__(self, logger: LoggerPort, plugin_dir: str | None = None) -> None:
|
||
"""初始化插件加载器。
|
||
|
||
参数:
|
||
logger: 日志被驱动端口,用于记录加载过程中的调试与成功日志。
|
||
plugin_dir: 插件目录路径(P1 PLG-INSTALL/UNINSTALL 文件级操作
|
||
注入),用于 ``installFromSource`` 复制插件文件与 ``uninstall``
|
||
删除插件文件。为 ``None`` 时仅可执行 ``load``,文件级安装/卸载
|
||
抛 ``ValidationError``(未配置插件目录)。
|
||
"""
|
||
self._logger = logger
|
||
self._plugin_dir = plugin_dir
|
||
|
||
async def load(
|
||
self,
|
||
manifest: ChannelManifest,
|
||
host: PluginHost,
|
||
) -> PluginManifest:
|
||
"""加载插件,返回 ``PluginManifest``。
|
||
|
||
通过 ``importlib`` 动态导入 ``manifest.entry_module`` 指定的模块,
|
||
调用其 ``CHANNEL_ENTRY`` 入口函数,校验返回清单 ID 一致性后返回。
|
||
|
||
参数:
|
||
manifest: 渠道清单,提供插件入口模块路径与声明 ID。
|
||
host: 插件宿主,传入 ``CHANNEL_ENTRY`` 供插件获取端口与注册扩展点。
|
||
|
||
返回:
|
||
插件清单,包含渠道清单与扩展点声明。
|
||
|
||
抛出:
|
||
DependencyError: 插件模块导入失败(保留原始 ``ImportError`` 链)。
|
||
ValidationError: ``CHANNEL_ENTRY`` 缺失或不可调用,或清单 ID 不一致。
|
||
InternalError: ``CHANNEL_ENTRY`` 执行失败(保留原始异常链)。
|
||
"""
|
||
await self._logger.debug(
|
||
f"加载插件模块: entry_module={manifest.entry_module}",
|
||
plugin_id=manifest.id,
|
||
)
|
||
|
||
try:
|
||
module = importlib.import_module(manifest.entry_module)
|
||
except ImportError as e:
|
||
raise DependencyError(
|
||
manifest.entry_module,
|
||
Error(str(e)),
|
||
) from e
|
||
|
||
entry_fn: CHANNEL_ENTRY | None = getattr(module, "CHANNEL_ENTRY", None)
|
||
if entry_fn is None or not callable(entry_fn):
|
||
raise ValidationError(
|
||
"CHANNEL_ENTRY",
|
||
f"插件入口函数未找到或不可调用: {manifest.entry_module}",
|
||
)
|
||
|
||
try:
|
||
plugin_manifest = entry_fn(host)
|
||
except Exception as e:
|
||
raise InternalError(Error(str(e))) from e
|
||
|
||
if plugin_manifest.manifest.id != manifest.id:
|
||
raise ValidationError(
|
||
"manifest.id",
|
||
f"清单 ID 不匹配: 声明={manifest.id}, 实际={plugin_manifest.manifest.id}",
|
||
)
|
||
|
||
await self._logger.info(
|
||
f"插件加载成功: plugin_id={manifest.id}",
|
||
plugin_id=manifest.id,
|
||
entry_module=manifest.entry_module,
|
||
)
|
||
|
||
return plugin_manifest
|
||
|
||
async def installFromSource(
|
||
self,
|
||
source_type: str,
|
||
source: str,
|
||
version: str | None,
|
||
force: bool,
|
||
plugin_dir: str | None = None,
|
||
) -> PluginManifest:
|
||
"""从来源安装插件文件到插件目录,返回 ``PluginManifest``(PLG-INSTALL)。
|
||
|
||
按 ``source_type`` 分派安装策略:
|
||
- ``path``: 从本地路径复制插件目录到 ``plugin_dir`` 下的同名子目录。
|
||
- ``url`` / ``registry``: 暂不支持,抛 ``NotImplementedError``
|
||
(保留扩展点)。
|
||
|
||
复制完成后解析 ``manifest.json`` 构造 ``ChannelManifest``(不含
|
||
``adapters`` 等扩展点,需后续 ``load`` 调用 ``CHANNEL_ENTRY`` 获取完整
|
||
``PluginManifest``)。安装元数据(``installed_at`` / ``install_source``
|
||
/ ``install_version``)写入返回的 ``PluginManifest``。
|
||
|
||
参数:
|
||
source_type: 来源类型(``path`` / ``url`` / ``registry``)。
|
||
source: 来源路径或 URL(``path`` 时为本地插件目录绝对路径)。
|
||
version: 期望安装的版本号(可选,记录到 ``install_version``)。
|
||
force: 是否强制覆盖已存在插件目录。``False`` 时目标目录已存在
|
||
抛 ``ConflictError``。
|
||
plugin_dir: 覆盖默认插件目录(可选,默认使用 ``__init__`` 注入的
|
||
``self._plugin_dir``)。
|
||
|
||
返回:
|
||
安装后的 ``PluginManifest``,含 ``installed_at`` / ``install_source``
|
||
/ ``install_version`` 元数据,但 ``adapters`` 等扩展点字段为空元组
|
||
(需后续 ``load`` 调用 ``CHANNEL_ENTRY`` 填充)。
|
||
|
||
抛出:
|
||
ValidationError: ``source_type`` 非法 / ``source`` 为空 / 插件目录
|
||
未配置 / 本地来源路径不存在 / ``manifest.json`` 缺失或格式非法。
|
||
ConflictError: 目标插件目录已存在且 ``force=False``。
|
||
DependencyError: 文件系统操作失败(复制 / 删除)。
|
||
"""
|
||
target_dir = plugin_dir if plugin_dir is not None else self._plugin_dir
|
||
if not target_dir:
|
||
raise ValidationError(
|
||
"plugin_dir",
|
||
"plugin_dir is not configured for PluginLoader, cannot perform installFromSource",
|
||
)
|
||
if not source:
|
||
raise ValidationError("source", "source must not be empty")
|
||
if source_type not in ("path", "url", "registry"):
|
||
raise ValidationError(
|
||
"source_type",
|
||
f"unsupported source_type: {source_type}",
|
||
)
|
||
if source_type in ("url", "registry"):
|
||
raise NotImplementedError(
|
||
f"installFromSource source_type={source_type}",
|
||
)
|
||
|
||
# source_type == "path":本地目录复制安装
|
||
if not os.path.exists(source) or not os.path.isdir(source):
|
||
raise ValidationError(
|
||
"source",
|
||
f"source path does not exist or is not a directory: {source}",
|
||
)
|
||
source_manifest_path = os.path.join(source, "manifest.json")
|
||
if not os.path.exists(source_manifest_path):
|
||
raise ValidationError(
|
||
"manifest.json",
|
||
f"source manifest.json not found: {source_manifest_path}",
|
||
)
|
||
|
||
plugin_id = self._readPluginIdFromManifest(source_manifest_path)
|
||
target_plugin_dir = os.path.join(target_dir, plugin_id)
|
||
if os.path.exists(target_plugin_dir):
|
||
if not force:
|
||
await self._logger.warn(
|
||
f"插件目录已存在,未启用 force 覆盖: {target_plugin_dir}",
|
||
plugin_id=plugin_id,
|
||
target_plugin_dir=target_plugin_dir,
|
||
)
|
||
raise ConflictError(resource="plugin_dir")
|
||
shutil.rmtree(target_plugin_dir)
|
||
|
||
os.makedirs(target_dir, exist_ok=True)
|
||
try:
|
||
shutil.copytree(source, target_plugin_dir)
|
||
except OSError as e:
|
||
raise DependencyError(
|
||
"plugin_install",
|
||
Error(str(e)),
|
||
) from e
|
||
|
||
manifest = self._parseManifestForInstall(os.path.join(target_plugin_dir, "manifest.json"))
|
||
from datetime import UTC, datetime
|
||
|
||
plugin_manifest = PluginManifest(
|
||
manifest=manifest,
|
||
installed_at=datetime.now(UTC),
|
||
install_source=source,
|
||
install_version=version,
|
||
)
|
||
await self._logger.info(
|
||
f"插件安装成功: plugin_id={plugin_id}",
|
||
plugin_id=plugin_id,
|
||
source_type=source_type,
|
||
source=source,
|
||
)
|
||
return plugin_manifest
|
||
|
||
async def uninstall(
|
||
self,
|
||
plugin_id: str,
|
||
plugin_dir: str | None = None,
|
||
) -> tuple[str, ...]:
|
||
"""删除插件目录文件,返回删除的文件列表(PLG-UNINSTALL-FILE)。
|
||
|
||
定位 ``plugin_dir/{plugin_id}`` 目录,遍历其下所有文件(相对
|
||
``plugin_id`` 目录的相对路径),删除整个目录后返回文件列表供审计
|
||
追溯。
|
||
|
||
参数:
|
||
plugin_id: 插件 ID(同时为插件目录名)。
|
||
plugin_dir: 覆盖默认插件目录(可选,默认使用 ``__init__`` 注入的
|
||
``self._plugin_dir``)。
|
||
|
||
返回:
|
||
已删除的文件相对路径元组(相对 ``plugin_id`` 目录),按字典序
|
||
排序。
|
||
|
||
抛出:
|
||
ValidationError: 插件目录未配置。
|
||
NotFoundError: 插件目录不存在。
|
||
DependencyError: 文件系统删除失败。
|
||
"""
|
||
target_dir = plugin_dir if plugin_dir is not None else self._plugin_dir
|
||
if not target_dir:
|
||
raise ValidationError(
|
||
"plugin_dir",
|
||
"plugin_dir is not configured for PluginLoader, cannot perform uninstall",
|
||
)
|
||
plugin_path = os.path.join(target_dir, plugin_id)
|
||
if not os.path.exists(plugin_path):
|
||
raise NotFoundError(
|
||
resource="plugin_dir",
|
||
id=plugin_id,
|
||
)
|
||
if not os.path.isdir(plugin_path):
|
||
raise ValidationError(
|
||
"plugin_dir",
|
||
f"plugin path is not a directory: {plugin_path}",
|
||
)
|
||
|
||
removed_files: list[str] = []
|
||
for root, _dirs, files in os.walk(plugin_path):
|
||
for filename in files:
|
||
full_path = os.path.join(root, filename)
|
||
rel_path = os.path.relpath(full_path, plugin_path)
|
||
removed_files.append(rel_path)
|
||
removed_files.sort()
|
||
|
||
try:
|
||
shutil.rmtree(plugin_path)
|
||
except OSError as e:
|
||
raise DependencyError(
|
||
"plugin_uninstall",
|
||
Error(str(e)),
|
||
) from e
|
||
|
||
await self._logger.info(
|
||
f"插件卸载成功: plugin_id={plugin_id}",
|
||
plugin_id=plugin_id,
|
||
files_removed=len(removed_files),
|
||
)
|
||
return tuple(removed_files)
|
||
|
||
@staticmethod
|
||
def _readPluginIdFromManifest(manifest_path: str) -> str:
|
||
"""读取 ``manifest.json`` 顶层 ``id`` 字段,供安装目标目录命名。
|
||
|
||
参数:
|
||
manifest_path: ``manifest.json`` 文件绝对路径。
|
||
|
||
返回:
|
||
插件 ID 字符串。
|
||
|
||
抛出:
|
||
ValidationError: JSON 解析失败 / 根节点非对象 / 缺少 ``id`` 字段。
|
||
"""
|
||
try:
|
||
with open(manifest_path, encoding="utf-8") as f:
|
||
data = json.load(f)
|
||
except (OSError, json.JSONDecodeError) as e:
|
||
raise ValidationError(
|
||
field="manifest.json",
|
||
message=f"清单文件解析失败: {manifest_path}: {e}",
|
||
) from e
|
||
if not isinstance(data, dict) or "id" not in data:
|
||
raise ValidationError(
|
||
field="manifest.json",
|
||
message=f"清单缺少 id 字段: {manifest_path}",
|
||
)
|
||
plugin_id = data["id"]
|
||
if not isinstance(plugin_id, str) or not plugin_id:
|
||
raise ValidationError(
|
||
field="manifest.json",
|
||
message=f"清单 id 字段必须为非空字符串: {manifest_path}",
|
||
)
|
||
return plugin_id
|
||
|
||
def _parseManifestForInstall(self, manifest_path: str) -> ChannelManifest:
|
||
"""解析 ``manifest.json`` 为 ``ChannelManifest``(安装阶段轻量解析)。
|
||
|
||
复用 ``PluginLifecycleManager._parse_manifest`` 的同款字段映射逻辑,
|
||
但独立实现以避免与 ``PluginLifecycleManager`` 形成循环依赖。仅解析
|
||
``ChannelManifest`` 字段,不解析 ``PluginManifest`` 扩展点。
|
||
|
||
参数:
|
||
manifest_path: ``manifest.json`` 文件路径。
|
||
|
||
返回:
|
||
解析后的渠道清单。
|
||
|
||
抛出:
|
||
ValidationError: JSON 解析失败 / 必填字段缺失 / 字段值非法。
|
||
"""
|
||
from yuxi.channels.contract.dtos.capability import ChannelCapabilities
|
||
from yuxi.channels.contract.dtos.channel import ChannelType
|
||
from yuxi.channels.contract.dtos.config import ConfigField
|
||
from yuxi.channels.contract.dtos.plugin import EnvVar
|
||
from yuxi.channels.contract.plugin.manifest import (
|
||
AcquireMethod,
|
||
CredentialStrategy,
|
||
CredentialType,
|
||
FailurePolicy,
|
||
PluginDependency,
|
||
ResourceQuota,
|
||
)
|
||
|
||
try:
|
||
with open(manifest_path, encoding="utf-8") as f:
|
||
data = json.load(f)
|
||
except (OSError, json.JSONDecodeError) as e:
|
||
raise ValidationError(
|
||
field="manifest.json",
|
||
message=f"清单文件解析失败: {manifest_path}: {e}",
|
||
) from e
|
||
|
||
if not isinstance(data, dict):
|
||
raise ValidationError(
|
||
field="manifest.json",
|
||
message=f"清单根节点必须为对象: {manifest_path}",
|
||
)
|
||
|
||
required_fields = [
|
||
"id",
|
||
"name",
|
||
"version",
|
||
"channel_type",
|
||
"entry_module",
|
||
"capabilities",
|
||
"config_schema",
|
||
"provides",
|
||
"lifecycle",
|
||
"compatibility",
|
||
"failure_policy",
|
||
]
|
||
for field_name in required_fields:
|
||
if field_name not in data:
|
||
raise ValidationError(
|
||
field=field_name,
|
||
message=f"清单缺少必填字段: {field_name}",
|
||
)
|
||
|
||
channel_type_raw = data.get("channel_type")
|
||
if not isinstance(channel_type_raw, str) or not channel_type_raw:
|
||
raise ValidationError(
|
||
field="channel_type",
|
||
message="channel_type must be a non-empty string",
|
||
)
|
||
channel_type = ChannelType(channel_type_raw)
|
||
|
||
try:
|
||
failure_policy = FailurePolicy(data["failure_policy"])
|
||
except ValueError as e:
|
||
raise ValidationError(
|
||
field="failure_policy",
|
||
message=f"未知的失败策略: {data['failure_policy']}",
|
||
) from e
|
||
|
||
caps_data = data.get("capabilities") or {}
|
||
capabilities = ChannelCapabilities(
|
||
rich_message=caps_data.get("rich_message", False),
|
||
streaming=caps_data.get("streaming", False),
|
||
typing_indicator=caps_data.get("typing_indicator", False),
|
||
message_edit=caps_data.get("message_edit", False),
|
||
message_recall=caps_data.get("message_recall", False),
|
||
supports_reaction=caps_data.get("supports_reaction", False),
|
||
supports_pin=caps_data.get("supports_pin", False),
|
||
supports_card_update=caps_data.get("supports_card_update", False),
|
||
supports_card_update_streaming=caps_data.get("supports_card_update_streaming", False),
|
||
supports_image_inbound=caps_data.get("supports_image_inbound", False),
|
||
supports_video_inbound=caps_data.get("supports_video_inbound", False),
|
||
supports_image_outbound=caps_data.get("supports_image_outbound", False),
|
||
supports_video_outbound=caps_data.get("supports_video_outbound", False),
|
||
mention=caps_data.get("mention", False),
|
||
command=caps_data.get("command", False),
|
||
directory=caps_data.get("directory", False),
|
||
doctor=caps_data.get("doctor", False),
|
||
whitelist=caps_data.get("whitelist", False),
|
||
wizard=caps_data.get("wizard", False),
|
||
tools=caps_data.get("tools", False),
|
||
status=caps_data.get("status", False),
|
||
probeable=caps_data.get("probeable", False),
|
||
identity_resolver=caps_data.get("identity_resolver", False),
|
||
lifecycle=caps_data.get("lifecycle", False),
|
||
supports_qr_login=caps_data.get("supports_qr_login", False),
|
||
agent_collaboration=caps_data.get("agent_collaboration", False),
|
||
)
|
||
config_schema = tuple(
|
||
ConfigField(
|
||
key=cf["key"],
|
||
type=cf["type"],
|
||
required=cf.get("required", False),
|
||
default=cf.get("default"),
|
||
hot_reloadable=cf.get("hot_reloadable", True),
|
||
constraints=cf.get("constraints"),
|
||
)
|
||
for cf in data.get("config_schema", [])
|
||
)
|
||
depends = tuple(
|
||
PluginDependency(
|
||
plugin_id=dep["plugin_id"],
|
||
version_range=dep["version_range"],
|
||
)
|
||
for dep in data.get("depends", [])
|
||
)
|
||
quota_data = data.get("resource_quota")
|
||
resource_quota = (
|
||
ResourceQuota(
|
||
max_cpu=quota_data.get("max_cpu"),
|
||
max_memory=quota_data.get("max_memory"),
|
||
max_connections=quota_data.get("max_connections"),
|
||
max_calls_per_sec=quota_data.get("max_calls_per_sec"),
|
||
)
|
||
if quota_data
|
||
else None
|
||
)
|
||
env_vars = tuple(
|
||
EnvVar(
|
||
name=ev["name"],
|
||
description=ev["description"],
|
||
required=ev.get("required", True),
|
||
default=ev.get("default"),
|
||
sensitive=ev.get("sensitive", False),
|
||
)
|
||
for ev in data.get("env_vars", [])
|
||
)
|
||
|
||
cs_data = data.get("credential_strategy")
|
||
credential_strategy = (
|
||
CredentialStrategy(
|
||
type=CredentialType(cs_data["type"]),
|
||
acquire_method=AcquireMethod(cs_data["acquire_method"]),
|
||
required_fields=tuple(cs_data.get("required_fields", [])),
|
||
optional_fields=tuple(cs_data.get("optional_fields", [])),
|
||
supports_rotation=cs_data.get("supports_rotation", False),
|
||
supports_revocation=cs_data.get("supports_revocation", False),
|
||
)
|
||
if cs_data
|
||
else None
|
||
)
|
||
|
||
return ChannelManifest(
|
||
id=data["id"],
|
||
name=data["name"],
|
||
version=data["version"],
|
||
channel_type=channel_type,
|
||
provides=tuple(data["provides"]),
|
||
entry_module=data["entry_module"],
|
||
capabilities=capabilities,
|
||
config_schema=config_schema,
|
||
depends=depends,
|
||
lifecycle=tuple(data["lifecycle"]),
|
||
compatibility=data["compatibility"],
|
||
failure_policy=failure_policy,
|
||
resource_quota=resource_quota,
|
||
accessible_ports=tuple(data.get("accessible_ports", [])),
|
||
injectable_pipelines=tuple(data.get("injectable_pipelines", [])),
|
||
skills=tuple(data.get("skills", [])),
|
||
env_vars=env_vars,
|
||
critical=bool(data.get("critical", False)),
|
||
requires_dm_pairing=bool(data.get("requires_dm_pairing", True)),
|
||
requires_outbound_delivery=bool(data.get("requires_outbound_delivery", True)),
|
||
credential_strategy=credential_strategy,
|
||
max_message_length=int(data.get("max_message_length", 4096)),
|
||
supports_credential_cloning=bool(data.get("supports_credential_cloning", False)),
|
||
)
|