183 lines
7.4 KiB
Python
183 lines
7.4 KiB
Python
"""
|
|
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")
|