ForcePilot/backend/package/yuxi/channels/application/lifecycle/manifest_loader.py
Kris 53e067aa4b feat: 多渠道网关核心功能迭代
包含以下变更:
1. 重构微信WOC账户模型,移除固定DEFAULT_ACCOUNT
2. 新增路由绑定管理、目录搜索导出能力
3. 扩展消息查询与出站管理过滤条件
4. 新增SSE事件广播、敏感字段/配置作用域注册表
5. 新增审计日志与配对过期定时任务
6. 优化会话处理与参数校验逻辑
7. 修复sender_id校验与outbox序列化问题
2026-07-07 16:20:40 +08:00

354 lines
14 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.

"""清单加载器。
提供 ``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_typeChannelType 为 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() 转换异常,封装为 ValidationErrorINV-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
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"),
)
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)