新增六边形架构核心代码包,包含: 1. 协议适配器层:HTTP/SMTP/IMAP/SSH/GRPC等多协议适配器实现 2. 认证插件体系:基础认证、API密钥、HMAC等多类型认证插件 3. 执行编排框架:工具执行器、上下文构建、运行时治理组件 4. 用例端口与DTO:定义领域服务端口与数据传输对象 5. 厂商集成包框架:支持第三方系统集成扩展 6. 基础设施装配层:实现依赖注入与服务装配 所有代码遵循六边形架构设计原则,实现端口与适配器解耦,支持动态扩展与自动发现。
339 lines
13 KiB
Python
339 lines
13 KiB
Python
"""Microsoft 365 discover / preview_tools / create_tools handler 实现。
|
||
|
||
handler 签名遵循 ``OperationHandler`` 协议:
|
||
``(source_type: str, system_config: dict[str, Any]) -> Awaitable[Any]``。
|
||
|
||
handler 内部职责(见设计文档 §3.7 / §4.2 / §6.5):
|
||
|
||
1. 从 ``system_config`` 提取 ``cloud`` / ``api_version`` / ``auth_type`` /
|
||
``_resolved_token`` 等。
|
||
2. 推导 ``auth_mode``(``application`` / ``delegated``)。
|
||
3. 构建 ``auth_headers``(token 由 use_cases 层注入到
|
||
``system_config["_resolved_token"]``)。
|
||
4. 构造带 429 退避的 ``request_fn``,可选调用 ``GET /v1.0/$metadata`` 补全字段类型。
|
||
5. 调用 ``Microsoft365ConfigGenerator`` 生成 Tool 配置草稿。
|
||
|
||
429 退避分层(见设计文档 §6.5):
|
||
|
||
- **L1: handler 内部退避** — 本模块 ``_request_with_retry`` 实现(解析 Retry-After)。
|
||
- **L2: 工具运行时退避** — 通过工具 ``retry_policy.retry_status_codes: [429]`` 配置,
|
||
由框架 executor 基于 tenacity 重试。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
from collections.abc import Awaitable, Callable
|
||
from typing import TYPE_CHECKING, Any
|
||
|
||
from yuxi.external_systems.exceptions import (
|
||
AuthError,
|
||
DomainValidationError,
|
||
ExecutionError,
|
||
RateLimitExceededError,
|
||
)
|
||
from yuxi.external_systems.integrations.microsoft365.cloud_endpoints import (
|
||
resolve_graph_host,
|
||
validate_cloud,
|
||
)
|
||
from yuxi.external_systems.integrations.microsoft365.error_extractor import (
|
||
map_microsoft365_error,
|
||
)
|
||
from yuxi.external_systems.integrations.microsoft365.generators import (
|
||
Microsoft365ConfigGenerator,
|
||
)
|
||
from yuxi.external_systems.integrations.microsoft365.resource_catalog import (
|
||
RESOURCE_CATALOG,
|
||
TEAMS_RESOURCE_CATALOG,
|
||
TEAMS_RESOURCES,
|
||
)
|
||
from yuxi.external_systems.integrations.microsoft365.teams_permissions import (
|
||
get_teams_permissions,
|
||
)
|
||
from yuxi.external_systems.integrations.schemas import GeneratedToolsDraft
|
||
|
||
if TYPE_CHECKING:
|
||
import httpx
|
||
|
||
# discover/preview/create 阶段 429 退避参数
|
||
_RETRY_MAX_ATTEMPTS = 5
|
||
_RETRY_BASE_DELAY = 1.0
|
||
|
||
# Graph 通用请求头(除 Authorization 外)
|
||
_GRAPH_HEADERS: dict[str, str] = {
|
||
"Accept": "application/json",
|
||
"Content-Type": "application/json; charset=utf-8",
|
||
}
|
||
|
||
# auth_type -> auth_mode 映射
|
||
_AUTH_TYPE_TO_MODE: dict[str, str] = {
|
||
"microsoft365_client_credentials": "application",
|
||
"microsoft365_authorization_code": "delegated",
|
||
}
|
||
|
||
|
||
def _resolve_auth_mode(system_config: dict[str, Any]) -> str:
|
||
"""从 ``system_config["auth_type"]`` 推导 ``auth_mode``。
|
||
|
||
- ``microsoft365_client_credentials`` → ``application``
|
||
- ``microsoft365_authorization_code`` → ``delegated``
|
||
"""
|
||
auth_type = system_config.get("auth_type", "")
|
||
if auth_type not in _AUTH_TYPE_TO_MODE:
|
||
raise DomainValidationError(
|
||
f"microsoft365 不支持的 auth_type: {auth_type},仅允许 {list(_AUTH_TYPE_TO_MODE.keys())}"
|
||
)
|
||
return _AUTH_TYPE_TO_MODE[auth_type]
|
||
|
||
|
||
def _resolve_cloud(system_config: dict[str, Any]) -> str:
|
||
"""从 ``system_config["connection_config"]["cloud"]`` 读取并校验 cloud。"""
|
||
connection_config = system_config.get("connection_config") or {}
|
||
cloud = connection_config.get("cloud", "global")
|
||
return validate_cloud(cloud)
|
||
|
||
|
||
def _resolve_api_version(system_config: dict[str, Any]) -> str:
|
||
"""从 ``system_config["connection_config"]["api_version"]`` 读取,默认 ``v1.0``。"""
|
||
connection_config = system_config.get("connection_config") or {}
|
||
return str(connection_config.get("api_version", "v1.0"))
|
||
|
||
|
||
def _resolve_base_url(system_config: dict[str, Any]) -> str:
|
||
"""从 ``system_config["connection_config"]["base_url"]`` 读取,缺省由 cloud 推导。"""
|
||
connection_config = system_config.get("connection_config") or {}
|
||
base_url = connection_config.get("base_url")
|
||
if base_url:
|
||
return str(base_url).rstrip("/")
|
||
cloud = _resolve_cloud(system_config)
|
||
return resolve_graph_host(cloud)
|
||
|
||
|
||
def _resolve_resources(system_config: dict[str, Any]) -> list[str]:
|
||
"""从 ``discovery_options.resources`` 读取,缺省返回全部 P0 + Teams 资源维度。"""
|
||
discovery_options = system_config.get("discovery_options") or {}
|
||
resources = discovery_options.get("resources")
|
||
if resources:
|
||
return list(resources)
|
||
return [item["resource"] for item in RESOURCE_CATALOG] + [item["resource"] for item in TEAMS_RESOURCE_CATALOG]
|
||
|
||
|
||
def _build_auth_headers(system_config: dict[str, Any]) -> dict[str, str]:
|
||
"""从 ``system_config["_resolved_token"]`` 提取 token,构建 Graph 请求头。
|
||
|
||
handler 不自行换取 token——token 获取/刷新由 use_cases 层通过
|
||
``TokenManager.get_token`` 预处理后注入到 ``system_config["_resolved_token"]``。
|
||
若 ``_resolved_token`` 缺失,抛 ``AuthError``(与 dynamics365 一致)。
|
||
"""
|
||
token_info = system_config.get("_resolved_token")
|
||
if not isinstance(token_info, dict) or not token_info.get("access_token"):
|
||
raise AuthError("microsoft365: system_config 缺少 _resolved_token,use_cases 层未注入 token")
|
||
access_token = token_info["access_token"]
|
||
headers = dict(_GRAPH_HEADERS)
|
||
headers["Authorization"] = f"Bearer {access_token}"
|
||
return headers
|
||
|
||
|
||
async def _request_with_retry(
|
||
method: str,
|
||
url: str,
|
||
headers: dict[str, str],
|
||
*,
|
||
max_retries: int = _RETRY_MAX_ATTEMPTS,
|
||
base_delay: float = _RETRY_BASE_DELAY,
|
||
) -> httpx.Response:
|
||
"""handler 内部 HTTP 调用的 429 退避(仅用于 discover/preview/create 阶段)。
|
||
|
||
优先使用响应头 ``Retry-After``,否则指数退避(``base_delay * 2 ** attempt``)。
|
||
运行时工具执行的 429 退避由框架 executor + 工具 ``retry_policy`` 处理。
|
||
|
||
非 2xx 响应(非 429)通过 ``map_microsoft365_error`` 转换为
|
||
``ExternalSystemError`` 子类。网络层异常(超时 / 连接错误)转换为
|
||
``ExecutionError``。
|
||
"""
|
||
import httpx
|
||
|
||
try:
|
||
async with httpx.AsyncClient() as client:
|
||
last_response: httpx.Response | None = None
|
||
for attempt in range(max_retries + 1):
|
||
response = await client.request(method, url, headers=headers)
|
||
if response.status_code != 429:
|
||
last_response = response
|
||
break
|
||
if attempt == max_retries:
|
||
retry_after_header = response.headers.get("Retry-After", "60")
|
||
try:
|
||
retry_after = int(retry_after_header)
|
||
except ValueError:
|
||
retry_after = 60
|
||
raise RateLimitExceededError(
|
||
f"Microsoft Graph 限流,已重试 {max_retries} 次仍失败: {url}",
|
||
retry_after=retry_after,
|
||
limit_type="qps",
|
||
)
|
||
# 优先使用 Retry-After 头,否则指数退避
|
||
retry_after_header = response.headers.get("Retry-After")
|
||
if retry_after_header:
|
||
try:
|
||
delay = float(retry_after_header)
|
||
except ValueError:
|
||
delay = base_delay * (2**attempt)
|
||
else:
|
||
delay = base_delay * (2**attempt)
|
||
await asyncio.sleep(delay)
|
||
except httpx.RequestError as exc:
|
||
raise ExecutionError(f"Microsoft Graph 网络请求失败: {exc}") from exc
|
||
|
||
assert last_response is not None
|
||
if last_response.is_success:
|
||
return last_response
|
||
# 非 2xx:解析错误 body 并映射为 ExternalSystemError 子类
|
||
try:
|
||
error_body = last_response.json()
|
||
except Exception:
|
||
error_body = {"error": {"message": last_response.text}}
|
||
raise map_microsoft365_error(last_response.status_code, error_body)
|
||
|
||
|
||
def _make_request_fn() -> Callable[[str, str, dict[str, str]], Awaitable[httpx.Response]]:
|
||
"""构造带 429 退避的 request_fn,供元数据发现函数使用。"""
|
||
|
||
async def request_fn(method: str, url: str, headers: dict[str, str]) -> httpx.Response:
|
||
return await _request_with_retry(method, url, headers)
|
||
|
||
return request_fn
|
||
|
||
|
||
def _build_generator_payload(
|
||
system_config: dict[str, Any],
|
||
auth_headers: dict[str, str],
|
||
) -> dict[str, Any]:
|
||
"""从 system_config 构造生成器 payload(见 ``Microsoft365ConfigGenerator.generate``)。"""
|
||
return {
|
||
"auth_mode": _resolve_auth_mode(system_config),
|
||
"cloud": _resolve_cloud(system_config),
|
||
"api_version": _resolve_api_version(system_config),
|
||
"base_url": _resolve_base_url(system_config),
|
||
"auth_headers": auth_headers,
|
||
"auth_type": system_config.get("auth_type", "microsoft365_client_credentials"),
|
||
"auth_config": system_config.get("auth_config") or {},
|
||
"resources": _resolve_resources(system_config),
|
||
"request_fn": _make_request_fn(),
|
||
}
|
||
|
||
|
||
def _has_teams_resources(resources: list[str]) -> bool:
|
||
"""判断资源列表中是否包含 Teams 维度资源。"""
|
||
return any(resource in TEAMS_RESOURCES for resource in resources)
|
||
|
||
|
||
async def _verify_teams_permissions(
|
||
system_config: dict[str, Any],
|
||
auth_mode: str,
|
||
auth_headers: dict[str, str],
|
||
) -> None:
|
||
"""验证 Teams 权限已授予(见设计文档 §4.2 / §6.4)。
|
||
|
||
通过调用 Graph API 健康检查端点验证 Teams 权限:
|
||
- application 模式:``GET {base_url}/v1.0/teams?$top=1``
|
||
- delegated 模式:``GET {base_url}/v1.0/me/joinedTeams``
|
||
|
||
权限验证失败时由 ``_request_with_retry`` → ``map_microsoft365_error`` 抛出
|
||
``AccessDeniedError`` / ``AuthError``,**不静默标记 ``permission_missing``**——
|
||
权限缺失属于配置问题,应在 discover 阶段暴露而非运行时触发 403。
|
||
"""
|
||
base_url = _resolve_base_url(system_config)
|
||
api_version = _resolve_api_version(system_config)
|
||
if auth_mode == "application":
|
||
url = f"{base_url}/{api_version}/teams?$top=1"
|
||
else:
|
||
url = f"{base_url}/{api_version}/me/joinedTeams"
|
||
await _request_with_retry("GET", url, auth_headers)
|
||
|
||
|
||
async def discover_microsoft365(
|
||
source_type: str,
|
||
system_config: dict[str, Any],
|
||
) -> list[dict[str, Any]]:
|
||
"""discover handler:发现 Microsoft 365 可用资源(含 Teams 维度)。
|
||
|
||
handler 不自行换取 token,token 由 use_cases 层注入到
|
||
``system_config["_resolved_token"]["access_token"]``(见设计文档 §3.7)。
|
||
|
||
Returns:
|
||
资源摘要列表,每项含 ``resource`` / ``paths``(按 auth_mode 选择 ``/me``
|
||
或 ``/users/{id}`` 或 ``/teams/{id}``)/ ``required_permissions`` / ``methods``。
|
||
"""
|
||
auth_mode = _resolve_auth_mode(system_config)
|
||
resources = _resolve_resources(system_config)
|
||
auth_headers = _build_auth_headers(system_config)
|
||
|
||
# Teams 资源在发现范围内时,调用 Graph API 验证 Teams 权限已授予。
|
||
# 权限验证失败抛 AccessDeniedError / AuthError,不静默标记 permission_missing。
|
||
if _has_teams_resources(resources):
|
||
await _verify_teams_permissions(system_config, auth_mode, auth_headers)
|
||
|
||
results: list[dict[str, Any]] = []
|
||
for resource in resources:
|
||
# M365 通用资源维度
|
||
for item in RESOURCE_CATALOG:
|
||
if item["resource"] != resource:
|
||
continue
|
||
paths_entry = item.get("paths", {})
|
||
results.append(
|
||
{
|
||
"resource": resource,
|
||
"paths": paths_entry.get(auth_mode, ""),
|
||
"required_permissions": item.get("permissions", {}).get(auth_mode, []),
|
||
"methods": ["GET", "POST", "PATCH", "DELETE"],
|
||
}
|
||
)
|
||
break
|
||
# Teams 资源维度
|
||
if resource in TEAMS_RESOURCES:
|
||
for item in TEAMS_RESOURCE_CATALOG:
|
||
if item["resource"] != resource:
|
||
continue
|
||
paths_entry = item.get("paths", {})
|
||
results.append(
|
||
{
|
||
"resource": resource,
|
||
"paths": paths_entry.get(auth_mode, ""),
|
||
"required_permissions": get_teams_permissions(resource, auth_mode),
|
||
"methods": ["GET", "POST", "PATCH", "DELETE"],
|
||
}
|
||
)
|
||
break
|
||
return results
|
||
|
||
|
||
async def preview_microsoft365_tools(
|
||
source_type: str,
|
||
system_config: dict[str, Any],
|
||
) -> list[dict[str, Any]]:
|
||
"""preview_tools handler:预览将生成的 Tool 列表(不持久化)。
|
||
|
||
Returns:
|
||
工具预览列表,每项为 ``ExternalToolCreateInput`` 兼容的 dict。
|
||
"""
|
||
auth_headers = _build_auth_headers(system_config)
|
||
payload = _build_generator_payload(system_config, auth_headers)
|
||
generator = Microsoft365ConfigGenerator()
|
||
return await generator.generate(payload)
|
||
|
||
|
||
async def create_microsoft365_tools(
|
||
source_type: str,
|
||
system_config: dict[str, Any],
|
||
) -> GeneratedToolsDraft:
|
||
"""create_tools handler:生成 Tool 草稿,返回 ``GeneratedToolsDraft``(不直接持久化)。
|
||
|
||
持久化由 use_cases 层 ``integration_tool_service`` / ``import_service`` 统一完成。
|
||
"""
|
||
auth_headers = _build_auth_headers(system_config)
|
||
payload = _build_generator_payload(system_config, auth_headers)
|
||
generator = Microsoft365ConfigGenerator()
|
||
tool_configs = await generator.generate(payload)
|
||
return GeneratedToolsDraft(tool_configs=tool_configs, override_existing=False)
|