ForcePilot/backend/package/yuxi/channels/application/lifecycle/manifest_loader.py
Kris 1c6bed1403 feat: 完成微信公众号插件全链路修复与基础设施增强
本次提交包含多项核心改进:
1. 新增微信公众号插件拉取传输模式配置,完善manifest与manifest加载逻辑
2. 新增运行态状态机与幂等冲突错误体系,补充错误映射与领域错误导出
3. 优化出站与入站上下文,新增幂等键、流式中断标记等字段
4. 完善发件箱仓储与模型,新增失败条目查询、投递原子语义字段
5. 修复签名验证阶段异常捕获逻辑,防御性处理内置NotImplementedError
6. 新增出站预算释放方法,完善机器人循环预算管控
7. 优化出站管道格式阶段,新增消息长度校验逻辑
8. 完善出站打字指示器阶段,新增重复启动防御与状态同步
9. 重构出站标记失败阶段,按源状态分支处理状态转换
10. 新增入站幂等过滤阶段,修复入站路由阶段空指针问题
11. 优化出站恢复扫描器,修复状态机调用与聚合根重建逻辑
12. 完善微信公众号适配器,新增类型校验与异常包装
13. 修复数据库事务回滚逻辑,简化不必要的显式回滚操作
2026-07-07 18:54:33 +08:00

364 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
# 解析 transport_modeTask 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
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"),
transport_mode=transport_mode,
)
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)