ForcePilot/backend/package/yuxi/external_systems/integrations/twilio/operations.py
Kris 55188ff7fa refactor: 整理代码结构并修复多处细节问题
1. 调整导入顺序与格式,修复重复导入
2. 替换通用异常捕获为更精准的ValueError
3. 重构持久层仓库的get_deleted/list_deleted方法,添加ORM转领域模型逻辑
4. 完善配置校验与参数合法性检查,新增缺失字段校验
5. 优化多行字符串与函数调用的换行格式
6. 修复通知服务状态判定逻辑与文档描述
7. 简化无用的TYPE_CHECKING代码块
8. 重构ServiceNow元数据发现逻辑,增加字段合法性校验
9. 修复数据库查询验证器的子查询处理逻辑
10. 完善领域模型的初始化校验与属性语义
2026-07-11 07:34:48 +08:00

455 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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
if last_response is None:
raise ExecutionError(f"Twilio 请求失败: 无可用响应: {url}")
if last_response.is_success:
return last_response
# 非 2xx解析错误 body 并映射为 ExternalSystemError 子类
try:
error_body = last_response.json()
except ValueError:
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
]