ForcePilot/backend/package/yuxi/channels/application/lifecycle/plugin_loader.py
Kris 8eead29de0 refactor: 批量清理冗余空行,优化部分枚举使用方式
1.  移除所有适配器文件中多余的空导入行
2.  调整ValidationError继承,移除不必要的ValueError继承
3.  修正多处ChannelType使用方式,从.value改为直接使用枚举实例
4.  优化飞书插件部分硬编码渠道类型为枚举实例
5.  更新wechat_ilink插件清单与适配器配置
6.  新增飞书目录适配器缓存清理支持判断与iLink生命周期适配器凭据轮换支持判断
7.  优化配置处理器历史查询逻辑,区分键不存在与无历史记录场景
2026-07-04 00:14:56 +08:00

532 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""插件加载器。
通过 ``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)),
)