"""Twilio discover / preview_tools / create_tools handler 实现 + Webhook 签名验证 + 资源可达性探测。 handler 签名遵循 ``OperationHandler`` 协议: ``(source_type: str, system_config: dict[str, Any]) -> Awaitable[Any]``。 handler 内部职责(见设计文档 §7.4): 1. 从 ``system_config`` 提取 ``connection_config`` / ``auth_config`` / ``resolved_credential`` 2. 构建 Basic Auth 请求头(Account SID + Auth Token) 3. 调用 Twilio API 探测资源可达性(discover)或调用生成器(preview/create) 与 dynamics365 的关键差异:dynamics365 使用 OAuth2(``is_token_based=True``), ``system_config`` 含 ``_resolved_token``;Twilio 使用 Basic Auth(``is_token_based=False``), ``system_config`` 含 ``resolved_credential``(dict 形态,含 username / password)。 429 退避分层(见设计文档 §6.4): - discover/preview/create 阶段:本模块 ``_request_with_retry`` 实现(解析 Retry-After) - 运行时工具执行:通过工具 ``retry_policy.retry_status_codes: [429]`` 配置(见 generators.py) """ from __future__ import annotations import asyncio import base64 import hashlib import hmac from collections.abc import Awaitable, Callable from typing import TYPE_CHECKING, Any from yuxi.external_systems.exceptions import ( AuthError, DomainValidationError, ExecutionError, RateLimitExceededError, WebhookError, ) from yuxi.external_systems.integrations.schemas import GeneratedToolsDraft from yuxi.external_systems.integrations.twilio.error_mapper import ( map_twilio_error, ) from yuxi.external_systems.integrations.twilio.generators import ( TwilioConfigGenerator, ) from yuxi.external_systems.integrations.twilio.metadata import ( DEFAULT_API_VERSION, DEFAULT_BASE_URL, ) if TYPE_CHECKING: import httpx # discover/preview/create 阶段 429 退避参数(与 dynamics365 一致) _RETRY_MAX_ATTEMPTS = 5 _RETRY_BASE_DELAY = 1.0 # discover 阶段资源可达性探测的路径与描述 # accessible=True 表示该资源可被当前账户访问 _RESOURCE_PROBES: list[dict[str, Any]] = [ { "resource": "Messages", "path_template": "/{api_version}/Accounts/{account_sid}/Messages.json", "operations": ["list", "get", "create"], "description": "短信发送与查询", }, { "resource": "Calls", "path_template": "/{api_version}/Accounts/{account_sid}/Calls.json", "operations": ["list", "get", "create"], "description": "语音通话发起与查询", }, { "resource": "IncomingPhoneNumbers", "path_template": "/{api_version}/Accounts/{account_sid}/IncomingPhoneNumbers.json", "operations": ["list", "get", "create", "update", "delete"], "description": "已购号码管理", }, { "resource": "AvailablePhoneNumbers", "path_template": ("/{api_version}/Accounts/{account_sid}/AvailablePhoneNumbers/US/Local.json"), "operations": ["list"], "description": "可用号码查询(按国家)", }, { "resource": "UsageRecords", "path_template": "/{api_version}/Accounts/{account_sid}/Usage/Records.json", "operations": ["list"], "description": "用量与费用查询", }, ] # ═══════════════════════════════════════════════════════════════════════════ # Webhook 签名验证 # ═══════════════════════════════════════════════════════════════════════════ def verify_twilio_signature( auth_token: str, webhook_url: str, form_params: dict[str, Any], signature: str, ) -> bool: """验证 Twilio Webhook 签名。 Twilio 签名规则: 1. 将 webhook_url 与排序后的表单参数(key=value)拼接为字符串 2. 使用 auth_token 作为 HMAC-SHA256 密钥计算哈希 3. Base64 编码后与 ``X-Twilio-Signature`` 头比对 Args: auth_token: Account Auth Token(HMAC 密钥) webhook_url: Twilio 控制台配置的 Webhook URL(必须完全一致) form_params: Webhook 请求的表单参数(application/x-www-form-urlencoded) signature: ``X-Twilio-Signature`` 头的值(Base64) Returns: True 表示验签通过,False 表示签名不匹配。 """ # 1. 拼接 URL + 排序后的参数 sorted_params = sorted(form_params.items()) param_str = "".join(f"{k}{v}" for k, v in sorted_params) data = f"{webhook_url}{param_str}".encode() # 2. HMAC-SHA256 + Base64 expected = base64.b64encode(hmac.new(auth_token.encode("utf-8"), data, hashlib.sha256).digest()).decode("utf-8") # 3. 常量时间比对(防时序攻击) return hmac.compare_digest(expected, signature) def verify_twilio_signature_or_raise( auth_token: str, webhook_url: str, form_params: dict[str, Any], signature: str, *, account_sid: str | None = None, ) -> None: """验证 Twilio Webhook 签名,失败时抛 ``WebhookError``。 Args: auth_token: Account Auth Token(HMAC 密钥) webhook_url: Twilio 控制台配置的 Webhook URL form_params: Webhook 请求的表单参数 signature: ``X-Twilio-Signature`` 头的值 account_sid: Account SID(用于错误上下文,可选) Raises: WebhookError: 签名不匹配。 """ if not verify_twilio_signature(auth_token, webhook_url, form_params, signature): context = f" (account_sid={account_sid})" if account_sid else "" raise WebhookError( f"Twilio Webhook 签名验证失败{context}", details={ "webhook_url": webhook_url, "account_sid": account_sid, }, ) # ═══════════════════════════════════════════════════════════════════════════ # system_config 解析辅助函数 # ═══════════════════════════════════════════════════════════════════════════ def _resolve_base_url(system_config: dict[str, Any]) -> str: """从 system_config 提取 base_url,默认 https://api.twilio.com。""" connection_config = system_config.get("connection_config") or {} base_url = connection_config.get("base_url") or DEFAULT_BASE_URL return str(base_url) def _resolve_account_sid(system_config: dict[str, Any]) -> str: """从 system_config 提取 account_sid,校验非空。""" connection_config = system_config.get("connection_config") or {} account_sid = connection_config.get("account_sid") if not account_sid: raise DomainValidationError("twilio: connection_config.account_sid 不能为空") return str(account_sid) def _resolve_api_version(system_config: dict[str, Any]) -> str: """从 system_config 提取 api_version,默认 2010-04-01。""" connection_config = system_config.get("connection_config") or {} return str(connection_config.get("api_version", DEFAULT_API_VERSION)) def _build_basic_auth_headers(system_config: dict[str, Any]) -> dict[str, str]: """从 system_config 构造 Basic Auth 请求头。 Twilio Basic Auth: username=Account SID, password=Auth Token。 ``resolved_credential`` 为 use_cases 层注入的解析后凭证(dict 形态, 含 username / password;或 ``BasicCredential`` 实例)。 """ credential = system_config.get("resolved_credential") if credential is None: raise AuthError("twilio: system_config 缺少 resolved_credential,use_cases 层未注入解析后的凭证") # 兼容 dict 与 BasicCredential 两种形态 if isinstance(credential, dict): username = credential.get("username") password = credential.get("password") else: # BasicCredential 实例(框架 _resolve_credential 返回) username = getattr(credential, "username", None) password = getattr(credential, "password", None) if not username or not password: raise AuthError("twilio: resolved_credential 缺少 username 或 password") credentials = f"{username}:{password}".encode() token = base64.b64encode(credentials).decode("utf-8") return { "Authorization": f"Basic {token}", "Accept": "application/json", } # ═══════════════════════════════════════════════════════════════════════════ # HTTP 请求辅助(带 429 退避) # ═══════════════════════════════════════════════════════════════════════════ 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 阶段)。 与 dynamics365 ``_request_with_retry`` 模式一致: - 优先使用响应头 ``Retry-After``,否则指数退避 - 非 2xx 响应(非 429)通过 ``map_twilio_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", "1") try: retry_after = int(retry_after_header) except ValueError: retry_after = 1 raise RateLimitExceededError( f"Twilio 限流,已重试 {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"Twilio 网络请求失败: {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 = {"message": last_response.text} raise map_twilio_error(last_response.status_code, error_body) def _make_request_fn() -> Callable[[str, str, dict[str, str]], Awaitable[httpx.Response]]: """构造带 429 退避的 request_fn,供 ``_discover_resources`` 使用。""" 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_resources( base_url: str, api_version: str, account_sid: str, auth_headers: dict[str, str], request_fn: Callable[[str, str, dict[str, str]], Awaitable[httpx.Response]], ) -> list[dict[str, Any]]: """探测 Twilio 资源可达性,返回资源描述列表。 探测策略(见设计文档 §4.2): 1. 调用 ``GET /Accounts/{AccountSid}.json`` 获取账户信息 2. 并行探测 Messages / Calls / IncomingPhoneNumbers / AvailablePhoneNumbers / UsageRecords 资源可达性(``PageSize=1``) 3. 对可达资源构造资源描述 dict 4. 探测失败的资源标记 ``accessible=False``,不中断整体发现流程 """ resources: list[dict[str, Any]] = [] # 1. 账户信息(用于上下文,失败不中断) account_info: dict[str, Any] = {} try: account_url = f"{base_url}/{api_version}/Accounts/{account_sid}.json" resp = await request_fn("GET", account_url, auth_headers) account_info = resp.json() if resp.is_success else {} except Exception: # 账户信息获取失败不中断资源探测 account_info = {} # 2. 并行探测资源可达性 async def _probe(probe: dict[str, Any]) -> dict[str, Any]: path = probe["path_template"].format(api_version=api_version, account_sid=account_sid) url = f"{base_url}{path}?PageSize=1" try: resp = await request_fn("GET", url, auth_headers) accessible = resp.is_success except Exception: accessible = False return { "resource": probe["resource"], "path": probe["path_template"], "operations": probe["operations"], "accessible": accessible, "description": probe["description"], } probe_results = await asyncio.gather(*[_probe(p) for p in _RESOURCE_PROBES]) resources.extend(probe_results) # 3. 注入账户上下文(friendly_name / status / type) if account_info: for r in resources: r["account_friendly_name"] = account_info.get("friendly_name") r["account_status"] = account_info.get("status") r["account_type"] = account_info.get("type") return resources # ═══════════════════════════════════════════════════════════════════════════ # OperationHandler 实现 # ═══════════════════════════════════════════════════════════════════════════ async def discover_twilio( source_type: str, system_config: dict[str, Any], ) -> list[dict[str, Any]]: """discover handler:发现 Twilio 可用资源(账户信息 + 资源可达性探测)。 Returns: 资源摘要列表,每项含 resource / path / operations / accessible / description。 """ base_url = _resolve_base_url(system_config) account_sid = _resolve_account_sid(system_config) api_version = _resolve_api_version(system_config) auth_headers = _build_basic_auth_headers(system_config) request_fn = _make_request_fn() return await _discover_resources(base_url, api_version, account_sid, auth_headers, request_fn) async def preview_twilio_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 = TwilioConfigGenerator() return await generator.generate(payload) async def create_twilio_tools( source_type: str, system_config: dict[str, Any], ) -> GeneratedToolsDraft: """create_tools handler:生成工具草稿(不直接持久化)。 Returns: GeneratedToolsDraft,由 use_cases 层统一持久化。 """ payload = _build_generator_payload(system_config) generator = TwilioConfigGenerator() tool_configs = await generator.generate(payload) return GeneratedToolsDraft(tool_configs=tool_configs, override_existing=False) # ═══════════════════════════════════════════════════════════════════════════ # 生成器 payload 构造 # ═══════════════════════════════════════════════════════════════════════════ def _build_generator_payload(system_config: dict[str, Any]) -> dict[str, Any]: """从 system_config 构造生成器 payload。 生成器 payload 是 system_config 的子集,包含: - account_sid: str — 烘焙到 path_template - base_url: str - api_version: str - resources: list[dict] — discover 返回的资源描述(若已存在) - selected_resources: list[str] | None - auth_type: str - auth_config: dict """ discovery_options = system_config.get("discovery_options") or {} # 若 system_config 已含 discover 结果(由 use_cases 层注入),直接复用; # 否则使用全量资源(生成器内部按 selected_resources 过滤)。 resources = system_config.get("discovered_resources") or _get_default_resources() return { "account_sid": _resolve_account_sid(system_config), "base_url": _resolve_base_url(system_config), "api_version": _resolve_api_version(system_config), "resources": resources, "auth_type": (system_config.get("auth_config") or {}).get("auth_type", "basic"), "auth_config": system_config.get("auth_config") or {}, "selected_resources": discovery_options.get("selected_resources"), } def _get_default_resources() -> list[dict[str, Any]]: """返回默认资源列表(全部 accessible=True,供 preview/create 在未 discover 时使用)。""" return [ { "resource": probe["resource"], "path": probe["path_template"], "operations": probe["operations"], "accessible": True, "description": probe["description"], } for probe in _RESOURCE_PROBES ]