Merge pull request #218 from JustYay/dev

Kassa AI
This commit is contained in:
Vladislav Lisitsyn
2025-07-24 02:58:53 +03:00
committed by GitHub
6 changed files with 495 additions and 2 deletions
+2
View File
@@ -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"
+21 -2
View File
@@ -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")
+4
View File
@@ -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)
+371
View File
@@ -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
+3
View File
@@ -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)
+94
View File
@@ -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