"""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)