2026-07-07 16:20:40 +08:00
|
|
|
|
"""清单加载器。
|
|
|
|
|
|
|
|
|
|
|
|
提供 ``load_manifest_from_dir`` 工具函数,从插件目录读取 ``manifest.json``
|
|
|
|
|
|
并解析为 ``ChannelManifest``。合并原 ``PluginLoader._parseManifestForInstall``
|
|
|
|
|
|
与 ``PluginLifecycleManager._parse_manifest`` 的重复解析逻辑,作为 manifest
|
|
|
|
|
|
单真相源的统一解析入口(F-03)。
|
|
|
|
|
|
|
|
|
|
|
|
异常处理保留原始错误上下文(INV-7),使用 ``raise ... from e`` 保留 Python
|
|
|
|
|
|
异常链。必填字段缺失或值非法时抛 ``ValidationError``,不使用原生异常。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import dataclasses
|
|
|
|
|
|
import json
|
|
|
|
|
|
import os
|
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
|
|
from yuxi.channels.contract.dtos.capability import ChannelCapabilities
|
|
|
|
|
|
from yuxi.channels.contract.dtos.channel import ChannelType
|
|
|
|
|
|
from yuxi.channels.contract.dtos.config import ConfigField, ConfigScope
|
|
|
|
|
|
from yuxi.channels.contract.dtos.plugin import EnvVar
|
|
|
|
|
|
from yuxi.channels.contract.errors.client import ValidationError
|
|
|
|
|
|
from yuxi.channels.contract.plugin.manifest import (
|
|
|
|
|
|
AcquireMethod,
|
|
|
|
|
|
ChannelManifest,
|
|
|
|
|
|
CredentialStrategy,
|
|
|
|
|
|
CredentialType,
|
|
|
|
|
|
FailurePolicy,
|
|
|
|
|
|
PluginDependency,
|
|
|
|
|
|
ResourceQuota,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
__all__ = ["load_manifest_from_dir"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def load_manifest_from_dir(plugin_dir: str) -> ChannelManifest:
|
|
|
|
|
|
"""从插件目录加载 ``manifest.json`` 并解析为 ``ChannelManifest``。
|
|
|
|
|
|
|
|
|
|
|
|
读取 ``plugin_dir/manifest.json``,按字段映射构造 ``ChannelManifest``,
|
|
|
|
|
|
处理枚举转换与嵌套类型解析。必填字段缺失或值非法时抛 ``ValidationError``,
|
|
|
|
|
|
保留原始错误上下文(INV-7)。
|
|
|
|
|
|
|
|
|
|
|
|
参数:
|
|
|
|
|
|
plugin_dir: 插件目录路径,``manifest.json`` 位于该目录下。
|
|
|
|
|
|
|
|
|
|
|
|
返回:
|
|
|
|
|
|
解析后的渠道清单。
|
|
|
|
|
|
|
|
|
|
|
|
抛出:
|
|
|
|
|
|
ValidationError: JSON 解析失败、必填字段缺失或字段值非法。
|
|
|
|
|
|
"""
|
|
|
|
|
|
manifest_path = os.path.join(plugin_dir, "manifest.json")
|
|
|
|
|
|
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}",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 必填字段校验(FR21-P0-1: capabilities 与 config_schema 为必填;
|
|
|
|
|
|
# LFE-P0-002: provides/lifecycle/compatibility/failure_policy 为必填)
|
|
|
|
|
|
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(ChannelType 为 str 子类,校验非空字符串)
|
|
|
|
|
|
channel_type_raw = data["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)
|
|
|
|
|
|
|
|
|
|
|
|
# 解析 failure_policy 枚举
|
|
|
|
|
|
failure_policy_raw = data["failure_policy"]
|
|
|
|
|
|
try:
|
|
|
|
|
|
failure_policy = FailurePolicy(failure_policy_raw)
|
|
|
|
|
|
except ValueError as e:
|
|
|
|
|
|
raise ValidationError(
|
|
|
|
|
|
field="failure_policy",
|
|
|
|
|
|
message=f"未知的失败策略: {failure_policy_raw}",
|
|
|
|
|
|
) from e
|
|
|
|
|
|
|
|
|
|
|
|
capabilities = _parse_capabilities(data.get("capabilities"))
|
|
|
|
|
|
config_schema = tuple(_parse_config_field(cf) for cf in data.get("config_schema", []))
|
|
|
|
|
|
capability_requirements = _parse_capability_requirements(
|
|
|
|
|
|
data.get("capability_requirements"),
|
|
|
|
|
|
{f.name for f in dataclasses.fields(ChannelCapabilities)},
|
|
|
|
|
|
)
|
|
|
|
|
|
depends = tuple(_parse_dependency(dep) for dep in data.get("depends", []))
|
|
|
|
|
|
resource_quota = _parse_resource_quota(data.get("resource_quota"))
|
|
|
|
|
|
env_vars = tuple(_parse_env_var(ev) for ev in data.get("env_vars", []))
|
|
|
|
|
|
|
|
|
|
|
|
# 解析 credential_strategy(缺失时为 None,下游按 static/manual 兜底)
|
|
|
|
|
|
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
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 校验 compatibility 类型(dict 或 None,避免字符串/列表静默透传违反类型契约)
|
|
|
|
|
|
compatibility_raw = data["compatibility"]
|
|
|
|
|
|
if compatibility_raw is not None and not isinstance(compatibility_raw, dict):
|
|
|
|
|
|
raise ValidationError(
|
|
|
|
|
|
field="compatibility",
|
|
|
|
|
|
message=(
|
|
|
|
|
|
f"compatibility 必须为对象或 null,实际类型为 {type(compatibility_raw).__name__}: {compatibility_raw!r}"
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 解析 max_message_length(捕获 int() 转换异常,封装为 ValidationError,INV-7)
|
|
|
|
|
|
max_message_length_raw = data.get("max_message_length", 4096)
|
|
|
|
|
|
try:
|
|
|
|
|
|
max_message_length = int(max_message_length_raw)
|
|
|
|
|
|
except (TypeError, ValueError) as e:
|
|
|
|
|
|
raise ValidationError(
|
|
|
|
|
|
field="max_message_length",
|
|
|
|
|
|
message=(f"max_message_length 必须为整数,实际值为 {max_message_length_raw!r}"),
|
|
|
|
|
|
) from e
|
|
|
|
|
|
|
2026-07-07 18:54:33 +08:00
|
|
|
|
# 解析 transport_mode(Task 11.3):缺失时默认 "both",非法值抛 ValidationError
|
|
|
|
|
|
transport_mode_raw = data.get("transport_mode", "both")
|
|
|
|
|
|
if transport_mode_raw not in ("pull", "stream", "both"):
|
|
|
|
|
|
raise ValidationError(
|
|
|
|
|
|
field="transport_mode",
|
|
|
|
|
|
message=(f"transport_mode 必须为 pull/stream/both,实际值为 {transport_mode_raw!r}"),
|
|
|
|
|
|
)
|
|
|
|
|
|
transport_mode = transport_mode_raw
|
|
|
|
|
|
|
2026-07-07 16:20:40 +08:00
|
|
|
|
return ChannelManifest(
|
|
|
|
|
|
id=data["id"],
|
|
|
|
|
|
name=data["name"],
|
|
|
|
|
|
version=data["version"],
|
|
|
|
|
|
channel_type=channel_type,
|
|
|
|
|
|
provides=_to_tuple(data["provides"], "provides"),
|
|
|
|
|
|
entry_module=data["entry_module"],
|
|
|
|
|
|
capabilities=capabilities,
|
|
|
|
|
|
config_schema=config_schema,
|
|
|
|
|
|
capability_requirements=capability_requirements,
|
|
|
|
|
|
depends=depends,
|
|
|
|
|
|
lifecycle=_to_tuple(data["lifecycle"], "lifecycle"),
|
|
|
|
|
|
compatibility=compatibility_raw,
|
|
|
|
|
|
failure_policy=failure_policy,
|
|
|
|
|
|
resource_quota=resource_quota,
|
|
|
|
|
|
accessible_ports=_to_tuple(data.get("accessible_ports", []), "accessible_ports"),
|
|
|
|
|
|
injectable_pipelines=_to_tuple(data.get("injectable_pipelines", []), "injectable_pipelines"),
|
|
|
|
|
|
skills=_to_tuple(data.get("skills", []), "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=max_message_length,
|
|
|
|
|
|
supports_credential_cloning=bool(data.get("supports_credential_cloning", False)),
|
|
|
|
|
|
icon=data.get("icon"),
|
2026-07-07 18:54:33 +08:00
|
|
|
|
transport_mode=transport_mode,
|
2026-07-07 16:20:40 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_capability_requirements(
|
|
|
|
|
|
data: list[Any] | None,
|
|
|
|
|
|
valid_fields: set[str],
|
|
|
|
|
|
) -> tuple[tuple[str, tuple[str, ...]], ...]:
|
|
|
|
|
|
"""解析能力运行时依赖声明。
|
|
|
|
|
|
|
|
|
|
|
|
每项格式为 ``[能力字段名, [依赖配置键清单]]``,校验能力字段名必须是
|
|
|
|
|
|
``ChannelCapabilities`` 的合法字段,依赖键必须是字符串数组。
|
|
|
|
|
|
非法结构抛 ``ValidationError`` 并保留字段名上下文(INV-7)。
|
|
|
|
|
|
"""
|
|
|
|
|
|
if data is None:
|
|
|
|
|
|
return ()
|
|
|
|
|
|
if not isinstance(data, list):
|
|
|
|
|
|
raise ValidationError(
|
|
|
|
|
|
field="capability_requirements",
|
|
|
|
|
|
message="capability_requirements 必须为数组",
|
|
|
|
|
|
)
|
|
|
|
|
|
result: list[tuple[str, tuple[str, ...]]] = []
|
|
|
|
|
|
for idx, item in enumerate(data):
|
|
|
|
|
|
if not isinstance(item, (list, tuple)) or len(item) != 2:
|
|
|
|
|
|
raise ValidationError(
|
|
|
|
|
|
field="capability_requirements",
|
|
|
|
|
|
message=f"第 {idx} 项必须为 [capability, [keys]] 结构: {item!r}",
|
|
|
|
|
|
)
|
|
|
|
|
|
field_name, keys = item
|
|
|
|
|
|
if field_name not in valid_fields:
|
|
|
|
|
|
raise ValidationError(
|
|
|
|
|
|
field="capability_requirements",
|
|
|
|
|
|
message=f"第 {idx} 项引用未知能力字段: {field_name}",
|
|
|
|
|
|
)
|
|
|
|
|
|
if not isinstance(keys, list) or not all(isinstance(k, str) for k in keys):
|
|
|
|
|
|
raise ValidationError(
|
|
|
|
|
|
field="capability_requirements",
|
|
|
|
|
|
message=f"能力 {field_name} 的依赖项必须为字符串数组: {keys!r}",
|
|
|
|
|
|
)
|
|
|
|
|
|
result.append((field_name, tuple(keys)))
|
|
|
|
|
|
return tuple(result)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_capabilities(
|
|
|
|
|
|
data: dict[str, Any] | None,
|
|
|
|
|
|
) -> ChannelCapabilities | None:
|
|
|
|
|
|
"""解析能力集合 JSON 为 ``ChannelCapabilities``。
|
|
|
|
|
|
|
|
|
|
|
|
完整映射 ``ChannelCapabilities`` 的 26 个 bool 能力声明字段
|
|
|
|
|
|
(基础消息能力 + 消息操作子能力 + 媒体能力 + 扩展能力 + 生命周期
|
|
|
|
|
|
与协作能力),未声明的字段默认 ``False``。``data`` 为 ``None`` 时
|
|
|
|
|
|
返回 ``None``。
|
|
|
|
|
|
"""
|
|
|
|
|
|
if data is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
return ChannelCapabilities(
|
|
|
|
|
|
rich_message=data.get("rich_message", False),
|
|
|
|
|
|
streaming=data.get("streaming", False),
|
|
|
|
|
|
typing_indicator=data.get("typing_indicator", False),
|
|
|
|
|
|
message_edit=data.get("message_edit", False),
|
|
|
|
|
|
message_recall=data.get("message_recall", False),
|
|
|
|
|
|
supports_reaction=data.get("supports_reaction", False),
|
|
|
|
|
|
supports_pin=data.get("supports_pin", False),
|
|
|
|
|
|
supports_card_update=data.get("supports_card_update", False),
|
|
|
|
|
|
supports_card_update_streaming=data.get("supports_card_update_streaming", False),
|
|
|
|
|
|
supports_image_inbound=data.get("supports_image_inbound", False),
|
|
|
|
|
|
supports_video_inbound=data.get("supports_video_inbound", False),
|
|
|
|
|
|
supports_image_outbound=data.get("supports_image_outbound", False),
|
|
|
|
|
|
supports_video_outbound=data.get("supports_video_outbound", False),
|
|
|
|
|
|
mention=data.get("mention", False),
|
|
|
|
|
|
command=data.get("command", False),
|
|
|
|
|
|
directory=data.get("directory", False),
|
|
|
|
|
|
doctor=data.get("doctor", False),
|
|
|
|
|
|
whitelist=data.get("whitelist", False),
|
|
|
|
|
|
wizard=data.get("wizard", False),
|
|
|
|
|
|
tools=data.get("tools", False),
|
|
|
|
|
|
status=data.get("status", False),
|
|
|
|
|
|
probeable=data.get("probeable", False),
|
|
|
|
|
|
identity_resolver=data.get("identity_resolver", False),
|
|
|
|
|
|
lifecycle=data.get("lifecycle", False),
|
|
|
|
|
|
supports_qr_login=data.get("supports_qr_login", False),
|
|
|
|
|
|
agent_collaboration=data.get("agent_collaboration", False),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_config_field(data: dict[str, Any]) -> ConfigField:
|
|
|
|
|
|
"""解析配置字段 JSON 为 ``ConfigField``。
|
|
|
|
|
|
|
|
|
|
|
|
解析 ``sensitive`` 顶层字段(F-01),缺失时默认 ``False``(向后兼容)。
|
|
|
|
|
|
旧声明 ``constraints={"sensitive": true}`` 不再被消费,应迁移至顶层
|
|
|
|
|
|
``"sensitive": true`` 字段(与 ``ConfigField.sensitive`` 对齐)。
|
|
|
|
|
|
|
|
|
|
|
|
解析 ``scope`` 顶层字段(F-02),缺失或为 ``None`` 时默认 ``None``(向后
|
|
|
|
|
|
兼容)。值为字符串时通过 ``ConfigScope`` 枚举转换,非法值抛
|
|
|
|
|
|
``ValidationError`` 保留原始错误上下文(INV-7)。
|
|
|
|
|
|
"""
|
|
|
|
|
|
scope_raw = data.get("scope")
|
|
|
|
|
|
scope: ConfigScope | None = None
|
|
|
|
|
|
if scope_raw is not None:
|
|
|
|
|
|
try:
|
|
|
|
|
|
scope = ConfigScope(scope_raw)
|
|
|
|
|
|
except ValueError as e:
|
|
|
|
|
|
raise ValidationError(
|
|
|
|
|
|
field="scope",
|
|
|
|
|
|
message=f"未知的配置作用域: {scope_raw}",
|
|
|
|
|
|
) from e
|
|
|
|
|
|
return ConfigField(
|
|
|
|
|
|
key=data["key"],
|
|
|
|
|
|
type=data["type"],
|
|
|
|
|
|
required=data.get("required", False),
|
|
|
|
|
|
default=data.get("default"),
|
|
|
|
|
|
hot_reloadable=data.get("hot_reloadable", True),
|
|
|
|
|
|
sensitive=bool(data.get("sensitive", False)),
|
|
|
|
|
|
scope=scope,
|
|
|
|
|
|
constraints=data.get("constraints"),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_dependency(data: dict[str, Any]) -> PluginDependency:
|
|
|
|
|
|
"""解析插件依赖 JSON 为 ``PluginDependency``。"""
|
|
|
|
|
|
return PluginDependency(
|
|
|
|
|
|
plugin_id=data["plugin_id"],
|
|
|
|
|
|
version_range=data["version_range"],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_resource_quota(
|
|
|
|
|
|
data: dict[str, Any] | None,
|
|
|
|
|
|
) -> ResourceQuota | None:
|
|
|
|
|
|
"""解析资源配额 JSON 为 ``ResourceQuota``。"""
|
|
|
|
|
|
if data is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
return ResourceQuota(
|
|
|
|
|
|
max_cpu=data.get("max_cpu"),
|
|
|
|
|
|
max_memory=data.get("max_memory"),
|
|
|
|
|
|
max_connections=data.get("max_connections"),
|
|
|
|
|
|
max_calls_per_sec=data.get("max_calls_per_sec"),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_env_var(data: dict[str, Any]) -> EnvVar:
|
|
|
|
|
|
"""解析环境变量 JSON 为 ``EnvVar``。"""
|
|
|
|
|
|
return EnvVar(
|
|
|
|
|
|
name=data["name"],
|
|
|
|
|
|
description=data.get("description", ""),
|
|
|
|
|
|
required=data.get("required", True),
|
|
|
|
|
|
default=data.get("default"),
|
|
|
|
|
|
sensitive=data.get("sensitive", False),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _to_tuple(value: Any, field_name: str) -> tuple:
|
|
|
|
|
|
"""将清单数组字段转换为 tuple,校验 list 类型。
|
|
|
|
|
|
|
|
|
|
|
|
若 ``value`` 非 list,抛 ``ValidationError``,避免字符串被静默拆分为
|
|
|
|
|
|
字符元组(如 ``tuple("init")`` → ``('i','n','i','t')``)导致下游
|
|
|
|
|
|
``verifyLifecycleConsistency`` 报出困惑错误。
|
|
|
|
|
|
|
|
|
|
|
|
参数:
|
|
|
|
|
|
value: manifest 中的字段值。
|
|
|
|
|
|
field_name: 字段名,用于错误信息定位。
|
|
|
|
|
|
|
|
|
|
|
|
返回:
|
|
|
|
|
|
转换后的 tuple。
|
|
|
|
|
|
|
|
|
|
|
|
抛出:
|
|
|
|
|
|
ValidationError: ``value`` 非 list 类型。
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not isinstance(value, list):
|
|
|
|
|
|
raise ValidationError(
|
|
|
|
|
|
field=field_name,
|
|
|
|
|
|
message=(f"{field_name} 必须为数组,实际类型为 {type(value).__name__}: {value!r}"),
|
|
|
|
|
|
)
|
|
|
|
|
|
return tuple(value)
|