diff --git a/handlers/buttons.py b/handlers/buttons.py index 4eb79b97..beeb8dda 100644 --- a/handlers/buttons.py +++ b/handlers/buttons.py @@ -94,4 +94,6 @@ STARS_BOT = "🤖 Бот для покупки звезд" WATA_RU = "🇷🇺 WATA: Карты РФ" WATA_SBP = "🏦 WATA: СБП" WATA_INT = "🌍 WATA: Международные карты" +KASSAI_CARDS = "💳 KassaAI: Карты РФ" +KASSAI_SBP = "🏦 KassaAI: СБП" STARS_BOT_URL = "https://t.me/PremiumBot" diff --git a/handlers/pay.py b/handlers/pay.py index 72e39b0c..6055e957 100644 --- a/handlers/pay.py +++ b/handlers/pay.py @@ -13,6 +13,7 @@ from config import ( CRYPTO_BOT_ENABLE, DONATIONS_ENABLE, FREEKASSA_ENABLE, + KASSAI_ENABLE, ROBOKASSA_ENABLE, STARS_ENABLE, WATA_INT_ENABLE, @@ -28,6 +29,8 @@ from handlers.buttons import ( COUPON, CRYPTOBOT, FREEKASSA, + KASSAI_CARDS, + KASSAI_SBP, MAIN_MENU, PAYMENT, ROBOKASSA, @@ -40,6 +43,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.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 from handlers.payments.wata import process_callback_pay_wata @@ -61,6 +65,10 @@ async def handle_pay(callback_query: CallbackQuery, state: FSMContext, session: payment_handlers.append(process_callback_pay_yookassa) if YOOMONEY_ENABLE: payment_handlers.append(process_callback_pay_yoomoney) + if KASSAI_ENABLE: + payment_handlers.append(process_callback_pay_kassai) + if WATA_RU_ENABLE or WATA_SBP_ENABLE or WATA_INT_ENABLE: + payment_handlers.append(process_callback_pay_wata) if CRYPTO_BOT_ENABLE: payment_handlers.append(process_callback_pay_cryptobot) if STARS_ENABLE: @@ -69,8 +77,6 @@ async def handle_pay(callback_query: CallbackQuery, state: FSMContext, session: payment_handlers.append(process_callback_pay_robokassa) if FREEKASSA_ENABLE: payment_handlers.append(process_callback_pay_freekassa) - if WATA_RU_ENABLE or WATA_SBP_ENABLE or WATA_INT_ENABLE: - payment_handlers.append(process_callback_pay_wata) if len(payment_handlers) == 1: await callback_query.answer() @@ -82,6 +88,9 @@ async def handle_pay(callback_query: CallbackQuery, state: FSMContext, session: builder.row(InlineKeyboardButton(text=YOOKASSA, callback_data="pay_yookassa")) if YOOMONEY_ENABLE: builder.row(InlineKeyboardButton(text=YOOMONEY, callback_data="pay_yoomoney")) + 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 CRYPTO_BOT_ENABLE: builder.row(InlineKeyboardButton(text=CRYPTOBOT, callback_data="pay_cryptobot")) if STARS_ENABLE: @@ -175,3 +184,13 @@ async def handle_pay_wata_sbp(callback_query: CallbackQuery, state: FSMContext, @router.callback_query(F.data == "pay_wata_int") async def handle_pay_wata_int(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession): await process_callback_pay_wata(callback_query, state, session, cassa_name="int") + + +@router.callback_query(F.data == "pay_kassai_cards") +async def handle_pay_kassai_cards(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession): + await process_callback_pay_kassai(callback_query, state, session, method_name="cards") + + +@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") diff --git a/handlers/payments/__init__.py b/handlers/payments/__init__.py index 0848fce9..5a90d4bf 100644 --- a/handlers/payments/__init__.py +++ b/handlers/payments/__init__.py @@ -5,6 +5,7 @@ from aiogram import Router from config import ( CRYPTO_BOT_ENABLE, FREEKASSA_ENABLE, + KASSAI_ENABLE, ROBOKASSA_ENABLE, STARS_ENABLE, YOOKASSA_ENABLE, @@ -14,6 +15,7 @@ from config import ( from .cryprobot_pay import router as cryprobot_router from .freekassa_pay import router as freekassa_router from .gift import router as gift_router +from .kassai import router as kassai_router from .robokassa_pay import router as robokassa_router from .stars_pay import router as stars_router from .yookassa_pay import router as yookassa_router @@ -34,6 +36,8 @@ if CRYPTO_BOT_ENABLE: router.include_router(cryprobot_router) if STARS_ENABLE: router.include_router(stars_router) +if KASSAI_ENABLE: + router.include_router(kassai_router) router.include_router(wata_router) router.include_router(gift_router) diff --git a/handlers/payments/kassai.py b/handlers/payments/kassai.py new file mode 100644 index 00000000..a07180bc --- /dev/null +++ b/handlers/payments/kassai.py @@ -0,0 +1,371 @@ +import aiohttp +import hashlib +import hmac +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 ( + KASSAI_ENABLE, KASSAI_API_KEY, KASSAI_SECRET_KEY, KASSAI_DOMAIN, KASSAI_SHOP_ID, + REDIRECT_LINK, FAIL_REDIRECT_LINK, WEBHOOK_HOST, KASSAI_IP +) + +from handlers.buttons import BACK, PAY_2, KASSAI_CARDS, KASSAI_SBP +from handlers.texts import ( + KASSAI_CARDS_DESCRIPTION, KASSAI_SBP_DESCRIPTION, + KASSAI_PAYMENT_MESSAGE, ENTER_SUM, PAYMENT_OPTIONS, KASSAI_PAYMENT_TITLE +) +from handlers.utils import edit_or_send_message +from logger import logger + +router = Router() + + +class ReplenishBalanceKassaiState(StatesGroup): + choosing_method = State() + choosing_amount = State() + waiting_for_payment_confirmation = State() + entering_custom_amount = State() + + +KASSAI_PAYMENT_METHODS = [ + {"enable": KASSAI_ENABLE, "method": 36, "name": "cards", "button": KASSAI_CARDS, "desc": KASSAI_CARDS_DESCRIPTION}, + {"enable": KASSAI_ENABLE, "method": 44, "name": "sbp", "button": KASSAI_SBP, "desc": KASSAI_SBP_DESCRIPTION}, +] + + +@router.callback_query(F.data == "pay_kassai") +async def process_callback_pay_kassai(callback_query: types.CallbackQuery, state: FSMContext, session: AsyncSession, method_name: str = None): + tg_id = callback_query.message.chat.id + logger.info(f"User {tg_id} initiated KassaAI payment.") + + if method_name: + method = next((m for m in KASSAI_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'kassai_amount|{method_name}|{PAYMENT_OPTIONS[i]["callback_data"].split("|")[1]}', + ), + InlineKeyboardButton( + text=PAYMENT_OPTIONS[i + 1]["text"], + callback_data=f'kassai_amount|{method_name}|{PAYMENT_OPTIONS[i + 1]["callback_data"].split("|")[1]}', + ), + ) + else: + builder.row( + InlineKeyboardButton( + text=PAYMENT_OPTIONS[i]["text"], + callback_data=f'kassai_amount|{method_name}|{PAYMENT_OPTIONS[i]["callback_data"].split("|")[1]}', + ) + ) + builder.row(InlineKeyboardButton(text="Ввести сумму", callback_data=f"kassai_custom_amount|{method_name}")) + builder.row(InlineKeyboardButton(text=BACK, callback_data="pay")) + + await callback_query.message.delete() + new_message = await callback_query.message.answer( + text=method["desc"], + reply_markup=builder.as_markup(), + ) + await state.update_data(message_id=new_message.message_id, chat_id=new_message.chat.id, kassai_method=method_name) + await state.set_state(ReplenishBalanceKassaiState.choosing_amount) + return + + builder = InlineKeyboardBuilder() + for method in KASSAI_PAYMENT_METHODS: + if method["enable"]: + builder.row(InlineKeyboardButton(text=method["button"], callback_data=f'kassai_method|{method["name"]}')) + builder.row(InlineKeyboardButton(text=BACK, callback_data="balance")) + + await callback_query.message.delete() + new_message = await callback_query.message.answer( + text="Выберите способ оплаты через KassaAI:", + reply_markup=builder.as_markup(), + ) + await state.update_data(message_id=new_message.message_id, chat_id=new_message.chat.id) + await state.set_state(ReplenishBalanceKassaiState.choosing_method) + + +@router.callback_query(F.data.startswith("kassai_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 KASSAI_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(kassai_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'kassai_amount|{method_name}|{PAYMENT_OPTIONS[i]["callback_data"].split("|")[1]}', + ), + InlineKeyboardButton( + text=PAYMENT_OPTIONS[i + 1]["text"], + callback_data=f'kassai_amount|{method_name}|{PAYMENT_OPTIONS[i + 1]["callback_data"].split("|")[1]}', + ), + ) + else: + builder.row( + InlineKeyboardButton( + text=PAYMENT_OPTIONS[i]["text"], + callback_data=f'kassai_amount|{method_name}|{PAYMENT_OPTIONS[i]["callback_data"].split("|")[1]}', + ) + ) + builder.row(InlineKeyboardButton(text="Ввести сумму", callback_data=f"kassai_custom_amount|{method_name}")) + builder.row(InlineKeyboardButton(text=BACK, callback_data="pay_kassai")) + + await callback_query.message.delete() + new_message = await callback_query.message.answer( + text=method["desc"], + reply_markup=builder.as_markup(), + ) + await state.update_data(message_id=new_message.message_id, chat_id=new_message.chat.id) + await state.set_state(ReplenishBalanceKassaiState.choosing_amount) + + +@router.callback_query(F.data.startswith("kassai_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(kassai_method=method_name) + + builder = InlineKeyboardBuilder() + builder.row(InlineKeyboardButton(text=BACK, callback_data=f"pay_kassai_{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(ReplenishBalanceKassaiState.entering_custom_amount) + + +@router.message(ReplenishBalanceKassaiState.entering_custom_amount) +async def handle_custom_amount_input(message: types.Message, state: FSMContext): + data = await state.get_data() + method_name = data.get("kassai_method") + method = next((m for m in KASSAI_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 method_name == "sbp" and 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_kassai_payment_link(amount, message.chat.id, method) + + confirm_keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text=PAY_2, url=payment_url)], + [InlineKeyboardButton(text=BACK, callback_data="pay_kassai")], + ] + ) + + await edit_or_send_message( + target_message=message, + text=KASSAI_PAYMENT_MESSAGE.format(amount=amount), + reply_markup=confirm_keyboard, + force_text=True, + ) + await state.set_state(ReplenishBalanceKassaiState.waiting_for_payment_confirmation) + + +@router.callback_query(F.data.startswith("kassai_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 KASSAI_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_kassai_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="pay_kassai")], + ] + ) + + await edit_or_send_message( + target_message=callback_query.message, + text=KASSAI_PAYMENT_MESSAGE.format(amount=amount), + reply_markup=confirm_keyboard, + force_text=True, + ) + await state.set_state(ReplenishBalanceKassaiState.waiting_for_payment_confirmation) + + +async def generate_kassai_payment_link(amount: int, tg_id: int, method: dict) -> str: + """ + Создание заказа в KassaAI и получение ссылки на оплату + """ + nonce = int(time.time()) + unique_payment_id = f"{nonce}_{tg_id}" + url = f"https://api.fk.life/v1/orders/create?paymentId={unique_payment_id}" + + headers = { + "Content-Type": "application/json", + } + + client_email = f"{tg_id}@{KASSAI_DOMAIN}" + client_ip = KASSAI_IP + + data_for_signature = { + "shopId": KASSAI_SHOP_ID, + "nonce": nonce, + "i": method["method"], + "email": client_email, + "ip": client_ip, + "amount": int(amount), + "currency": "RUB" + } + + sorted_keys = sorted(data_for_signature.keys()) + values = [str(data_for_signature[key]) for key in sorted_keys] + sign_string = "|".join(values) + + signature = hmac.new( + KASSAI_API_KEY.encode('utf-8'), + sign_string.encode('utf-8'), + hashlib.sha256 + ).hexdigest() + + data = data_for_signature.copy() + data["signature"] = signature + + try: + async with aiohttp.ClientSession() as session: + async with session.post(url, headers=headers, json=data, timeout=60) as resp: + if resp.status == 200: + try: + resp_json = await resp.json() + if resp_json.get("type") == "success": + payment_url = resp_json.get("location") + if payment_url: + logger.info(f"KassaAI payment URL created for user {tg_id}") + return payment_url + else: + logger.error(f"KassaAI: No location in response: {resp_json}") + return "https://fk.life/" + else: + logger.error(f"KassaAI: Unsuccessful response: {resp_json}") + return "https://fk.life/" + except Exception as e: + logger.error(f"KassaAI: Error parsing JSON response: {e}") + text = await resp.text() + logger.error(f"KassaAI: Response content: {text}") + return "https://fk.life/" + else: + try: + error_json = await resp.json() + logger.error(f"KassaAI API error: status={resp.status}, response={error_json}") + except Exception: + text = await resp.text() + logger.error(f"KassaAI API error: status={resp.status}, non-JSON response: {text}") + return "https://fk.life/" + except Exception as e: + logger.error(f"Error creating KassaAI order: {e}") + return "https://fk.life/" + + +def verify_kassai_signature(data: dict, signature: str) -> bool: + """ + Проверка подписи вебхука KassaAI согласно документации FreeKassa + Формат: MERCHANT_ID:AMOUNT:SECRET_KEY2:MERCHANT_ORDER_ID + """ + try: + sign_string = ( + f"{KASSAI_SHOP_ID}:" + f"{data.get('AMOUNT', '')}:" + f"{KASSAI_SECRET_KEY}:" + f"{data.get('MERCHANT_ORDER_ID', '')}" + ) + + expected_signature = hashlib.md5(sign_string.encode('utf-8')).hexdigest() + + result = signature.upper() == expected_signature.upper() + + if not result: + logger.error(f"KassaAI signature mismatch. Expected: {expected_signature}, Got: {signature}") + logger.error(f"Sign string: {sign_string}") + + return result + except Exception as e: + logger.error(f"Ошибка проверки подписи KassaAI: {e}") + return False \ No newline at end of file diff --git a/web/__init__.py b/web/__init__.py index 753601aa..ac14748b 100644 --- a/web/__init__.py +++ b/web/__init__.py @@ -6,11 +6,14 @@ 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 WATA_WEBHOOK_PATH = "/wata/webhook" +KASSAI_WEBHOOK_PATH = "/kassai/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) diff --git a/web/kassai_payment.py b/web/kassai_payment.py new file mode 100644 index 00000000..dd6cc603 --- /dev/null +++ b/web/kassai_payment.py @@ -0,0 +1,94 @@ +import hashlib +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 handlers.payments.kassai import verify_kassai_signature +from config import KASSAI_SECRET_KEY, KASSAI_SHOP_ID +from logger import logger + +processed_payments = set() + + +async def kassai_payment_webhook(request: web.Request): + """ + Обработчик вебхука от KassaAI для подтверждения оплаты + """ + try: + data = await request.post() + data_dict = dict(data) + + logger.info(f"KassaAI webhook received from {request.remote}") + + signature = data_dict.get("SIGN") + if not signature: + logger.error("KassaAI: Missing SIGN in request") + return web.Response(status=400, text="Signature missing") + + if not verify_kassai_webhook_signature(data_dict, signature): + logger.error("KassaAI: Invalid signature") + return web.Response(status=400, text="Invalid signature") + + merchant_order_id = data_dict.get("MERCHANT_ORDER_ID") + amount = data_dict.get("AMOUNT") + p_email = data_dict.get("P_EMAIL") + + if not amount: + logger.error(f"KassaAI: Missing AMOUNT") + return web.Response(status=400, text="Missing required fields") + + if merchant_order_id and merchant_order_id in processed_payments: + logger.warning(f"KassaAI: Duplicate payment {merchant_order_id}") + return web.Response(status=200, text="YES") + + try: + if p_email and "@" in p_email: + tg_id = int(p_email.split("@")[0]) + else: + logger.error(f"KassaAI: Invalid email format: {p_email}") + return web.Response(status=400, text="Invalid email format") + + amount_float = float(amount) + except (ValueError, TypeError) as e: + logger.error(f"KassaAI: Invalid data format: {e}") + return web.Response(status=400, text="Invalid data format") + + async with async_session_maker() as session: + await update_balance(session, tg_id, amount_float) + await send_payment_success_notification(tg_id, amount_float, session) + await add_payment(session, tg_id, amount_float, "kassai") + + if merchant_order_id: + processed_payments.add(merchant_order_id) + + logger.info(f"✅ KassaAI: Payment processed for user {tg_id}, amount {amount_float}") + return web.Response(status=200, text="YES") + + except Exception as e: + logger.error(f"KassaAI webhook error: {e}") + return web.Response(status=500, text="Internal server error") + + +def verify_kassai_webhook_signature(data: dict, signature: str) -> bool: + """ + Проверка подписи вебхука KassaAI согласно документации FreeKassa + """ + try: + sign_string = ( + f"{data.get('MERCHANT_ID', '')}:" + f"{data.get('AMOUNT', '')}:" + f"{KASSAI_SECRET_KEY}:" + f"{data.get('MERCHANT_ORDER_ID', '')}" + ) + + expected_signature = hashlib.md5(sign_string.encode('utf-8')).hexdigest() + result = signature.upper() == expected_signature.upper() + + if not result: + logger.error(f"KassaAI webhook signature mismatch") + + return result + + except Exception as e: + logger.error(f"KassaAI signature verification error: {e}") + return False \ No newline at end of file