"""Notion discover / preview_tools / create_tools handler 实现。 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. 调用 Notion API 聚合元数据(search / databases / users) 4. 调用 ``NotionConfigGenerator`` 生成工具配置草稿 429 退避分层(见设计文档 §6.2): - discover/preview/create 阶段:本模块 ``_request_with_retry`` 实现(解析 Retry-After) - 运行时工具执行:通过工具 ``retry_policy.retry_status_codes: [429, 502, 503, 504]`` 配置 """ from __future__ import annotations import asyncio import time from typing import TYPE_CHECKING, Any from yuxi.external_systems.exceptions import ( AuthError, DomainValidationError, ExecutionError, RateLimitExceededError, ) from yuxi.external_systems.integrations.notion.constants import ( _NOTION_HEADERS, _RETRY_BASE_DELAY, _RETRY_MAX_ATTEMPTS, ) from yuxi.external_systems.integrations.notion.error_extractor import ( map_notion_error, ) from yuxi.external_systems.integrations.notion.generators import ( NotionConfigGenerator, ) from yuxi.external_systems.integrations.schemas import GeneratedToolsDraft if TYPE_CHECKING: pass # ── system_config 解析辅助 ──────────────────────────────────────────────── def _build_auth_headers(system_config: dict[str, Any]) -> dict[str, str]: """从 system_config 提取 token,构建 Notion 请求头。 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("notion: system_config 缺少 _resolved_token,use_cases 层未注入 token") access_token = token_info["access_token"] headers = dict(_NOTION_HEADERS) headers["Authorization"] = f"Bearer {access_token}" return headers def _resolve_base_url(system_config: dict[str, Any]) -> str: """从 system_config 提取 base_url。""" connection_config = system_config.get("connection_config") or {} base_url = connection_config.get("base_url") if not base_url: raise DomainValidationError("notion: system_config.connection_config.base_url 不能为空") return str(base_url).rstrip("/") def _resolve_selected_tools(system_config: dict[str, Any]) -> list[str] | None: """从 system_config 提取用户选择的工具 slug 列表。""" discovery_options = system_config.get("discovery_options") or {} selected = discovery_options.get("selected_tools") return list(selected) if selected else None # ── 429 退避 HTTP 请求 ──────────────────────────────────────────────────── async def _request_with_retry( base_url: str, endpoint: str, headers: dict[str, str], *, method_verb: str = "GET", params: dict[str, Any] | None = None, json_body: dict[str, Any] | None = None, ) -> dict[str, Any]: """带 429 退避的 HTTP 请求(discover/preview/create 阶段使用)。 解析 ``Retry-After`` 头部(秒数),按指数退避重试,最多 ``_RETRY_MAX_ATTEMPTS`` 次。 其他 4xx/5xx 错误通过 ``map_notion_error`` 转换为 ``ExternalSystemError`` 子类抛出。 网络层异常(超时 / 连接错误)转换为 ``ExecutionError``。 Returns: 解析后的 JSON 响应 dict。 """ import httpx url = f"{base_url}/{endpoint}" last_error: Exception | None = None for attempt in range(_RETRY_MAX_ATTEMPTS): try: async with httpx.AsyncClient() as client: if method_verb == "GET": resp = await client.get(url, headers=headers, params=params) else: resp = await client.request(method_verb, url, headers=headers, json=json_body) if resp.status_code == 429: if attempt == _RETRY_MAX_ATTEMPTS - 1: retry_after_header = resp.headers.get("Retry-After", "60") try: retry_after = int(retry_after_header) except ValueError: retry_after = 60 raise RateLimitExceededError( f"notion: 限流,已重试 {_RETRY_MAX_ATTEMPTS} 次仍失败: {url}", retry_after=retry_after, limit_type="qps", ) retry_after_header = resp.headers.get("Retry-After") if retry_after_header: try: delay = float(retry_after_header) except ValueError: delay = _RETRY_BASE_DELAY * (2**attempt) else: delay = _RETRY_BASE_DELAY * (2**attempt) await asyncio.sleep(delay) continue if resp.status_code >= 400: try: error_body = resp.json() except Exception: error_body = {} raise map_notion_error(resp.status_code, error_body) try: return resp.json() except Exception as exc: raise ExecutionError(f"notion: 响应 JSON 解析失败: {url}: {exc}") from exc except httpx.HTTPError as exc: last_error = exc if attempt == _RETRY_MAX_ATTEMPTS - 1: break await asyncio.sleep(_RETRY_BASE_DELAY * (2**attempt)) continue raise ExecutionError(f"notion: 请求 {url} 失败,已重试 {_RETRY_MAX_ATTEMPTS} 次: {last_error}") # ── cursor 分页自动迭代 ────────────────────────────────────────────────── async def _paginate( base_url: str, endpoint: str, headers: dict[str, str], params: dict[str, Any], ) -> list[dict[str, Any]]: """cursor 分页自动迭代,聚合所有页结果(GET 请求)。 Args: endpoint: API 端点(如 ``users``) params: 初始查询参数(不含 ``start_cursor``) Returns: 聚合后的资源列表 """ aggregated: list[dict[str, Any]] = [] cursor: str | None = None while True: query = {**params} if cursor: query["start_cursor"] = cursor resp = await _request_with_retry( base_url, endpoint, headers, method_verb="GET", params=query, ) results = resp.get("results", []) if isinstance(results, list): aggregated.extend(results) if not resp.get("has_more"): break cursor = resp.get("next_cursor") if not cursor: break return aggregated async def _paginate_search( base_url: str, headers: dict[str, str], payload: dict[str, Any], ) -> list[dict[str, Any]]: """search 接口专用分页(POST + JSON body)。""" aggregated: list[dict[str, Any]] = [] cursor: str | None = None while True: body = {**payload} if cursor: body["start_cursor"] = cursor resp = await _request_with_retry( base_url, "search", headers, method_verb="POST", json_body=body, ) results = resp.get("results", []) if isinstance(results, list): aggregated.extend(results) if not resp.get("has_more"): break cursor = resp.get("next_cursor") if not cursor: break return aggregated # ── Notion 资源标题提取 ────────────────────────────────────────────────── def _extract_database_title(db: dict[str, Any]) -> str: """从 Notion database 对象提取标题(title 字段为 rich_text 数组)。""" title_list = db.get("title", []) if isinstance(title_list, list): return "".join(t.get("plain_text", "") for t in title_list if isinstance(t, dict)) return "" def _extract_page_title(page: dict[str, Any]) -> str: """从 Notion page 对象提取标题(properties 中 type=title 的属性)。""" properties = page.get("properties", {}) if not isinstance(properties, dict): return "" for prop in properties.values(): if not isinstance(prop, dict): continue if prop.get("type") == "title": title_list = prop.get("title", []) if isinstance(title_list, list): return "".join(t.get("plain_text", "") for t in title_list if isinstance(t, dict)) return "" # ── 元数据发现辅助 ──────────────────────────────────────────────────────── async def _discover_databases_with_properties( base_url: str, headers: dict[str, str], ) -> list[dict[str, Any]]: """发现数据库并获取每个数据库的完整结构(含 properties)。 用于 preview/create 阶段:先通过 search 获取数据库列表, 再对每个数据库调用 ``GET /v1/databases/{id}`` 获取完整 properties。 """ databases = await _paginate_search( base_url, headers, payload={"page_size": 100, "filter": {"value": "database", "property": "object"}}, ) result: list[dict[str, Any]] = [] for db in databases: if not isinstance(db, dict): continue db_id = db.get("id") if not db_id: continue resp = await _request_with_retry( base_url, f"databases/{db_id}", headers, method_verb="GET", ) result.append( { "id": db_id, "title": _extract_database_title(db), "properties": resp.get("properties", {}) if isinstance(resp, dict) else {}, } ) return result # ── 三个 OperationHandler 实现 ──────────────────────────────────────────── async def discover_notion( source_type: str, system_config: dict[str, Any], ) -> list[dict[str, Any]]: """discover handler:发现 Notion 工作区可用资源(数据库 + 页面 + 用户)。 Returns: 资源列表,每项形如: - ``{"type": "database", "id": "...", "title": "...", "url": "...", "parent_type": "..."}`` - ``{"type": "page", "id": "...", "title": "...", "url": "...", "parent_type": "..."}`` - ``{"type": "user", "id": "...", "name": "...", "type_detail": "...", "email": "..."}`` """ base_url = _resolve_base_url(system_config) headers = _build_auth_headers(system_config) # 通过 search 接口聚合所有可访问的 database 与 page databases = await _paginate_search( base_url, headers, payload={"page_size": 100, "filter": {"value": "database", "property": "object"}}, ) pages = await _paginate_search( base_url, headers, payload={"page_size": 100, "filter": {"value": "page", "property": "object"}}, ) users = await _paginate(base_url, "users", headers, params={"page_size": 100}) return [ *[ { "type": "database", "id": r.get("id", ""), "title": _extract_database_title(r), "url": r.get("url"), "parent_type": (r.get("parent") or {}).get("type"), } for r in databases if isinstance(r, dict) ], *[ { "type": "page", "id": r.get("id", ""), "title": _extract_page_title(r), "url": r.get("url"), "parent_type": (r.get("parent") or {}).get("type"), } for r in pages if isinstance(r, dict) ], *[ { "type": "user", "id": u.get("id", ""), "name": u.get("name"), "type_detail": u.get("type"), "email": (u.get("person") or {}).get("email") if isinstance(u.get("person"), dict) else None, } for u in users if isinstance(u, dict) ], ] async def preview_notion_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 = NotionConfigGenerator() return await generator.generate(payload) async def create_notion_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 = NotionConfigGenerator() 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. 解析用户选择的工具 slug 列表(可选) 3. 发现数据库并获取完整结构(含 properties) 4. 汇总为生成器 payload """ base_url = _resolve_base_url(system_config) auth_headers = _build_auth_headers(system_config) auth_config = system_config.get("auth_config") or {} auth_type = system_config.get("auth_type", "bearer") selected_tools = _resolve_selected_tools(system_config) discovered_databases = await _discover_databases_with_properties(base_url, auth_headers) payload: dict[str, Any] = { "base_url": base_url, "auth_headers": auth_headers, "auth_config": auth_config, "auth_type": auth_type, "discovered_databases": discovered_databases, } if selected_tools is not None: payload["selected_tools"] = selected_tools return payload # ── 健康检查 ───────────────────────────────────────────────────────────── async def health_check_notion(system_config: dict[str, Any]) -> dict[str, Any]: """Notion 健康检查:调用 ``GET /v1/users/me`` 验证 Token 有效性。 供 use_cases 层直接调用,不注册到 ``IntegrationOperationRegistry``。 Returns:: { "status": "healthy" | "unhealthy", "bot_id": "...", "workspace_name": "Acme", "duration_ms": 123, } """ start = time.monotonic() try: base_url = _resolve_base_url(system_config) headers = _build_auth_headers(system_config) resp = await _request_with_retry( base_url, "users/me", headers, method_verb="GET", ) bot_id: str | None = None if resp.get("type") == "bot": bot = resp.get("bot") or {} owner = bot.get("owner") or {} user = owner.get("user") or {} bot_id = user.get("id") if isinstance(user, dict) else None return { "status": "healthy", "bot_id": bot_id, "workspace_name": resp.get("workspace_name"), "duration_ms": int((time.monotonic() - start) * 1000), } except Exception as exc: return { "status": "unhealthy", "detail": str(exc), "duration_ms": int((time.monotonic() - start) * 1000), }