ForcePilot/backend/package/yuxi/channel/extensions/generic_webhook/outbound.py
Kris 71e90075e2 feat(generic-webhook): 新增通用Webhook管道插件
实现了完整的通用Webhook通道插件,支持入站请求接收、JSONPath字段映射、多种认证方式、出站Webhook推送,以及端点配置管理、去重、安全校验等功能
2026-05-21 10:47:58 +08:00

151 lines
5.3 KiB
Python

import asyncio
import logging
import httpx
from jinja2 import BaseLoader, StrictUndefined
from jinja2.sandbox import SandboxedEnvironment
from yuxi.channel.extensions.generic_webhook.types import OutboundConfig, OutboundResult
logger = logging.getLogger("yuxi.channel.generic_webhook.outbound")
MAX_RETRIES = 3
RETRY_BACKOFF_BASE = 1.0
RETRYABLE_STATUSES = frozenset({429, 500, 502, 503, 504})
CONNECT_TIMEOUT = 10.0
REQUEST_TIMEOUT = 30.0
class TemplateRenderer:
def __init__(self):
self._env = SandboxedEnvironment(
loader=BaseLoader(),
undefined=StrictUndefined,
autoescape=False,
extensions=[],
)
def render(self, template_str: str, context: dict) -> str:
if not template_str:
return ""
try:
template = self._env.from_string(template_str)
return template.render(**context)
except Exception as e:
logger.error("模板渲染失败: %s", e)
raise ValueError(f"模板渲染失败: {e}")
class WebhookOutbound:
def __init__(self):
self._http: httpx.AsyncClient | None = None
self._renderer = TemplateRenderer()
async def _ensure_http(self):
if self._http is None:
self._http = httpx.AsyncClient(
timeout=httpx.Timeout(CONNECT_TIMEOUT, read=REQUEST_TIMEOUT),
)
async def send(self, config: OutboundConfig, context: dict) -> OutboundResult:
if not config or not config.url:
return OutboundResult(success=False, error="未配置出站 URL")
retry_max = min(config.retry_max or MAX_RETRIES, 5)
backoff = config.retry_backoff_base or RETRY_BACKOFF_BASE
await self._ensure_http()
body = self._render_payload(config, context)
headers = self._build_headers(config, body)
for attempt in range(retry_max):
try:
resp = await self._http.request(
method=config.method or "POST",
url=config.url,
content=body,
headers=headers,
)
if 200 <= resp.status_code < 300:
return OutboundResult(success=True, status_code=resp.status_code, attempt=attempt + 1)
retry_statuses = set(config.retry_statuses) if config.retry_statuses else RETRYABLE_STATUSES
if resp.status_code not in retry_statuses:
logger.warning(
"出站请求失败(不可重试): endpoint %s, status=%d, body=%s",
context.get("endpoint_id"),
resp.status_code,
resp.text[:200],
)
return OutboundResult(
success=False,
status_code=resp.status_code,
error=f"HTTP {resp.status_code}: {resp.text[:200]}",
attempt=attempt + 1,
)
logger.warning(
"出站请求失败(可重试): endpoint %s, status=%d, attempt=%d/%d",
context.get("endpoint_id"),
resp.status_code,
attempt + 1,
retry_max,
)
except (httpx.ConnectError, httpx.TimeoutException, httpx.RemoteProtocolError) as e:
logger.warning(
"出站请求网络异常: endpoint %s, attempt=%d/%d, error=%s",
context.get("endpoint_id"),
attempt + 1,
retry_max,
e,
)
except Exception as e:
logger.error("出站请求未知异常: %s", e)
return OutboundResult(success=False, error=str(e), attempt=attempt + 1)
if attempt < retry_max - 1:
delay = backoff * (2**attempt)
await asyncio.sleep(delay)
return OutboundResult(
success=False,
error=f"重试 {retry_max} 次后仍失败",
attempt=retry_max,
)
def _build_headers(self, config: OutboundConfig, body: str) -> dict:
headers = {"Content-Type": config.content_type or "application/json"}
if config.auth_type == "bearer" and config.auth_token:
headers["Authorization"] = f"Bearer {config.auth_token}"
elif config.auth_type == "header" and config.auth_token and config.auth_header_name:
headers[config.auth_header_name] = config.auth_token
elif config.auth_type == "hmac_sha256" and config.auth_token:
import hashlib
import hmac
signature = hmac.new(
config.auth_token.encode(),
body.encode(),
hashlib.sha256,
).hexdigest()
headers[config.auth_header_name or "X-Signature-256"] = signature
return headers
def _render_payload(self, config: OutboundConfig, context: dict) -> str:
if not config.payload_template:
import json
return json.dumps(context, ensure_ascii=False, default=str)
return self._renderer.render(config.payload_template, context)
async def close(self):
if self._http:
await self._http.aclose()
self._http = None