"""Zendesk discover / preview_tools / create_tools handler 实现。 handler 签名遵循 ``OperationHandler`` 协议: ``(source_type: str, system_config: dict[str, Any]) -> Awaitable[Any]``。 handler 内部职责(见设计文档 §7.4): 1. 从 ``system_config`` 提取 ``base_url`` / ``auth_config`` / ``_resolved_token`` 等 2. 构建 ``auth_headers``(token 由 use_cases 层注入到 ``system_config["_resolved_token"]``) 3. 构造带 429 退避的 ``request_fn``,注入到生成器 payload 4. 调用 ``discover_zendesk_metadata`` 或 ``ZendeskConfigGenerator`` 429 退避分层(见设计文档 §5.6): - discover/preview/create 阶段:本模块 ``_request_with_retry`` 实现(解析 Retry-After) - 运行时工具执行:通过工具 ``retry_policy.retry_status_codes: [429, 502, 503, 504]`` 配置(见 generators.py) 认证头构建(见设计文档 §3.5): - OAuth2: ``system_config["_resolved_token"]["access_token"]`` → Bearer - basic (API Token): ``system_config["auth_config"]["username"]`` / ``["password"]`` → Basic - token 由 use_cases 层通过 token_manager.get_token 预处理后注入到 ``system_config["_resolved_token"]``,handler 不自行换取 token """ from __future__ import annotations import asyncio import base64 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.schemas import GeneratedToolsDraft from yuxi.external_systems.integrations.zendesk.error_extractor import ( map_zendesk_error, ) from yuxi.external_systems.integrations.zendesk.generators import ( ZendeskConfigGenerator, ) from yuxi.external_systems.integrations.zendesk.metadata import ( resolve_oauth_endpoints, ) from yuxi.external_systems.integrations.zendesk.zendesk_metadata import ( discover_zendesk_metadata, ) if TYPE_CHECKING: import httpx # discover/preview/create 阶段 429 退避参数 _RETRY_MAX_ATTEMPTS = 5 _RETRY_BASE_DELAY = 1.0 # Zendesk 通用请求头(除 Authorization 外) _ZENDESK_HEADERS: dict[str, str] = { "Accept": "application/json", "Content-Type": "application/json", } def _build_auth_headers(system_config: dict[str, Any]) -> dict[str, str]: """从 system_config 提取凭证,构建 Zendesk 请求头。 两种认证形态: - OAuth2: ``system_config["_resolved_token"]["access_token"]`` → Bearer - basic (API Token): ``system_config["auth_config"]["username"]`` / ``["password"]`` → Basic token 由 use_cases 层通过 ``token_manager.get_token`` 预处理后注入到 ``system_config["_resolved_token"]``。handler 不自行换取 token。 """ headers = dict(_ZENDESK_HEADERS) token_info = system_config.get("_resolved_token") if isinstance(token_info, dict) and token_info.get("access_token"): headers["Authorization"] = f"Bearer {token_info['access_token']}" return headers # 非 token 类认证(basic):由 use_cases 层解析后注入 auth_config auth_config = system_config.get("auth_config") or {} username = auth_config.get("username") password = auth_config.get("password") if username and password: encoded = base64.b64encode(f"{username}:{password}".encode()).decode() headers["Authorization"] = f"Basic {encoded}" return headers raise AuthError("zendesk: system_config 缺少 _resolved_token 或 auth_config 凭证,use_cases 层未注入凭证") def _resolve_base_url(system_config: dict[str, Any]) -> str: """从 system_config 提取 base_url,支持 subdomain 拼接。""" connection_config = system_config.get("connection_config") or {} base_url = connection_config.get("base_url") if not base_url: subdomain = connection_config.get("subdomain") if not subdomain: raise DomainValidationError("zendesk: system_config.connection_config 需包含 base_url 或 subdomain") base_url = f"https://{subdomain}.zendesk.com" return str(base_url).rstrip("/") def _resolve_resources(system_config: dict[str, Any]) -> list[str] | None: """从 system_config 提取资源过滤白名单。""" discovery_options = system_config.get("discovery_options") or {} return discovery_options.get("resources") async def _request_with_retry( method: str, url: str, headers: dict[str, str], params: dict[str, Any] | None = None, *, 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_zendesk_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, params=params) 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"Zendesk 限流,已重试 {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"Zendesk 网络请求失败: {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_zendesk_error(last_response.status_code, error_body) def _make_request_fn() -> Callable[ [str, str, dict[str, str], dict[str, Any] | None], Awaitable[httpx.Response], ]: """构造带 429 退避的 request_fn,供 discover_zendesk_metadata 使用。""" async def request_fn( method: str, url: str, headers: dict[str, str], params: dict[str, Any] | None = None, ) -> httpx.Response: return await _request_with_retry(method, url, headers, params) return request_fn async def discover_zendesk( source_type: str, system_config: dict[str, Any], ) -> list[dict[str, Any]]: """discover handler:发现 Zendesk 实例可用资源摘要。 Returns: 资源摘要列表,每项含 ``name`` / ``display_name`` / ``field_count``。 """ base_url = _resolve_base_url(system_config) auth_headers = _build_auth_headers(system_config) resources = _resolve_resources(system_config) request_fn = _make_request_fn() metadata = await discover_zendesk_metadata( base_url, auth_headers, resources=resources, request_fn=request_fn, ) return [ { "name": r.name, "display_name": r.display_name, "field_count": len(r.fields), } for r in metadata ] async def preview_zendesk_tools( source_type: str, system_config: dict[str, Any], ) -> list[dict[str, Any]]: """preview_tools handler:预览将生成的工具列表。 Returns: 工具预览列表,每项为 ExternalToolCreateInput 兼容的 dict。 """ payload = _build_generator_payload(system_config) generator = ZendeskConfigGenerator() return await generator.generate(payload) async def create_zendesk_tools( source_type: str, system_config: dict[str, Any], ) -> GeneratedToolsDraft: """create_tools handler:生成工具草稿(不直接持久化)。 Returns: GeneratedToolsDraft,由 use_cases 层统一持久化。 """ payload = _build_generator_payload(system_config) generator = ZendeskConfigGenerator() tool_configs = await generator.generate(payload) return GeneratedToolsDraft(tool_configs=tool_configs, override_existing=False) def _build_generator_payload(system_config: dict[str, Any]) -> dict[str, Any]: """从 system_config 构造生成器 payload。 生成器 payload 是 system_config 的子集,包含: - base_url: str - auth_headers: dict[str, str] - auth_config: dict - auth_type: str - resources: list[str] | None - request_fn: 带 429 退避的 HTTP 请求函数 OAuth2 模式需将 ``token_url`` 注入 ``auth_config``(供框架级插件刷新)。 """ # OAuth2 模式需将 token_url 注入 auth_config(供框架级插件刷新) auth_config = dict(system_config.get("auth_config") or {}) if auth_config.get("auth_type") == "oauth2_authorization_code": base_url = _resolve_base_url(system_config) endpoints = resolve_oauth_endpoints(base_url) auth_config.setdefault("token_url", endpoints["token_url"]) return { "base_url": _resolve_base_url(system_config), "auth_headers": _build_auth_headers(system_config), "auth_config": auth_config, "auth_type": system_config.get("auth_type", "basic"), "resources": _resolve_resources(system_config), "request_fn": _make_request_fn(), }