Files
Click/services/webhook_server.py
Yuriy Yuriev 44ce29d2e0 fix
2026-07-05 16:59:33 +07:00

204 lines
8.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 pathlib import Path
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 _save_lost_webhook(self, payload: dict) -> None:
"""Сохраняет необработанный вебхук в файл и уведомляет админа."""
lost_file = Path("data/lost_webhooks.json")
try:
data = []
if lost_file.exists():
with open(lost_file, "r", encoding="utf-8") as f:
data = json.load(f)
data.append({**payload, "_saved_at": datetime.now().isoformat(timespec="seconds")})
with open(lost_file, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
logger.info(f"Lost webhook saved: uuid={payload.get('uuid')}")
except Exception as e:
logger.error(f"Failed to save lost webhook: {e}")
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")
existing = await self._payment_storage.get(payment_uuid)
if existing and existing.get("processed"):
logger.info(f"Webhook: payment {payment_uuid} already processed — skipping duplicate")
return web.Response(text="ok")
payment = await self._payment_storage.pop(payment_uuid)
if not payment:
logger.warning(f"Webhook: payment {payment_uuid} not found in storage")
await self._save_lost_webhook(payload)
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._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")