Implement webhook
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Global webhook delivery helpers."""
|
||||
@@ -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],
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user