Merge pull request #222 from JustYay/dev

Crypto-payment via Heleket
This commit is contained in:
Vladislav Lisitsyn
2025-07-24 23:31:46 +03:00
committed by GitHub
7 changed files with 498 additions and 0 deletions
+3
View File
@@ -97,3 +97,6 @@ WATA_INT = "🌍 WATA: Международные карты"
KASSAI_CARDS = "💳 KassaAI: Карты РФ"
KASSAI_SBP = "🏦 KassaAI: СБП"
STARS_BOT_URL = "https://t.me/PremiumBot"
# Кнопки Heleket
HELEKET_CRYPTO = "₿ Heleket: Crypto"
+12
View File
@@ -13,6 +13,7 @@ from config import (
CRYPTO_BOT_ENABLE,
DONATIONS_ENABLE,
FREEKASSA_ENABLE,
HELEKET_ENABLE,
KASSAI_ENABLE,
ROBOKASSA_ENABLE,
STARS_ENABLE,
@@ -29,6 +30,7 @@ from handlers.buttons import (
COUPON,
CRYPTOBOT,
FREEKASSA,
HELEKET_CRYPTO,
KASSAI_CARDS,
KASSAI_SBP,
MAIN_MENU,
@@ -43,6 +45,7 @@ from handlers.buttons import (
)
from handlers.payments.cryprobot_pay import process_callback_pay_cryptobot
from handlers.payments.freekassa_pay import process_callback_pay_freekassa
from handlers.payments.heleket import process_callback_pay_heleket
from handlers.payments.kassai import process_callback_pay_kassai
from handlers.payments.robokassa_pay import process_callback_pay_robokassa
from handlers.payments.stars_pay import process_callback_pay_stars
@@ -67,6 +70,8 @@ async def handle_pay(callback_query: CallbackQuery, state: FSMContext, session:
payment_handlers.append(process_callback_pay_yoomoney)
if KASSAI_ENABLE:
payment_handlers.append(process_callback_pay_kassai)
if HELEKET_ENABLE:
payment_handlers.append(process_callback_pay_heleket)
if WATA_RU_ENABLE or WATA_SBP_ENABLE or WATA_INT_ENABLE:
payment_handlers.append(process_callback_pay_wata)
if CRYPTO_BOT_ENABLE:
@@ -91,6 +96,8 @@ async def handle_pay(callback_query: CallbackQuery, state: FSMContext, session:
if KASSAI_ENABLE:
builder.row(InlineKeyboardButton(text=KASSAI_CARDS, callback_data="pay_kassai_cards"))
builder.row(InlineKeyboardButton(text=KASSAI_SBP, callback_data="pay_kassai_sbp"))
if HELEKET_ENABLE:
builder.row(InlineKeyboardButton(text=HELEKET_CRYPTO, callback_data="pay_heleket_crypto"))
if CRYPTO_BOT_ENABLE:
builder.row(InlineKeyboardButton(text=CRYPTOBOT, callback_data="pay_cryptobot"))
if STARS_ENABLE:
@@ -194,3 +201,8 @@ async def handle_pay_kassai_cards(callback_query: CallbackQuery, state: FSMConte
@router.callback_query(F.data == "pay_kassai_sbp")
async def handle_pay_kassai_sbp(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
await process_callback_pay_kassai(callback_query, state, session, method_name="sbp")
@router.callback_query(F.data == "pay_heleket_crypto")
async def handle_pay_heleket_crypto(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
await process_callback_pay_heleket(callback_query, state, session, method_name="crypto")
+6
View File
@@ -10,6 +10,7 @@ from config import (
STARS_ENABLE,
YOOKASSA_ENABLE,
YOOMONEY_ENABLE,
HELEKET_ENABLE,
)
from .cryprobot_pay import router as cryprobot_router
@@ -21,6 +22,7 @@ from .stars_pay import router as stars_router
from .yookassa_pay import router as yookassa_router
from .yoomoney_pay import router as yoomoney_router
from .wata import router as wata_router
from .heleket import router as heleket_router
router = Router(name="payments_main_router")
@@ -38,6 +40,10 @@ if STARS_ENABLE:
router.include_router(stars_router)
if KASSAI_ENABLE:
router.include_router(kassai_router)
if YOOMONEY_ENABLE:
router.include_router(yoomoney_router)
if HELEKET_ENABLE:
router.include_router(heleket_router)
router.include_router(wata_router)
router.include_router(gift_router)
+351
View File
@@ -0,0 +1,351 @@
import aiohttp
import hashlib
import base64
import time
import json
from aiogram import F, Router, types
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from sqlalchemy.ext.asyncio import AsyncSession
from config import (
HELEKET_ENABLE, HELEKET_MERCHANT_ID, HELEKET_API_KEY,
HELEKET_SUCCESS_URL, HELEKET_RETURN_URL, HELEKET_CALLBACK_URL,
HELEKET_CURRENCY_RATE
)
from handlers.buttons import BACK, PAY_2, HELEKET_CRYPTO
from handlers.texts import (
HELEKET_CRYPTO_DESCRIPTION, HELEKET_PAYMENT_MESSAGE, ENTER_SUM, PAYMENT_OPTIONS, HELEKET_PAYMENT_TITLE
)
from handlers.utils import edit_or_send_message
from logger import logger
router = Router()
class ReplenishBalanceHeleket(StatesGroup):
choosing_method = State()
choosing_amount = State()
waiting_for_payment_confirmation = State()
entering_custom_amount = State()
HELEKET_PAYMENT_METHODS = [
{"enable": HELEKET_ENABLE, "currency": "USD", "to_currency": None, "name": "crypto", "button": HELEKET_CRYPTO, "desc": HELEKET_CRYPTO_DESCRIPTION},
]
@router.callback_query(F.data == "pay_heleket")
async def process_callback_pay_heleket(callback_query: types.CallbackQuery, state: FSMContext, session: AsyncSession, method_name: str = None):
try:
tg_id = callback_query.message.chat.id
logger.info(f"User {tg_id} initiated Heleket payment.")
await state.clear()
if method_name:
method = next((m for m in HELEKET_PAYMENT_METHODS if m["name"] == method_name and m["enable"]), None)
if not method:
await edit_or_send_message(
target_message=callback_query.message,
text="Ошибка: выбранный способ оплаты недоступен.",
reply_markup=types.InlineKeyboardMarkup(),
force_text=True,
)
return
builder = InlineKeyboardBuilder()
for i in range(0, len(PAYMENT_OPTIONS), 2):
if i + 1 < len(PAYMENT_OPTIONS):
builder.row(
InlineKeyboardButton(
text=PAYMENT_OPTIONS[i]["text"],
callback_data=f'heleket_amount|{method_name}|{PAYMENT_OPTIONS[i]["callback_data"].split("|")[1]}',
),
InlineKeyboardButton(
text=PAYMENT_OPTIONS[i + 1]["text"],
callback_data=f'heleket_amount|{method_name}|{PAYMENT_OPTIONS[i + 1]["callback_data"].split("|")[1]}',
),
)
else:
builder.row(
InlineKeyboardButton(
text=PAYMENT_OPTIONS[i]["text"],
callback_data=f'heleket_amount|{method_name}|{PAYMENT_OPTIONS[i]["callback_data"].split("|")[1]}',
)
)
builder.row(InlineKeyboardButton(text="Ввести сумму", callback_data=f"heleket_custom_amount|{method_name}"))
builder.row(InlineKeyboardButton(text=BACK, callback_data="balance"))
await edit_or_send_message(
target_message=callback_query.message,
text=method["desc"],
reply_markup=builder.as_markup(),
force_text=True,
)
await state.update_data(heleket_method=method_name)
await state.set_state(ReplenishBalanceHeleket.choosing_amount)
return
builder = InlineKeyboardBuilder()
for method in HELEKET_PAYMENT_METHODS:
if method["enable"]:
builder.row(InlineKeyboardButton(text=method["button"], callback_data=f'heleket_method|{method["name"]}'))
builder.row(InlineKeyboardButton(text=BACK, callback_data="balance"))
await edit_or_send_message(
target_message=callback_query.message,
text="Выберите способ оплаты через Heleket:",
reply_markup=builder.as_markup(),
force_text=True,
)
await state.update_data(message_id=callback_query.message.message_id, chat_id=callback_query.message.chat.id)
await state.set_state(ReplenishBalanceHeleket.choosing_method)
except Exception as e:
logger.error(f"Error in process_callback_pay_heleket for user {callback_query.message.chat.id}: {e}")
await callback_query.answer("Произошла ошибка при инициализации платежа. Попробуйте позже.", show_alert=True)
@router.callback_query(F.data.startswith("heleket_method|"))
async def process_method_selection(callback_query: types.CallbackQuery, state: FSMContext):
method_name = callback_query.data.split("|")[1]
method = next((m for m in HELEKET_PAYMENT_METHODS if m["name"] == method_name), None)
if not method or not method["enable"]:
await edit_or_send_message(
target_message=callback_query.message,
text="Ошибка: выбранный способ оплаты недоступен.",
reply_markup=types.InlineKeyboardMarkup(),
force_text=True,
)
return
await state.update_data(heleket_method=method_name)
builder = InlineKeyboardBuilder()
for i in range(0, len(PAYMENT_OPTIONS), 2):
if i + 1 < len(PAYMENT_OPTIONS):
builder.row(
InlineKeyboardButton(
text=PAYMENT_OPTIONS[i]["text"],
callback_data=f'heleket_amount|{method_name}|{PAYMENT_OPTIONS[i]["callback_data"].split("|")[1]}',
),
InlineKeyboardButton(
text=PAYMENT_OPTIONS[i + 1]["text"],
callback_data=f'heleket_amount|{method_name}|{PAYMENT_OPTIONS[i + 1]["callback_data"].split("|")[1]}',
),
)
else:
builder.row(
InlineKeyboardButton(
text=PAYMENT_OPTIONS[i]["text"],
callback_data=f'heleket_amount|{method_name}|{PAYMENT_OPTIONS[i]["callback_data"].split("|")[1]}',
)
)
builder.row(InlineKeyboardButton(text="Ввести сумму", callback_data=f"heleket_custom_amount|{method_name}"))
builder.row(InlineKeyboardButton(text=BACK, callback_data="pay_heleket"))
await edit_or_send_message(
target_message=callback_query.message,
text=method["desc"],
reply_markup=builder.as_markup(),
force_text=True,
)
await state.update_data(message_id=callback_query.message.message_id, chat_id=callback_query.message.chat.id)
await state.set_state(ReplenishBalanceHeleket.choosing_amount)
@router.callback_query(F.data.startswith("heleket_custom_amount|"))
async def process_custom_amount_button(callback_query: types.CallbackQuery, state: FSMContext):
method_name = callback_query.data.split("|")[1]
await state.update_data(heleket_method=method_name)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"pay_heleket_{method_name}"))
await edit_or_send_message(
target_message=callback_query.message,
text=ENTER_SUM,
reply_markup=builder.as_markup(),
force_text=True,
)
await state.set_state(ReplenishBalanceHeleket.entering_custom_amount)
@router.message(ReplenishBalanceHeleket.entering_custom_amount)
async def handle_custom_amount_input(message: types.Message, state: FSMContext):
data = await state.get_data()
method_name = data.get("heleket_method")
method = next((m for m in HELEKET_PAYMENT_METHODS if m["name"] == method_name), None)
if not method or not method["enable"]:
await edit_or_send_message(
target_message=message,
text="Ошибка: выбранный способ оплаты недоступен.",
reply_markup=types.InlineKeyboardMarkup(),
force_text=True,
)
return
try:
amount = int(message.text.strip())
if amount <= 0:
raise ValueError
if amount < 10:
await edit_or_send_message(
target_message=message,
text="Минимальная сумма для оплаты криптовалютой — 10 рублей.",
reply_markup=types.InlineKeyboardMarkup(),
force_text=True,
)
return
except Exception:
await edit_or_send_message(
target_message=message,
text="Некорректная сумма. Введите целое число больше 0.",
reply_markup=types.InlineKeyboardMarkup(),
force_text=True,
)
return
await state.update_data(amount=amount)
payment_url = await generate_heleket_payment_link(amount, message.chat.id, method)
confirm_keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text=PAY_2, url=payment_url)],
[InlineKeyboardButton(text=BACK, callback_data="balance")],
]
)
await edit_or_send_message(
target_message=message,
text=HELEKET_PAYMENT_MESSAGE.format(amount=amount),
reply_markup=confirm_keyboard,
force_text=True,
)
await state.set_state(ReplenishBalanceHeleket.waiting_for_payment_confirmation)
@router.callback_query(F.data.startswith("heleket_amount|"))
async def process_amount_selection(callback_query: types.CallbackQuery, state: FSMContext):
parts = callback_query.data.split("|")
method_name = parts[1]
amount_str = parts[2]
method = next((m for m in HELEKET_PAYMENT_METHODS if m["name"] == method_name), None)
if not method or not method["enable"]:
await edit_or_send_message(
target_message=callback_query.message,
text="Ошибка: выбранный способ оплаты недоступен.",
reply_markup=types.InlineKeyboardMarkup(),
force_text=True,
)
return
try:
amount = int(amount_str)
if amount <= 0:
raise ValueError
except Exception:
await edit_or_send_message(
target_message=callback_query.message,
text="Некорректная сумма.",
reply_markup=types.InlineKeyboardMarkup(),
force_text=True,
)
return
await state.update_data(amount=amount)
payment_url = await generate_heleket_payment_link(amount, callback_query.message.chat.id, method)
confirm_keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text=PAY_2, url=payment_url)],
[InlineKeyboardButton(text=BACK, callback_data="balance")],
]
)
await edit_or_send_message(
target_message=callback_query.message,
text=HELEKET_PAYMENT_MESSAGE.format(amount=amount),
reply_markup=confirm_keyboard,
force_text=True,
)
await state.set_state(ReplenishBalanceHeleket.waiting_for_payment_confirmation)
async def generate_heleket_payment_link(amount: int, tg_id: int, method: dict) -> str:
"""
Создание платежа в Heleket и получение ссылки на оплату
"""
url = "https://api.heleket.com/v1/payment"
unique_order_id = f"{int(time.time())}_{tg_id}"
usd_amount = round(amount / HELEKET_CURRENCY_RATE, 2)
data = {
"amount": str(usd_amount),
"currency": method["currency"],
"order_id": unique_order_id,
"url_success": HELEKET_SUCCESS_URL,
"url_return": HELEKET_RETURN_URL,
"url_callback": HELEKET_CALLBACK_URL,
"additional_data": f"tg_id:{tg_id},rub_amount:{amount}",
}
if method.get("to_currency"):
data["to_currency"] = method["to_currency"]
json_data = json.dumps(data, separators=(',', ':'))
base64_data = base64.b64encode(json_data.encode('utf-8')).decode('utf-8')
sign_string = base64_data + HELEKET_API_KEY
signature = hashlib.md5(sign_string.encode('utf-8')).hexdigest()
headers = {
"merchant": HELEKET_MERCHANT_ID,
"sign": signature,
"Content-Type": "application/json",
}
try:
async with aiohttp.ClientSession() as session:
async with session.post(url, headers=headers, data=json_data, timeout=60) as resp:
if resp.status == 200:
try:
resp_json = await resp.json()
if resp_json.get("state") == 0:
payment_url = resp_json.get("result", {}).get("url")
if payment_url:
logger.info(f"Heleket payment URL created for user {tg_id}")
return payment_url
else:
logger.error(f"Heleket: No URL in response: {resp_json}")
return "https://heleket.com/"
else:
logger.error(f"Heleket: Unsuccessful response: {resp_json}")
return "https://heleket.com/"
except Exception as e:
logger.error(f"Heleket: Error parsing JSON response: {e}")
text = await resp.text()
logger.error(f"Heleket: Response content: {text}")
return "https://heleket.com/"
else:
try:
error_json = await resp.json()
logger.error(f"Heleket API error: status={resp.status}, response={error_json}")
except Exception:
text = await resp.text()
logger.error(f"Heleket API error: status={resp.status}, non-JSON response: {text}")
return "https://heleket.com/"
except Exception as e:
logger.error(f"Error creating Heleket payment: {e}")
return "https://heleket.com/"
+2
View File
@@ -101,6 +101,7 @@ async def process_callback_pay_kassai(callback_query: types.CallbackQuery, state
target_message=callback_query.message,
text="Выберите способ оплаты через KassaAI:",
reply_markup=builder.as_markup(),
force_text=True,
)
await state.update_data(message_id=callback_query.message.message_id, chat_id=callback_query.message.chat.id)
await state.set_state(ReplenishBalanceKassaiState.choosing_method)
@@ -153,6 +154,7 @@ async def process_method_selection(callback_query: types.CallbackQuery, state: F
target_message=callback_query.message,
text=method["desc"],
reply_markup=builder.as_markup(),
force_text=True,
)
await state.update_data(message_id=callback_query.message.message_id, chat_id=callback_query.message.chat.id)
await state.set_state(ReplenishBalanceKassaiState.choosing_amount)
+3
View File
@@ -7,13 +7,16 @@ from config import TBLOCKER_WEBHOOK_PATH
from .tblocker import tblocker_webhook
from .wata_payment import wata_payment_webhook
from .kassai_payment import kassai_payment_webhook
from .heleket_payment import heleket_payment_webhook
WATA_WEBHOOK_PATH = "/wata/webhook"
KASSAI_WEBHOOK_PATH = "/kassai/webhook"
HELEKET_WEBHOOK_PATH = "/heleket/webhook"
async def register_web_routes(router: UrlDispatcher) -> None:
router.add_post(TBLOCKER_WEBHOOK_PATH, tblocker_webhook)
router.add_post(WATA_WEBHOOK_PATH, wata_payment_webhook)
router.add_post(KASSAI_WEBHOOK_PATH, kassai_payment_webhook)
router.add_post(HELEKET_WEBHOOK_PATH, heleket_payment_webhook)
+121
View File
@@ -0,0 +1,121 @@
import hashlib
import base64
import json
from aiohttp import web
from database import add_payment, async_session_maker, update_balance
from handlers.payments.utils import send_payment_success_notification
from config import HELEKET_API_KEY, HELEKET_CURRENCY_RATE
from logger import logger
processed_payments = set()
async def heleket_payment_webhook(request: web.Request):
"""
Обработчик вебхука от Heleket для подтверждения оплаты
"""
try:
data = await request.json()
logger.info(f"Heleket webhook received from {request.remote}")
logger.info(f"Heleket webhook data: {data}")
signature = data.get("sign", "")
if not verify_heleket_webhook_signature(data, signature):
logger.error("Heleket: Invalid signature")
return web.Response(status=400, text="Invalid signature")
uuid = data.get("uuid")
order_id = data.get("order_id")
payment_status = data.get("status")
amount = data.get("amount")
payment_amount = data.get("payment_amount")
additional_data = data.get("additional_data", "")
logger.info(f"Heleket payment: uuid={uuid}, order_id={order_id}, status={payment_status}, amount={amount}, payment_amount={payment_amount}")
if payment_status != "paid":
logger.info(f"Heleket: Payment not completed, status={payment_status}")
return web.Response(status=200, text="OK")
if not amount or not uuid:
logger.error(f"Heleket: Missing amount or uuid")
return web.Response(status=400, text="Missing required fields")
if uuid in processed_payments:
logger.warning(f"Heleket: Duplicate payment uuid={uuid}")
return web.Response(status=200, text="OK")
if order_id and order_id in processed_payments:
logger.warning(f"Heleket: Duplicate payment order_id={order_id}")
return web.Response(status=200, text="OK")
tg_id = None
rub_amount = None
try:
if additional_data and "tg_id:" in additional_data:
parts = additional_data.split(",")
for part in parts:
if part.startswith("tg_id:"):
tg_id = int(part.split("tg_id:")[1])
elif part.startswith("rub_amount:"):
rub_amount = float(part.split("rub_amount:")[1])
elif order_id and "_" in order_id:
tg_id = int(order_id.split("_")[1])
usd_amount = float(amount)
rub_amount = usd_amount * HELEKET_CURRENCY_RATE
else:
logger.error(f"Heleket: Cannot extract tg_id from data")
return web.Response(status=400, text="Cannot extract user ID")
except (ValueError, IndexError) as e:
logger.error(f"Heleket: Error extracting tg_id or rub_amount: {e}")
return web.Response(status=400, text="Invalid user ID or amount format")
if not rub_amount:
logger.error(f"Heleket: Could not determine rub_amount")
return web.Response(status=400, text="Cannot determine amount")
async with async_session_maker() as session:
await update_balance(session, tg_id, rub_amount)
await send_payment_success_notification(tg_id, rub_amount, session)
await add_payment(session, tg_id, rub_amount, "heleket")
processed_payments.add(uuid)
if order_id:
processed_payments.add(order_id)
logger.info(f"✅ Heleket: Payment processed for user {tg_id}, amount {rub_amount} RUB (${amount}), uuid={uuid}")
return web.Response(status=200, text="OK")
except Exception as e:
logger.error(f"Heleket webhook error: {e}")
return web.Response(status=500, text="Internal server error")
def verify_heleket_webhook_signature(data: dict, signature: str) -> bool:
"""
Проверка подписи вебхука Heleket
"""
try:
data_without_sign = {k: v for k, v in data.items() if k != "sign"}
json_data = json.dumps(data_without_sign, separators=(',', ':'))
base64_data = base64.b64encode(json_data.encode('utf-8')).decode('utf-8')
sign_string = base64_data + HELEKET_API_KEY
expected_signature = hashlib.md5(sign_string.encode('utf-8')).hexdigest()
result = signature.upper() == expected_signature.upper()
if not result:
logger.error(f"Heleket webhook signature mismatch")
logger.error(f"Expected: {expected_signature}, Got: {signature}")
logger.error(f"Base64 data: {base64_data}")
logger.error(f"Sign string: {sign_string}")
return result
except Exception as e:
logger.error(f"Heleket signature verification error: {e}")
return False