""" 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(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(user_id, added_clicks) await self._rub_storage.add(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")