Files
Click/services/webhook_server.py
T
Yuriy Yuriev d9fd1ffbbd fix pay
2026-05-22 15:51:25 +07:00

165 lines
6.9 KiB
Python

"""
Webhook-сервер для приёма уведомлений от Heleket об оплате.
Heleket шлёт POST на url_callback когда статус счёта меняется.
Сервер верифицирует подпись, начисляет баланс и уведомляет пользователя в Telegram.
"""
import asyncio
import base64
import hashlib
import json
import logging
from typing import Optional
from aiohttp import web
from config.settings import settings
from managers.storage import PaymentStorage, BalanceStorage, RubleBalanceStorage
logger = logging.getLogger(__name__)
# Доверенный IP Heleket из документации
HELEKET_IP = "31.133.220.8"
def _verify_sign(payload: dict, api_key: str) -> bool:
"""Верифицирует подпись webhook-запроса от Heleket.
Алгоритм: md5( base64( json(payload_without_sign) ) + api_key )
ВАЖНО: PHP json_encode() экранирует '/' как '\/', Python — нет.
Heleket вычисляет подпись на PHP, поэтому вручную экранируем слэши.
"""
received_sign = payload.get("sign", "")
data = {k: v for k, v in payload.items() if k != "sign"}
data_json = json.dumps(data, separators=(",", ":"), ensure_ascii=False)
data_json = data_json.replace("/", "\\/") # имитируем PHP json_encode
data_b64 = base64.b64encode(data_json.encode("utf-8")).decode("utf-8")
expected = hashlib.md5((data_b64 + api_key).encode("utf-8")).hexdigest()
return expected == received_sign
class HelketWebhookServer:
"""
aiohttp сервер для вебхуков Heleket.
Запускается параллельно с Telegram-ботом.
"""
def __init__(
self,
payment_storage: PaymentStorage,
balance_storage: BalanceStorage,
rub_storage: RubleBalanceStorage,
bot, # aiogram Bot
port: int = 8080,
):
self._payment_storage = payment_storage
self._balance_storage = balance_storage
self._rub_storage = rub_storage
self._bot = bot
self._port = port
self._app = web.Application()
self._runner: Optional[web.AppRunner] = None
self._api_key = settings.HELEKET_API_KEY
self._app.router.add_post("/heleket/webhook", self._handle_webhook)
self._app.router.add_get("/health", self._handle_health)
async def start(self) -> None:
self._runner = web.AppRunner(self._app)
await self._runner.setup()
site = web.TCPSite(self._runner, "0.0.0.0", self._port)
await site.start()
logger.info(f"Heleket webhook server started on port {self._port}")
async def stop(self) -> None:
if self._runner:
await self._runner.cleanup()
logger.info("Heleket webhook server stopped")
async def _handle_health(self, request: web.Request) -> web.Response:
return web.Response(text="ok")
async def _handle_webhook(self, request: web.Request) -> web.Response:
# Проверяем IP (опционально — может быть за прокси/CDN)
client_ip = request.headers.get("X-Forwarded-For", request.remote)
if settings.HELEKET_WHITELIST_IP and client_ip and HELEKET_IP not in client_ip:
logger.warning(f"Webhook from unknown IP: {client_ip}")
return web.Response(status=403, text="Forbidden")
try:
payload = await request.json()
except Exception:
logger.warning("Webhook: invalid JSON body")
return web.Response(status=400, text="Bad Request")
logger.debug(f"Webhook payload: {payload}")
# Верифицируем подпись если есть API ключ
if self._api_key and not _verify_sign(payload, self._api_key):
logger.warning(f"Webhook: invalid signature. uuid={payload.get('uuid')}")
return web.Response(status=400, text="Invalid signature")
status = payload.get("status", "")
payment_uuid = payload.get("uuid", "")
# paid_over — клиент заплатил больше суммы счёта, тоже засчитываем
if status not in ("paid", "paid_over"):
logger.info(f"Webhook: uuid={payment_uuid} status={status} — ignored")
return web.Response(text="ok")
# Ищем платёж в хранилище
payment = await self._payment_storage.get(payment_uuid)
if not payment:
logger.warning(f"Webhook: payment {payment_uuid} not found in storage")
return web.Response(text="ok") # уже обработан или неизвестный
user_id = payment.get("user_id")
chat_id = payment.get("chat_id") or user_id
clicks = payment.get("clicks")
rub = payment.get("rub", 0)
try:
if clicks:
# Прямая покупка переходов
await self._balance_storage.add_balance(user_id, clicks)
added_clicks = clicks
msg = (
f"✅ Оплата получена!\n\n"
f"💳 Сумма: {rub}\n"
f"🖱 Начислено: {clicks} переходов\n"
f"📊 Проверьте баланс в личном кабинете"
)
else:
# Пополнение рублёвого баланса → конвертируем в переходы
added_clicks = int(rub // settings.CLICK_PRICE_RUB)
await self._balance_storage.add_balance(user_id, added_clicks)
await self._rub_storage.add_balance(user_id, rub)
msg = (
f"✅ Пополнение получено!\n\n"
f"💳 Сумма: {rub}\n"
f"🖱 Начислено: {added_clicks} переходов "
f"({rub} ÷ {settings.CLICK_PRICE_RUB} ₽/переход)\n"
f"📊 Проверьте баланс в личном кабинете"
)
await self._payment_storage.delete(payment_uuid)
logger.info(
f"Webhook: payment {payment_uuid} confirmed — "
f"user={user_id} +{added_clicks} clicks"
)
# Уведомляем пользователя
if chat_id:
try:
await self._bot.send_message(chat_id, msg)
except Exception as e:
logger.warning(f"Webhook: failed to notify user {user_id}: {e}")
except Exception as e:
logger.error(f"Webhook: error processing payment {payment_uuid}: {e}", exc_info=True)
return web.Response(status=500, text="Internal error")
return web.Response(text="ok")