diff --git a/handlers/buttons.py b/handlers/buttons.py index beeb8dda..e4e1b18f 100644 --- a/handlers/buttons.py +++ b/handlers/buttons.py @@ -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" diff --git a/handlers/pay.py b/handlers/pay.py index 6055e957..5c58d30a 100644 --- a/handlers/pay.py +++ b/handlers/pay.py @@ -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") diff --git a/handlers/payments/__init__.py b/handlers/payments/__init__.py index 5a90d4bf..f9c3551d 100644 --- a/handlers/payments/__init__.py +++ b/handlers/payments/__init__.py @@ -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) diff --git a/handlers/payments/heleket.py b/handlers/payments/heleket.py new file mode 100644 index 00000000..7ca18dd7 --- /dev/null +++ b/handlers/payments/heleket.py @@ -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/" \ No newline at end of file diff --git a/handlers/payments/kassai.py b/handlers/payments/kassai.py index a09ec810..0f8311ce 100644 --- a/handlers/payments/kassai.py +++ b/handlers/payments/kassai.py @@ -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) diff --git a/web/__init__.py b/web/__init__.py index ac14748b..f4ed9945 100644 --- a/web/__init__.py +++ b/web/__init__.py @@ -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) diff --git a/web/heleket_payment.py b/web/heleket_payment.py new file mode 100644 index 00000000..fd80fddf --- /dev/null +++ b/web/heleket_payment.py @@ -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 \ No newline at end of file