ForcePilot/backend/package/yuxi/external_systems/integrations/twilio/operations.py
Kris 74709ba2d3 feat: 完成外部系统限界上下文核心代码实现
新增六边形架构核心代码包,包含:
1. 协议适配器层:HTTP/SMTP/IMAP/SSH/GRPC等多协议适配器实现
2. 认证插件体系:基础认证、API密钥、HMAC等多类型认证插件
3. 执行编排框架:工具执行器、上下文构建、运行时治理组件
4. 用例端口与DTO:定义领域服务端口与数据传输对象
5. 厂商集成包框架:支持第三方系统集成扩展
6. 基础设施装配层:实现依赖注入与服务装配

所有代码遵循六边形架构设计原则,实现端口与适配器解耦,支持动态扩展与自动发现。
2026-06-20 22:12:42 +08:00

454 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
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
]