""" Webhook-сервер для приёма уведомлений от Heleket об оплате. Heleket шлёт POST на url_callback когда статус счёта меняется. Сервер верифицирует подпись, начисляет баланс и уведомляет пользователя в Telegram. """ import asyncio import base64 import hashlib import json import logging from datetime import datetime from typing import Optional from aiohttp import web from config.settings import settings from managers.storage import PaymentStorage, BalanceStorage, RubleBalanceStorage, PaymentHistoryStorage logger = logging.getLogger(__name__) 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 — нет, делаем вручную. """ 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("/", "\\/") 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: def __init__( self, payment_storage: PaymentStorage, balance_storage: BalanceStorage, rub_storage: RubleBalanceStorage, history_storage: PaymentHistoryStorage, bot, port: int = 8080, ): self._payment_storage = payment_storage self._balance_storage = balance_storage self._rub_storage = rub_storage self._history_storage = history_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, access_log=None) 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: 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.info(f"Webhook received: uuid={payload.get('uuid')} status={payload.get('status')}") 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", "") 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 (already processed?)") 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) msg = ( f"✅ Оплата получена!\n\n" f"💳 Сумма: {rub} ₽\n" f"🖱 Начислено: {clicks} переходов" ) history_record = { "type": "clicks", "rub": rub, "clicks": clicks, "uuid": payment_uuid, "at": datetime.now().isoformat(timespec="seconds"), } logger.info(f"Webhook: user={user_id} +{clicks} clicks") else: # Пополнение — начисляем только рубли, переходы пользователь купит сам await self._rub_storage.add_balance(user_id, rub) new_rub = await self._rub_storage.get_balance(user_id) msg = ( f"✅ Баланс пополнен!\n\n" f"💳 Сумма: {rub} ₽\n" f"🪙 Рублей на балансе: {new_rub} ₽\n\n" f"Перейдите в кабинет чтобы купить переходы." ) history_record = { "type": "topup", "rub": rub, "uuid": payment_uuid, "at": datetime.now().isoformat(timespec="seconds"), } logger.info(f"Webhook: user={user_id} +{rub} RUB") await self._payment_storage.delete(payment_uuid) await self._history_storage.add(user_id, history_record) if chat_id: from aiogram.types import InlineKeyboardButton from aiogram.utils.keyboard import InlineKeyboardBuilder builder = InlineKeyboardBuilder() builder.row(InlineKeyboardButton(text="🏠 Личный кабинет", callback_data="my_balance")) kb = builder.as_markup() msg_id = payment.get("msg_id") edited = False if msg_id: try: await self._bot.edit_message_text( msg, chat_id=chat_id, message_id=msg_id, reply_markup=kb ) edited = True except Exception: pass if not edited: try: await self._bot.send_message(chat_id, msg, reply_markup=kb) 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_uuid}: {e}", exc_info=True) return web.Response(status=500, text="Internal error") return web.Response(text="ok")