Implement webhook

This commit is contained in:
Xin Wang
2026-08-07 14:08:49 +08:00
parent 39ee5ef96e
commit b2e99baa19
14 changed files with 886 additions and 34 deletions
+1
View File
@@ -0,0 +1 @@
"""Global webhook delivery helpers."""
+86
View File
@@ -0,0 +1,86 @@
"""Serialize, sign and send one webhook event."""
from __future__ import annotations
from dataclasses import dataclass
import hashlib
import hmac
import json
import time
from typing import Any
import httpx
from services.webhooks.security import validate_webhook_url
WEBHOOK_TIMEOUT_SECONDS = 10.0
@dataclass(frozen=True)
class DeliveryResult:
ok: bool
status_code: int | None
latency_ms: int
error: str = ""
def canonical_json(payload: dict[str, Any]) -> bytes:
return json.dumps(
payload,
ensure_ascii=False,
separators=(",", ":"),
sort_keys=True,
).encode("utf-8")
def webhook_signature(secret: str, timestamp: str, body: bytes) -> str:
signed = timestamp.encode("ascii") + b"." + body
digest = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
return f"v1={digest}"
def should_retry(status_code: int | None) -> bool:
return status_code is None or status_code in {408, 429} or status_code >= 500
async def deliver_webhook(
*,
url: str,
secret: str,
event_id: str,
payload: dict[str, Any],
) -> DeliveryResult:
"""Deliver without following redirects; callers decide whether to retry."""
safe_url = await validate_webhook_url(url)
body = canonical_json(payload)
timestamp = str(int(time.time()))
headers = {
"Content-Type": "application/json",
"X-AIVideo-Event-Id": event_id,
"X-AIVideo-Timestamp": timestamp,
"X-AIVideo-Signature": webhook_signature(secret, timestamp, body),
}
started = time.monotonic()
try:
async with httpx.AsyncClient(
timeout=WEBHOOK_TIMEOUT_SECONDS,
follow_redirects=False,
) as client:
response = await client.post(safe_url, content=body, headers=headers)
latency_ms = round((time.monotonic() - started) * 1000)
if 200 <= response.status_code < 300:
return DeliveryResult(True, response.status_code, latency_ms)
return DeliveryResult(
False,
response.status_code,
latency_ms,
f"目标服务返回 HTTP {response.status_code}",
)
except httpx.HTTPError as exc:
return DeliveryResult(
False,
None,
round((time.monotonic() - started) * 1000),
str(exc)[:2048],
)
+69
View File
@@ -0,0 +1,69 @@
"""Build durable webhook events in the same transaction as domain updates."""
from __future__ import annotations
from datetime import UTC, datetime
import uuid
from typing import Any
from db.models import ConversationSession, SiteSetting, WebhookDelivery
from sqlalchemy.ext.asyncio import AsyncSession
ANALYSIS_WEBHOOK_KEY = "analysis_webhook"
ANALYSIS_COMPLETED_EVENT = "call.analysis.completed"
def analysis_completed_payload(
conversation: ConversationSession,
result: dict[str, Any],
*,
event_id: str,
timestamp: datetime,
) -> dict[str, Any]:
return {
"id": event_id,
"event": ANALYSIS_COMPLETED_EVENT,
"timestamp": timestamp.isoformat(),
"data": {
"conversationId": conversation.id,
"assistantId": conversation.assistant_id,
"assistantName": conversation.assistant_name,
"analysis": result,
},
}
async def enqueue_analysis_completed(
session: AsyncSession,
conversation: ConversationSession,
result: dict[str, Any],
) -> WebhookDelivery | None:
setting = await session.get(SiteSetting, ANALYSIS_WEBHOOK_KEY)
values = dict(setting.value or {}) if setting else {}
secrets = dict(setting.secrets or {}) if setting else {}
url = str(values.get("url") or "")
secret = str(secrets.get("secret") or "")
if not values.get("enabled") or not url or not secret:
return None
event_id = f"evt_{uuid.uuid4().hex}"
now = datetime.now(UTC)
delivery = WebhookDelivery(
id=f"dlv_{uuid.uuid4().hex}",
event_id=event_id,
event_type=ANALYSIS_COMPLETED_EVENT,
conversation_id=conversation.id,
payload=analysis_completed_payload(
conversation,
result,
event_id=event_id,
timestamp=now,
),
url=url,
secrets={"secret": secret},
status="pending",
next_attempt_at=now,
)
session.add(delivery)
return delivery
+61
View File
@@ -0,0 +1,61 @@
"""Validate webhook destinations before every outbound request."""
from __future__ import annotations
import asyncio
import ipaddress
import socket
from urllib.parse import urlsplit
class UnsafeWebhookUrl(ValueError):
"""Raised when a URL could reach local or otherwise non-public infrastructure."""
def __init__(self, message: str, *, retryable: bool = False):
super().__init__(message)
self.retryable = retryable
def _public_ip(address: str) -> bool:
try:
return ipaddress.ip_address(address).is_global
except ValueError:
return False
async def validate_webhook_url(url: str) -> str:
"""Require HTTPS and ensure every currently resolved address is public."""
value = url.strip()
parsed = urlsplit(value)
if parsed.scheme != "https" or not parsed.hostname:
raise UnsafeWebhookUrl("Webhook URL 必须是完整的 HTTPS 地址")
if parsed.username or parsed.password:
raise UnsafeWebhookUrl("Webhook URL 不能包含用户名或密码")
try:
port = parsed.port or 443
except ValueError as exc:
raise UnsafeWebhookUrl("Webhook URL 端口无效") from exc
try:
direct_ip = ipaddress.ip_address(parsed.hostname)
except ValueError:
direct_ip = None
if direct_ip is not None:
if not direct_ip.is_global:
raise UnsafeWebhookUrl("Webhook URL 不能指向内网或本机地址")
return value
try:
records = await asyncio.to_thread(
socket.getaddrinfo,
parsed.hostname,
port,
type=socket.SOCK_STREAM,
)
except OSError as exc:
raise UnsafeWebhookUrl("Webhook 域名暂时无法解析", retryable=True) from exc
addresses = {str(record[4][0]) for record in records}
if not addresses or any(not _public_ip(address) for address in addresses):
raise UnsafeWebhookUrl("Webhook 域名解析到了内网或非公网地址")
return value
+120
View File
@@ -0,0 +1,120 @@
"""PostgreSQL-backed webhook delivery worker with bounded retries."""
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from db.models import WebhookDelivery
from db.session import SessionLocal
from loguru import logger
from services.webhooks.delivery import DeliveryResult, deliver_webhook, should_retry
from services.webhooks.security import UnsafeWebhookUrl
from sqlalchemy import or_, select, update
POLL_INTERVAL_SECONDS = 2.0
MAX_ATTEMPTS = 5
RETRY_DELAYS_SECONDS = (30, 120, 600, 3600)
@dataclass(frozen=True)
class ClaimedDelivery:
id: str
event_id: str
url: str
secret: str
payload: dict
async def recover_interrupted_webhooks() -> None:
async with SessionLocal() as session:
await session.execute(
update(WebhookDelivery)
.where(WebhookDelivery.status == "processing")
.values(status="pending", next_attempt_at=datetime.now(UTC))
)
await session.commit()
async def claim_pending_webhook() -> ClaimedDelivery | None:
now = datetime.now(UTC)
async with SessionLocal() as session:
row = (
await session.execute(
select(WebhookDelivery)
.where(
WebhookDelivery.status == "pending",
or_(
WebhookDelivery.next_attempt_at.is_(None),
WebhookDelivery.next_attempt_at <= now,
),
)
.order_by(WebhookDelivery.next_attempt_at, WebhookDelivery.created_at)
.with_for_update(skip_locked=True)
.limit(1)
)
).scalar_one_or_none()
if not row:
return None
row.status = "processing"
await session.commit()
return ClaimedDelivery(
id=row.id,
event_id=row.event_id,
url=row.url,
secret=str((row.secrets or {}).get("secret") or ""),
payload=dict(row.payload or {}),
)
async def finish_delivery(delivery_id: str, result: DeliveryResult) -> None:
async with SessionLocal() as session:
row = await session.get(WebhookDelivery, delivery_id)
if not row or row.status != "processing":
return
row.attempt_count += 1
row.last_status_code = result.status_code
row.last_error = result.error[:2048]
if result.ok:
row.status = "delivered"
row.delivered_at = datetime.now(UTC)
row.next_attempt_at = None
elif should_retry(result.status_code) and row.attempt_count < MAX_ATTEMPTS:
delay_index = min(row.attempt_count - 1, len(RETRY_DELAYS_SECONDS) - 1)
row.status = "pending"
row.next_attempt_at = datetime.now(UTC) + timedelta(
seconds=RETRY_DELAYS_SECONDS[delay_index]
)
else:
row.status = "failed"
row.next_attempt_at = None
await session.commit()
async def run_webhook_worker() -> None:
logger.info("通话分析 Webhook worker 已启动")
while True:
try:
delivery = await claim_pending_webhook()
if delivery:
try:
result = await deliver_webhook(
url=delivery.url,
secret=delivery.secret,
event_id=delivery.event_id,
payload=delivery.payload,
)
except UnsafeWebhookUrl as exc:
# DNS outages are transient; policy violations are permanent.
result = DeliveryResult(
False, None if exc.retryable else 400, 0, str(exc)
)
await finish_delivery(delivery.id, result)
continue
except asyncio.CancelledError:
raise
except Exception as exc:
logger.error(f"Webhook worker 暂时不可用:{exc}")
await asyncio.sleep(POLL_INTERVAL_SECONDS)