mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-01-20 11:50:27 +00:00
193 lines
6.5 KiB
Python
193 lines
6.5 KiB
Python
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import hashlib
|
||
import hmac
|
||
import json
|
||
import logging
|
||
from dataclasses import dataclass
|
||
from typing import Any, Optional
|
||
|
||
import aiohttp
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.database.crud.webhook import (
|
||
get_active_webhooks_for_event,
|
||
record_webhook_delivery,
|
||
update_webhook_stats,
|
||
)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
@dataclass
|
||
class DeliveryResult:
|
||
"""Результат доставки webhook."""
|
||
|
||
webhook: Any
|
||
event_type: str
|
||
payload: dict[str, Any]
|
||
status: str
|
||
response_status: Optional[int] = None
|
||
response_body: Optional[str] = None
|
||
error_message: Optional[str] = None
|
||
|
||
|
||
class WebhookService:
|
||
"""Сервис для отправки webhooks."""
|
||
|
||
def __init__(self) -> None:
|
||
self._session: Optional[aiohttp.ClientSession] = None
|
||
|
||
async def _get_session(self) -> aiohttp.ClientSession:
|
||
"""Получить или создать HTTP сессию."""
|
||
if self._session is None or self._session.closed:
|
||
timeout = aiohttp.ClientTimeout(total=10, connect=5)
|
||
self._session = aiohttp.ClientSession(timeout=timeout)
|
||
return self._session
|
||
|
||
async def close(self) -> None:
|
||
"""Закрыть HTTP сессию."""
|
||
if self._session and not self._session.closed:
|
||
await self._session.close()
|
||
|
||
def _sign_payload(self, payload: str, secret: str) -> str:
|
||
"""Подписать payload с помощью секрета."""
|
||
return hmac.new(
|
||
secret.encode("utf-8"),
|
||
payload.encode("utf-8"),
|
||
hashlib.sha256,
|
||
).hexdigest()
|
||
|
||
async def send_webhook(
|
||
self,
|
||
db: AsyncSession,
|
||
event_type: str,
|
||
payload: dict[str, Any],
|
||
) -> None:
|
||
"""Отправить webhook для события."""
|
||
webhooks = await get_active_webhooks_for_event(db, event_type)
|
||
|
||
if not webhooks:
|
||
logger.debug("No active webhooks for event type: %s", event_type)
|
||
return
|
||
|
||
# Выполняем HTTP запросы параллельно (без операций с БД)
|
||
tasks = [
|
||
self._deliver_webhook_http(webhook, event_type, payload)
|
||
for webhook in webhooks
|
||
]
|
||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||
|
||
# Записываем результаты в БД последовательно (избегаем concurrent session access)
|
||
for result in results:
|
||
if isinstance(result, Exception):
|
||
logger.exception("Unexpected error during webhook delivery: %s", result)
|
||
continue
|
||
if isinstance(result, DeliveryResult):
|
||
await self._record_result(db, result)
|
||
|
||
async def _deliver_webhook_http(
|
||
self,
|
||
webhook: Any,
|
||
event_type: str,
|
||
payload: dict[str, Any],
|
||
) -> DeliveryResult:
|
||
"""Выполнить HTTP доставку webhook (без операций с БД)."""
|
||
payload_json = json.dumps(payload, default=str, ensure_ascii=False)
|
||
headers = {
|
||
"Content-Type": "application/json",
|
||
"X-Webhook-Event": event_type,
|
||
"X-Webhook-Id": str(webhook.id),
|
||
}
|
||
|
||
# Добавляем подпись, если есть секрет
|
||
if webhook.secret:
|
||
signature = self._sign_payload(payload_json, webhook.secret)
|
||
headers["X-Webhook-Signature"] = f"sha256={signature}"
|
||
|
||
try:
|
||
session = await self._get_session()
|
||
async with session.post(
|
||
webhook.url,
|
||
data=payload_json,
|
||
headers=headers,
|
||
) as response:
|
||
response_body = await response.text()
|
||
# Ограничиваем размер ответа для хранения
|
||
if len(response_body) > 1000:
|
||
response_body = response_body[:1000] + "... (truncated)"
|
||
|
||
status = "success" if 200 <= response.status < 300 else "failed"
|
||
error_message = None
|
||
if status == "failed":
|
||
error_message = f"HTTP {response.status}: {response_body[:500]}"
|
||
|
||
return DeliveryResult(
|
||
webhook=webhook,
|
||
event_type=event_type,
|
||
payload=payload,
|
||
status=status,
|
||
response_status=response.status,
|
||
response_body=response_body,
|
||
error_message=error_message,
|
||
)
|
||
|
||
except asyncio.TimeoutError:
|
||
return DeliveryResult(
|
||
webhook=webhook,
|
||
event_type=event_type,
|
||
payload=payload,
|
||
status="failed",
|
||
error_message="Request timeout",
|
||
)
|
||
|
||
except Exception as error:
|
||
return DeliveryResult(
|
||
webhook=webhook,
|
||
event_type=event_type,
|
||
payload=payload,
|
||
status="failed",
|
||
error_message=str(error),
|
||
)
|
||
|
||
async def _record_result(self, db: AsyncSession, result: DeliveryResult) -> None:
|
||
"""Записать результат доставки в БД (последовательно)."""
|
||
try:
|
||
await record_webhook_delivery(
|
||
db,
|
||
webhook_id=result.webhook.id,
|
||
event_type=result.event_type,
|
||
payload=result.payload,
|
||
status=result.status,
|
||
response_status=result.response_status,
|
||
response_body=result.response_body,
|
||
error_message=result.error_message,
|
||
)
|
||
|
||
await update_webhook_stats(db, result.webhook, result.status == "success")
|
||
|
||
if result.status == "success":
|
||
logger.info(
|
||
"Webhook %s delivered successfully to %s",
|
||
result.webhook.id,
|
||
result.webhook.url,
|
||
)
|
||
else:
|
||
logger.warning(
|
||
"Webhook %s delivery failed: %s",
|
||
result.webhook.id,
|
||
result.error_message,
|
||
)
|
||
except Exception as error:
|
||
logger.exception(
|
||
"Failed to record webhook delivery result for %s: %s",
|
||
result.webhook.id,
|
||
error,
|
||
)
|
||
|
||
|
||
# Глобальный экземпляр сервиса
|
||
webhook_service = WebhookService()
|
||
|