From c877aef71158bc7328cac7c43782762e0479a73f Mon Sep 17 00:00:00 2001 From: Boris Kovalskii <36034823+JustYay@users.noreply.github.com> Date: Thu, 24 Jul 2025 03:10:15 +1000 Subject: [PATCH 1/2] Kassa AI --- handlers/buttons.py | 2 + handlers/pay.py | 19 ++ handlers/payments/__init__.py | 4 + handlers/payments/kassai.py | 405 ++++++++++++++++++++++++++++++++++ web/__init__.py | 3 + web/kassai_payment.py | 134 +++++++++++ 6 files changed, 567 insertions(+) create mode 100644 handlers/payments/kassai.py create mode 100644 web/kassai_payment.py 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..152b7b59 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 @@ -69,6 +73,8 @@ 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 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) @@ -90,6 +96,9 @@ async def handle_pay(callback_query: CallbackQuery, state: FSMContext, session: builder.row(InlineKeyboardButton(text=ROBOKASSA, callback_data="pay_robokassa")) if FREEKASSA_ENABLE: builder.row(InlineKeyboardButton(text=FREEKASSA, callback_data="pay_freekassa")) + 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 WATA_RU_ENABLE: builder.row(InlineKeyboardButton(text=WATA_RU, callback_data="pay_wata_ru")) if WATA_SBP_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..11d4408e --- /dev/null +++ b/handlers/payments/kassai.py @@ -0,0 +1,405 @@ +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] + # Теперь сумма приходит прямо в parts[2] + 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 (timestamp в секундах) + nonce = int(time.time()) + + # Генерируем уникальный paymentId для отслеживания заказа + unique_payment_id = f"{nonce}_{tg_id}" + + # Формируем URL с query параметрами (включая paymentId) + url = f"https://api.fk.life/v1/orders/create?paymentId={unique_payment_id}" + + headers = { + "Content-Type": "application/json", + } + + # Формируем email клиента (тг_id@домен) + client_email = f"{tg_id}@{KASSAI_DOMAIN}" + + # Используем IP сервера из конфигурации + client_ip = KASSAI_IP # IP сервера + + # Подготавливаем ТОЛЬКО обязательные данные для подписи и запроса + # Согласно документации: shopId, nonce, i, email, ip, amount, currency + data_for_signature = { + "shopId": KASSAI_SHOP_ID, + "nonce": nonce, + "i": method["method"], + "email": client_email, + "ip": client_ip, + "amount": int(amount), # Целое число + "currency": "RUB" + } + + # Формируем подпись согласно документации PHP примера: + # ksort($data); hash_hmac('sha256', implode('|', $data), $api_key) + sorted_keys = sorted(data_for_signature.keys()) + values = [str(data_for_signature[key]) for key in sorted_keys] + sign_string = "|".join(values) + + # Отладочная информация + logger.info(f"KassaAI signature debug (correct algorithm):") + logger.info(f"Sorted keys: {sorted_keys}") + logger.info(f"Values: {values}") + logger.info(f"Sign string: {sign_string}") + logger.info(f"API key length: {len(KASSAI_API_KEY)}") + logger.info(f"Sending to URL: {url}") + + # HMAC SHA256 как в PHP примере + signature = hmac.new( + KASSAI_API_KEY.encode('utf-8'), + sign_string.encode('utf-8'), + hashlib.sha256 + ).hexdigest() + + logger.info(f"Generated signature: {signature}") + + # Данные для отправки (только обязательные поля + подпись) + data = data_for_signature.copy() + data["signature"] = signature + + logger.info(f"Sending data: {data}") + + 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() + logger.info(f"KassaAI API response: {resp_json}") + + # API возвращает успешный ответ со ссылкой в поле location + if resp_json.get("type") == "success": + payment_url = resp_json.get("location") + if payment_url: + logger.info(f"KassaAI payment URL created: {payment_url}") + return payment_url + else: + logger.error(f"KassaAI: Нет поля location в успешном ответе: {resp_json}") + return "https://fk.life/" + else: + logger.error(f"KassaAI: Неуспешный ответ: {resp_json}") + return "https://fk.life/" + except Exception as e: + logger.error(f"KassaAI: Ошибка при разборе JSON ответа: {e}") + text = await resp.text() + logger.error(f"KassaAI: Содержимое ответа: {text}") + return "https://fk.life/" + else: + try: + error_json = await resp.json() + logger.error(f"Ошибка KassaAI API: статус={resp.status}, ответ={error_json}") + except Exception: + text = await resp.text() + logger.error(f"Ошибка KassaAI API: статус={resp.status}, не-JSON ответ: {text}") + return "https://fk.life/" + except Exception as e: + logger.error(f"Ошибка при создании заказа KassaAI: {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: + # Формируем строку для подписи согласно документации: + # MERCHANT_ID:AMOUNT:SECRET_KEY2:MERCHANT_ORDER_ID + sign_string = ( + f"{KASSAI_SHOP_ID}:" + f"{data.get('AMOUNT', '')}:" + f"{KASSAI_SECRET_KEY}:" # Используем SECRET_KEY2 для вебхуков + f"{data.get('MERCHANT_ORDER_ID', '')}" + ) + + # Вычисляем MD5 хеш + 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..21bc94fa --- /dev/null +++ b/web/kassai_payment.py @@ -0,0 +1,134 @@ +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 для подтверждения оплаты + Формат: MERCHANT_ID=123&AMOUNT=100&intid=123456&MERCHANT_ORDER_ID=test_order&SIGN=... + """ + try: + # Получаем данные из POST запроса (form-data) + data = await request.post() + data_dict = dict(data) + + # Логируем входящий запрос + logger.info(f"KassaAI webhook received: {data_dict}") + + # Получаем подпись из параметров + signature = data_dict.get("SIGN") + + if not signature: + logger.error("KassaAI: Нет подписи SIGN в запросе!") + return web.Response(status=400, text="Signature missing") + + # Проверяем подпись согласно документации KassaAI + if not verify_kassai_webhook_signature(data_dict, signature): + logger.error("KassaAI: Подпись не прошла проверку!") + return web.Response(status=400, text="Invalid signature") + + # Логируем все важные параметры + logger.info( + f"KassaAI webhook details: " + f"MERCHANT_ID={data_dict.get('MERCHANT_ID')}, " + f"AMOUNT={data_dict.get('AMOUNT')}, " + f"intid={data_dict.get('intid')}, " + f"MERCHANT_ORDER_ID={data_dict.get('MERCHANT_ORDER_ID')}, " + f"P_EMAIL={data_dict.get('P_EMAIL')}, " + f"CUR_ID={data_dict.get('CUR_ID')}, " + f"SIGN={data_dict.get('SIGN')}" + ) + + # Получаем необходимые данные + 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: отсутствует AMOUNT: {data_dict}") + return web.Response(status=400, text="Missing required fields") + + # Проверяем уникальность платежа по MERCHANT_ORDER_ID (если есть) + if merchant_order_id and merchant_order_id in processed_payments: + logger.warning(f"KassaAI: платеж MERCHANT_ORDER_ID={merchant_order_id} уже был обработан. Игнорируем дубликат.") + return web.Response(status=200, text="YES") # Возвращаем успех чтобы не было повторных попыток + + try: + # Извлекаем tg_id из email (формат: tg_id@domain) + if p_email and "@" in p_email: + tg_id = int(p_email.split("@")[0]) + logger.info(f"KassaAI: извлечен tg_id={tg_id} из email={p_email}") + else: + logger.error(f"KassaAI: некорректный email для извлечения tg_id: {p_email}") + return web.Response(status=400, text="Invalid email format") + + amount_float = float(amount) + logger.info(f"KassaAI: обрабатываем платеж MERCHANT_ORDER_ID={merchant_order_id}, tg_id={tg_id}, amount={amount_float}") + except (ValueError, TypeError) as e: + logger.error(f"KassaAI: некорректные данные tg_id или amount: {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") + + # Добавляем MERCHANT_ORDER_ID в список обработанных платежей (если есть) + if merchant_order_id: + processed_payments.add(merchant_order_id) + + logger.info(f"✅ KassaAI: баланс пополнен для пользователя {tg_id} на {amount_float}, MERCHANT_ORDER_ID={merchant_order_id}") + + # Возвращаем ответ согласно документации KassaAI + return web.Response(status=200, text="YES") + + except Exception as e: + logger.error(f"Ошибка обработки KassaAI webhook: {e}") + return web.Response(status=500, text="Internal server error") + + +def verify_kassai_webhook_signature(data: dict, signature: str) -> bool: + """ + Проверка подписи вебхука KassaAI согласно документации FreeKassa + Формат: MERCHANT_ID:AMOUNT:SECRET_KEY2:MERCHANT_ORDER_ID + """ + try: + # Формируем строку для подписи согласно документации FreeKassa: + # MERCHANT_ID:AMOUNT:SECRET_KEY2:MERCHANT_ORDER_ID + sign_string = ( + f"{data.get('MERCHANT_ID', '')}:" + f"{data.get('AMOUNT', '')}:" + f"{KASSAI_SECRET_KEY}:" # Используем SECRET_KEY2 для вебхуков + f"{data.get('MERCHANT_ORDER_ID', '')}" + ) + + # Вычисляем MD5 хеш + 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:") + logger.error(f"Expected: {expected_signature}") + logger.error(f"Received: {signature}") + logger.error(f"Sign string: {sign_string}") + logger.error(f"MERCHANT_ID: {data.get('MERCHANT_ID')}") + logger.error(f"AMOUNT: {data.get('AMOUNT')}") + logger.error(f"MERCHANT_ORDER_ID: {data.get('MERCHANT_ORDER_ID')}") + + return result + + except Exception as e: + logger.error(f"Ошибка проверки подписи KassaAI webhook: {e}") + return False \ No newline at end of file From c12e61fb41d4f2240ec662b7cf9ade372cbdd88c Mon Sep 17 00:00:00 2001 From: Boris Kovalskii <36034823+JustYay@users.noreply.github.com> Date: Thu, 24 Jul 2025 09:42:23 +1000 Subject: [PATCH 2/2] Clear code --- handlers/pay.py | 14 ++++---- handlers/payments/kassai.py | 56 +++++++------------------------ web/kassai_payment.py | 66 ++++++++----------------------------- 3 files changed, 31 insertions(+), 105 deletions(-) diff --git a/handlers/pay.py b/handlers/pay.py index 152b7b59..6055e957 100644 --- a/handlers/pay.py +++ b/handlers/pay.py @@ -65,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: @@ -73,10 +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 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 len(payment_handlers) == 1: await callback_query.answer() @@ -88,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: @@ -96,9 +99,6 @@ async def handle_pay(callback_query: CallbackQuery, state: FSMContext, session: builder.row(InlineKeyboardButton(text=ROBOKASSA, callback_data="pay_robokassa")) if FREEKASSA_ENABLE: builder.row(InlineKeyboardButton(text=FREEKASSA, callback_data="pay_freekassa")) - 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 WATA_RU_ENABLE: builder.row(InlineKeyboardButton(text=WATA_RU, callback_data="pay_wata_ru")) if WATA_SBP_ENABLE: diff --git a/handlers/payments/kassai.py b/handlers/payments/kassai.py index 11d4408e..a07180bc 100644 --- a/handlers/payments/kassai.py +++ b/handlers/payments/kassai.py @@ -226,7 +226,6 @@ async def handle_custom_amount_input(message: types.Message, state: FSMContext): async def process_amount_selection(callback_query: types.CallbackQuery, state: FSMContext): parts = callback_query.data.split("|") method_name = parts[1] - # Теперь сумма приходит прямо в parts[2] amount_str = parts[2] method = next((m for m in KASSAI_PAYMENT_METHODS if m["name"] == method_name), None) @@ -276,101 +275,72 @@ async def generate_kassai_payment_link(amount: int, tg_id: int, method: dict) -> """ Создание заказа в KassaAI и получение ссылки на оплату """ - # Генерируем уникальный nonce (timestamp в секундах) nonce = int(time.time()) - - # Генерируем уникальный paymentId для отслеживания заказа unique_payment_id = f"{nonce}_{tg_id}" - - # Формируем URL с query параметрами (включая paymentId) url = f"https://api.fk.life/v1/orders/create?paymentId={unique_payment_id}" headers = { "Content-Type": "application/json", } - # Формируем email клиента (тг_id@домен) client_email = f"{tg_id}@{KASSAI_DOMAIN}" + client_ip = KASSAI_IP - # Используем IP сервера из конфигурации - client_ip = KASSAI_IP # IP сервера - - # Подготавливаем ТОЛЬКО обязательные данные для подписи и запроса - # Согласно документации: shopId, nonce, i, email, ip, amount, currency data_for_signature = { "shopId": KASSAI_SHOP_ID, "nonce": nonce, "i": method["method"], "email": client_email, "ip": client_ip, - "amount": int(amount), # Целое число + "amount": int(amount), "currency": "RUB" } - # Формируем подпись согласно документации PHP примера: - # ksort($data); hash_hmac('sha256', implode('|', $data), $api_key) sorted_keys = sorted(data_for_signature.keys()) values = [str(data_for_signature[key]) for key in sorted_keys] sign_string = "|".join(values) - # Отладочная информация - logger.info(f"KassaAI signature debug (correct algorithm):") - logger.info(f"Sorted keys: {sorted_keys}") - logger.info(f"Values: {values}") - logger.info(f"Sign string: {sign_string}") - logger.info(f"API key length: {len(KASSAI_API_KEY)}") - logger.info(f"Sending to URL: {url}") - - # HMAC SHA256 как в PHP примере signature = hmac.new( KASSAI_API_KEY.encode('utf-8'), sign_string.encode('utf-8'), hashlib.sha256 ).hexdigest() - logger.info(f"Generated signature: {signature}") - - # Данные для отправки (только обязательные поля + подпись) data = data_for_signature.copy() data["signature"] = signature - logger.info(f"Sending data: {data}") - 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() - logger.info(f"KassaAI API response: {resp_json}") - - # API возвращает успешный ответ со ссылкой в поле location if resp_json.get("type") == "success": payment_url = resp_json.get("location") if payment_url: - logger.info(f"KassaAI payment URL created: {payment_url}") + logger.info(f"KassaAI payment URL created for user {tg_id}") return payment_url else: - logger.error(f"KassaAI: Нет поля location в успешном ответе: {resp_json}") + logger.error(f"KassaAI: No location in response: {resp_json}") return "https://fk.life/" else: - logger.error(f"KassaAI: Неуспешный ответ: {resp_json}") + logger.error(f"KassaAI: Unsuccessful response: {resp_json}") return "https://fk.life/" except Exception as e: - logger.error(f"KassaAI: Ошибка при разборе JSON ответа: {e}") + logger.error(f"KassaAI: Error parsing JSON response: {e}") text = await resp.text() - logger.error(f"KassaAI: Содержимое ответа: {text}") + logger.error(f"KassaAI: Response content: {text}") return "https://fk.life/" else: try: error_json = await resp.json() - logger.error(f"Ошибка KassaAI API: статус={resp.status}, ответ={error_json}") + logger.error(f"KassaAI API error: status={resp.status}, response={error_json}") except Exception: text = await resp.text() - logger.error(f"Ошибка KassaAI API: статус={resp.status}, не-JSON ответ: {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"Ошибка при создании заказа KassaAI: {e}") + logger.error(f"Error creating KassaAI order: {e}") return "https://fk.life/" @@ -380,19 +350,15 @@ def verify_kassai_signature(data: dict, signature: str) -> bool: Формат: MERCHANT_ID:AMOUNT:SECRET_KEY2:MERCHANT_ORDER_ID """ try: - # Формируем строку для подписи согласно документации: - # MERCHANT_ID:AMOUNT:SECRET_KEY2:MERCHANT_ORDER_ID sign_string = ( f"{KASSAI_SHOP_ID}:" f"{data.get('AMOUNT', '')}:" - f"{KASSAI_SECRET_KEY}:" # Используем SECRET_KEY2 для вебхуков + f"{KASSAI_SECRET_KEY}:" f"{data.get('MERCHANT_ORDER_ID', '')}" ) - # Вычисляем MD5 хеш expected_signature = hashlib.md5(sign_string.encode('utf-8')).hexdigest() - # Сравниваем подписи (регистронезависимо) result = signature.upper() == expected_signature.upper() if not result: diff --git a/web/kassai_payment.py b/web/kassai_payment.py index 21bc94fa..dd6cc603 100644 --- a/web/kassai_payment.py +++ b/web/kassai_payment.py @@ -7,128 +7,88 @@ 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 для подтверждения оплаты - Формат: MERCHANT_ID=123&AMOUNT=100&intid=123456&MERCHANT_ORDER_ID=test_order&SIGN=... """ try: - # Получаем данные из POST запроса (form-data) data = await request.post() data_dict = dict(data) - # Логируем входящий запрос - logger.info(f"KassaAI webhook received: {data_dict}") + logger.info(f"KassaAI webhook received from {request.remote}") - # Получаем подпись из параметров signature = data_dict.get("SIGN") - if not signature: - logger.error("KassaAI: Нет подписи SIGN в запросе!") + logger.error("KassaAI: Missing SIGN in request") return web.Response(status=400, text="Signature missing") - # Проверяем подпись согласно документации KassaAI if not verify_kassai_webhook_signature(data_dict, signature): - logger.error("KassaAI: Подпись не прошла проверку!") + logger.error("KassaAI: Invalid signature") return web.Response(status=400, text="Invalid signature") - # Логируем все важные параметры - logger.info( - f"KassaAI webhook details: " - f"MERCHANT_ID={data_dict.get('MERCHANT_ID')}, " - f"AMOUNT={data_dict.get('AMOUNT')}, " - f"intid={data_dict.get('intid')}, " - f"MERCHANT_ORDER_ID={data_dict.get('MERCHANT_ORDER_ID')}, " - f"P_EMAIL={data_dict.get('P_EMAIL')}, " - f"CUR_ID={data_dict.get('CUR_ID')}, " - f"SIGN={data_dict.get('SIGN')}" - ) - - # Получаем необходимые данные 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: отсутствует AMOUNT: {data_dict}") + logger.error(f"KassaAI: Missing AMOUNT") return web.Response(status=400, text="Missing required fields") - # Проверяем уникальность платежа по MERCHANT_ORDER_ID (если есть) if merchant_order_id and merchant_order_id in processed_payments: - logger.warning(f"KassaAI: платеж MERCHANT_ORDER_ID={merchant_order_id} уже был обработан. Игнорируем дубликат.") - return web.Response(status=200, text="YES") # Возвращаем успех чтобы не было повторных попыток + logger.warning(f"KassaAI: Duplicate payment {merchant_order_id}") + return web.Response(status=200, text="YES") try: - # Извлекаем tg_id из email (формат: tg_id@domain) if p_email and "@" in p_email: tg_id = int(p_email.split("@")[0]) - logger.info(f"KassaAI: извлечен tg_id={tg_id} из email={p_email}") else: - logger.error(f"KassaAI: некорректный email для извлечения tg_id: {p_email}") + logger.error(f"KassaAI: Invalid email format: {p_email}") return web.Response(status=400, text="Invalid email format") amount_float = float(amount) - logger.info(f"KassaAI: обрабатываем платеж MERCHANT_ORDER_ID={merchant_order_id}, tg_id={tg_id}, amount={amount_float}") except (ValueError, TypeError) as e: - logger.error(f"KassaAI: некорректные данные tg_id или amount: {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") - # Добавляем MERCHANT_ORDER_ID в список обработанных платежей (если есть) if merchant_order_id: processed_payments.add(merchant_order_id) - logger.info(f"✅ KassaAI: баланс пополнен для пользователя {tg_id} на {amount_float}, MERCHANT_ORDER_ID={merchant_order_id}") - - # Возвращаем ответ согласно документации KassaAI + 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: {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 - Формат: MERCHANT_ID:AMOUNT:SECRET_KEY2:MERCHANT_ORDER_ID """ try: - # Формируем строку для подписи согласно документации FreeKassa: - # MERCHANT_ID:AMOUNT:SECRET_KEY2:MERCHANT_ORDER_ID sign_string = ( f"{data.get('MERCHANT_ID', '')}:" f"{data.get('AMOUNT', '')}:" - f"{KASSAI_SECRET_KEY}:" # Используем SECRET_KEY2 для вебхуков + f"{KASSAI_SECRET_KEY}:" f"{data.get('MERCHANT_ORDER_ID', '')}" ) - # Вычисляем MD5 хеш 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:") - logger.error(f"Expected: {expected_signature}") - logger.error(f"Received: {signature}") - logger.error(f"Sign string: {sign_string}") - logger.error(f"MERCHANT_ID: {data.get('MERCHANT_ID')}") - logger.error(f"AMOUNT: {data.get('AMOUNT')}") - logger.error(f"MERCHANT_ORDER_ID: {data.get('MERCHANT_ORDER_ID')}") + logger.error(f"KassaAI webhook signature mismatch") return result except Exception as e: - logger.error(f"Ошибка проверки подписи KassaAI webhook: {e}") + logger.error(f"KassaAI signature verification error: {e}") return False \ No newline at end of file