本次提交包含多项核心改进: 1. 新增微信公众号插件拉取传输模式配置,完善manifest与manifest加载逻辑 2. 新增运行态状态机与幂等冲突错误体系,补充错误映射与领域错误导出 3. 优化出站与入站上下文,新增幂等键、流式中断标记等字段 4. 完善发件箱仓储与模型,新增失败条目查询、投递原子语义字段 5. 修复签名验证阶段异常捕获逻辑,防御性处理内置NotImplementedError 6. 新增出站预算释放方法,完善机器人循环预算管控 7. 优化出站管道格式阶段,新增消息长度校验逻辑 8. 完善出站打字指示器阶段,新增重复启动防御与状态同步 9. 重构出站标记失败阶段,按源状态分支处理状态转换 10. 新增入站幂等过滤阶段,修复入站路由阶段空指针问题 11. 优化出站恢复扫描器,修复状态机调用与聚合根重建逻辑 12. 完善微信公众号适配器,新增类型校验与异常包装 13. 修复数据库事务回滚逻辑,简化不必要的显式回滚操作
364 lines
14 KiB
Python
364 lines
14 KiB
Python
"""清单加载器。
|
||
|
||
提供 ``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
|
||
|
||
# 解析 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
|
||
|
||
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)
|