新增六边形架构核心代码包,包含: 1. 协议适配器层:HTTP/SMTP/IMAP/SSH/GRPC等多协议适配器实现 2. 认证插件体系:基础认证、API密钥、HMAC等多类型认证插件 3. 执行编排框架:工具执行器、上下文构建、运行时治理组件 4. 用例端口与DTO:定义领域服务端口与数据传输对象 5. 厂商集成包框架:支持第三方系统集成扩展 6. 基础设施装配层:实现依赖注入与服务装配 所有代码遵循六边形架构设计原则,实现端口与适配器解耦,支持动态扩展与自动发现。
454 lines
19 KiB
Python
454 lines
19 KiB
Python
"""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
|
||
]
|