"""HubSpot discover / preview_tools / create_tools handler 实现 + 元数据发现 + Search 渲染 + 错误提取器。 handler 签名遵循 ``OperationHandler`` 协议: ``(source_type: str, system_config: dict[str, Any]) -> Awaitable[Any]``。 handler 内部职责: 1. 从 ``system_config`` 提取 ``base_url`` / ``auth_config`` / ``_resolved_token`` 2. 构建 ``auth_headers``(token 由 use_cases 层注入到 ``system_config["_resolved_token"]``) 3. 构造带 429 退避的 ``request_fn``,发起元数据发现请求 4. 调用 ``HubSpotConfigGenerator`` 生成工具配置草稿 两层错误处理(见设计文档 §8): - **L1**: ``extract_hubspot_error`` — 注册到 ``http_error_extractor_registry``, 由框架 executor 在运行时工具执行失败时调用,提取友好错误消息字符串。 - **L2**: ``map_hubspot_error`` — 不注册到任何注册表,仅在 handler 内部 discover/preview/create 阶段失败后调用,将 HTTP 状态码映射为 ``ExternalSystemError`` 子类。 429 退避分层(见设计文档 §6.4): - discover/preview/create 阶段:本模块 ``_request_with_retry`` 实现 - 运行时工具执行:通过工具 ``retry_policy.retry_status_codes: [429]`` 配置 """ from __future__ import annotations import asyncio from collections.abc import Awaitable, Callable from typing import TYPE_CHECKING, Any from yuxi.external_systems.exceptions import ( AccessDeniedError, AuthError, EntityNotFoundError, ExecutionError, QuotaExceededError, RateLimitExceededError, ) from yuxi.external_systems.integrations.hubspot.generators import ( HubSpotConfigGenerator, ) from yuxi.external_systems.integrations.hubspot.metadata import ( API_BASE_URL, ASSOCIATIONS_PATH_TEMPLATE, DEFAULT_STANDARD_OBJECTS, PROPERTIES_PATH_TEMPLATE, SCHEMAS_PATH, STANDARD_OBJECT_TYPE_IDS, ) 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 # HubSpot 通用请求头(除 Authorization 外) _HUBSPOT_HEADERS: dict[str, str] = { "Accept": "application/json", "Content-Type": "application/json", } # ── 错误提取器(L1)────────────────────────────────────────────────────── def extract_hubspot_error(response: httpx.Response) -> str | None: """从 HubSpot 错误响应中提取友好错误信息(L1)。 HubSpot 错误响应为 JSON 对象,含顶层 ``status`` / ``message`` / ``correlationId`` 与 ``errors`` 数组:: { "status": "error", "message": "Property values were not valid", "correlationId": "a1b2c3d4-e5f6-7890", "errors": [ { "message": "The property 'email' is required", "in": "propertyName", "code": "REQUIRED_PROPERTY", "context": {"propertyName": ["email"]} } ], "category": "VALIDATION_ERROR" } Returns: 拼接后的错误消息字符串(含 message + errors[].code/message + correlationId), 非 HubSpot 错误格式时返回 None。 """ try: body = response.json() except Exception: return None if not isinstance(body, dict): return None if body.get("status") != "error": return None parts: list[str] = [] top_message = body.get("message", "") if top_message: parts.append(top_message) errors = body.get("errors") or [] for err in errors: if not isinstance(err, dict): continue code = err.get("code", "UNKNOWN") msg = err.get("message", "") context = err.get("context") or {} context_hint = f" (context: {context})" if context else "" parts.append(f"[{code}] {msg}{context_hint}") correlation_id = body.get("correlationId") if correlation_id: parts.append(f"correlationId: {correlation_id}") return "; ".join(parts) if parts else None def map_hubspot_error(status_code: int, error_body: dict[str, Any]) -> Exception: """将 HubSpot 错误响应转换为 ExternalSystemError 子类(L2)。 仅在 handler 内部(discover/preview/create 阶段)使用,不注册到框架。 运行时工具执行的错误由框架 executor 统一包装为 ExecutionError。 错误码映射见设计文档 §8.3。 """ message = error_body.get("message", "") if isinstance(error_body, dict) else "" category = error_body.get("category", "") if isinstance(error_body, dict) else "" correlation_id = error_body.get("correlationId", "") if isinstance(error_body, dict) else "" context_hint = f" (correlationId={correlation_id})" if correlation_id else "" full_message = f"HubSpot: {message}{context_hint}" if message else f"HubSpot 错误{context_hint}" if status_code == 401: return AuthError(f"HubSpot 认证失败: {full_message} (category={category})") if status_code == 403: return AccessDeniedError(f"HubSpot 访问被拒: {full_message} (category={category})") if status_code == 404: return EntityNotFoundError(f"HubSpot 资源不存在: {full_message}") if status_code == 409: return ExecutionError(f"HubSpot 冲突: {full_message}") if status_code == 429: # RATE_LIMIT_EXCEEDED vs DAILY_LIMIT_EXCEEDED 按 category 区分 if category == "DAILY_LIMIT_EXCEEDED": return QuotaExceededError(f"HubSpot 每日配额耗尽: {full_message}") return RateLimitExceededError( f"HubSpot 限流: {full_message}", retry_after=60, limit_type="qps", ) if 500 <= status_code < 600: return ExecutionError(f"HubSpot 服务端错误: {full_message} (status={status_code})") return ExecutionError(f"HubSpot 未知错误: {full_message} (status={status_code})") # ── Search API 请求体渲染 ───────────────────────────────────────────────── def _render_search_body( filter_groups: list[dict[str, Any]], properties: list[str] | None = None, sorts: list[dict[str, Any]] | None = None, after: str | None = None, limit: int = 50, ) -> dict[str, Any]: """渲染 HubSpot Search API 请求体。 Args: filter_groups: 筛选组列表,每组内 filters 为 AND 关系,组间为 OR 关系。 每条 filter 含 propertyName / operator / value。 operator 支持: EQ / NEQ / LT / LTE / GT / GTE / BETWEEN / IN / NOT_IN / CONTAINS_TOKEN / NOT_CONTAINS_TOKEN / HAS_PROPERTY / NOT_HAS_PROPERTY。 properties: 返回的属性列表(默认返回全部)。 sorts: 排序规则列表。 after: 分页游标(上一页响应的 after 值)。 limit: 每页记录数(最大 200)。 """ body: dict[str, Any] = {"filterGroups": filter_groups} if properties: body["properties"] = properties if sorts: body["sorts"] = sorts paging: dict[str, Any] = {"limit": min(limit, 200)} if after: paging["after"] = after body["paging"] = paging return body # ── system_config 解析辅助 ──────────────────────────────────────────────── def _build_auth_headers(system_config: dict[str, Any]) -> dict[str, str]: """从 system_config 提取 token,构建 HubSpot 请求头。 system_config 中 token 由 use_cases 层通过 ``token_manager.get_token`` 预处理后 注入到 ``system_config["_resolved_token"]["access_token"]``。handler 不自行换取 token——token 获取是 use_cases 层的职责。 """ token_info = system_config.get("_resolved_token") if not isinstance(token_info, dict) or not token_info.get("access_token"): raise AuthError("hubspot: system_config 缺少 _resolved_token,use_cases 层未注入 token") access_token = token_info["access_token"] headers = dict(_HUBSPOT_HEADERS) headers["Authorization"] = f"Bearer {access_token}" return headers def _resolve_base_url(system_config: dict[str, Any]) -> str: """从 system_config 提取 base_url,默认 ``https://api.hubapi.com``。""" connection_config = system_config.get("connection_config") or {} base_url = connection_config.get("base_url") or API_BASE_URL return str(base_url).rstrip("/") def _resolve_auth_type(system_config: dict[str, Any]) -> str: """从 system_config 提取 auth_type,默认 private_app_token。""" return str(system_config.get("auth_type", "private_app_token")) def _resolve_selected_objects(system_config: dict[str, Any]) -> list[str]: """从 system_config 提取用户选择的标准对象 FQN 列表。 默认返回 ``DEFAULT_STANDARD_OBJECTS``(contacts / companies / deals / tickets)。 """ discovery_options = system_config.get("discovery_options") or {} selected = discovery_options.get("selected_objects") if selected: return list(selected) return list(DEFAULT_STANDARD_OBJECTS) def _resolve_association_pairs( system_config: dict[str, Any], ) -> list[tuple[str, str]]: """从 system_config 提取需要发现关联标签的对象对列表。 返回 ``[(from_type, to_type), ...]``,默认空列表(不发现关联)。 ``discovery_options.association_pairs`` 格式:``[{"from": "0-1", "to": "0-2"}]``。 """ discovery_options = system_config.get("discovery_options") or {} pairs_raw = discovery_options.get("association_pairs") or [] pairs: list[tuple[str, str]] = [] for pair in pairs_raw: if not isinstance(pair, dict): continue from_type = pair.get("from") to_type = pair.get("to") if from_type and to_type: pairs.append((str(from_type), str(to_type))) return pairs # ── 429 退避 HTTP 请求 ──────────────────────────────────────────────────── 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_hubspot_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"HubSpot 限流,已重试 {max_retries} 次仍失败: {url}", retry_after=retry_after, limit_type="qps", ) 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"HubSpot 网络请求失败: {exc}") from exc assert last_response is not None if last_response.is_success: return last_response try: error_body = last_response.json() except Exception: error_body = {"message": last_response.text} raise map_hubspot_error(last_response.status_code, error_body) def _make_request_fn() -> Callable[..., 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 # ── 元数据发现函数 ──────────────────────────────────────────────────────── async def _discover_schemas( request_fn: Callable[..., Awaitable[httpx.Response]], base_url: str, headers: dict[str, str], ) -> list[dict[str, Any]]: """调用 ``GET /crm/v3/schemas`` 获取自定义对象 schema 列表。 自定义对象类型 ID 为 ``2-XXX``;标准对象 schema 不在此处列出。 """ response = await request_fn("GET", f"{base_url}{SCHEMAS_PATH}", headers) data = response.json() return data.get("results", []) if isinstance(data, dict) else [] async def _discover_properties( request_fn: Callable[..., Awaitable[httpx.Response]], base_url: str, object_type_id: str, headers: dict[str, str], ) -> list[dict[str, Any]]: """调用 ``GET /crm/v3/properties/{objectTypeId}`` 获取对象属性元数据。""" path = PROPERTIES_PATH_TEMPLATE.format(object_type_id=object_type_id) response = await request_fn("GET", f"{base_url}{path}", headers) data = response.json() return data.get("results", []) if isinstance(data, dict) else [] async def _discover_association_labels( request_fn: Callable[..., Awaitable[httpx.Response]], base_url: str, from_type: str, to_type: str, headers: dict[str, str], ) -> list[dict[str, Any]]: """调用 ``GET /crm/v4/associations/{from}/{to}/labels`` 获取关联类型标签。""" path = ASSOCIATIONS_PATH_TEMPLATE.format(**{"from": from_type, "to": to_type}) response = await request_fn("GET", f"{base_url}{path}", headers) data = response.json() return data.get("results", []) if isinstance(data, dict) else [] # ── 三个 OperationHandler 实现 ──────────────────────────────────────────── async def discover_hubspot( source_type: str, system_config: dict[str, Any], ) -> list[dict[str, Any]]: """discover handler:发现 HubSpot 可用对象列表(标准对象 + 自定义对象 Schemas)。 Returns: 对象摘要列表,每项含 ``object_type_id`` / ``name`` / ``is_standard`` / ``label``。 """ base_url = _resolve_base_url(system_config) auth_headers = _build_auth_headers(system_config) selected_objects = _resolve_selected_objects(system_config) request_fn = _make_request_fn() # 标准对象(用户选择的) results: list[dict[str, Any]] = [] for fqn in selected_objects: type_id = STANDARD_OBJECT_TYPE_IDS.get(fqn, fqn) results.append( { "object_type_id": type_id, "name": fqn, "label": fqn.replace("_", " ").title(), "is_standard": True, } ) # 自定义对象(Schemas API) custom_schemas = await _discover_schemas(request_fn, base_url, auth_headers) for schema in custom_schemas: type_id = schema.get("objectTypeId", "") name = schema.get("name", type_id) labels = schema.get("labels") or {} label = labels.get("singular", name) if isinstance(labels, dict) else name results.append( { "object_type_id": type_id, "name": name, "label": label, "is_standard": False, } ) return results async def preview_hubspot_tools( source_type: str, system_config: dict[str, Any], ) -> list[dict[str, Any]]: """preview_tools handler:预览将生成的工具列表。 Returns: 工具预览列表,每项为 ExternalToolCreateInput 兼容的 dict。 """ payload = await _build_generator_payload(system_config) generator = HubSpotConfigGenerator() return await generator.generate(payload) async def create_hubspot_tools( source_type: str, system_config: dict[str, Any], ) -> GeneratedToolsDraft: """create_tools handler:生成工具草稿(不直接持久化)。 Returns: GeneratedToolsDraft,由 use_cases 层统一持久化。 """ payload = await _build_generator_payload(system_config) generator = HubSpotConfigGenerator() tool_configs = await generator.generate(payload) return GeneratedToolsDraft(tool_configs=tool_configs, override_existing=False) # ── 生成器 payload 构造 ─────────────────────────────────────────────────── async def _build_generator_payload(system_config: dict[str, Any]) -> dict[str, Any]: """从 system_config 构造生成器 payload。 流程: 1. 解析 base_url / auth_headers / auth_type / auth_config 2. 解析用户选择的标准对象 FQN 列表 3. 发现自定义对象 Schemas 4. 为每个对象(标准 + 自定义)发现属性元数据 5. 可选:发现关联标签(基于 discovery_options.association_pairs) 6. 汇总为生成器 payload """ base_url = _resolve_base_url(system_config) auth_headers = _build_auth_headers(system_config) auth_type = _resolve_auth_type(system_config) auth_config = system_config.get("auth_config") or {} selected_objects = _resolve_selected_objects(system_config) association_pairs = _resolve_association_pairs(system_config) request_fn = _make_request_fn() # 发现自定义对象 Schemas custom_schemas = await _discover_schemas(request_fn, base_url, auth_headers) # 合并标准对象与自定义对象 → (objectTypeId, fqn) 列表 all_objects: list[tuple[str, str]] = [] for fqn in selected_objects: type_id = STANDARD_OBJECT_TYPE_IDS.get(fqn, fqn) all_objects.append((type_id, fqn)) for schema in custom_schemas: type_id = schema.get("objectTypeId", "") fqn = schema.get("name", type_id) if type_id: all_objects.append((type_id, fqn)) # 为每个对象发现属性元数据 properties_map: dict[str, list[dict[str, Any]]] = {} for type_id, _fqn in all_objects: properties_map[type_id] = await _discover_properties(request_fn, base_url, type_id, auth_headers) # 可选:发现关联标签 association_labels: list[dict[str, Any]] = [] for from_type, to_type in association_pairs: labels = await _discover_association_labels(request_fn, base_url, from_type, to_type, auth_headers) if labels: association_labels.append({"from": from_type, "to": to_type, "labels": labels}) return { "standard_objects": selected_objects, "custom_schemas": custom_schemas, "properties_map": properties_map, "association_labels": association_labels, "base_url": base_url, "auth_type": auth_type, "auth_config": auth_config, }