192 lines
6.1 KiB
Python
192 lines
6.1 KiB
Python
import asyncio
|
|
import contextlib
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import logging
|
|
from dataclasses import dataclass
|
|
from datetime import UTC, datetime, timedelta
|
|
from typing import Any
|
|
|
|
import asyncpg
|
|
import httpx2
|
|
|
|
from utils.env import env
|
|
from utils.read import watches
|
|
from utils.read import webhooks as webhooks_read
|
|
from utils.read.models import AlertItem, DeliveryView, WebhookView
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
TIMEOUT_SECONDS = 10
|
|
SCAN_SECONDS = 30
|
|
BATCH = 20
|
|
RETRY_DELAYS = (30, 90, 270, 810, 2430)
|
|
ERROR_CHARS = 300
|
|
USER_AGENT = "beavergram-webhooks"
|
|
|
|
|
|
def sign(secret: str, body: bytes) -> str:
|
|
digest = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
|
return f"sha256={digest}"
|
|
|
|
|
|
def _encode(body: dict[str, Any]) -> str:
|
|
return json.dumps(body, ensure_ascii=False, separators=(",", ":"))
|
|
|
|
|
|
def _with_url(body: dict[str, Any], path: str) -> dict[str, Any]:
|
|
if env.api.public_url:
|
|
body["url"] = env.api.public_url.rstrip("/") + path
|
|
return body
|
|
|
|
|
|
def alert_body(item: AlertItem) -> dict[str, Any]:
|
|
data = item.model_dump(mode="json", exclude={"id", "seen"})
|
|
return _with_url({"type": "alert", "alert_id": item.id, **data}, item.path)
|
|
|
|
|
|
def test_body(webhook: WebhookView) -> dict[str, Any]:
|
|
body = {
|
|
"type": "test",
|
|
"alert_id": None,
|
|
"account_id": webhook.account_id,
|
|
"ts": datetime.now(UTC).isoformat(),
|
|
"event": "test",
|
|
"watch": None,
|
|
"chat": None,
|
|
"message": None,
|
|
"peer": None,
|
|
"path": "/app",
|
|
}
|
|
return _with_url(body, "/app")
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Target:
|
|
delivery_id: int
|
|
event: str
|
|
url: str
|
|
secret: str
|
|
|
|
|
|
async def post(
|
|
client: httpx2.AsyncClient, target: Target, body: str
|
|
) -> tuple[int | None, str | None]:
|
|
raw = body.encode()
|
|
headers = {
|
|
"Content-Type": "application/json",
|
|
"User-Agent": USER_AGENT,
|
|
"X-Beavergram-Event": target.event,
|
|
"X-Beavergram-Delivery": str(target.delivery_id),
|
|
"X-Beavergram-Signature": sign(target.secret, raw),
|
|
}
|
|
try:
|
|
response = await client.post(target.url, content=raw, headers=headers)
|
|
except httpx2.HTTPError as exc:
|
|
return None, (str(exc) or type(exc).__name__)[:ERROR_CHARS]
|
|
if response.is_success:
|
|
return response.status_code, None
|
|
reason = response.text.strip()[:ERROR_CHARS] or response.reason_phrase
|
|
return response.status_code, reason or f"HTTP {response.status_code}"
|
|
|
|
|
|
def new_client() -> httpx2.AsyncClient:
|
|
return httpx2.AsyncClient(timeout=TIMEOUT_SECONDS, follow_redirects=False)
|
|
|
|
|
|
async def _body(pool: asyncpg.Pool, row: asyncpg.Record) -> str | None:
|
|
if row["body"] is not None:
|
|
return row["body"]
|
|
if row["alert_id"] is None:
|
|
return None
|
|
item = await watches.get_alert(pool, row["alert_id"])
|
|
if item is None:
|
|
return None
|
|
body = _encode(alert_body(item))
|
|
await webhooks_read.save_body(pool, row["id"], body)
|
|
return body
|
|
|
|
|
|
async def _deliver(
|
|
pool: asyncpg.Pool, client: httpx2.AsyncClient, row: asyncpg.Record
|
|
) -> None:
|
|
body = await _body(pool, row)
|
|
if body is None:
|
|
await webhooks_read.finish(
|
|
pool, row["id"], webhooks_read.STATUS_FAILED, None, "alert is gone"
|
|
)
|
|
return
|
|
target = Target(row["id"], row["event"], row["url"], row["secret"])
|
|
code, error = await post(client, target, body)
|
|
if error is None:
|
|
await webhooks_read.finish(
|
|
pool, row["id"], webhooks_read.STATUS_DELIVERED, code, None
|
|
)
|
|
return
|
|
attempt = row["attempts"]
|
|
if attempt > len(RETRY_DELAYS):
|
|
await webhooks_read.finish(
|
|
pool, row["id"], webhooks_read.STATUS_FAILED, code, error
|
|
)
|
|
return
|
|
retry_at = datetime.now(UTC) + timedelta(seconds=RETRY_DELAYS[attempt - 1])
|
|
await webhooks_read.finish(
|
|
pool, row["id"], webhooks_read.STATUS_PENDING, code, error, retry_at
|
|
)
|
|
|
|
|
|
async def send_test(pool: asyncpg.Pool, webhook: WebhookView) -> DeliveryView | None:
|
|
body = _encode(test_body(webhook))
|
|
delivery_id = await webhooks_read.start_test(pool, webhook.id, body)
|
|
async with new_client() as client:
|
|
target = Target(delivery_id, "test", webhook.url, webhook.secret)
|
|
code, error = await post(client, target, body)
|
|
status = webhooks_read.STATUS_FAILED if error else webhooks_read.STATUS_DELIVERED
|
|
await webhooks_read.finish(pool, delivery_id, status, code, error)
|
|
return await webhooks_read.get_delivery(pool, delivery_id)
|
|
|
|
|
|
class Outbox:
|
|
def __init__(self) -> None:
|
|
self._wake = asyncio.Event()
|
|
|
|
def wake(self) -> None:
|
|
self._wake.set()
|
|
|
|
async def run(self, pool: asyncpg.Pool) -> None:
|
|
released = False
|
|
async with new_client() as client:
|
|
while True:
|
|
self._wake.clear()
|
|
try:
|
|
if not released:
|
|
await webhooks_read.release_stuck(pool)
|
|
released = True
|
|
await self._drain(pool, client)
|
|
delay = await self._delay(pool)
|
|
except Exception:
|
|
logger.exception("Webhook delivery failed")
|
|
delay = SCAN_SECONDS
|
|
with contextlib.suppress(TimeoutError):
|
|
await asyncio.wait_for(self._wake.wait(), timeout=delay)
|
|
|
|
async def _drain(self, pool: asyncpg.Pool, client: httpx2.AsyncClient) -> None:
|
|
while rows := await webhooks_read.claim_due(pool, BATCH):
|
|
results = await asyncio.gather(
|
|
*(_deliver(pool, client, row) for row in rows), return_exceptions=True
|
|
)
|
|
for result in results:
|
|
if isinstance(result, Exception):
|
|
logger.error("Webhook delivery crashed: %r", result)
|
|
|
|
async def _delay(self, pool: asyncpg.Pool) -> float:
|
|
due = await webhooks_read.next_due(pool)
|
|
if due is None:
|
|
return SCAN_SECONDS
|
|
wait = (due - datetime.now(UTC)).total_seconds()
|
|
return min(SCAN_SECONDS, max(wait, 1.0))
|
|
|
|
|
|
outbox = Outbox()
|