ForcePilot/backend/package/yuxi/external_systems/integrations/twilio/operations.py

454 lines
19 KiB
Python
Raw Normal View History

"""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 TokenHMAC 密钥
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 TokenHMAC 密钥
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_credentialuse_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
]